๐Ÿ” Authentication โ€” Complete Guide๏ƒ

Daycry Auth supports multiple authentication methods. This guide explains how to use each one, including all security features added in recent versions.

๐Ÿ“‹ Index๏ƒ


Session Authenticator๏ƒ

Best for: Traditional web applications using server-side sessions.

Basic Usage๏ƒ

// Login attempt
$credentials = [
    'email'    => $this->request->getPost('email'),
    'password' => $this->request->getPost('password'),
];

$result = auth('session')->attempt($credentials);

if ($result->isOK()) {
    return redirect()->to('/dashboard');
}

return redirect()->back()->with('error', $result->reason());

Helper Functions๏ƒ

// Check if authenticated
if (auth()->loggedIn()) { ... }

// Get current user
$user = auth()->user();

// Programmatic login (skip credential check)
auth()->login($user);
auth()->login($user, remember: true); // With "remember me"

// Login by user ID
auth()->loginById(42);

// Check credentials without logging in
$result = auth()->check($credentials);
if ($result->isOK()) {
    $user = $result->extraInfo(); // The matched User object
}

// Logout
auth()->logout();

Session Configuration๏ƒ

// app/Config/Auth.php
public array $sessionConfig = [
    'field'               => 'user',       // Key stored in $_SESSION
    'allowRemembering'    => true,         // Enable "remember me" cookie
    'rememberCookieName'  => 'remember',
    'rememberLength'      => 30 * DAY,
    'trackDeviceSessions' => false,        // See Device Sessions guide
];

Remember Me๏ƒ

When a user logs in with remember: true, a long-lived cookie is set. On future visits, even after the session expires, the user is automatically recognized and logged back in.

$remember = (bool) $this->request->getPost('remember');
auth()->attempt($credentials, $remember);

Per-User Account Lockout๏ƒ

After a configurable number of failed password attempts, the userโ€™s account is temporarily locked. This is independent of IP-based blocking.

Configuration๏ƒ

// app/Config/Auth.php
public int $userMaxAttempts = 5;     // Attempts before lockout (0 = disabled)
public int $userLockoutTime  = 3600; // Lockout duration in seconds

How It Works๏ƒ

  1. Wrong password โ†’ failed_login_count increments

  2. Reaches userMaxAttempts โ†’ locked_until is set on the user record

  3. Any further login attempt before locked_until โ†’ error with minutes remaining

  4. Lockout expires โ†’ counter resets automatically on next attempt

  5. Successful login โ†’ counter always resets to 0

Unlocking Manually (Admin)๏ƒ

model(\Daycry\Auth\Models\UserModel::class)->update($userId, [
    'failed_login_count' => 0,
    'locked_until'       => null,
]);

Concurrency safety๏ƒ

recordFailedAttempt() uses an atomic SQL increment expression rather than a read-modify-write pattern, so concurrent failed-login attempts cannot race past userMaxAttempts before the lockout fires.


Compromised-Password Recheck on Login๏ƒ

Optional opt-in: after a successful password verification, re-test the live password against HaveIBeenPwned and flag the account for forced reset if it appears in a known breach corpus.

// app/Config/AuthSecurity.php
public bool $recheckPwnedOnLogin = true;

// HIBP timeouts
public float $pwnedPasswordsConnectTimeout = 1.0;
public float $pwnedPasswordsTimeout        = 3.0;

What happens on a hit๏ƒ

  1. Login proceeds and the session is created normally โ€” the user is not kicked out mid-flow.

  2. The userโ€™s email_password identity is marked force_reset = 1.

  3. On their next request the force-reset filter (or your equivalent) bounces them through the force-password-reset flow.

What happens on HIBP failure๏ƒ

The recheck is wrapped in try/catch. Timeouts, network errors, and 5xx responses are logged at warning level and login proceeds normally โ€” the recheck never blocks login.


Access Token Authenticator๏ƒ

