๐Ÿ›ก๏ธ Audit Log & Compliance๏ƒ

This guide covers Daycry Authโ€™s compliance and observability features:

  • Granular audit log โ€” separate event-level table for sensitive account changes

  • Suspicious login detection โ€” IP / device anomaly alerts

  • Compromised-password recheck on login โ€” opt-in HIBP recheck

  • Password history โ€” prevent reuse of recent passwords

  • Password rotation policy โ€” force resets after a configurable age

  • GDPR helpers โ€” data export and account anonymization

These features are independent โ€” enable only the ones you need.

๐Ÿ“‹ Index๏ƒ


Audit Log๏ƒ

A second log table โ€” auth_audit_logs โ€” captures account-level events that need long-term traceability, separate from auth_logs (request-level activity) and auth_logins (login attempts).

What gets recorded๏ƒ

Every event written by \Daycry\Auth\Services\AuditLogger lands in auth_audit_logs with:

Column

Description

user_id

The user the event affects (null for system-level events).

actor_id

The user that triggered the event (admin or self). Defaults to user_id.

event_type

One of the EVENT_* constants on AuditLogger.

ip_address

Resolved from the active IncomingRequest (null in CLI).

user_agent

Resolved from the active IncomingRequest.

metadata

Free-form JSON with extra context.

created_at

Datetime stamp.

Built-in events๏ƒ

AuditLogger defines the canonical event identifiers โ€” use these constants instead of free-form strings:

Constant

When it fires

EVENT_TOTP_ENABLED

HasTotp::confirmTotp() succeeds

EVENT_TOTP_DISABLED

HasTotp::disableTotp()

EVENT_TOTP_ADMIN_RESET

auth:totp reset CLI command

EVENT_PASSWORD_CHANGED

Password change via ForcePasswordResetController

EVENT_PASSWORD_RESET

Password reset via PasswordResetController

EVENT_USER_LOCKED

UserLockoutManager triggers a lockout

EVENT_USER_UNLOCKED

Lockout expires automatically

EVENT_GROUP_ASSIGNED / EVENT_GROUP_REVOKED

Authorizable::addGroup() / removeGroup()

EVENT_PERMISSION_GRANTED / EVENT_PERMISSION_REVOKED

Authorizable::addPermission() / removePermission()

EVENT_TOKEN_REVOKED

AccessTokenRepository::softRevokeAccessToken() / softRevokeAllAccessTokens()

EVENT_REFRESH_TOKEN_REVOKED

JwtController::logout() / JwtTokenRepository::softRevokeRefreshToken()

EVENT_TRUSTED_DEVICE_ADDED

User ticks โ€œTrust this deviceโ€ on 2FA

EVENT_SUSPICIOUS_LOGIN

SuspiciousLoginDetector flags a login

EVENT_USER_ANONYMIZED

auth:gdpr anonymize CLI command

EVENT_OAUTH_LINKED / EVENT_OAUTH_UNLINKED

Reserved for application use

EVENT_EMAIL_CHANGE_REQUEST / EVENT_EMAIL_CHANGED

Reserved for application use

EVENT_TRUSTED_DEVICE_REMOVED

Reserved for application use

Recording your own events๏ƒ

AuditLogger::record() is the single entry point โ€” failures are logged at warning level but never propagate, so audit failure cannot break the user-facing flow:

use Daycry\Auth\Services\AuditLogger;

(new AuditLogger())->record(
    AuditLogger::EVENT_PASSWORD_CHANGED,
    userId: $user->id,
    metadata: ['source' => 'profile_form'],
    actorId: auth()->id() // optional โ€” defaults to $userId
);

Querying the audit log๏ƒ

Use AuditLogModel directly or via auth:audit:

use Daycry\Auth\Models\AuditLogModel;

/** @var AuditLogModel $audit */
$audit = model(AuditLogModel::class);

// Last 50 events for a user
$entries = $audit->recentForUser($userId, 50);

// All TOTP-enabled events in the last 30 days
$entries = $audit->recentByType(
    AuditLogger::EVENT_TOTP_ENABLED,
    \CodeIgniter\I18n\Time::now()->subDays(30),
);

// Each entry's metadata is decoded automatically
foreach ($entries as $entry) {
    echo $entry->event_type . ' at ' . $entry->created_at . "\n";
    var_dump($entry->getMetadata());
}

CLI: auth:audit๏ƒ

# Last 7 days, 100 rows max (default)
php spark auth:audit

# Last 24 hours
php spark auth:audit --since=24h

# Filter by user
php spark auth:audit --user=alice@example.com

# Filter by event
php spark auth:audit --type=totp.enabled --since=30d --limit=200

--since accepts Ns, Nm, Nh, Nd, Nw (e.g. 90m, 2d, 1w).

Database๏ƒ

The migration 2026-05-07-000002_create_audit_logs_table.php creates the table with composite indexes on (user_id, created_at) and (event_type, created_at). The table name is configurable via Auth::$tables['audit_logs'] (defaults to auth_audit_logs).


