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:
parent
e09f763db3
commit
cf146973f2
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -16,7 +16,9 @@
|
|||
<!-- Preloader -->
|
||||
<div id="preloader"></div>
|
||||
|
||||
<?php include __DIR__ . '/header.php'; ?>
|
||||
<!-- Header -->
|
||||
<?php include __DIR__ . '/header.php'; ?>
|
||||
|
||||
|
||||
<!-- Page Content -->
|
||||
<?= $content ?>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 you’re looking for doesn’t exist or may have been moved.<br>
|
||||
But hey, we’re 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]);
|
||||
?>
|
||||
|
|
@ -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">We’re sorry to see you go. If you’d 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 you’re 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]);
|
||||
?>
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
?>
|
||||
|
|
|
|||
|
|
@ -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">You’ve Been Unsubscribed</h2>
|
||||
<h2 class="mb-3">Unsubscribe Successful</h2>
|
||||
<p class="lead">
|
||||
We’ve 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]);
|
||||
?>
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
?>
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
?>
|
||||
Loading…
Reference in New Issue