Best for: REST APIs, mobile apps, machine-to-machine integrations.

Enable Access Tokens๏ƒ

// app/Config/Auth.php
public bool $accessTokenEnabled = true;

// Unused token lifetime
public int $unusedAccessTokenLifetime = YEAR;

// Throttle `last_used_at` writes โ€” at most one DB UPDATE per token per N
// seconds even when the same token is used for thousands of requests.
// 0 = always write (the legacy behaviour).
public int $tokenLastUsedThrottle = 60;

// Header name (in app/Config/Auth.php)
public array $authenticatorHeader = [
    'access_token' => 'X-API-KEY',
];

Generate a Token๏ƒ

// Typically done in a login controller after verifying credentials
$token = auth()->user()->generateAccessToken('mobile-app');

return $this->response->setJSON([
    'token'      => $token->raw_token, // Return the raw token ONCE โ€” it's never stored in plaintext
    'token_type' => 'Bearer',
]);

Use the Token in Requests๏ƒ

GET /api/users HTTP/1.1
X-API-KEY: your_raw_token_here

Or via query string:

GET /api/users?token=your_raw_token_here

Protect Routes with the Token Filter๏ƒ

// app/Config/Routes.php
$routes->group('api', ['filter' => 'tokens'], static function ($routes) {
    $routes->get('users', 'API\UsersController::index');
    $routes->get('profile', 'API\ProfileController::show');
});

Token Management๏ƒ

$user = auth()->user();

// Generate with name and optional scopes
$token = $user->generateAccessToken('dashboard', ['posts.read', 'posts.write']);

// List all tokens
$tokens = $user->accessTokens();

// Revoke a specific token
$user->revokeAccessToken($token);

// Revoke all tokens (e.g., on password change)
$user->revokeAllAccessTokens();

// Check permission scope on the current token
$currentToken = auth('access_token')->user()->currentAccessToken();
if ($currentToken->can('posts.write')) { ... }

Soft Revocation๏ƒ

Tokens can be soft-revoked (marked with a revoked_at timestamp) without being deleted:

use Daycry\Auth\Models\AccessTokenRepository;
use Daycry\Auth\Models\UserIdentityModel;

$repo = new AccessTokenRepository(model(UserIdentityModel::class));

// By raw token
$repo->softRevokeAccessToken($user, $rawToken);

// All tokens for a user (e.g. on password change)
$repo->softRevokeAllAccessTokens($user);

Each soft-revocation writes an EVENT_TOKEN_REVOKED entry to the audit log automatically. Soft-revoked tokens are excluded from all lookups via the revoked_at IS NULL filter, but remain in the database for audit trail.

Scope Enforcement๏ƒ

Personal access tokens carry a list of scopes (stored in the extra column, mapped via the scopes datamap). The token-scope: filter validates them on a per-route basis:

$routes->get('api/posts',  'Posts::index',  ['filter' => 'tokens,token-scope:posts.read']);
$routes->post('api/posts', 'Posts::create', ['filter' => 'tokens,token-scope:posts.read,posts.write']);

The * wildcard scope satisfies any check. See Filters โ€” Token Scope Filter for details.

Admin CLI๏ƒ

Bulk-revoke all tokens for a user (useful on password compromise / staff offboarding):

php spark auth:tokens revoke -e alice@example.com
php spark auth:tokens revoke -e alice@example.com --type=access_token

See CLI โ€” auth:tokens for the full reference.


JWT Authenticator๏ƒ

Best for: Stateless APIs, microservices, single-page applications.

Configuration๏ƒ

JWT configuration is managed by the daycry/jwt library. In app/Config/Auth.php:

use Daycry\Auth\Authentication\JWT\Adapters\DaycryJWTAdapter;

public string $jwtAdapter = DaycryJWTAdapter::class;

Configure the JWT library in app/Config/JWT.php:

