All checks were successful
test-and-lint / test-and-lint (pull_request) Successful in 2m50s
266 lines
12 KiB
Markdown
266 lines
12 KiB
Markdown
# 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:
|
|
|
|
```json
|
|
{
|
|
"requires_2fa": true,
|
|
"two_factor_token": "..."
|
|
}
|
|
```
|
|
|
|
5. The frontend shows the second login step.
|
|
6. Client sends `POST /api/login/2fa` with:
|
|
|
|
```json
|
|
{
|
|
"two_factor_token": "...",
|
|
"code": "123456"
|
|
}
|
|
```
|
|
|
|
7. The server validates the purpose token against the expected purpose `2fa_login`.
|
|
8. The server loads the user from the purpose-token claim.
|
|
9. The supplied code is checked as a TOTP code first.
|
|
10. If the TOTP check fails, the supplied value is normalized and checked as a recovery code.
|
|
11. If either check succeeds, the server issues the normal access/refresh token session.
|
|
12. 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:
|
|
|
|
```json
|
|
{
|
|
"secret": "BASE32SECRET",
|
|
"setup_token": "...",
|
|
"otpauth_url": "otpauth://totp/...",
|
|
"qr_code": "data:image/png;base64,..."
|
|
}
|
|
```
|
|
|
|
6. The frontend displays the QR code and the manual setup key.
|
|
7. User scans the QR code or enters the secret manually into an authenticator app.
|
|
8. User submits the setup token and a current TOTP code to `POST /api/2fa/enable`.
|
|
9. Server validates the setup token and the TOTP code.
|
|
10. Server stores the TOTP secret, enables 2FA, invalidates any previous recovery codes, and generates a fresh recovery-code set.
|
|
11. Server revokes existing refresh sessions and issues a new current session.
|
|
12. 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:
|
|
|
|
```json
|
|
{
|
|
"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:
|
|
|
|
```json
|
|
{
|
|
"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:
|
|
|
|
```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.
|