feat(unsubscribe): secure token-based unsubscribe flow with flexible services

- Created TokenService for generic HMAC token generation and validation
- Created UnsubscribeTokenService to wrap TokenService with email+timestamp logic and TTL
- Updated UnsubscribeController to require valid signed tokens for GET /unsubscribe
- Token is generated using email + ts + shared secret, validated against TTL (default 24h)
- Confirm view now inaccessible unless accessed via system-generated link
- Deprecated static helper in favor of service architecture
This commit is contained in:
essae 2025-05-24 17:21:52 -04:00
parent e09f763db3
commit cf146973f2
12 changed files with 280 additions and 57 deletions

View File

@ -1,7 +1,7 @@
<?php
/**
* File: VerificationController.php
* Version: 1.10
* Version: 1.11
* Path: /app/Controllers/VerificationController.php
* Purpose: Handles email verification for newsletter and contact messages, including code expiration and attempt logging.
* Now wired to use EmailService for all post-verification messaging, including unified contact+newsletter handling.
@ -114,7 +114,7 @@ public function verify(string $code): void
}
// Mark the submission as verified
$update = $db->prepare("UPDATE $table SET is_verified = 1, verification_code = NULL WHERE id = ?");
$update = $db->prepare("UPDATE $table SET is_verified = 1 WHERE id = ?");
$update->execute([$subscriber['id']]);
Logger::info("Subscriber verified: ID {$subscriber['id']} via $type");

View File

@ -0,0 +1,48 @@
<?php
/**
* File: TokenService.php
* Version: 1.0
* Path: app/Services/
* Purpose: Provides generic token generation and validation using HMAC.
*/
namespace WizdomNetworks\WizeWeb\Services;
class TokenService
{
/**
* Generate an HMAC token from a string payload.
*
* @param string $data The string to sign (e.g. email+timestamp).
* @param string $secret Secret key.
* @return string HMAC token.
*/
public function generate(string $data, string $secret): string
{
return hash_hmac('sha256', $data, $secret);
}
/**
* Validate a token against expected data, with optional TTL enforcement.
*
* @param string $data Original payload used to generate token.
* @param string $token Supplied token.
* @param string $secret Secret key used to validate.
* @param int|null $timestamp Unix timestamp used in original payload.
* @param int $ttlSeconds Time-to-live in seconds (default 86400 = 1 day).
* @return bool
*/
public function isValid(string $data, string $token, string $secret, ?int $timestamp = null, int $ttlSeconds = 86400): bool
{
$expected = $this->generate($data, $secret);
if (!hash_equals($expected, $token)) {
return false;
}
if ($timestamp !== null && abs(time() - $timestamp) > $ttlSeconds) {
return false;
}
return true;
}
}

View File

@ -0,0 +1,51 @@
<?php
/**
* File: UnsubscribeTokenService.php
* Version: 1.0
* Path: app/Services/
* Purpose: Wrapper for generating and validating unsubscribe tokens using TokenService.
*/
namespace WizdomNetworks\WizeWeb\Services;
use WizdomNetworks\WizeWeb\Services\TokenService;
class UnsubscribeTokenService
{
private TokenService $tokenService;
private string $secret;
private int $ttl;
public function __construct(TokenService $tokenService)
{
$this->tokenService = $tokenService;
$this->secret = $_ENV['UNSUBSCRIBE_SECRET'] ?? 'changeme';
$this->ttl = 86400; // default: 24 hours
}
/**
* Create an unsubscribe token.
*
* @param string $email
* @param int $timestamp
* @return string
*/
public function generate(string $email, int $timestamp): string
{
return $this->tokenService->generate($email . $timestamp, $this->secret);
}
/**
* Validate an unsubscribe token.
*
* @param string $email
* @param int $timestamp
* @param string $token
* @return bool
*/
public function isValid(string $email, int $timestamp, string $token): bool
{
$data = $email . $timestamp;
return $this->tokenService->isValid($data, $token, $this->secret, $timestamp, $this->ttl);
}
}

View File

@ -0,0 +1,45 @@
<?php
/**
* File: UnsubscribeTokenHelper.php
* Version: 1.0
* Path: app/Utilities/
* Purpose: Provides secure token generation and validation for unsubscribe links.
*/
namespace WizdomNetworks\WizeWeb\Utilities;
class UnsubscribeTokenHelper
{
/**
* Generate a secure token for an email + timestamp
*
* @param string $email
* @param int $timestamp
* @return string
*/
public static function generate(string $email, int $timestamp): string
{
$secret = $_ENV['UNSUBSCRIBE_SECRET'] ?? 'changeme';
return hash_hmac('sha256', $email . $timestamp, $secret);
}
/**
* Validate a token with an expiration window (default 24h)
*
* @param string $email
* @param int $timestamp
* @param string $token
* @param int $validForSeconds
* @return bool
*/
public static function isValid(string $email, int $timestamp, string $token, int $validForSeconds = 86400): bool
{
$expected = self::generate($email, $timestamp);
if (!hash_equals($expected, $token)) {
return false;
}
// Check timestamp freshness
return abs(time() - $timestamp) <= $validForSeconds;
}
}