public string $algorithmUsed    = 'HS256';
public string $secretKey        = 'your-secret-key'; // Use env('JWT_SECRET') in production
public string $issuer           = 'your-app';
public string $audience         = 'your-users';
public int    $timeToLive       = HOUR;      // Access token TTL
public int    $allowedClockSkew = 60;        // Tolerance in seconds

Use the JWT Filter๏ƒ

// app/Config/Routes.php
$routes->group('api', ['filter' => 'jwt'], static function ($routes) {
    $routes->get('profile', 'API\ProfileController::show');
    $routes->get('posts',   'API\PostsController::index');
});

Authorization Header๏ƒ

GET /api/profile HTTP/1.1
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...

JWT Refresh Tokens๏ƒ

The built-in JwtController provides stateless login with automatic refresh token rotation. Each refresh token is one-time use โ€” a new one is issued with every refresh.

Register the JWT Routes๏ƒ

// app/Config/Routes.php
$routes->post('auth/jwt/login',   'Daycry\Auth\Controllers\JwtController::login',   ['as' => 'jwt-login']);
$routes->post('auth/jwt/refresh', 'Daycry\Auth\Controllers\JwtController::refresh', ['as' => 'jwt-refresh']);
$routes->post('auth/jwt/logout',  'Daycry\Auth\Controllers\JwtController::logout',  ['as' => 'jwt-logout']);

Or use the routes from app/Config/Auth.php:

// Already included in the 'jwt' route group

Configure Refresh Token Lifetime๏ƒ

// app/Config/Auth.php
public int $jwtRefreshLifetime = 30 * DAY; // Refresh token validity

Login๏ƒ

POST /auth/jwt/login
Content-Type: application/x-www-form-urlencoded

email=user@example.com&password=secret

Response:

{
    "access_token":  "eyJ0eXAiOiJKV1Qi...",
    "refresh_token": "a3f8c2d1e4b7...",
    "user_id":       42,
    "token_type":    "Bearer"
}

Refresh an Expired Access Token๏ƒ

POST /auth/jwt/refresh
Content-Type: application/x-www-form-urlencoded

user_id=42&refresh_token=a3f8c2d1e4b7...

Response:

{
    "access_token":  "eyJ0eXAiOiJKV1Qi...",
    "refresh_token": "newrefreshtoken...",
    "user_id":       42,
    "token_type":    "Bearer"
}

The old refresh token is immediately revoked. Store the new one.

Logout (Revoke Refresh Token)๏ƒ

POST /auth/jwt/logout
Content-Type: application/x-www-form-urlencoded

user_id=42&refresh_token=a3f8c2d1e4b7...

Client-Side Flow (JavaScript example)๏ƒ

// On 401: try refreshing before giving up
async function apiFetch(url, options = {}) {
    let response = await fetch(url, {
        ...options,
        headers: {
            ...options.headers,
            Authorization: `Bearer ${localStorage.getItem('access_token')}`,
        },
    });

    if (response.status === 401) {
        // Try to refresh
        const refreshResponse = await fetch('/auth/jwt/refresh', {
            method: 'POST',
            headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
            body: new URLSearchParams({
                user_id:       localStorage.getItem('user_id'),
                refresh_token: localStorage.getItem('refresh_token'),
            }),
        });

        if (refreshResponse.ok) {
            const data = await refreshResponse.json();
            localStorage.setItem('access_token',  data.access_token);
            localStorage.setItem('refresh_token', data.refresh_token);

            // Retry original request with new token
            response = await apiFetch(url, options);
        } else {
            // Redirect to login
            window.location.href = '/login';
        }
    }

    return response;
}


Guest Authenticator๏ƒ

Best for: Routes that work for both authenticated users and anonymous visitors.

// Returns null if not logged in โ€” never fails or redirects
$user = auth('guest')->user();

if ($user !== null) {
    echo "Hello, {$user->email}!";
} else {
    echo "Hello, guest!";
}

Password Reset๏ƒ

Users who have forgotten their password can request a reset link by email.

