added passkey support (closes #6)
All checks were successful
test-and-lint / test-and-lint (pull_request) Successful in 2m50s

This commit is contained in:
2026-06-10 03:24:31 +02:00
parent 01ec41288a
commit fb3be56959
18 changed files with 1680 additions and 61 deletions

View File

@@ -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.

View File

@@ -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:

View File

@@ -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.