View File

@ -16,7 +16,9 @@
<!-- Preloader -->
<div id="preloader"></div>
<?php include __DIR__ . '/header.php'; ?>
<!-- Header -->
<?php include __DIR__ . '/header.php'; ?>
<!-- Page Content -->
<?= $content ?>

View File

@ -8,8 +8,10 @@
<!-- insert actual nav here -->
<?php
use WizdomNetworks\WizeWeb\Core\View;
use WizdomNetworks\WizeWeb\Core\View;
if (empty($hideNavbar) || $hideNavbar !== true){
View::renderPartial('navbar');
}
?>
<!-- end nav -->
</div>

View File

@ -0,0 +1,35 @@
<?php
// File: 404.php
// Version: 1.0
// Purpose: Custom 404 error page with Wizdom branding.
use WizdomNetworks\WizeWeb\Core\View;
ob_start();
?>
<section class="verify-section section pt-5" style="padding-top: 7rem;">
<div class="container text-center">
<div class="icon mb-4" style="padding-top: 3rem;">
<i class="bi bi-question-circle text-warning" style="font-size: 4rem;"></i>
</div>
<h2 class="mb-3">Oops... This page got lost in the cloud</h2>
<p class="lead">
The page youre looking for doesnt exist or may have been moved.<br>
But hey, were Wizdom Networks we can find anything. Almost.
</p>
<div class="my-4">
<img src="/assets/img/lost-in-the-cloud.webp" alt="404 - Not Found" class="img-fluid rounded shadow" style="max-width: 400px;">
</div>
<div class="mt-5 text-center">
<a href="/" class="btn btn-outline-primary">Return to Home</a>
</div>
</div>
</section>
<?php
$html = ob_get_clean();
View::render('layouts/arsha', ['content' => $html, 'hideNavbar' => true]);
?>

View File

@ -1,29 +1,42 @@
<section class="newsletter-action section pt-5">
<?php
// File: unsubscribe_confirm.php
// Version: 2.0
// Purpose: Asks user to confirm they want to unsubscribe before processing.
use WizdomNetworks\WizeWeb\Core\View;
ob_start();
?>
<section class="verify-section section pt-5" style="padding-top: 7rem;">
<div class="container text-center">
<div class="icon mb-4">
<i class="bi bi-envelope-x text-warning" style="font-size: 4rem;"></i>
<div class="icon mb-4" style="padding-top: 3rem;">
<i class="bi bi-check-circle text-success" style="font-size: 4rem;"></i>
</div>
<h2 class="mb-3">Unsubscribe from “Words of Wizdom”</h2>
<p class="lead">Were sorry to see you go. If youd like to stop receiving our emails, confirm below.</p>
<h2 class="mb-3">Are you sure you want to unsubscribe?</h2>
<p class="lead">
We'll miss you! If you'd still like to stop receiving emails from us, confirm below.
</p>
<div class="my-4">
<img src="/assets/img/newsletter-thanks.webp" alt="Unsubscribe" class="img-fluid rounded shadow" style="max-width: 400px;">
<img src="/assets/img/unsubscribed.webp" alt="Unsubscribed" class="img-fluid rounded shadow" style="max-width: 400px;">
</div>
<form action="/unsubscribe" method="post" class="row justify-content-center mt-3" style="max-width: 600px; margin: 0 auto;">
<input type="hidden" name="email" value="<?= htmlspecialchars($email) ?>">
<form action="/unsubscribe" method="post" class="row justify-content-center mt-4">
<input type="hidden" name="email" value="<?= htmlspecialchars($email ?? '') ?>">
<div class="col-12 mb-3">
<textarea name="unsubscribe_reason" class="form-control" rows="3" placeholder="Optional: Let us know why youre leaving"></textarea>
</div>
<div class="col-12 mb-3">
<textarea name="unsubscribe_reason" rows="3" class="form-control" placeholder="Optional: Let us know why you're unsubscribing..."></textarea>
</div>
<div class="col-12">
<button type="submit" class="btn btn-danger">Unsubscribe Me</button>
</div>
</form>
<div class="mt-4">
<a href="/" class="btn btn-outline-secondary">Never mind take me back</a>
</div>
<div class="col-12 text-center">
<button type="submit" class="btn btn-danger">Confirm Unsubscribe</button>
</div>
</form>
</div>
</section>
<?php
$html = ob_get_clean();
View::render('layouts/arsha', ['content' => $html, 'hideNavbar' => true]);
?>

View File

