The Neuron CMS provides a secure, feature-rich public user registration system with email verification, rate limiting, and comprehensive security protections. The system supports configurable workflows, from open registration to invitation-only models, with complete control over user activation and role assignment.
The registration system includes:
The registration system consists of the following classes:
Neuron\Cms\Services\Member\RegistrationService): Core registration logic, validation, user creationNeuron\Cms\Controllers\Member\Registration): HTTP endpoints for registration workflowNeuron\Cms\Services\Auth\EmailVerifier): Email verification token management and validationNeuron\Cms\Auth\ResendVerificationThrottle): Rate limiting for resend verification emailsNeuron\Cms\Auth\PasswordHasher): Password hashing using Argon2id/BcryptNeuron\Cms\Services\Auth\CsrfToken): CSRF token generation and validationNeuron\Cms\Models\User): User entity with roles and statusesUser fills form → CSRF validation → DTO creation → Input validation
→ Business rule validation → User creation → Email verification (if enabled)
→ Event emission → Redirect to confirmation page
User clicks link → Token validation → Expiration check → User activation
→ Event emission → Redirect to success page
Registration configuration is defined in config/auth.yaml under the member section.
member:
# Registration settings
registration_enabled: true # Enable/disable public user registration
require_email_verification: true # Require email verification before account activation
default_role: subscriber # Default role for new members
# Email verification settings
verification_token_expiration_minutes: 60 # Token validity in minutes
verification_url: /verify-email # Verification endpoint URL
resend_throttle_seconds: 60 # Minimum time between resend requests
| Option | Type | Default | Description |
|---|---|---|---|
registration_enabled |
boolean | true |
Enable or disable public user registration |
require_email_verification |
boolean | true |
Require email verification before account activation |
default_role |
string | subscriber |
Default role assigned to new users |
verification_token_expiration_minutes |
integer | 60 |
Token validity duration in minutes |
verification_url |
string | /verify-email |
URL path for verification endpoint |
resend_throttle_seconds |
integer | 60 |
Minimum time between resend requests (deprecated, uses rate limiting) |
Password strength requirements are defined in auth.passwords:
auth:
passwords:
min_length: 8
require_uppercase: true
require_lowercase: true
require_numbers: true
require_special_chars: false
hash_algorithm: argon2id # argon2id or bcrypt
The system supports four predefined roles (defined in User::class):
ROLE_ADMIN (admin): Full system accessROLE_EDITOR (editor): Content management accessROLE_AUTHOR (author): Own content managementROLE_SUBSCRIBER (subscriber): Read-only access (default for new registrations)Users can have one of three statuses:
STATUS_ACTIVE (active): User can log in and access the systemSTATUS_INACTIVE (inactive): User account pending email verificationSTATUS_SUSPENDED (suspended): User account suspended by administratorThe registration form is displayed at the /register route:
public function showRegistrationForm( Request $request ): string
{
// Check if registration is enabled
if( !$this->_registrationService->isRegistrationEnabled() )
{
return $this->renderHtml( HttpResponseStatus::NOT_FOUND,
[ 'message' => 'Registration is currently disabled.' ],
'errors/404' );
}
// Display registration form with CSRF token
return $this->renderHtml( HttpResponseStatus::OK,
[
'csrf_token' => $this->_csrfToken->getToken(),
'title' => 'Register'
],
'member/register' );
}
Form Fields:
username: 3-50 characters, alphanumeric and underscore onlyemail: Valid email address formatpassword: Must meet password strength requirementspassword_confirmation: Must match passwordcsrf_token: CSRF protection token (hidden field)Example Registration Form:
<form method="POST" action="/register">
<?= csrf_field() ?>
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" required>
<div class="form-text">3-50 characters, letters, numbers, and underscores only</div>
</div>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" name="email" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required>
<div class="form-text">Minimum 8 characters, must include uppercase, lowercase, and numbers</div>
</div>
<div class="mb-3">
<label for="password_confirmation" class="form-label">Confirm Password</label>
<input type="password" class="form-control" id="password_confirmation" name="password_confirmation" required>
</div>
<button type="submit" class="btn btn-primary">Register</button>
</form>
When the form is submitted, the controller validates the CSRF token:
public function processRegistration( Request $request ): never
{
// Validate CSRF token
$token = $request->post( 'csrf_token' ) ?? '';
if( !$this->_csrfToken->validate( $token ) )
{
$this->redirectWithError( 'register', [], 'error', 'CSRF token validation failed' );
}
// ... continue with registration
}
Registration data is validated using a DTO (Data Transfer Object):
try
{
$dto = $this->createDtoFromRequest( 'registration', $request );
}
catch( Exception $e )
{
// Validation failed
$this->redirectWithError( 'register', [], 'error', $e->getMessage() );
}
The registration DTO validates:
RegistrationService creates the user account:
public function register( string $username,
string $email,
string $password,
string $passwordConfirmation ): User
{
// Check if registration is enabled
if( !$this->isRegistrationEnabled() )
{
throw new Exception( 'User registration is currently disabled.' );
}
// Validate input
$this->validateRegistration( $username, $email, $password, $passwordConfirmation );
// Create user
$user = new User();
$user->setUsername( $username );
$user->setEmail( $email );
$user->setPasswordHash( $this->_passwordHasher->hash( $password ) );
// Set default role from settings
$defaultRole = $this->_settings->get( 'member', 'default_role' ) ?? User::ROLE_SUBSCRIBER;
$user->setRole( $defaultRole );
// Determine if email verification is required
$requireVerification = $this->_settings->get( 'member', 'require_email_verification' ) ?? true;
if( $requireVerification )
{
$user->setStatus( User::STATUS_INACTIVE );
$user->setEmailVerified( false );
}
else
{
$user->setStatus( User::STATUS_ACTIVE );
$user->setEmailVerified( true );
}
// Create user in database
$this->_userRepository->create( $user );
// Send verification email if required
if( $requireVerification )
{
try
{
$this->_emailVerifier->sendVerificationEmail( $user );
}
catch( Exception $e )
{
// Log error but don't fail registration
Log::error( "Failed to send verification email: " . $e->getMessage() );
}
}
// Emit user created event
if( $this->_emitter )
{
$this->_emitter->emit( new UserCreatedEvent( $user ) );
}
return $user;
}
Username must:
/^[a-zA-Z0-9_]+$/private function validateUsername( string $username ): void
{
if( strlen( $username ) < 3 || strlen( $username ) > 50 )
{
throw new Exception( 'Username must be between 3 and 50 characters.' );
}
if( !preg_match( '/^[a-zA-Z0-9_]+$/', $username ) )
{
throw new Exception( 'Username can only contain letters, numbers, and underscores.' );
}
}
Email must:
private function validateEmail( string $email ): void
{
if( !filter_var( $email, FILTER_VALIDATE_EMAIL ) )
{
throw new Exception( 'Invalid email address.' );
}
}
Password must meet configured strength requirements:
private function validatePassword( string $password, string $passwordConfirmation ): void
{
if( $password !== $passwordConfirmation )
{
throw new Exception( 'Passwords do not match.' );
}
if( !$this->_passwordHasher->meetsRequirements( $password ) )
{
throw new Exception( 'Password does not meet strength requirements.' );
}
}
PasswordHasher checks:
require_uppercase: true)require_lowercase: true)require_numbers: true)require_special_chars: true)Uniqueness checks ensure:
private function validateUserBusinessRules( string $username, string $email ): void
{
$existingUser = $this->_userRepository->findByUsername( $username );
if( $existingUser )
{
throw new Exception( 'Username is already taken.' );
}
$existingUser = $this->_userRepository->findByEmail( $email );
if( $existingUser )
{
throw new Exception( 'Email address is already registered.' );
}
}
If email verification is required, EmailVerifier sends a verification email:
public function sendVerificationEmail( User $user ): bool
{
// Delete any existing tokens for this user
$this->_tokenRepository->deleteByUserId( $user->getId() );
// Generate secure random token
$plainToken = bin2hex( random_bytes( 32 ) );
$hashedToken = hash( 'sha256', $plainToken );
// Create and store token
$token = new EmailVerificationToken( $user->getId(),
$hashedToken,
$this->_tokenExpirationMinutes );
$this->_tokenRepository->create( $token );
// Send verification email
$this->sendEmail( $user, $plainToken );
return true;
}
Token Security:
After successful registration, user is redirected to a confirmation page:
$this->redirect( 'verify_email_sent', [], [
'success',
'Registration successful! Please check your email to verify your account.'
] );
active, email marked as verifiedhttps://example.com/verify-email?token=abc123...def789
Where:
/verify-email: Verification endpoint URL (configurable)token: 64-character hex string (plain token, not hashed)When user clicks the verification link:
public function verify( Request $request ): never
{
$token = $request->get( 'token' ) ?? '';
if( empty( $token ) )
{
$this->redirectWithError( 'login', [], 'error', 'Invalid verification token.' );
}
try
{
$user = $this->_emailVerifier->verifyEmail( $token );
$this->redirect( 'verify_email_success', [], [
'success',
'Email verified successfully! You can now log in.'
] );
}
catch( Exception $e )
{
$this->redirectWithError( 'login', [], 'error', $e->getMessage() );
}
}
public function verifyEmail( string $plainToken ): User
{
// Validate token
$token = $this->validateToken( $plainToken );
if( !$token )
{
throw new Exception( 'Invalid or expired verification token.' );
}
// Get user
$user = $this->_userRepository->findById( $token->getUserId() );
if( !$user )
{
throw new Exception( 'User not found.' );
}
// Activate user account
$user->setEmailVerified( true );
$user->setStatus( User::STATUS_ACTIVE );
// Update user in database
$this->_userRepository->update( $user );
// Delete used token
$this->_tokenRepository->delete( $token->getId() );
// Emit event
if( $this->_emitter )
{
$this->_emitter->emit( new EmailVerifiedEvent( $user ) );
}
return $user;
}
Tokens expire after a configurable duration (default: 60 minutes):
public function validateToken( string $plainToken ): ?EmailVerificationToken
{
$hashedToken = hash( 'sha256', $plainToken );
$token = $this->_tokenRepository->findByToken( $hashedToken );
if( !$token )
{
return null;
}
// Check expiration
if( $token->isExpired() )
{
// Delete expired token
$this->_tokenRepository->delete( $token->getId() );
return null;
}
return $token;
}
EmailVerificationToken model:
public function isExpired(): bool
{
return new DateTimeImmutable() > $this->_expiresAt;
}
Users can request a new verification email if the original expired or was lost:
public function resendVerification( Request $request ): never
{
// Get email and client IP
$email = $request->post( 'email' ) ?? '';
$clientIp = $this->_ipResolver->resolve( $_SERVER );
// Check rate limits
if( !$this->_resendThrottle->allow( $clientIp, $email ) )
{
// Rate limit exceeded - return generic success to prevent enumeration
$this->redirect( 'verify_email_sent', [], [
'success',
'If an account exists with that email, a verification email has been sent.'
] );
}
try
{
// Resend verification email
$this->_emailVerifier->resendVerification( $email );
// Always show success message (don't reveal if email exists)
$this->redirect( 'verify_email_sent', [], [
'success',
'If an account exists with that email, a verification email has been sent.'
] );
}
catch( Exception $e )
{
// Don't reveal specific error details to prevent enumeration
$this->redirect( 'verify_email_sent', [], [
'success',
'If an account exists with that email, a verification email has been sent.'
] );
}
}
Enumeration Protection: The system always returns a generic success message, regardless of whether the email exists in the database. This prevents attackers from determining valid email addresses.
ResendVerificationThrottle implements two layers of rate limiting:
Both limits must pass for the request to be allowed.
class ResendVerificationThrottle
{
// Default limits
private int $_ipLimit = 5; // 5 requests per IP
private int $_ipWindow = 300; // 5 minutes
private int $_emailLimit = 1; // 1 request per email
private int $_emailWindow = 300; // 5 minutes
public function __construct( ?IRateLimitStorage $storage = null, array $config = [] )
{
// Allow configuration overrides
if( isset( $config['ip_limit'] ) )
{
$this->_ipLimit = (int) $config['ip_limit'];
}
if( isset( $config['ip_window'] ) )
{
$this->_ipWindow = (int) $config['ip_window'];
}
if( isset( $config['email_limit'] ) )
{
$this->_emailLimit = (int) $config['email_limit'];
}
if( isset( $config['email_window'] ) )
{
$this->_emailWindow = (int) $config['email_window'];
}
}
}
By default, rate limits are stored in the filesystem:
$rateLimitConfig = new RateLimitConfig( [
'storage' => 'file',
'file_path' => sys_get_temp_dir() . '/neuron/rate_limits/resend_verification',
'key_prefix' => 'resend_verify_'
]);
$storage = RateLimitStorageFactory::create( $rateLimitConfig );
Storage Options:
file: Filesystem storage (default)database: Database storage (if configured)sync: In-memory storage (for testing)public function allow( string $ip, string $email ): bool
{
// Check IP-based limit
$ipKey = 'ip:' . $ip;
if( !$this->_storage->allow( $ipKey, $this->_ipLimit, $this->_ipWindow ) )
{
return false;
}
// Check email-based limit (use hash to prevent storing plain emails)
$emailKey = 'email:' . hash( 'sha256', strtolower( trim( $email ) ) );
if( !$this->_storage->allow( $emailKey, $this->_emailLimit, $this->_emailWindow ) )
{
return false;
}
return true;
}
Email Hashing: Email addresses are hashed with SHA-256 before storage to prevent storing plain email addresses in rate limit data.
Get remaining attempts and reset times:
// Get remaining IP attempts
$remaining = $this->_resendThrottle->getRemainingIpAttempts( $clientIp );
// Get remaining email attempts
$remaining = $this->_resendThrottle->getRemainingEmailAttempts( $email );
// Get IP reset time
$resetTime = $this->_resendThrottle->getIpResetTime( $clientIp );
// Get email reset time
$resetTime = $this->_resendThrottle->getEmailResetTime( $email );
Reset rate limits for specific IP or email:
// Reset IP limit
$this->_resendThrottle->resetIp( $clientIp );
// Reset email limit
$this->_resendThrottle->resetEmail( $email );
// Clear all rate limit data
$this->_resendThrottle->clear();
All state-changing requests (POST, PUT, DELETE) are protected by CSRF tokens:
// Generate CSRF token
$token = $this->_csrfToken->getToken();
// Validate CSRF token
if( !$this->_csrfToken->validate( $token ) )
{
throw new Exception( 'CSRF token validation failed' );
}
CSRF tokens are:
Include in Forms:
<?= csrf_field() ?>
Generates:
<input type="hidden" name="csrf_token" value="abc123...def789">
The system prevents user enumeration through several mechanisms:
Generic Success Messages: Resend verification endpoint always returns generic success message:
'If an account exists with that email, a verification email has been sent.'
This prevents attackers from determining:
Timing Attack Prevention: RegistrationService performs dummy hash verification for non-existent users to normalize response time:
// In password verification
if( !$user )
{
// Perform dummy hash to prevent timing attack
$this->_passwordHasher->verify( $password, '$2y$10$dummyhashstring' );
return false;
}
Passwords are hashed using PHP's native password_hash() with Argon2id or Bcrypt:
// Hash password
$hash = $this->_passwordHasher->hash( 'SecurePassword123' );
// Verify password
$valid = $this->_passwordHasher->verify( 'SecurePassword123', $hash );
Hash Algorithm Selection:
Automatic Rehashing: Passwords are automatically rehashed during login if stronger algorithms become available.
Email verification tokens are secured through:
Random Generation: 32 random bytes (256 bits of entropy)
$plainToken = bin2hex( random_bytes( 32 ) );
Hashed Storage: Only SHA-256 hash stored in database
$hashedToken = hash( 'sha256', $plainToken );
Limited Lifetime: Tokens expire after 60 minutes (default, configurable)
Single Use: Tokens are deleted immediately after successful verification
Secure Transmission: Tokens sent only via email, never logged or exposed
All user input is validated:
Username: Regex pattern /^[a-zA-Z0-9_]+$/ prevents injection attacks
Email: filter_var( $email, FILTER_VALIDATE_EMAIL ) ensures valid format
Password: Strength requirements prevent weak passwords
DTO Validation: Structured validation using Data Transfer Objects
User repository uses parameterized queries (via ORM) to prevent SQL injection:
// Safe: parameterized query
$user = $this->_userRepository->findByUsername( $username );
// Unsafe: string concatenation (NEVER DO THIS)
// $query = "SELECT * FROM users WHERE username = '$username'";
Emitted when a new user is created:
Event::emit( new UserCreatedEvent( $user ) );
Event Data:
Example Listener:
use Neuron\Cms\Events\UserCreatedEvent;
class SendWelcomeEmailListener
{
public function handle( UserCreatedEvent $event ): void
{
$user = $event->getUser();
// Send welcome email
// Log to analytics
// Create user directory
// etc.
}
}
Emitted when a user verifies their email:
Event::emit( new EmailVerifiedEvent( $user ) );
Event Data:
Example Listener:
use Neuron\Cms\Events\EmailVerifiedEvent;
class NotifyAdminOfNewUserListener
{
public function handle( EmailVerifiedEvent $event ): void
{
$user = $event->getUser();
// Notify administrators
// Grant initial permissions
// Create default content
// etc.
}
}
In config/events.yaml:
events:
Neuron\Cms\Events\UserCreatedEvent:
- App\Listeners\SendWelcomeEmailListener
- App\Listeners\CreateUserDirectoryListener
- App\Listeners\LogToAnalyticsListener
Neuron\Cms\Events\EmailVerifiedEvent:
- App\Listeners\NotifyAdminOfNewUserListener
- App\Listeners\GrantInitialPermissionsListener
To disable public registration:
In config/auth.yaml:
member:
registration_enabled: false
When disabled, the registration page returns 404:
if( !$this->_registrationService->isRegistrationEnabled() )
{
return $this->renderHtml( HttpResponseStatus::NOT_FOUND,
[ 'message' => 'Registration is currently disabled.' ],
'errors/404' );
}
To allow immediate account activation without email verification:
In config/auth.yaml:
member:
require_email_verification: false
New users will have:
active (instead of inactive)true (instead of false)To change the default role for new users:
In config/auth.yaml:
member:
default_role: author # Instead of subscriber
Available roles:
admin: Full system accesseditor: Content management accessauthor: Own content managementsubscriber: Read-only accessOverride RegistrationService to add custom validation:
namespace App\Services;
use Neuron\Cms\Services\Member\RegistrationService as BaseRegistrationService;
class CustomRegistrationService extends BaseRegistrationService
{
protected function validateRegistration( string $username,
string $email,
string $password,
string $passwordConfirmation ): void
{
// Call parent validation
parent::validateRegistration( $username, $email, $password, $passwordConfirmation );
// Add custom validation
if( !$this->isDomainAllowed( $email ) )
{
throw new Exception( 'Email domain not allowed for registration.' );
}
if( $this->isUsernameBlacklisted( $username ) )
{
throw new Exception( 'Username not allowed.' );
}
}
private function isDomainAllowed( string $email ): bool
{
$domain = substr( strrchr( $email, '@' ), 1 );
$allowedDomains = [ 'example.com', 'example.org' ];
return in_array( $domain, $allowedDomains );
}
private function isUsernameBlacklisted( string $username ): bool
{
$blacklist = [ 'admin', 'root', 'system', 'test' ];
return in_array( strtolower( $username ), $blacklist );
}
}
Register Custom Service:
Registry::getInstance()->set( 'RegistrationService', new CustomRegistrationService( $userRepository,
$passwordHasher,
$emailVerifier,
$settingManager,
$emitter ) );
Extend User model and registration form to add custom fields:
Extended User Model:
namespace App\Models;
use Neuron\Cms\Models\User as BaseUser;
class User extends BaseUser
{
private ?string $_phoneNumber = null;
private ?string $_company = null;
public function getPhoneNumber(): ?string
{
return $this->_phoneNumber;
}
public function setPhoneNumber( ?string $phoneNumber ): self
{
$this->_phoneNumber = $phoneNumber;
return $this;
}
public function getCompany(): ?string
{
return $this->_company;
}
public function setCompany( ?string $company ): self
{
$this->_company = $company;
return $this;
}
}
Update Registration Form:
<div class="mb-3">
<label for="phone_number" class="form-label">Phone Number</label>
<input type="tel" class="form-control" id="phone_number" name="phone_number">
</div>
<div class="mb-3">
<label for="company" class="form-label">Company</label>
<input type="text" class="form-control" id="company" name="company">
</div>
Override Registration Controller:
namespace App\Controllers;
use Neuron\Cms\Controllers\Member\Registration as BaseRegistration;
class Registration extends BaseRegistration
{
public function processRegistration( Request $request ): never
{
// ... CSRF validation and DTO creation ...
// Extract custom fields
$phoneNumber = $request->post( 'phone_number' );
$company = $request->post( 'company' );
// Create user
$user = $this->_registrationService->register( $dto->getUsername(),
$dto->getEmail(),
$dto->getPassword(),
$dto->getPasswordConfirmation() );
// Set custom fields
$user->setPhoneNumber( $phoneNumber );
$user->setCompany( $company );
// Update user
$this->_userRepository->update( $user );
// ... redirect to confirmation page ...
}
}
Override email verification template:
Create Template: resources/views/email/custom_verification.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Verify Your Email</title>
</head>
<body>
<h1>Welcome to {{ SiteName }}!</h1>
<p>Hi {{ Username }},</p>
<p>Thank you for registering! Please verify your email address by clicking the link below:</p>
<p><a href="{{ VerificationLink }}">Verify Email Address</a></p>
<p>This link will expire in {{ ExpirationMinutes }} minutes.</p>
<p>If you didn't create an account, please ignore this email.</p>
</body>
</html>
Override EmailVerifier:
namespace App\Services;
use Neuron\Cms\Services\Auth\EmailVerifier as BaseEmailVerifier;
class CustomEmailVerifier extends BaseEmailVerifier
{
protected function sendEmail( User $user, string $plainToken ): void
{
$verificationLink = $this->_verificationUrl . '?token=' . urlencode( $plainToken );
$siteName = $this->_settings->get( 'site', 'name' ) ?? 'Neuron CMS';
$templateData = [
'VerificationLink' => $verificationLink,
'ExpirationMinutes' => $this->_tokenExpirationMinutes,
'SiteName' => $siteName,
'Username' => $user->getUsername()
];
// Use custom template
$this->_emailService->sendTemplate( 'email/custom_verification', // Custom template
$user->getEmail(),
'Verify Your Email - ' . $siteName,
$templateData );
}
}
Implement invitation-only registration by requiring invitation tokens:
Create Invitation Model:
namespace App\Models;
use Neuron\Orm\Model;
class Invitation extends Model
{
private ?int $_id = null;
private string $_email;
private string $_token;
private bool $_used = false;
private ?DateTimeImmutable $_expiresAt = null;
private ?DateTimeImmutable $_createdAt = null;
// ... getters and setters ...
}
Override Registration Service:
namespace App\Services;
use Neuron\Cms\Services\Member\RegistrationService as BaseRegistrationService;
class InvitationRegistrationService extends BaseRegistrationService
{
private InvitationRepository $_invitationRepository;
public function register( string $username,
string $email,
string $password,
string $passwordConfirmation,
string $invitationToken = '' ): User
{
// Validate invitation token
if( !$this->validateInvitation( $email, $invitationToken ) )
{
throw new Exception( 'Invalid or expired invitation token.' );
}
// Mark invitation as used
$this->markInvitationUsed( $invitationToken );
// Continue with normal registration
return parent::register( $username, $email, $password, $passwordConfirmation );
}
private function validateInvitation( string $email, string $token ): bool
{
$invitation = $this->_invitationRepository->findByToken( $token );
if( !$invitation )
{
return false;
}
if( $invitation->isUsed() )
{
return false;
}
if( $invitation->isExpired() )
{
return false;
}
if( $invitation->getEmail() !== $email )
{
return false;
}
return true;
}
private function markInvitationUsed( string $token ): void
{
$invitation = $this->_invitationRepository->findByToken( $token );
$invitation->setUsed( true );
$this->_invitationRepository->update( $invitation );
}
}
Update Registration Form:
<div class="mb-3">
<label for="invitation_token" class="form-label">Invitation Code</label>
<input type="text" class="form-control" id="invitation_token" name="invitation_token" required>
<div class="form-text">Enter the invitation code you received via email</div>
</div>
The verification email template is used to send email verification links to users.
Template Location: Defined in EmailVerifier::sendEmail()
Template Data:
| Variable | Type | Description | Example |
|---|---|---|---|
VerificationLink |
string | Full URL with token | https://example.com/verify-email?token=abc... |
ExpirationMinutes |
integer | Token validity duration | 60 |
SiteName |
string | Site name from settings | Neuron CMS |
Username |
string | User's username | john_doe |
Example Template:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Verify Your Email - {{ SiteName }}</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}
.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.button {
display: inline-block;
padding: 12px 24px;
background-color: #007bff;
color: #ffffff;
text-decoration: none;
border-radius: 4px;
margin: 20px 0;
}
.footer {
margin-top: 40px;
font-size: 12px;
color: #666;
}
</style>
</head>
<body>
<div class="container">
<h1>Verify Your Email Address</h1>
<p>Hi {{ Username }},</p>
<p>Thank you for registering with {{ SiteName }}! To complete your registration, please verify your email address by clicking the button below:</p>
<p>
<a href="{{ VerificationLink }}" class="button">Verify Email Address</a>
</p>
<p>Or copy and paste this link into your browser:</p>
<p>{{ VerificationLink }}</p>
<p>This verification link will expire in {{ ExpirationMinutes }} minutes.</p>
<p>If you did not create an account, please ignore this email.</p>
<div class="footer">
<p>This is an automated email from {{ SiteName }}. Please do not reply.</p>
</div>
</div>
</body>
</html>
EmailVerifier uses the email service to send the template:
private function sendEmail( User $user, string $plainToken ): void
{
$verificationLink = $this->_verificationUrl . '?token=' . urlencode( $plainToken );
$siteName = $this->_settings->get( 'site', 'name' ) ?? 'Neuron CMS';
$templateData = [
'VerificationLink' => $verificationLink,
'ExpirationMinutes' => $this->_tokenExpirationMinutes,
'SiteName' => $siteName,
'Username' => $user->getUsername()
];
try
{
$this->_emailService->sendTemplate( 'verification_email',
$user->getEmail(),
'Verify Your Email - ' . $siteName,
$templateData );
}
catch( Exception $e )
{
throw new Exception( 'Failed to send verification email: ' . $e->getMessage() );
}
}
See Email System Guide for detailed information on email templates.
Routes are defined in config/routes.yaml:
# Member registration
register:
method: GET
route: /register
controller: Neuron\Cms\Controllers\Member\Registration@showRegistrationForm
register_post:
method: POST
route: /register
controller: Neuron\Cms\Controllers\Member\Registration@processRegistration
request: registration
filter: csrf
verify_email_sent:
method: GET
route: /verify-email-sent
controller: Neuron\Cms\Controllers\Member\Registration@showVerificationSent
verify_email:
method: GET
route: /verify-email
controller: Neuron\Cms\Controllers\Member\Registration@verify
verify_email_success:
method: GET
route: /verify-email-success
controller: Neuron\Cms\Controllers\Member\Registration@showVerificationSuccess
resend_verification:
method: POST
route: /resend-verification
controller: Neuron\Cms\Controllers\Member\Registration@resendVerification
filter: csrf
| Route | Method | Filter | Request | Description |
|---|---|---|---|---|
/register |
GET | None | None | Display registration form |
/register |
POST | csrf |
registration |
Process registration form submission |
/verify-email-sent |
GET | None | None | Confirmation page after registration |
/verify-email |
GET | None | None | Email verification endpoint (with token parameter) |
/verify-email-success |
GET | None | None | Success page after email verification |
/resend-verification |
POST | csrf |
None | Resend verification email |
Symptom: 404 error when accessing /register
Solutions:
Check registration enabled:
# config/auth.yaml
member:
registration_enabled: true
Check routes configuration:
# config/routes.yaml
register:
method: GET
route: /register
controller: Neuron\Cms\Controllers\Member\Registration@showRegistrationForm
Clear route cache (if caching enabled):
rm -rf storage/cache/routes/*
Symptom: Form submits but validation errors not shown
Solutions:
Check flash messages in template:
<?php if( isset( $error ) ): ?>
<div class="alert alert-danger"><?= htmlspecialchars( $error ) ?></div>
<?php endif; ?>
Check redirect includes error message:
$this->redirectWithError( 'register', [], 'error', 'Validation failed' );
Check session started:
$sessionManager->start();
Symptom: Error "Username is already taken" but username should be available
Solutions:
Check database for existing user:
SELECT * FROM users WHERE username = 'desired_username';
Check case sensitivity:
Clear soft-deleted users (if soft delete implemented):
DELETE FROM users WHERE deleted_at IS NOT NULL;
Symptom: User completes registration but doesn't receive verification email
Solutions:
Check email configuration:
# config/email.yaml
email:
driver: smtp
host: smtp.gmail.com
port: 587
username: [email protected]
password: your-app-password
encryption: tls
from_address: [email protected]
from_name: Neuron CMS
Check email service logs:
// In EmailVerifier::sendEmail()
Log::info( "Sending verification email to: " . $user->getEmail() );
Check spam folder: Verification emails may be filtered as spam
Test email sending:
$emailService->send(
'[email protected]',
'Test Email',
'This is a test email.'
);
Check queue processing (if using queue):
# Start queue worker
./vendor/bin/neuron queue:work
See Background Jobs Guide for queue configuration.
Symptom: User clicks verification link but receives "expired token" error
Solutions:
Increase token expiration:
# config/auth.yaml
member:
verification_token_expiration_minutes: 120 # 2 hours instead of 60
Resend verification email:
/resend-verification endpointCheck server time:
echo date( 'Y-m-d H:i:s' ); // Should match actual time
Check timezone configuration:
# config/neuron.yaml
system:
timezone: UTC # Or your local timezone
Symptom: Form submission fails with "CSRF token validation failed"
Solutions:
Check CSRF token in form:
<?= csrf_field() ?>
Check session started before token generation:
$sessionManager->start();
$token = $csrfToken->getToken();
Check cookie settings:
# config/auth.yaml
auth:
session:
cookie_secure: false # Set to false for HTTP (development)
cookie_samesite: Lax
Check browser accepts cookies:
neuron_session cookie existsSymptom: User cannot resend verification email, receives rate limit error
Solutions:
Check rate limit configuration:
// In ResendVerificationThrottle
private int $_ipLimit = 5; // 5 requests per IP
private int $_ipWindow = 300; // 5 minutes
private int $_emailLimit = 1; // 1 request per email
private int $_emailWindow = 300; // 5 minutes
Reset rate limit manually:
$resendThrottle->resetIp( $clientIp );
$resendThrottle->resetEmail( $email );
Wait for rate limit window to expire (default: 5 minutes)
Clear rate limit storage:
rm -rf /tmp/neuron/rate_limits/resend_verification/*
Symptom: User verifies email but still cannot log in
Solutions:
Check user status in database:
SELECT id, username, email, status, email_verified
FROM users
WHERE email = '[email protected]';
Should show:
status: activeemail_verified: 1 or trueCheck EmailVerifiedEvent listener:
Manual activation:
UPDATE users
SET status = 'active', email_verified = 1
WHERE email = '[email protected]';
Symptom: Same email can register multiple accounts
Solutions:
Check unique constraint in database:
CREATE UNIQUE INDEX idx_users_email ON users (email);
Check validation in RegistrationService:
private function validateUserBusinessRules( string $username, string $email ): void
{
$existingUser = $this->_userRepository->findByEmail( $email );
if( $existingUser )
{
throw new Exception( 'Email address is already registered.' );
}
}
Symptom: Valid password rejected with "does not meet strength requirements"
Solutions:
Check password configuration:
# config/auth.yaml
auth:
passwords:
min_length: 8
require_uppercase: true
require_lowercase: true
require_numbers: true
require_special_chars: false # Check if this should be true
Test password validation:
$hasher = new PasswordHasher();
$result = $hasher->meetsRequirements( 'TestPassword123' );
var_dump( $result ); // Should be true
Adjust requirements for development:
auth:
passwords:
min_length: 6
require_uppercase: false
require_lowercase: false
require_numbers: false
require_special_chars: false
cookie_secure: true in configuration