Member Registration Guide

Overview

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.

Features

The registration system includes:

Architecture

Core Components

The registration system consists of the following classes:

Registration Workflow

User fills form → CSRF validation → DTO creation → Input validation
→ Business rule validation → User creation → Email verification (if enabled)
→ Event emission → Redirect to confirmation page

Email Verification Workflow

User clicks link → Token validation → Expiration check → User activation
→ Event emission → Redirect to success page

Configuration

Registration configuration is defined in config/auth.yaml under the member section.

Member Configuration

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

Configuration Options

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 Configuration

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

User Roles

The system supports four predefined roles (defined in User::class):

User Statuses

Users can have one of three statuses:

Registration Workflow

Step 1: Display Registration Form

The 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:

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>

Step 2: Form Submission and CSRF Validation

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
}

Step 3: DTO Creation and Validation

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:

Step 4: User Creation

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;
}

Step 5: Validation Rules

Username Validation

Username must:

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 Validation

Email must:

private function validateEmail( string $email ): void
{
    if( !filter_var( $email, FILTER_VALIDATE_EMAIL ) )
    {
        throw new Exception( 'Invalid email address.' );
    }
}

Password Validation

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:

Business Rule Validation

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.' );
    }
}

Step 6: Email Verification (if enabled)

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:

Step 7: Redirect to Confirmation Page

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.'
] );

Email Verification

Verification Token Lifecycle

  1. Token Generation: 32 random bytes converted to 64-character hex string
  2. Token Storage: SHA-256 hash stored in database with expiration timestamp
  3. Email Sending: Plain token sent in verification link
  4. Token Validation: User clicks link, token is hashed and compared
  5. Expiration Check: Token must be used within expiration window (default: 60 minutes)
  6. Account Activation: User status changed to active, email marked as verified
  7. Token Deletion: Used token is deleted from database

Verification Link Format

https://example.com/verify-email?token=abc123...def789

Where:

Verification Endpoint

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() );
    }
}

Email Verification Process

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;
}

Token Expiration

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;
}

Resending Verification Emails

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.

Rate Limiting

Dual-Layer Throttling

ResendVerificationThrottle implements two layers of rate limiting:

  1. IP-based limiting: 5 requests per 5 minutes per IP address
  2. Email-based limiting: 1 request per 5 minutes per email address

Both limits must pass for the request to be allowed.

Rate Limit Configuration

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'];
        }
    }
}

Rate Limit Storage

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:

Rate Limit Checking

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.

Rate Limit Status

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 );

Administrative Override

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();

Security Considerations

CSRF Protection

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">

Enumeration Protection

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;
}

Password Security

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.

Token Security

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

Input Validation

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

SQL Injection Protection

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'";

Event Integration

UserCreatedEvent

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.
    }
}

EmailVerifiedEvent

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.
    }
}

Registering Event Listeners

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

Customization

Disabling Registration

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' );
}

Disabling Email Verification

To allow immediate account activation without email verification:

In config/auth.yaml:

member:
  require_email_verification: false

New users will have:

Changing Default Role

To change the default role for new users:

In config/auth.yaml:

member:
  default_role: author  # Instead of subscriber

Available roles:

Custom Validation Rules

Override 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 ) );

Adding Custom Fields

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 ...
    }
}

Custom Email Templates

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 );
    }
}

Invitation-Only Registration

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>

Email Templates

Verification Email Template

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>

Sending Template

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

Registration Routes

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 Parameters

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

Troubleshooting

Registration Form Not Displaying

Symptom: 404 error when accessing /register