@ -1,17 +1,34 @@
<section class="newsletter-action section pt-5">
<?php
// File: unsubscribe_failed.php
// Version: 1.0
// Purpose: Displays error message if an unsubscribe request fails or is invalid.
use WizdomNetworks\WizeWeb\Core\View;
ob_start();
?>
<section class="verify-section section pt-5" style="padding-top: 7rem;">
<div class="container text-center">
<div class="icon mb-4">
<div class="icon mb-4" style="padding-top: 3rem;">
<i class="bi bi-x-circle text-danger" style="font-size: 4rem;"></i>
</div>
<h2 class="mb-3">Unsubscribe Failed</h2>
<p class="lead"><?= htmlspecialchars($reason) ?></p>
<p class="lead">
<?= htmlspecialchars($reason ?? 'We were unable to process your unsubscribe request.') ?>
</p>
<div class="my-4">
<img src="/assets/img/newsletter-thanks.webp" alt="Error" class="img-fluid rounded shadow" style="max-width: 400px;">
</div>
<div class="mt-4">
<div class="mt-4 text-center">
<a href="/" class="btn btn-outline-primary">Return to Home</a>
</div>
</div>
</section>
<?php
$html = ob_get_clean();
View::render('layouts/arsha', ['content' => $html, 'hideNavbar' => true]);
?>

View File

@ -1,22 +1,35 @@
<section class="newsletter-action section pt-5">
<?php
// File: unsubscribe_success.php
// Version: 1.1
// Purpose: Confirmation message shown after a successful unsubscribe (used as success view).
use WizdomNetworks\WizeWeb\Core\View;
ob_start();
?>
<section class="verify-section section pt-5" style="padding-top: 7rem;">
<div class="container text-center">
<div class="icon mb-4">
<div class="icon mb-4" style="padding-top: 3rem;">
<i class="bi bi-check-circle text-success" style="font-size: 4rem;"></i>
</div>
<h2 class="mb-3">Youve Been Unsubscribed</h2>
<h2 class="mb-3">Unsubscribe Successful</h2>
<p class="lead">
Weve removed <strong><?= htmlspecialchars($email) ?></strong> from our mailing list.
<?php if (!empty($alreadyUnsubscribed)): ?>
(You were already unsubscribed.)
<?php endif; ?>
</p>
You've successfully been removed from our mailing list.<br>
If this was a mistake, you can re-subscribe anytime.
</p>
<div class="my-4">
<img src="/assets/img/newsletter-thanks.webp" alt="Unsubscribed" class="img-fluid rounded shadow" style="max-width: 400px;">
<img src="/assets/img/unsubscribed.webp" alt="Unsubscribed" class="img-fluid rounded shadow" style="max-width: 400px;">
</div>
<div class="mt-4">
<a href="/" class="btn btn-primary">Return to Home</a>
<div class="mt-5 text-center">
<a href="/" class="btn btn-outline-primary">Return to Home</a>
</div>
</div>
</section>
<?php
$html = ob_get_clean();
View::render('layouts/arsha', ['content' => $html, 'hideNavbar' => true]);
?>

View File

@ -1,18 +1,16 @@
<?php
// File: verify_failed.php
// Version: 1.1
// Path: /resources/views/pages/verify_failed.php
// Version: 1.2
// Purpose: Displays failure message and allows resend of verification links.
// Project: Wizdom Networks Website
use WizdomNetworks\WizeWeb\Core\View;
ob_start();
?>
<section class="verify-section section pt-5">
<section class="verify-section section pt-5" style="padding-top: 7rem;">
<div class="container text-center">
<div class="icon mb-4">
<div class="icon mb-4" style="padding-top: 3rem;">
<i class="bi bi-x-circle text-danger" style="font-size: 4rem;"></i>
</div>
<h2 class="mb-3">Verification Failed</h2>
@ -42,18 +40,17 @@ ob_start();
</form>
</div>
<?php else: ?>
<div class="mt-4">
<a href="/" class="btn btn-primary">Start a New Submission</a>
</div>
<div class="mt-4 text-center">
<a href="/" class="btn btn-primary">Start a New Submission</a>
</div>
<?php endif; ?>
</div>
<div class="mt-4">
<a href="/" class="btn btn-outline-primary">Return to Home</a>
</div>
<div class="mt-4 text-center">
<a href="/" class="btn btn-outline-primary">Return to Home</a>
</div>
</div>
</section>
<?php
$html = ob_get_clean();
View::render('layouts/arsha', ['content' => $html]);
View::render('layouts/arsha', ['content' => $html, 'hideNavbar' => true]);
?>

View File

@ -8,9 +8,9 @@ use WizdomNetworks\WizeWeb\Core\View;
ob_start();
?>
<section class="verify-section section pt-5">
<section class="verify-section section pt-5" style="padding-top: 7rem;">
<div class="container text-center">
<div class="icon mb-4">
<div class="icon mb-4" style="padding-top: 3rem;">
<i class="bi bi-check-circle text-success" style="font-size: 4rem;"></i>
</div>
<h2 class="mb-3">Email Verified</h2>
@ -51,12 +51,12 @@ ob_start();
<?php endif; ?>
<!-- Return Button -->
<div class="mt-5">
<a href="/" class="btn btn-outline-primary">Return to Home</a>
</div>
<div class="mt-5 text-center">
<a href="/" class="btn btn-outline-primary">Return to Home</a>
</div>
</div>
</section>
<?php
$html = ob_get_clean();
View::render('layouts/arsha', ['content' => $html]);
View::render('layouts/arsha', ['content' => $html, 'hideNavbar' => true]);
?>