Suspicious Login Detection๏ƒ

After every successful login, SuspiciousLoginDetector compares the new login against the userโ€™s recent history. When something looks off, it fires the suspicious-login event and writes an audit entry โ€” your application listens to the event to deliver an email / Slack / push notification.

Enable๏ƒ

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

Detection signals๏ƒ

Each login is checked against the userโ€™s history (last 30 days). The current implementation flags:

Flag

Source

Test

new_ip

auth_logins

No successful login from this IP in the lookback window

new_device

auth_device_sessions

No prior session row with this exact User-Agent

The detector is forward-compatible โ€” additional signals (geo-IP, ASN reputation, time-of-day, JA3 fingerprint) can be added without changing the integration point.

React with an event listener๏ƒ

// app/Config/Events.php
use CodeIgniter\Events\Events;
use Daycry\Auth\Entities\User;

Events::on('suspicious-login', static function (User $user, array $flags, string $ip, string $ua): void {
    helper('email');

    $email = emailer()
        ->setFrom(setting('Email.fromEmail'), (string) setting('Email.fromName'))
        ->setTo($user->email)
        ->setSubject(lang('Auth.suspiciousLoginSubject'))
        ->setMessage(view('Daycry\\Auth\\Views\\Email\\suspicious_login_alert', [
            'user'      => $user,
            'flags'     => $flags,
            'ipAddress' => $ip,
            'userAgent' => $ua,
            'date'      => \CodeIgniter\I18n\Time::now()->toDateTimeString(),
        ]));

    $email->send(false);
});

The package ships Views/Email/suspicious_login_alert.php as a starting template โ€” copy it to app/Views/ and customize.

What lands in the audit log๏ƒ

Every flagged login also writes an EVENT_SUSPICIOUS_LOGIN row with the flags and request context as metadata, so you have a permanent record even if the email fails to send.


Compromised-Password Recheck on Login๏ƒ

PwnedValidator is normally only used during registration / password change. Enable recheckPwnedOnLogin to also test the current password against HaveIBeenPwned on every successful login โ€” useful for catching passwords that were safe when the account was created but appeared in a breach later.

Enable๏ƒ

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

// HIBP timeouts (already documented in 02-configuration.md)
public float $pwnedPasswordsConnectTimeout = 1.0;
public float $pwnedPasswordsTimeout        = 3.0;

Behaviour๏ƒ

  1. Login proceeds normally; password is verified.

  2. After verification, the live password is hashed (SHA-1 prefix per HIBPโ€™s k-anonymity API) and queried.

  3. If found in a breach, the userโ€™s email_password identity gets force_reset = 1 โ€” the next request bounces them through the force-reset flow (no immediate logout, so they finish what they were doing first).

  4. If HIBP is unreachable / slow, the failure is logged at warning and login proceeds โ€” the recheck never blocks login.

Cost๏ƒ

One outbound HTTPS call per login when enabled. Tune pwnedPasswordsTimeout (default 3s) based on your latency budget. Disabled by default for that reason.


Password History (No Reuse)๏ƒ

Forces users to pick a password that does not match any of their last N hashes. Required for SOC 2 / ISO 27001 environments.

Enable๏ƒ

// app/Config/AuthSecurity.php
public int $passwordHistorySize = 5; // remember last 5

Add the validator to your password validator chain:

public array $passwordValidators = [
    \Daycry\Auth\Authentication\Passwords\CompositionValidator::class,
    \Daycry\Auth\Authentication\Passwords\NothingPersonalValidator::class,
    \Daycry\Auth\Authentication\Passwords\DictionaryValidator::class,
    \Daycry\Auth\Authentication\Passwords\HistoryValidator::class, // <โ€” add this
];

How it works๏ƒ

  1. On every persisted password change, PasswordChangeRecorder writes the previous hash into auth_password_history (one row per change, indexed on user_id, created_at).

  2. Older entries beyond the configured retention window are pruned automatically.

  3. On the next password change, HistoryValidator runs password_verify() against each retained hash โ€” if any match, the new password is rejected with Auth.passwordHistoryReuse.

Hooks๏ƒ

The recorder is wired automatically into:

  • PasswordResetController::resetAction() โ€” token-based reset.

  • ForcePasswordResetController::resetAction() โ€” current-password gated reset.

For custom password change flows (e.g. an admin-side password set), call:

use Daycry\Auth\Services\PasswordChangeRecorder;

$previousHash = $user->password_hash;
$user->setPassword($newPassword);
$userModel->save($user);

(new PasswordChangeRecorder())->record($user, $previousHash);

Database๏ƒ

Migration 2026-05-07-000005_create_password_history_table.php. Table is configurable via Auth::$tables['password_history'].


Password Rotation Policy๏ƒ

Forces a password reset once password_changed_at is older than passwordMaxAge seconds.

Enable๏ƒ