Solutions:

  1. Check registration enabled:

    # config/auth.yaml
    member:
      registration_enabled: true
    
  2. Check routes configuration:

    # config/routes.yaml
    register:
      method: GET
      route: /register
      controller: Neuron\Cms\Controllers\Member\Registration@showRegistrationForm
    
  3. Clear route cache (if caching enabled):

    rm -rf storage/cache/routes/*
    

Validation Errors Not Displaying

Symptom: Form submits but validation errors not shown

Solutions:

  1. Check flash messages in template:

    <?php if( isset( $error ) ): ?>
        <div class="alert alert-danger"><?= htmlspecialchars( $error ) ?></div>
    <?php endif; ?>
    
  2. Check redirect includes error message:

    $this->redirectWithError( 'register', [], 'error', 'Validation failed' );
    
  3. Check session started:

    $sessionManager->start();
    

Username Already Taken Error

Symptom: Error "Username is already taken" but username should be available

Solutions:

  1. Check database for existing user:

    SELECT * FROM users WHERE username = 'desired_username';
    
  2. Check case sensitivity:

    • Usernames are case-sensitive in validation
    • Database may be case-insensitive depending on collation
  3. Clear soft-deleted users (if soft delete implemented):

    DELETE FROM users WHERE deleted_at IS NOT NULL;
    

Verification Email Not Received

Symptom: User completes registration but doesn't receive verification email

Solutions:

  1. 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
    
  2. Check email service logs:

    // In EmailVerifier::sendEmail()
    Log::info( "Sending verification email to: " . $user->getEmail() );
    
  3. Check spam folder: Verification emails may be filtered as spam

  4. Test email sending:

    $emailService->send(
        '[email protected]',
        'Test Email',
        'This is a test email.'
    );
    
  5. Check queue processing (if using queue):

    # Start queue worker
    ./vendor/bin/neuron queue:work
    

    See Background Jobs Guide for queue configuration.

Verification Link Expired

Symptom: User clicks verification link but receives "expired token" error

Solutions:

  1. Increase token expiration:

    # config/auth.yaml
    member:
      verification_token_expiration_minutes: 120  # 2 hours instead of 60
    
  2. Resend verification email:

    • User can request new email via /resend-verification endpoint
  3. Check server time:

    echo date( 'Y-m-d H:i:s' );  // Should match actual time
    
  4. Check timezone configuration:

    # config/neuron.yaml
    system:
      timezone: UTC  # Or your local timezone
    

CSRF Token Validation Failed

Symptom: Form submission fails with "CSRF token validation failed"

Solutions:

  1. Check CSRF token in form:

    <?= csrf_field() ?>
    
  2. Check session started before token generation:

    $sessionManager->start();
    $token = $csrfToken->getToken();
    
  3. Check cookie settings:

    # config/auth.yaml
    auth:
      session:
        cookie_secure: false  # Set to false for HTTP (development)
        cookie_samesite: Lax
    
  4. Check browser accepts cookies:

    • Open browser developer tools
    • Check Application > Cookies
    • Verify neuron_session cookie exists

Rate Limit Exceeded

Symptom: User cannot resend verification email, receives rate limit error

Solutions:

  1. 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
    
  2. Reset rate limit manually:

    $resendThrottle->resetIp( $clientIp );
    $resendThrottle->resetEmail( $email );
    
  3. Wait for rate limit window to expire (default: 5 minutes)

  4. Clear rate limit storage:

    rm -rf /tmp/neuron/rate_limits/resend_verification/*
    

Account Not Activated After Verification

Symptom: User verifies email but still cannot log in

Solutions:

  1. Check user status in database:

    SELECT id, username, email, status, email_verified
    FROM users
    WHERE email = '[email protected]';
    

    Should show:

    • status: active
    • email_verified: 1 or true
  2. Check EmailVerifiedEvent listener:

    • Ensure no listener is reverting account status
  3. Manual activation:

    UPDATE users
    SET status = 'active', email_verified = 1
    WHERE email = '[email protected]';
    

Duplicate Email Registration

Symptom: Same email can register multiple accounts

Solutions:

  1. Check unique constraint in database:

    CREATE UNIQUE INDEX idx_users_email ON users (email);
    
  2. 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.' );
        }
    }
    

Password Does Not Meet Requirements

Symptom: Valid password rejected with "does not meet strength requirements"

Solutions:

  1. 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
    
  2. Test password validation:

    $hasher = new PasswordHasher();
    $result = $hasher->meetsRequirements( 'TestPassword123' );
    var_dump( $result );  // Should be true
    
  3. Adjust requirements for development:

    auth:
      passwords:
        min_length: 6
        require_uppercase: false
        require_lowercase: false
        require_numbers: false
        require_special_chars: false
    

Best Practices

Security

  1. Always use HTTPS in production: Set cookie_secure: true in configuration
  2. Enable email verification: Prevent fake account creation
  3. Configure strong password requirements: Balance security and usability
  4. Monitor rate limits: Detect and prevent abuse
  5. Regular token cleanup: Delete expired tokens to prevent database bloat
  6. Audit user registrations: Monitor for suspicious patterns
  7. Implement honeypot fields: Catch automated bots
  8. Use CAPTCHA for high-traffic sites: Prevent automated registration

User Experience

  1. Clear error messages: Provide specific guidance for validation failures
  2. Password strength indicator: Show real-time feedback as user types
  3. Auto-focus first field: Improve form usability
  4. Remember form data on error: Don't make users re-enter everything
  5. Mobile-friendly forms: Optimize for mobile devices
  6. Email confirmation page: Set clear expectations
  7. Resend verification link: Make it easy to request new token
  8. Help text: Explain requirements clearly

Configuration

  1. Document settings: Comment all configuration options
  2. Use environment variables: Keep sensitive data out of version control
  3. Separate dev/prod configs: Different settings for different environments
  4. Version configuration: Track changes in version control
  5. Default to secure: Err on the side of caution

Monitoring

  1. Log registration events: Track user creation and verification
  2. Monitor email delivery: Track send success/failure rates
  3. Alert on failures: Notify administrators of issues
  4. Track conversion rates: Monitor registration completion rates
  5. Analyze drop-off points: Identify UX issues

Testing

  1. Test all validation rules: Ensure they work correctly
  2. Test token expiration: Verify expired tokens are rejected
  3. Test rate limiting: Ensure limits are enforced
  4. Test email delivery: Verify emails are sent and received
  5. Test enumeration protection: Ensure no information leakage
  6. Test CSRF protection: Verify token validation works
  7. Test edge cases: Empty fields, SQL injection attempts, XSS attempts

Additional Resources