How It Works๏ƒ

  1. User visits /password-reset and enters their email

  2. If the email matches an account, a secure token is emailed

  3. User clicks the link โ†’ sees a โ€œSet new passwordโ€ form

  4. On success, Events::trigger('passwordReset', $user) fires

Routes (already in Config/Auth.php)๏ƒ

// GET  /password-reset          โ†’ requestView()
// POST /password-reset          โ†’ requestAction()
// GET  /password-reset/message  โ†’ messageView()
// GET  /password-reset/{token}  โ†’ resetView()
// POST /password-reset/{token}  โ†’ resetAction()

Configuration๏ƒ

// app/Config/Auth.php
public int $passwordResetLifetime = HOUR; // Token validity

Listen for Reset Completion๏ƒ

// app/Config/Events.php
Events::on('passwordReset', static function (object $user): void {
    // Revoke all access tokens for security
    $user->revokeAllAccessTokens();
    log_message('notice', "Password reset for {$user->email}");
});

Force Password Reset๏ƒ

Administrators can flag user accounts to require a password change on next login.

Flag a User for Password Reset๏ƒ

// Force a specific user to reset their password
$user = model(\Daycry\Auth\Models\UserModel::class)->find($userId);

model(\Daycry\Auth\Models\UserIdentityModel::class)
    ->forceMultiplePasswordReset([$userId]);

// Force ALL users (e.g., after a security breach)
model(\Daycry\Auth\Models\UserIdentityModel::class)
    ->forceGlobalPasswordReset();

How It Works๏ƒ

Once flagged, the ForcePasswordResetFilter intercepts any request from that user and redirects them to /force-reset. After a successful password change, the flag is cleared and they proceed normally.

Apply the Filter๏ƒ

// app/Config/Filters.php
public array $aliases = [
    'force-reset' => \Daycry\Auth\Filters\ForcePasswordResetFilter::class,
];

// app/Config/Routes.php โ€” apply to all authenticated routes
$routes->group('dashboard', ['filter' => 'session,force-reset'], static function ($routes) {
    $routes->get('/', 'Dashboard::index');
});

Pre-Authentication Events๏ƒ

Two events fire before any database check occurs, letting you inspect or log the incoming data:

// app/Config/Events.php
use CodeIgniter\Events\Events;

// Fires before credentials are checked during login
Events::on('pre-login', static function (array $credentials): void {
    log_message('debug', 'Login attempt: ' . ($credentials['email'] ?? '?'));
});

// Fires before registration is processed
Events::on('pre-register', static function (array $postData): void {
    log_message('debug', 'Registration attempt: ' . ($postData['email'] ?? '?'));
});

See Logging & Monitoring for more event examples.


Switching Between Authenticators๏ƒ

Multiple Authenticators in One App๏ƒ

// Web users use sessions
$routes->group('dashboard', ['filter' => 'session'], ...);

// API clients use JWT
$routes->group('api', ['filter' => 'jwt'], ...);

// Try all methods in order (chain filter)
$routes->group('flexible', ['filter' => 'chain'], ...);

Chain Authenticator๏ƒ

The chain filter tries authenticators in order (configured in $authenticationChain) and stops at the first successful one:

// app/Config/Auth.php
public array $authenticationChain = ['session', 'access_token', 'jwt'];

Runtime Detection๏ƒ

if ($this->request->hasHeader('Authorization')) {
    $user = auth('jwt')->user();
} elseif ($this->request->hasHeader('X-API-KEY')) {
    $user = auth('access_token')->user();
} else {
    $user = auth('session')->user();
}

Custom Authenticators๏ƒ

Implement AuthenticatorInterface and use the instance() factory method for dependency injection:

<?php

namespace App\Authentication\Authenticators;

use Daycry\Auth\Authentication\Authenticators\Base;
use Daycry\Auth\Interfaces\AuthenticatorInterface;
use Daycry\Auth\Models\UserModel;
use Daycry\Auth\Models\UserIdentityModel;
use Daycry\Auth\Models\LoginModel;