// app/Config/AuthSecurity.php
public int $passwordMaxAge = 90 * DAY; // 90 days

Wire the filter๏ƒ

The password-age alias is registered automatically by Registrar. Apply it alongside auth (or session) on the routes that should enforce rotation:

// app/Config/Routes.php
$routes->group('app', ['filter' => 'session,password-age'], static function ($routes) {
    $routes->get('/dashboard', 'Dashboard::index');
});

Behaviour๏ƒ

  1. The filter runs after authentication.

  2. If password_changed_at is null (never recorded) the user is left alone โ€” older accounts grandfathered in.

  3. If password_changed_at is older than passwordMaxAge, the userโ€™s email_password identity gets force_reset = 1 and the request redirects to Config\Auth::forcePasswordResetRedirect() with Auth.passwordExpired.

Database๏ƒ

Migration 2026-05-07-000006_add_password_changed_at_to_users.php adds the column. PasswordChangeRecorder (same service used by Password History) stamps it on every persisted password change.

Migrating existing accounts: rows already in users will have password_changed_at = NULL. Either grandfather them (the filter ignores nulls) or run a one-shot UPDATE users SET password_changed_at = created_at WHERE password_changed_at IS NULL; if you want to start the rotation clock immediately.


GDPR Export & Anonymization๏ƒ

Two CLI subcommands satisfy the most common GDPR data-subject requests.

auth:gdpr export๏ƒ

Emits a JSON document covering everything the system knows about a user:

# To stdout
php spark auth:gdpr export -e alice@example.com

# Save to a file (recommended for large histories)
php spark auth:gdpr export -e alice@example.com -o /tmp/alice.json

The payload includes:

  • user โ€” identity row (id, uuid, username, email, active, lockout state, password rotation timestamp).

  • identities โ€” every row in auth_users_identities, with secrets redacted:

    • bcrypt password hashes are marked <redacted: bcrypt hash>

    • access / refresh tokens marked <redacted: hashed token>

    • other types (magic link, OAuth, TOTP secret) include the raw secret column (these are short-lived or already encrypted)

  • device_sessions โ€” full history (active + terminated).

  • login_history โ€” last 500 entries from auth_logins.

  • audit_log โ€” last 500 entries from auth_audit_logs, with metadata decoded.

  • password_history โ€” count only (the bcrypt hashes themselves are not personal data and exposing them would defeat the purpose of revoking a token).

  • backup_codes โ€” counts of remaining / used (never the raw codes).

auth:gdpr anonymize๏ƒ

Permanent, irreversible โ€” keeps the user row to preserve foreign-key integrity but replaces personal fields with placeholders:

php spark auth:gdpr anonymize -e alice@example.com

What happens:

  1. Identities, device sessions, password history, backup codes โ€” DELETE rows for the user.

  2. User row โ€” username becomes deleted_<id>, active = 0, lockout / rotation fields cleared.

  3. Audit log โ€” final EVENT_USER_ANONYMIZED entry with actor_id = user_id and metadata.initiator = cli.

The command prompts for confirmation before any destructive action. Login attempts (auth_logins) are kept by default โ€” they are append-only events, not personal data of the deleted user. Adapt to your interpretation of GDPR by extending GdprCommand::anonymizeAction().

When to use export vs anonymize๏ƒ

User asks for

Use

โ€œWhat data do you have on me?โ€ (Article 15 access)

export

โ€œSend me my dataโ€ (Article 20 portability)

export -o /path/to.json and email the file

โ€œDelete meโ€ (Article 17 erasure)

anonymize (preserves FK integrity) or hard auth:user delete (full deletion, may break logs / FK references)


Quick Configuration Reference๏ƒ

All compliance features are opt-in. Here is the full set of new toggles in app/Config/AuthSecurity.php:

namespace Config;

use Daycry\Auth\Config\AuthSecurity as AuthSecurityConfig;

class AuthSecurity extends AuthSecurityConfig
{
    // Compromised-password recheck (1 outbound HTTP call per login)
    public bool $recheckPwnedOnLogin = true;

    // Suspicious login detection (writes audit + fires event)
    public bool $suspiciousLoginAlerts = true;

    // Password history โ€” last 5 hashes
    public int $passwordHistorySize = 5;

    // Force password rotation every 90 days
    public int $passwordMaxAge = 90 * DAY;

    // Add HistoryValidator to the chain
    public array $passwordValidators = [
        \Daycry\Auth\Authentication\Passwords\CompositionValidator::class,
        \Daycry\Auth\Authentication\Passwords\NothingPersonalValidator::class,
        \Daycry\Auth\Authentication\Passwords\DictionaryValidator::class,
        \Daycry\Auth\Authentication\Passwords\HistoryValidator::class,
    ];
}

And the routing-level wiring in app/Config/Routes.php:

$routes->group('app', ['filter' => 'session,password-age'], static function ($routes) {
    // Routes that should enforce password rotation
});