Files
MiauInv/docs/AUTHENTICATION.md
miaurizius fb3be56959
All checks were successful
test-and-lint / test-and-lint (pull_request) Successful in 2m50s
added passkey support (closes #6)
2026-06-10 03:24:31 +02:00

12 KiB

Authentication Architecture

MiauInv uses signed JWT access tokens, database-backed refresh tokens, optional TOTP-based two-factor authentication, and optional WebAuthn passkey authentication. The authentication flow is implemented in the auth/, handlers/, storage/, and frontend/assets/js/ packages.

JWTs are signed with a symmetric secret from the JWT_SECRET environment variable. They are not encrypted. Access tokens and normal purpose tokens should therefore contain identity and authorization metadata only, not secrets. The short-lived 2FA setup token intentionally carries the not-yet-enabled TOTP secret because the same secret is already returned to the authenticated browser for QR/manual setup and is not stored server-side until confirmation.

Components

Component Location Responsibility
JWT helpers auth/jwt.go Access-token and purpose-token generation/validation.
Middleware auth/middleware.go Extracts access tokens from bearer headers or cookies and injects claims into the request context.
Password helpers auth/password.go bcrypt hashing and verification.
Login/account handlers handlers/account.go Register, login, 2FA, refresh, logout, account settings, and user metadata.
Passkey handlers handlers/passkeys.go WebAuthn/passkey registration, login, removal, and disable flows.
Persistent session storage storage/storage.go, storage/passkeys.go Refresh tokens, 2FA state, TOTP secret, recovery-code hashes, passkey credentials, and WebAuthn challenge state.
Frontend auth logic frontend/assets/js/auth.js, frontend/assets/js/login.js, frontend/assets/js/api.js Login UI, passkey login, token refresh, account settings, 2FA UI interactions, and passkey UI interactions.

Token Types

Token Storage/Transport Lifetime Purpose
Access token access_token HTTP-only secure cookie and JSON response body 15 minutes Authenticates normal API and page requests.
Refresh token refresh_token HTTP-only secure cookie and JSON response body 7 days Rotates sessions after access-token expiry. Stored in the database only as a hash.
2FA challenge token JSON response from /api/login 5 minutes Allows /api/login/2fa to complete login after password verification. It is purpose-bound to 2fa_login.
2FA setup token JSON response from /api/2fa/setup 10 minutes Carries the not-yet-enabled TOTP secret until /api/2fa/enable validates the first code. It is purpose-bound to 2fa_setup.
Passkey ceremony token JSON response from /api/passkeys/*/options 5 minutes Opaque server-side reference to WebAuthn session data stored in passkey_challenges. Used for passkey registration and login.

Normal Login Flow Without 2FA

  1. Client sends POST /api/login with username and password.
  2. Server loads the user by username.
  3. Server verifies the bcrypt password hash.
  4. If 2FA is disabled, the server issues:
    • a signed access token,
    • a high-entropy refresh token,
    • HTTP-only secure cookies for both tokens,
    • a JSON response containing the same token values and user metadata.
  5. The refresh token is hashed before being stored in the refresh_tokens table.

Login Flow With 2FA Enabled

  1. Client sends POST /api/login with username and password.
  2. Server verifies the password.
  3. If two_factor_enabled is true, the server does not issue a full session.
  4. Instead, the server returns:
{
  "requires_2fa": true,
  "two_factor_token": "..."
}
  1. The frontend shows the second login step.
  2. Client sends POST /api/login/2fa with:
{
  "two_factor_token": "...",
  "code": "123456"
}
  1. The server validates the purpose token against the expected purpose 2fa_login.
  2. The server loads the user from the purpose-token claim.
  3. The supplied code is checked as a TOTP code first.
  4. If the TOTP check fails, the supplied value is normalized and checked as a recovery code.
  5. If either check succeeds, the server issues the normal access/refresh token session.
  6. If a recovery code was used, it is marked as used and cannot be used again.

Passkey Registration Flow

Passkeys are managed from /profile/settings. Registration requires an authenticated session and current-password confirmation.

  1. Client sends POST /api/passkeys/register/options with a passkey name and the current password.
  2. Server verifies the password.
  3. Server creates WebAuthn registration options with resident-key and user-verification requirements.
  4. Server stores the WebAuthn session data in passkey_challenges and returns only an opaque session_token plus the public registration options.
  5. Browser calls navigator.credentials.create() with the returned public-key options.
  6. Client sends the browser credential response to POST /api/passkeys/register/finish.
  7. Server consumes the one-time challenge, verifies the WebAuthn response, stores the credential, revokes existing refresh sessions, and issues a new current session.

The server stores credential metadata and public-key credential data. It does not store private keys.

Passkey Login Flow

Passkey login is available from the normal login page.

  1. Client sends POST /api/passkeys/login/options.
  2. Server creates a discoverable passkey login challenge without requiring a username.
  3. Server stores the WebAuthn session data in passkey_challenges and returns an opaque session_token plus assertion options.
  4. Browser calls navigator.credentials.get() with the returned public-key options.
  5. Client sends the browser assertion response to POST /api/passkeys/login/finish.
  6. Server consumes the one-time challenge and verifies the WebAuthn assertion.
  7. The stored credential data is updated after successful login.
  8. Server issues a normal access/refresh session.

Passkey login is treated as a complete phishing-resistant sign-in method. The application requires WebAuthn user verification for passkey registration and login, so a valid passkey assertion is not followed by a separate TOTP challenge.

Passkey Management

The account settings page supports:

  • listing registered passkeys,
  • adding a passkey,
  • removing a single passkey,
  • disabling all passkeys.

Adding, removing, or disabling passkeys revokes existing refresh sessions and issues a fresh session for the current browser. Removing or disabling passkeys requires current-password confirmation.

TOTP Setup Flow

The account settings page at /profile/settings exposes the UI for TOTP setup.

  1. Authenticated user calls POST /api/2fa/setup.
  2. Server creates a TOTP secret using issuer MiauInv and the current username as the account name.
  3. Server creates a short-lived setup token containing the not-yet-enabled TOTP secret.
  4. The secret is not written to users.two_factor_secret during setup.
  5. Server returns:
{
  "secret": "BASE32SECRET",
  "setup_token": "...",
  "otpauth_url": "otpauth://totp/...",
  "qr_code": "data:image/png;base64,..."
}
  1. The frontend displays the QR code and the manual setup key.
  2. User scans the QR code or enters the secret manually into an authenticator app.
  3. User submits the setup token and a current TOTP code to POST /api/2fa/enable.
  4. Server validates the setup token and the TOTP code.
  5. Server stores the TOTP secret, enables 2FA, invalidates any previous recovery codes, and generates a fresh recovery-code set.
  6. Server revokes existing refresh sessions and issues a new current session.
  7. Recovery codes are returned once to the client for download/copying.

Recovery Codes

Recovery codes are generated when 2FA is enabled and when the user regenerates them. Enabling 2FA deletes any old recovery-code rows before inserting the new set.

Properties:

  • Generated with cryptographically secure randomness.
  • Formatted as four groups of five hexadecimal characters.
  • Normalized before hashing by removing spaces and hyphens and lowercasing the value.
  • Stored only as hashes in two_factor_recovery_codes.
  • Single-use only.
  • Displayed only immediately after generation/regeneration.
  • Downloaded client-side from the account settings page as miauinv-recovery-codes.txt.

Recovery-code login flow:

  1. User enters a recovery code in the same field as the TOTP code during the second login step.
  2. Server first attempts normal TOTP validation.
  3. If TOTP validation fails, server hashes the normalized recovery code.
  4. Server updates the matching unused row with used_at = now.
  5. If exactly one row was updated, the recovery-code login succeeds.

Recovery-Code Regeneration

POST /api/2fa/recovery-codes/regenerate requires:

{
  "password": "current-password",
  "code": "123456"
}

The server verifies the current password and a current TOTP code. If both are valid, all previous recovery codes are deleted and a new set is inserted. The new plaintext codes are returned once. The account settings UI warns the user when the number of unused recovery codes is low.

Disabling 2FA

POST /api/2fa/disable requires:

{
  "password": "current-password",
  "code": "123456"
}

If the password and TOTP code are valid, the server:

  1. Sets two_factor_enabled = 0.
  2. Clears two_factor_secret.
  3. Deletes recovery codes for the user.
  4. Revokes refresh tokens for the user.
  5. Clears authentication cookies.

The frontend redirects the user to /login after disabling 2FA because existing sessions are revoked.

Refresh Token Rotation

POST /api/refresh accepts the refresh token either from JSON:

{
  "refresh_token": "..."
}

or from the refresh_token cookie.

The server:

  1. Hashes the supplied token.
  2. Looks it up in refresh_tokens.
  3. Rejects revoked or expired tokens.
  4. Marks the used refresh token as revoked.
  5. Issues a new access token and refresh token.
  6. Stores the new refresh token hash.
  7. Updates the auth cookies.

Account Settings Security

The account settings page currently supports:

  • username changes,
  • password changes,
  • passkey registration,
  • passkey removal,
  • passkey disable,
  • TOTP 2FA setup,
  • TOTP 2FA disable,
  • recovery-code download after generation,
  • recovery-code regeneration.

Username changes require the current password.

Password changes require the current password, reject passwords longer than bcrypt's 72-byte effective limit, revoke existing refresh tokens, and issue a new session.

2FA activation also revokes existing refresh tokens and issues a new session for the current browser. Passkey changes also revoke existing refresh tokens and issue a new current session. Other devices must log in again and complete the configured authentication flow.

Middleware Behavior

Protected routes use AuthMiddleware.

The middleware checks tokens in this order:

  1. Authorization: Bearer <token> header.
  2. access_token cookie.

If no token is found:

  • /api/* routes receive 401 Unauthorized.
  • non-API routes redirect to /login.

If token validation fails:

  • /api/* routes receive 401 Unauthorized.
  • non-API routes clear the access cookie and redirect to /login.

Security Limitations and Follow-up Work

The current implementation is usable for private/self-hosted deployments, but these improvements should be prioritized before exposing it to untrusted public traffic:

  • Replace the current in-memory rate limiter with persistent or distributed rate limiting if the app is deployed across multiple instances.
  • Add audit logging for account security changes.
  • Add optional session/device management UI.
  • Consider encrypting TOTP secrets and passkey credential data at rest if the deployment threat model includes database disclosure.
  • Expand tests for all authentication and account settings handlers.