added passkey support (closes #6)
All checks were successful
test-and-lint / test-and-lint (pull_request) Successful in 2m50s
All checks were successful
test-and-lint / test-and-lint (pull_request) Successful in 2m50s
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Authentication Architecture
|
||||
|
||||
MiauInv uses signed JWT access tokens, database-backed refresh tokens, and optional TOTP-based two-factor authentication. The authentication flow is implemented in the `auth/`, `handlers/`, `storage/`, and `frontend/assets/js/` packages.
|
||||
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.
|
||||
|
||||
@@ -12,8 +12,9 @@ JWTs are signed with a symmetric secret from the `JWT_SECRET` environment variab
|
||||
| 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. |
|
||||
| Persistent session storage | `storage/storage.go` | Refresh tokens, 2FA state, TOTP secret, and recovery-code hashes. |
|
||||
| Frontend auth logic | `frontend/assets/js/auth.js`, `frontend/assets/js/login.js`, `frontend/assets/js/api.js` | Login UI, token refresh, account settings, and 2FA UI interactions. |
|
||||
| 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
|
||||
|
||||
@@ -23,6 +24,7 @@ JWTs are signed with a symmetric secret from the `JWT_SECRET` environment variab
|
||||
| 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
|
||||
|
||||
@@ -67,6 +69,47 @@ JWTs are signed with a symmetric secret from the `JWT_SECRET` environment variab
|
||||
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.
|
||||
@@ -178,6 +221,9 @@ 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,
|
||||
@@ -187,7 +233,7 @@ 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. Other devices must log in again and complete 2FA.
|
||||
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
|
||||
|
||||
@@ -215,5 +261,5 @@ The current implementation is usable for private/self-hosted deployments, but th
|
||||
- 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 at rest if the deployment threat model includes database disclosure.
|
||||
- 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.
|
||||
|
||||
@@ -6,13 +6,16 @@ MiauInv uses SQLite for persistent storage. The schema is initialized in `storag
|
||||
PRAGMA foreign_keys = ON;
|
||||
```
|
||||
|
||||
The database stores users, refresh tokens, 2FA recovery codes, inventory items, locations, projects, stock mappings, and project allocations.
|
||||
The database stores users, refresh tokens, 2FA recovery codes, passkey credentials, passkey challenge state, inventory items, locations, projects, stock mappings, and project allocations.
|
||||
|
||||
## Entity Overview
|
||||
|
||||
```text
|
||||
[users] 1 ──── N [refresh_tokens]
|
||||
[users] 1 ──── N [two_factor_recovery_codes]
|
||||
[users] 1 ──── N [passkey_credentials]
|
||||
|
||||
[passkey_challenges] stores short-lived WebAuthn ceremony state
|
||||
|
||||
[items] 1 ──── N [stock] N ──── 1 [locations]
|
||||
[items] 1 ──── N [project_items] N ──── 1 [projects]
|
||||
@@ -65,6 +68,34 @@ Stores recovery-code hashes for 2FA fallback login.
|
||||
|
||||
Recovery codes are deleted and replaced when the user regenerates them. A recovery code is consumed with an atomic update that only matches unused codes.
|
||||
|
||||
### `passkey_credentials`
|
||||
|
||||
Stores WebAuthn passkey credentials for account login. Private keys are not stored by MiauInv; they remain in the authenticator, platform passkey provider, browser, or security key.
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `id` | `TEXT` | Primary key | Passkey row UUID. |
|
||||
| `user_id` | `TEXT` | Not null, foreign key to `users(id)` with `ON DELETE CASCADE` | Owning user. |
|
||||
| `credential_id` | `TEXT` | Not null, unique | Base64url-encoded WebAuthn credential ID. |
|
||||
| `name` | `TEXT` | Not null | User-visible passkey name. |
|
||||
| `credential_data` | `TEXT` | Not null | Serialized WebAuthn credential data, including public-key material and authenticator metadata. |
|
||||
| `created_at` | `INTEGER` | Not null | Unix timestamp for creation. |
|
||||
| `last_used_at` | `INTEGER` | Nullable | Unix timestamp when the passkey was last used successfully. |
|
||||
|
||||
### `passkey_challenges`
|
||||
|
||||
Stores short-lived server-side WebAuthn session data for registration and login ceremonies. The browser receives only an opaque `session_token`.
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `token` | `TEXT` | Primary key | Opaque challenge token returned to the client. |
|
||||
| `user_id` | `TEXT` | Not null, default `''` | Owning user for registration challenges. Empty for discoverable login challenges. |
|
||||
| `ceremony` | `TEXT` | Not null | Challenge type, for example `register` or `login`. |
|
||||
| `session_data` | `TEXT` | Not null | Serialized WebAuthn session data. |
|
||||
| `expires_at` | `INTEGER` | Not null | Unix timestamp after which the challenge is rejected. |
|
||||
|
||||
Challenge rows are consumed once during the finish step and expired rows are cleaned up opportunistically.
|
||||
|
||||
### `items`
|
||||
|
||||
Stores tracked inventory items.
|
||||
@@ -124,7 +155,7 @@ Maps item quantities to projects.
|
||||
|
||||
Foreign keys are enabled per connection. Because most inventory foreign keys do not define explicit cascade behavior, SQLite blocks deletion of referenced items, locations, or projects while dependent rows exist.
|
||||
|
||||
`two_factor_recovery_codes.user_id` uses `ON DELETE CASCADE`, so deleting a user also deletes their recovery-code rows.
|
||||
`two_factor_recovery_codes.user_id` and `passkey_credentials.user_id` use `ON DELETE CASCADE`, so deleting a user also deletes their recovery-code and passkey rows.
|
||||
|
||||
### Uniqueness
|
||||
|
||||
@@ -134,6 +165,7 @@ The following values are unique:
|
||||
- `locations.name`
|
||||
- `projects.name`
|
||||
- `two_factor_recovery_codes(user_id, code_hash)`
|
||||
- `passkey_credentials.credential_id`
|
||||
|
||||
### Current schema limitations
|
||||
|
||||
@@ -162,6 +194,36 @@ When the password is updated:
|
||||
3. Existing refresh tokens for that user are revoked.
|
||||
4. A new session is issued.
|
||||
|
||||
### Passkey registration
|
||||
|
||||
When a passkey is registered:
|
||||
|
||||
1. The current password is verified.
|
||||
2. A WebAuthn registration challenge is stored in `passkey_challenges`.
|
||||
3. The finish step consumes the challenge.
|
||||
4. The WebAuthn credential is verified.
|
||||
5. A row is inserted into `passkey_credentials`.
|
||||
6. Existing refresh tokens for the user are revoked.
|
||||
7. A new session is issued for the current browser.
|
||||
|
||||
### Passkey login
|
||||
|
||||
When passkey login completes:
|
||||
|
||||
1. A discoverable WebAuthn login challenge is consumed from `passkey_challenges`.
|
||||
2. The credential assertion is verified against the stored credential data.
|
||||
3. The stored credential data and `last_used_at` value are updated.
|
||||
4. A normal access/refresh session is issued.
|
||||
|
||||
### Passkey removal
|
||||
|
||||
When a passkey is removed or all passkeys are disabled:
|
||||
|
||||
1. The current password is verified.
|
||||
2. The matching passkey row, or all rows for the user, are deleted.
|
||||
3. Existing refresh tokens for the user are revoked.
|
||||
4. A new session is issued for the current browser.
|
||||
|
||||
### 2FA enable
|
||||
|
||||
When 2FA is enabled:
|
||||
|
||||
@@ -4,7 +4,7 @@ This document summarizes the current security-relevant behavior of MiauInv. It i
|
||||
|
||||
## Authentication
|
||||
|
||||
MiauInv uses signed JWT access tokens, database-backed refresh tokens, and optional TOTP-based two-factor authentication.
|
||||
MiauInv uses signed JWT access tokens, database-backed refresh tokens, optional TOTP-based two-factor authentication, and optional WebAuthn passkeys.
|
||||
|
||||
JWTs are signed with `JWT_SECRET`. They are not encrypted. Normal access tokens and purpose tokens should therefore contain only identity and authorization metadata.
|
||||
|
||||
@@ -66,9 +66,23 @@ Recovery codes are single-use. During login, a submitted value is first checked
|
||||
|
||||
The account settings UI warns the user when the remaining unused recovery-code count is low.
|
||||
|
||||
## Passkeys
|
||||
|
||||
Passkeys use WebAuthn public-key credentials. The server stores credential metadata and public-key material, but not private keys. Private keys remain controlled by the authenticator, browser, operating system, or security key.
|
||||
|
||||
Passkey registration requires the current account password. Registration uses a server-side challenge stored in `passkey_challenges` and returned to the browser only as an opaque challenge token. The browser response is verified before the credential is stored.
|
||||
|
||||
Passkey login creates a one-time server-side challenge. The login flow uses discoverable passkeys and does not require entering a username first. User verification is required for passkey registration and login.
|
||||
|
||||
Passkey login is treated as a complete phishing-resistant sign-in method. Because passkey registration and login require WebAuthn user verification, the server issues a normal session after a valid passkey assertion instead of asking for an additional TOTP code.
|
||||
|
||||
When passkeys are added, removed, or disabled, existing refresh sessions are revoked and a fresh current session is issued.
|
||||
|
||||
Passkey ceremonies require HTTPS except for localhost. Reverse proxy deployments must preserve the correct public `Host`, `X-Forwarded-Host`, and `X-Forwarded-Proto` information so that the relying party origin and ID match the browser-visible origin.
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
Basic in-memory rate limiting protects login, 2FA, refresh, registration, and sensitive account endpoints.
|
||||
Basic in-memory rate limiting protects login, passkey ceremonies, 2FA, refresh, registration, and sensitive account endpoints.
|
||||
|
||||
This is suitable for a single-instance private deployment. It is not sufficient for multi-instance deployments because limiter state is process-local. A public or multi-instance deployment should use persistent or distributed rate limiting at the application, reverse proxy, or infrastructure layer.
|
||||
|
||||
@@ -76,8 +90,8 @@ This is suitable for a single-instance private deployment. It is not sufficient
|
||||
|
||||
- Automated testing is currently limited.
|
||||
- TOTP secrets are stored in the database after confirmation because the server must validate future codes.
|
||||
- Passkey credential metadata and public-key data are stored in the database after registration.
|
||||
- TOTP secrets are not encrypted at rest.
|
||||
- There is no dedicated session/device management UI yet.
|
||||
- There is no audit log for account security changes yet.
|
||||
- The current rate limiter is process-local and memory-only.
|
||||
- Passkeys/WebAuthn are intentionally not implemented yet.
|
||||
|
||||
Reference in New Issue
Block a user