class ApiKeyAuthenticator extends Base implements AuthenticatorInterface
{
    public const ID_TYPE_APIKEY = 'apikey';

    public static function instance(UserModel $provider): static
    {
        return new static(
            $provider,
            service('request'),
            model(UserIdentityModel::class),
            model(LoginModel::class),
        );
    }

    // ... implement abstract methods from Base
}

Register it:

// app/Config/Auth.php
public array $authenticators = [
    // ... existing
    'apikey' => \App\Authentication\Authenticators\ApiKeyAuthenticator::class,
];

Why HTTP Digest Auth is not supported๏ƒ

daycry/auth implements Basic Auth (BasicAuthFilter) but not HTTP Digest Auth (RFC 2617 / RFC 7616). This is a deliberate architectural decision, not an oversight. The reasons:

1. Incompatible with bcrypt / argon2๏ƒ

Digest requires the server to compute HA1 = MD5(username:realm:password) on every challenge. That requires the server to know the password (or a value derived from it).

This package stores passwords with bcrypt (or argon2), a one-way hash. Once hashed, the original password is unrecoverable, so HA1 cannot be computed at validation time.

Supporting Digest would force one of:

  • Storing HA1 alongside the bcrypt hash in a new column. HA1 is MD5 without salt โ€” effectively plaintext-equivalent for offline attacks. A database leak would expose Digest-authenticatable credentials, even though the bcrypt hash protects the real password.

  • Tying HA1 to the configured realm. Changing Auth.restRealm later would invalidate every stored HA1, forcing all users to re-enter their plaintext password.

  • Breaking backward compatibility: existing users could not use Digest until they logged in with their plaintext password again so the server could compute their HA1.

2. Effectively deprecated๏ƒ

RFC 7616 (2015) explicitly marks MD5-Digest as inadequate for production and adds SHA-256, but the ecosystem has not migrated. Modern HTTP clients, browsers and API consumers do not request Digest by default.

3. No security gain over Basic + TLS๏ƒ

  • With TLS: the Authorization: Basic ... header is encrypted in transit, so Basic is safe. Digest adds nothing.

  • Without TLS: neither scheme is safe. Digest is still vulnerable to downgrade attacks, does not protect the request body, and leaks the username in the clear.

If you are running this library in production you already need TLS โ€” and once you have TLS, Basic + Bearer/JWT covers the same ground without the architectural cost.

4. Modern alternatives are already in the package๏ƒ

Need

Use

Server-to-server API auth

Access Token (Bearer) or JWT

Web login with sessions

Session

Social login

OAuth 2.0

Passwordless

Magic Link

HTTP-level basic auth

BasicAuthFilter over TLS

If your use case really requires Digest๏ƒ

Rare cases (legacy embedded clients, specific compliance requirements, etc.):

  1. Prefer changing the client. Migrate to Bearer / JWT / Basic+TLS if you can.

  2. If you cannot, build a separate package on top of daycry/auth:

    • Migration: new table users_digest_ha1 (user_id, realm, ha1, created_at).

    • Endpoint: POST /digest/enable that accepts the userโ€™s plaintext password, computes HA1 server-side, and stores it. Per-user opt-in only.

    • Filter: a custom DigestAuthFilter that resolves credentials via that table, with a server-side nonce store (e.g. Services::cache(), TTL ~5 min) and replay protection via the nc (nonce-count) parameter.

    • Authenticator: extend Base with a check() that compares the request response field against MD5(HA1:nonce:nc:cnonce:qop:HA2).

    • Support at minimum qop=auth and SHA-256 in addition to MD5.

    • Document the database leakage risk (HA1 is not a strong hash).

The decision not to merge this into daycry/auth core is intentional โ€” keeping HA1 out of the canonical user table is a security boundary worth preserving.


๐Ÿ”— See also: