added passkey support (closes #6) #14

Merged
MiauRizius merged 1 commits from feature/6-passkey-support into main 2026-06-10 03:30:41 +02:00
18 changed files with 1680 additions and 61 deletions

View File

@@ -1,6 +1,6 @@
# MiauInv # MiauInv
MiauInv is a lightweight inventory, stock, and project allocation management system written in Go. It provides a server-rendered dashboard with HTMX-style page composition, vanilla JavaScript for API interactions, SQLite for persistence, JWT-based sessions, refresh-token rotation, account settings, and optional TOTP-based two-factor authentication. MiauInv is a lightweight inventory, stock, and project allocation management system written in Go. It provides a server-rendered dashboard with HTMX-style page composition, vanilla JavaScript for API interactions, SQLite for persistence, JWT-based sessions, refresh-token rotation, account settings, and optional TOTP-based two-factor authentication and WebAuthn passkey authentication.
The project is designed for self-hosted/private deployments. It is not a full enterprise asset-management platform, but it already covers the main workflows needed for tracking items, locations, stock distribution, and project allocations. The project is designed for self-hosted/private deployments. It is not a full enterprise asset-management platform, but it already covers the main workflows needed for tracking items, locations, stock distribution, and project allocations.
@@ -38,9 +38,9 @@ The project is designed for self-hosted/private deployments. It is not a full en
- Database-backed refresh tokens with rotation. - Database-backed refresh tokens with rotation.
- HTTP-only secure cookies for access and refresh tokens. - HTTP-only secure cookies for access and refresh tokens.
- Account settings page at `/profile/settings`. - Account settings page at `/profile/settings`.
- Passkey registration, login, removal, and full passkey disable from account settings.
- Username change with password confirmation. - Username change with password confirmation.
- Password change with old-password verification and session refresh. - Password change with old-password verification and session refresh.
- Avatar placeholder in the account settings UI for a later avatar implementation.
### Two-factor authentication ### Two-factor authentication
@@ -57,11 +57,21 @@ The project is designed for self-hosted/private deployments. It is not a full en
- The setup secret is only stored after the first valid authenticator code. - The setup secret is only stored after the first valid authenticator code.
- Existing refresh sessions are revoked when 2FA is enabled or disabled. - Existing refresh sessions are revoked when 2FA is enabled or disabled.
### Passkeys
- WebAuthn-based passkey registration from account settings.
- Passkey login from the normal sign-in page.
- Discoverable passkey login without entering a username first.
- User-verification required for registration and login.
- Server-side challenge storage using opaque one-time challenge tokens.
- Passkey removal with current-password confirmation.
- Full passkey disable with current-password confirmation.
- Existing refresh sessions are revoked when passkeys are added, removed, or disabled.
## Current Status ## Current Status
MiauInv is an active private project. The current version supports core inventory workflows and account-level security settings. Some areas are intentionally still basic: MiauInv is an active private project. The current version supports core inventory workflows and account-level security settings. Some areas are intentionally still basic:
- Avatar support is currently only represented by a placeholder in the UI.
- There is no dedicated admin panel yet. - There is no dedicated admin panel yet.
- Basic in-memory rate limiting protects login, 2FA, refresh, registration, and sensitive account endpoints. - Basic in-memory rate limiting protects login, 2FA, refresh, registration, and sensitive account endpoints.
- Automated testing is currently limited and will be expanded in future releases. - Automated testing is currently limited and will be expanded in future releases.
@@ -77,6 +87,7 @@ MiauInv is an active private project. The current version supports core inventor
| Authentication | JWT via `github.com/golang-jwt/jwt/v5` | | Authentication | JWT via `github.com/golang-jwt/jwt/v5` |
| Password hashing | bcrypt via `golang.org/x/crypto/bcrypt` | | Password hashing | bcrypt via `golang.org/x/crypto/bcrypt` |
| 2FA | TOTP via `github.com/pquerna/otp/totp` | | 2FA | TOTP via `github.com/pquerna/otp/totp` |
| Passkeys | WebAuthn via `github.com/go-webauthn/webauthn` |
| Frontend | Server-rendered HTML, HTMX-style structure, vanilla JavaScript | | Frontend | Server-rendered HTML, HTMX-style structure, vanilla JavaScript |
| Styling | Custom CSS with dark theme variables | | Styling | Custom CSS with dark theme variables |
| Deployment | Docker / Docker Compose | | Deployment | Docker / Docker Compose |
@@ -155,7 +166,7 @@ openssl rand -base64 48
| `/items` | `GET` | Yes | Item management view. | | `/items` | `GET` | Yes | Item management view. |
| `/locations` | `GET` | Yes | Location management view. | | `/locations` | `GET` | Yes | Location management view. |
| `/projects` | `GET` | Yes | Project management view. | | `/projects` | `GET` | Yes | Project management view. |
| `/profile/settings` | `GET` | Yes | Account settings, password changes, 2FA setup, 2FA disable, and recovery-code management. | | `/profile/settings` | `GET` | Yes | Account settings, password changes, passkey management, 2FA setup, 2FA disable, and recovery-code management. |
| `/profile/` | `GET` | No | Placeholder page for unfinished profile subpages. | | `/profile/` | `GET` | No | Placeholder page for unfinished profile subpages. |
| `/assets/*` | `GET` | No | Static CSS/JS assets. Minified CSS/JS variants are generated on request. | | `/assets/*` | `GET` | No | Static CSS/JS assets. Minified CSS/JS variants are generated on request. |
@@ -176,6 +187,13 @@ openssl rand -base64 48
| `/api/2fa/enable` | `POST` | Yes | Enables 2FA after validating the temporary setup token and a TOTP code. Replaces recovery codes and revokes old sessions. | | `/api/2fa/enable` | `POST` | Yes | Enables 2FA after validating the temporary setup token and a TOTP code. Replaces recovery codes and revokes old sessions. |
| `/api/2fa/disable` | `POST` | Yes | Disables 2FA after password and TOTP confirmation. Revokes sessions and clears auth cookies. | | `/api/2fa/disable` | `POST` | Yes | Disables 2FA after password and TOTP confirmation. Revokes sessions and clears auth cookies. |
| `/api/2fa/recovery-codes/regenerate` | `POST` | Yes | Invalidates existing recovery codes and returns a new set after password and TOTP confirmation. | | `/api/2fa/recovery-codes/regenerate` | `POST` | Yes | Invalidates existing recovery codes and returns a new set after password and TOTP confirmation. |
| `/api/passkeys` | `GET` | Yes | Lists passkeys registered for the current user. |
| `/api/passkeys` | `DELETE` | Yes | Removes one passkey after current-password confirmation. |
| `/api/passkeys/register/options` | `POST` | Yes | Creates a server-side passkey registration challenge after current-password confirmation. |
| `/api/passkeys/register/finish` | `POST` | Yes | Verifies and stores a new passkey credential. Revokes old sessions and issues a new current session. |
| `/api/passkeys/login/options` | `POST` | No | Creates a discoverable passkey login challenge. |
| `/api/passkeys/login/finish` | `POST` | No | Verifies passkey login and issues a full session. |
| `/api/passkeys/disable` | `POST` | Yes | Deletes all passkeys after current-password confirmation. Revokes old sessions and issues a new current session. |
### Inventory API ### Inventory API
@@ -304,10 +322,11 @@ For Docker deployments, place Caddy and MiauInv on the same Docker network and r
- Access tokens expire after 15 minutes. - Access tokens expire after 15 minutes.
- Refresh tokens expire after 7 days and are rotated on refresh. - Refresh tokens expire after 7 days and are rotated on refresh.
- Refresh tokens and recovery codes are stored in the database as hashes. - Refresh tokens and recovery codes are stored in the database as hashes.
- Passkey credentials store public-key credential data, not private keys. Private keys remain in the authenticator or platform passkey provider.
- TOTP secrets are currently stored in the database because the server must validate codes. Protect the database file accordingly. - TOTP secrets are currently stored in the database because the server must validate codes. Protect the database file accordingly.
- Recovery codes are only shown when generated. Users should download or copy them immediately. The UI warns when few unused codes remain. - Recovery codes are only shown when generated. Users should download or copy them immediately. The UI warns when few unused codes remain.
- 2FA disable and recovery-code regeneration require both the current password and a valid TOTP code. - 2FA disable and recovery-code regeneration require both the current password and a valid TOTP code.
- Basic in-memory rate limiting is enabled for login, 2FA, refresh, registration, and sensitive account endpoints. Use persistent or distributed rate limiting for multi-instance deployments. - Basic in-memory rate limiting is enabled for login, passkey ceremonies, 2FA, refresh, registration, and sensitive account endpoints. Use persistent or distributed rate limiting for multi-instance deployments.
- Automated testing is currently limited. Authentication, 2FA, recovery codes, rate limiting, account settings, and inventory handlers should be covered before production use. - Automated testing is currently limited. Authentication, 2FA, recovery codes, rate limiting, account settings, and inventory handlers should be covered before production use.
## Screenshots ## Screenshots

View File

@@ -1,6 +1,6 @@
# Authentication Architecture # 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. 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. | | 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. | | 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. | | 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. | | Passkey handlers | `handlers/passkeys.go` | WebAuthn/passkey registration, login, removal, and disable flows. |
| 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. | | 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 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. | | 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 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`. | | 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 ## 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. 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. 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 ## TOTP Setup Flow
The account settings page at `/profile/settings` exposes the UI for TOTP setup. 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, - username changes,
- password changes, - password changes,
- passkey registration,
- passkey removal,
- passkey disable,
- TOTP 2FA setup, - TOTP 2FA setup,
- TOTP 2FA disable, - TOTP 2FA disable,
- recovery-code download after generation, - 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. 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 ## 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. - 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 audit logging for account security changes.
- Add optional session/device management UI. - 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. - 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; 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 ## Entity Overview
```text ```text
[users] 1 ──── N [refresh_tokens] [users] 1 ──── N [refresh_tokens]
[users] 1 ──── N [two_factor_recovery_codes] [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 [stock] N ──── 1 [locations]
[items] 1 ──── N [project_items] N ──── 1 [projects] [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. 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` ### `items`
Stores tracked inventory 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. 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 ### Uniqueness
@@ -134,6 +165,7 @@ The following values are unique:
- `locations.name` - `locations.name`
- `projects.name` - `projects.name`
- `two_factor_recovery_codes(user_id, code_hash)` - `two_factor_recovery_codes(user_id, code_hash)`
- `passkey_credentials.credential_id`
### Current schema limitations ### Current schema limitations
@@ -162,6 +194,36 @@ When the password is updated:
3. Existing refresh tokens for that user are revoked. 3. Existing refresh tokens for that user are revoked.
4. A new session is issued. 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 ### 2FA enable
When 2FA is enabled: When 2FA is enabled:

View File

@@ -4,7 +4,7 @@ This document summarizes the current security-relevant behavior of MiauInv. It i
## Authentication ## 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. 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. 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 ## 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. 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. - Automated testing is currently limited.
- TOTP secrets are stored in the database after confirmation because the server must validate future codes. - 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. - TOTP secrets are not encrypted at rest.
- There is no dedicated session/device management UI yet. - There is no dedicated session/device management UI yet.
- There is no audit log for account security changes yet. - There is no audit log for account security changes yet.
- The current rate limiter is process-local and memory-only. - The current rate limiter is process-local and memory-only.
- Passkeys/WebAuthn are intentionally not implemented yet.

View File

@@ -130,6 +130,51 @@ input::placeholder {
color: #4b5563; color: #4b5563;
} }
.password-input-wrapper {
position: relative;
width: 100%;
}
.password-input-wrapper input {
padding-right: 3rem;
}
.password-toggle {
position: absolute;
top: 50%;
right: 0.45rem;
width: 2.25rem;
height: 2.25rem;
transform: translateY(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
cursor: pointer;
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
.password-toggle:hover {
color: var(--text);
background: rgba(255, 255, 255, 0.06);
}
.password-toggle:focus-visible {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
}
.password-toggle svg {
width: 1.15rem;
height: 1.15rem;
pointer-events: none;
}
.footer-text { .footer-text {
margin-top: 1.5rem; margin-top: 1.5rem;
font-size: 0.9rem; font-size: 0.9rem;

View File

@@ -41,6 +41,7 @@ document.addEventListener("DOMContentLoaded", () => {
if (document.getElementById('projects-table-body')) loadProjects(); if (document.getElementById('projects-table-body')) loadProjects();
if (document.getElementById('account-settings-content')) loadAccountSettings(); if (document.getElementById('account-settings-content')) loadAccountSettings();
setupPasswordVisibilityToggles();
loadProfile(); loadProfile();
}); });
@@ -487,6 +488,7 @@ async function loadProfile() {
// ---- ACCOUNT SETTINGS ---- // ---- ACCOUNT SETTINGS ----
let latestRecoveryCodes = []; let latestRecoveryCodes = [];
let pendingTwoFactorSetupToken = ""; let pendingTwoFactorSetupToken = "";
let currentPasskeys = [];
function showAccountSettingsMessage(message, type = 'success') { function showAccountSettingsMessage(message, type = 'success') {
const box = document.getElementById('account-settings-message'); const box = document.getElementById('account-settings-message');
@@ -496,6 +498,51 @@ function showAccountSettingsMessage(message, type = 'success') {
box.style.display = 'block'; box.style.display = 'block';
} }
function setupPasswordVisibilityToggles(root = document) {
const eyeIcon = `
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M2.25 12s3.5-6.75 9.75-6.75S21.75 12 21.75 12 18.25 18.75 12 18.75 2.25 12 2.25 12Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="12" cy="12" r="2.75" fill="none" stroke="currentColor" stroke-width="1.8"/>
</svg>`;
const eyeOffIcon = `
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M3 3l18 18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
<path d="M10.58 10.58A2.75 2.75 0 0 0 13.42 13.42" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
<path d="M7.1 7.7C3.95 9.55 2.25 12 2.25 12s3.5 6.75 9.75 6.75c1.65 0 3.08-.47 4.29-1.15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.8 5.55A9.2 9.2 0 0 1 12 5.25c6.25 0 9.75 6.75 9.75 6.75a15.3 15.3 0 0 1-2.3 2.95" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
root.querySelectorAll('input[type="password"]').forEach((input) => {
if (input.dataset.visibilityToggleAttached === 'true') return;
input.dataset.visibilityToggleAttached = 'true';
let wrapper = input.closest('.password-input-wrapper');
if (!wrapper) {
wrapper = document.createElement('div');
wrapper.className = 'password-input-wrapper';
input.parentNode.insertBefore(wrapper, input);
wrapper.appendChild(input);
}
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'password-toggle';
toggle.innerHTML = eyeIcon;
toggle.setAttribute('aria-label', `Show ${input.placeholder || 'password'}`);
toggle.setAttribute('title', 'Show password');
toggle.addEventListener('click', () => {
const show = input.type === 'password';
input.type = show ? 'text' : 'password';
toggle.innerHTML = show ? eyeOffIcon : eyeIcon;
toggle.setAttribute('aria-label', `${show ? 'Hide' : 'Show'} ${input.placeholder || 'password'}`);
toggle.setAttribute('title', show ? 'Hide password' : 'Show password');
});
wrapper.appendChild(toggle);
});
}
function setTwoFactorPanels(enabled) { function setTwoFactorPanels(enabled) {
const badge = document.getElementById('two-factor-badge'); const badge = document.getElementById('two-factor-badge');
const status = document.getElementById('two-factor-status'); const status = document.getElementById('two-factor-status');
@@ -552,16 +599,17 @@ async function loadAccountSettings() {
const data = await apiRequest('/api/profile'); const data = await apiRequest('/api/profile');
const usernameInput = document.getElementById('settings-username'); const usernameInput = document.getElementById('settings-username');
const avatarPreview = document.getElementById('settings-avatar-preview');
const remaining = document.getElementById('recovery-codes-remaining'); const remaining = document.getElementById('recovery-codes-remaining');
if (usernameInput) usernameInput.value = data.username || ''; if (usernameInput) usernameInput.value = data.username || '';
if (avatarPreview && data.username) avatarPreview.innerText = data.username[0].toLocaleUpperCase();
if (remaining) remaining.innerText = data.recovery_codes_remaining || 0; if (remaining) remaining.innerText = data.recovery_codes_remaining || 0;
updateRecoveryCodeWarning(data.recovery_codes_remaining || 0, !!data.recovery_codes_warning); updateRecoveryCodeWarning(data.recovery_codes_remaining || 0, !!data.recovery_codes_warning);
setTwoFactorPanels(!!data.two_factor_enabled); setTwoFactorPanels(!!data.two_factor_enabled);
setPasskeyPanels(!!data.passkeys_enabled, data.passkey_count || 0);
renderRecoveryCodes([]); renderRecoveryCodes([]);
loadPasskeys();
setupPasswordVisibilityToggles(document.getElementById('account-settings-content') || document);
} catch (err) { } catch (err) {
showAccountSettingsMessage(err.message || 'Failed to load account settings.', 'error'); showAccountSettingsMessage(err.message || 'Failed to load account settings.', 'error');
} }
@@ -581,10 +629,8 @@ async function saveAccountUsername(event) {
const username = document.getElementById('username'); const username = document.getElementById('username');
const avatar = document.getElementById('avatar'); const avatar = document.getElementById('avatar');
const avatarPreview = document.getElementById('settings-avatar-preview');
if (username) username.innerText = data.username; if (username) username.innerText = data.username;
if (avatar && data.username) avatar.innerText = data.username[0].toLocaleUpperCase(); if (avatar && data.username) avatar.innerText = data.username[0].toLocaleUpperCase();
if (avatarPreview && data.username) avatarPreview.innerText = data.username[0].toLocaleUpperCase();
} catch (err) { } catch (err) {
showAccountSettingsMessage(err.message || 'Could not update username.', 'error'); showAccountSettingsMessage(err.message || 'Could not update username.', 'error');
} }
@@ -681,8 +727,6 @@ async function enableTwoFactor(event) {
async function disableTwoFactor(event) { async function disableTwoFactor(event) {
event.preventDefault(); event.preventDefault();
if (!confirm('Disable 2FA for your account?')) return;
try { try {
await apiRequest('/api/2fa/disable', 'POST', { await apiRequest('/api/2fa/disable', 'POST', {
password: document.getElementById('two-factor-disable-password').value, password: document.getElementById('two-factor-disable-password').value,
@@ -707,8 +751,6 @@ async function disableTwoFactor(event) {
async function regenerateRecoveryCodes(event) { async function regenerateRecoveryCodes(event) {
event.preventDefault(); event.preventDefault();
if (!confirm('Generate new recovery codes? Existing unused codes will stop working.')) return;
try { try {
const data = await apiRequest('/api/2fa/recovery-codes/regenerate', 'POST', { const data = await apiRequest('/api/2fa/recovery-codes/regenerate', 'POST', {
password: document.getElementById('recovery-password').value, password: document.getElementById('recovery-password').value,
@@ -752,3 +794,247 @@ function downloadRecoveryCodes() {
link.remove(); link.remove();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
function webauthnSupported() {
return window.PublicKeyCredential && navigator.credentials;
}
function webauthnBase64URLToBuffer(value) {
const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
function webauthnBufferToBase64URL(buffer) {
const bytes = new Uint8Array(buffer);
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function prepareCredentialCreationOptions(options) {
const publicKey = options.publicKey || options;
publicKey.challenge = webauthnBase64URLToBuffer(publicKey.challenge);
publicKey.user.id = webauthnBase64URLToBuffer(publicKey.user.id);
if (Array.isArray(publicKey.excludeCredentials)) {
publicKey.excludeCredentials = publicKey.excludeCredentials.map((credential) => ({
...credential,
id: webauthnBase64URLToBuffer(credential.id)
}));
}
return publicKey;
}
function attestationCredentialToJSON(credential) {
const response = credential.response;
return {
id: credential.id,
rawId: webauthnBufferToBase64URL(credential.rawId),
type: credential.type,
response: {
clientDataJSON: webauthnBufferToBase64URL(response.clientDataJSON),
attestationObject: webauthnBufferToBase64URL(response.attestationObject),
transports: typeof response.getTransports === 'function' ? response.getTransports() : []
},
clientExtensionResults: credential.getClientExtensionResults()
};
}
function setPasskeyPanels(enabled, count) {
const badge = document.getElementById('passkey-badge');
const status = document.getElementById('passkey-status');
const unsupported = document.getElementById('passkey-unsupported-message');
const addForm = document.getElementById('passkey-add-form');
if (unsupported) unsupported.style.display = webauthnSupported() ? 'none' : 'block';
if (addForm) {
for (const element of addForm.elements) {
element.disabled = !webauthnSupported();
}
}
if (!badge || !status) return;
if (enabled) {
badge.textContent = 'Enabled';
badge.classList.add('success');
status.textContent = `${count} passkey${count === 1 ? '' : 's'} registered for this account.`;
} else {
badge.textContent = 'Disabled';
badge.classList.remove('success');
status.textContent = 'No passkeys are registered for this account.';
}
}
function renderPasskeys(passkeys) {
currentPasskeys = passkeys || [];
const list = document.getElementById('passkey-list');
if (!list) return;
if (currentPasskeys.length === 0) {
list.innerHTML = '<p style="color: var(--text-muted); margin: 0;">No passkeys registered.</p>';
return;
}
list.innerHTML = currentPasskeys.map((passkey) => {
const created = passkey.created_at ? new Date(passkey.created_at * 1000).toLocaleString() : 'unknown';
const lastUsed = passkey.last_used_at ? new Date(passkey.last_used_at * 1000).toLocaleString() : 'never';
const id = escapeAttr(passkey.id);
return `
<div style="border: 1px solid var(--border); border-radius: 12px; padding: 1rem; display: grid; gap: 0.75rem;">
<div style="display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; flex-wrap: wrap;">
<div>
<strong style="color: var(--text);">${escapeHTML(passkey.name || 'Passkey')}</strong>
<div style="color: var(--text-muted); font-size: 0.85rem; margin-top: 0.25rem;">Created: ${escapeHTML(created)}</div>
<div style="color: var(--text-muted); font-size: 0.85rem; margin-top: 0.25rem;">Last used: ${escapeHTML(lastUsed)}</div>
</div>
<button type="button" class="btn btn-secondary danger-btn" style="width: auto; padding: 0.45rem 0.8rem;" onclick="showPasskeyDeleteForm('${id}')">Remove</button>
</div>
<div id="passkey-delete-form-${id}" style="display: none; border-top: 1px solid var(--border); padding-top: 0.75rem;">
<p style="color: var(--text-muted); margin-bottom: 0.75rem;">Confirm with your current password to remove this passkey.</p>
<div class="form-group" style="margin-bottom: 0.75rem;">
<input type="password" id="passkey-delete-password-${id}" placeholder="Current password" autocomplete="current-password">
</div>
<div style="display: flex; gap: 0.75rem; flex-wrap: wrap;">
<button type="button" class="btn btn-secondary danger-btn" style="width: auto; padding: 0.45rem 0.8rem;" onclick="confirmDeletePasskey('${id}')">Confirm removal</button>
<button type="button" class="btn btn-secondary" style="width: auto; padding: 0.45rem 0.8rem;" onclick="hidePasskeyDeleteForm('${id}')">Cancel</button>
</div>
</div>
</div>
`;
}).join('');
setupPasswordVisibilityToggles(list);
}
async function loadPasskeys() {
if (!document.getElementById('passkey-list')) return;
try {
const data = await apiRequest('/api/passkeys');
renderPasskeys(data.passkeys || []);
setPasskeyPanels(!!data.passkeys_enabled, data.passkey_count || 0);
} catch (err) {
showAccountSettingsMessage(err.message || 'Could not load passkeys.', 'error');
}
}
async function addPasskey(event) {
event.preventDefault();
if (!webauthnSupported()) {
showAccountSettingsMessage('Passkeys are not supported by this browser.', 'error');
return;
}
const name = document.getElementById('passkey-name').value.trim() || 'Passkey';
const password = document.getElementById('passkey-add-password').value;
try {
const optionsData = await apiRequest('/api/passkeys/register/options', 'POST', { name, password });
const publicKey = prepareCredentialCreationOptions(optionsData.options);
const credential = await navigator.credentials.create({ publicKey });
if (!credential) {
throw new Error('Passkey creation was cancelled.');
}
const data = await apiRequest('/api/passkeys/register/finish', 'POST', {
session_token: optionsData.session_token,
name,
credential: attestationCredentialToJSON(credential)
});
if (data.access_token && data.refresh_token) {
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
}
document.getElementById('passkey-add-form').reset();
renderPasskeys(data.passkeys || []);
setPasskeyPanels(!!data.passkeys_enabled, data.passkey_count || 0);
showAccountSettingsMessage('Passkey added. Your session was refreshed.');
} catch (err) {
showAccountSettingsMessage(err.message || 'Could not add passkey.', 'error');
}
}
function showPasskeyDeleteForm(id) {
const form = document.getElementById(`passkey-delete-form-${id}`);
const input = document.getElementById(`passkey-delete-password-${id}`);
if (!form) return;
form.style.display = 'block';
setupPasswordVisibilityToggles(form);
if (input) input.focus();
}
function hidePasskeyDeleteForm(id) {
const form = document.getElementById(`passkey-delete-form-${id}`);
const input = document.getElementById(`passkey-delete-password-${id}`);
if (input) input.value = '';
if (form) form.style.display = 'none';
}
async function confirmDeletePasskey(id) {
if (!id) return;
const input = document.getElementById(`passkey-delete-password-${id}`);
const password = input ? input.value : '';
if (!password) {
showAccountSettingsMessage('Current password required to remove passkey.', 'error');
return;
}
try {
const data = await apiRequest('/api/passkeys', 'DELETE', { id, password });
if (data.access_token && data.refresh_token) {
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
}
renderPasskeys(data.passkeys || []);
setPasskeyPanels(!!data.passkeys_enabled, data.passkey_count || 0);
showAccountSettingsMessage('Passkey removed. Your session was refreshed.');
} catch (err) {
showAccountSettingsMessage(err.message || 'Could not remove passkey.', 'error');
}
}
async function deletePasskey(id) {
showPasskeyDeleteForm(id);
}
async function disablePasskeys(event) {
event.preventDefault();
try {
const data = await apiRequest('/api/passkeys/disable', 'POST', {
password: document.getElementById('passkey-disable-password').value
});
if (data.access_token && data.refresh_token) {
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
}
document.getElementById('passkey-disable-form').reset();
renderPasskeys([]);
setPasskeyPanels(false, 0);
showAccountSettingsMessage('Passkeys disabled. Your session was refreshed.');
} catch (err) {
showAccountSettingsMessage(err.message || 'Could not disable passkeys.', 'error');
}
}
function escapeHTML(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function escapeAttr(value) {
return escapeHTML(value).replace(/`/g, '&#96;');
}

View File

@@ -7,16 +7,70 @@ document.addEventListener("DOMContentLoaded", () => {
const twoFactorInput = document.getElementById("two-factor-code"); const twoFactorInput = document.getElementById("two-factor-code");
const twoFactorGroup = document.getElementById("two-factor-group"); const twoFactorGroup = document.getElementById("two-factor-group");
const submitButton = document.getElementById("login-submit"); const submitButton = document.getElementById("login-submit");
const passkeyLoginButton = document.getElementById("passkey-login-button");
const passkeyLoginHint = document.getElementById("passkey-login-hint");
let pendingTwoFactorToken = null; let pendingTwoFactorToken = null;
if (!form) return; if (!form) return;
setupPasswordVisibilityToggles();
function showError(message) { function showError(message) {
errorBox.textContent = message || "Login failed."; errorBox.textContent = message || "Login failed.";
errorBox.style.display = "block"; errorBox.style.display = "block";
} }
function clearError() {
errorBox.textContent = "";
errorBox.style.display = "none";
}
function setupPasswordVisibilityToggles(root = document) {
const eyeIcon = `
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M2.25 12s3.5-6.75 9.75-6.75S21.75 12 21.75 12 18.25 18.75 12 18.75 2.25 12 2.25 12Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="12" cy="12" r="2.75" fill="none" stroke="currentColor" stroke-width="1.8"/>
</svg>`;
const eyeOffIcon = `
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M3 3l18 18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
<path d="M10.58 10.58A2.75 2.75 0 0 0 13.42 13.42" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
<path d="M7.1 7.7C3.95 9.55 2.25 12 2.25 12s3.5 6.75 9.75 6.75c1.65 0 3.08-.47 4.29-1.15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.8 5.55A9.2 9.2 0 0 1 12 5.25c6.25 0 9.75 6.75 9.75 6.75a15.3 15.3 0 0 1-2.3 2.95" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
root.querySelectorAll('input[type="password"]').forEach((input) => {
if (input.dataset.visibilityToggleAttached === 'true') return;
input.dataset.visibilityToggleAttached = 'true';
let wrapper = input.closest('.password-input-wrapper');
if (!wrapper) {
wrapper = document.createElement('div');
wrapper.className = 'password-input-wrapper';
input.parentNode.insertBefore(wrapper, input);
wrapper.appendChild(input);
}
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'password-toggle';
toggle.innerHTML = eyeIcon;
toggle.setAttribute('aria-label', `Show ${input.placeholder || 'password'}`);
toggle.setAttribute('title', 'Show password');
toggle.addEventListener('click', () => {
const show = input.type === 'password';
input.type = show ? 'text' : 'password';
toggle.innerHTML = show ? eyeOffIcon : eyeIcon;
toggle.setAttribute('aria-label', `${show ? 'Hide' : 'Show'} ${input.placeholder || 'password'}`);
toggle.setAttribute('title', show ? 'Hide password' : 'Show password');
});
wrapper.appendChild(toggle);
});
}
function storeTokens(data) { function storeTokens(data) {
localStorage.setItem("access_token", data.access_token); localStorage.setItem("access_token", data.access_token);
localStorage.setItem("refresh_token", data.refresh_token); localStorage.setItem("refresh_token", data.refresh_token);
@@ -26,15 +80,131 @@ document.addEventListener("DOMContentLoaded", () => {
pendingTwoFactorToken = token; pendingTwoFactorToken = token;
usernameInput.disabled = true; usernameInput.disabled = true;
passwordInput.disabled = true; passwordInput.disabled = true;
if (passkeyLoginButton) passkeyLoginButton.disabled = true;
twoFactorGroup.style.display = "block"; twoFactorGroup.style.display = "block";
twoFactorInput.required = true; twoFactorInput.required = true;
twoFactorInput.focus(); twoFactorInput.focus();
submitButton.textContent = "Verify code"; submitButton.textContent = "Verify code";
} }
function webauthnSupported() {
return window.PublicKeyCredential && navigator.credentials;
}
function base64URLToBuffer(value) {
const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
function bufferToBase64URL(buffer) {
const bytes = new Uint8Array(buffer);
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function prepareCredentialRequestOptions(options) {
const publicKey = options.publicKey || options;
publicKey.challenge = base64URLToBuffer(publicKey.challenge);
if (Array.isArray(publicKey.allowCredentials)) {
publicKey.allowCredentials = publicKey.allowCredentials.map((credential) => ({
...credential,
id: base64URLToBuffer(credential.id)
}));
}
return publicKey;
}
function credentialToJSON(credential) {
const response = credential.response;
return {
id: credential.id,
rawId: bufferToBase64URL(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bufferToBase64URL(response.clientDataJSON),
authenticatorData: bufferToBase64URL(response.authenticatorData),
signature: bufferToBase64URL(response.signature),
userHandle: response.userHandle ? bufferToBase64URL(response.userHandle) : null
},
clientExtensionResults: credential.getClientExtensionResults()
};
}
async function handlePasskeyLogin() {
if (!webauthnSupported()) {
showError("Passkeys are not supported by this browser.");
return;
}
clearError();
if (passkeyLoginButton) passkeyLoginButton.disabled = true;
submitButton.disabled = true;
try {
const optionsResponse = await fetch("/api/passkeys/login/options", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
});
if (!optionsResponse.ok) {
throw new Error(await optionsResponse.text());
}
const optionsData = await optionsResponse.json();
const publicKey = prepareCredentialRequestOptions(optionsData.options);
const assertion = await navigator.credentials.get({ publicKey });
if (!assertion) {
throw new Error("Passkey authentication was cancelled.");
}
const finishResponse = await fetch("/api/passkeys/login/finish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_token: optionsData.session_token,
credential: credentialToJSON(assertion)
})
});
if (!finishResponse.ok) {
throw new Error(await finishResponse.text());
}
const data = await finishResponse.json();
if (data.requires_2fa) {
switchToTwoFactorMode(data.two_factor_token);
return;
}
storeTokens(data);
window.location.href = "/dashboard";
} catch (err) {
showError(err.message);
} finally {
if (!pendingTwoFactorToken && passkeyLoginButton) passkeyLoginButton.disabled = false;
submitButton.disabled = false;
}
}
if (passkeyLoginButton) {
if (!webauthnSupported()) {
passkeyLoginButton.disabled = true;
if (passkeyLoginHint) passkeyLoginHint.textContent = "Passkeys are not supported by this browser.";
} else {
passkeyLoginButton.addEventListener("click", handlePasskeyLogin);
}
}
form.addEventListener("submit", async (e) => { form.addEventListener("submit", async (e) => {
e.preventDefault(); e.preventDefault();
errorBox.style.display = "none"; clearError();
submitButton.disabled = true; submitButton.disabled = true;
try { try {

View File

@@ -9,7 +9,7 @@
<div class="modal-split" style="align-items: start;"> <div class="modal-split" style="align-items: start;">
<div class="card" style="max-width: 100%; text-align: left; padding: 1.5rem;"> <div class="card" style="max-width: 100%; text-align: left; padding: 1.5rem;">
<h2 style="font-size: 1.25rem; margin-bottom: 0.5rem; color: var(--text);">Profile</h2> <h2 style="font-size: 1.25rem; margin-bottom: 0.5rem; color: var(--text);">Profile</h2>
<p style="color: var(--text-muted); margin-bottom: 1.5rem;">Change your username. Avatar upload is planned for later.</p> <p style="color: var(--text-muted); margin-bottom: 1.5rem;">Change your username.</p>
<form id="username-form" onsubmit="saveAccountUsername(event)"> <form id="username-form" onsubmit="saveAccountUsername(event)">
<div class="form-group"> <div class="form-group">
@@ -22,17 +22,6 @@
</div> </div>
<button type="submit" class="btn btn-primary">Save username</button> <button type="submit" class="btn btn-primary">Save username</button>
</form> </form>
<div style="margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--border);">
<h3 style="font-size: 1rem; margin-bottom: 0.75rem; color: var(--text);">Avatar</h3>
<div style="display:flex; align-items:center; gap:1rem; color: var(--text-muted);">
<div id="settings-avatar-preview" class="avatar">M</div>
<div>
<div>Avatar upload is not implemented yet.</div>
<div style="font-size:0.85rem; margin-top:0.25rem;">This placeholder keeps the settings layout ready for it.</div>
</div>
</div>
</div>
</div> </div>
<div class="card" style="max-width: 100%; text-align: left; padding: 1.5rem;"> <div class="card" style="max-width: 100%; text-align: left; padding: 1.5rem;">
@@ -136,5 +125,48 @@
</div> </div>
</div> </div>
</div> </div>
<div class="card" style="max-width: 100%; text-align: left; padding: 1.5rem; margin-top: 1.5rem;">
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; flex-wrap: wrap;">
<div>
<h2 style="font-size: 1.25rem; margin-bottom: 0.5rem; color: var(--text);">Passkeys</h2>
<p id="passkey-status" style="color: var(--text-muted); margin-bottom: 1rem;">Loading passkey status...</p>
</div>
<span id="passkey-badge" class="badge">Unknown</span>
</div>
<p style="color: var(--text-muted); margin-bottom: 1rem;">Use device-bound or synced passkeys for phishing-resistant sign-in. Passkeys are protected by your browser, operating system, or security key.</p>
<div id="passkey-unsupported-message" class="message error" style="display: none; margin-bottom: 1rem;">This browser does not support passkeys.</div>
<div id="passkey-list" style="display: grid; gap: 0.75rem; margin-bottom: 1.5rem;"></div>
<div class="modal-split" style="align-items: start;">
<div>
<h3 style="font-size: 1rem; margin-bottom: 0.75rem; color: var(--text);">Add passkey</h3>
<p style="color: var(--text-muted); margin-bottom: 1rem;">Confirm with your current password, then follow your browser's passkey prompt.</p>
<form id="passkey-add-form" onsubmit="addPasskey(event)">
<div class="form-group">
<input type="text" id="passkey-name" placeholder="Passkey name" autocomplete="off">
</div>
<div class="form-group">
<input type="password" id="passkey-add-password" placeholder="Current password" required autocomplete="current-password">
</div>
<button type="submit" class="btn btn-primary" style="width: auto; padding: 0.6rem 1.2rem;">Add passkey</button>
</form>
</div>
<div>
<h3 style="font-size: 1rem; margin-bottom: 0.75rem; color: var(--text);">Disable passkeys</h3>
<p style="color: var(--text-muted); margin-bottom: 1rem;">This removes all stored passkeys and revokes active refresh sessions.</p>
<form id="passkey-disable-form" onsubmit="disablePasskeys(event)">
<div class="form-group">
<input type="password" id="passkey-disable-password" placeholder="Current password" required autocomplete="current-password">
</div>
<button type="submit" class="btn btn-secondary danger-btn">Disable all passkeys</button>
</form>
</div>
</div>
</div>
</div> </div>
{{ end }} {{ end }}

View File

@@ -36,6 +36,8 @@
</div> </div>
<button type="submit" id="login-submit" class="btn btn-primary">Sign In</button> <button type="submit" id="login-submit" class="btn btn-primary">Sign In</button>
<button type="button" id="passkey-login-button" class="btn btn-secondary" style="margin-top: 0.75rem;">Sign in with passkey</button>
<p id="passkey-login-hint" class="subtitle" style="margin-top: 0.75rem;">Use a saved passkey from this device, your browser, or a security key. No username is required for passkey sign-in.</p>
</form> </form>
<div id="error" class="message error"></div> <div id="error" class="message error"></div>

9
go.mod
View File

@@ -4,6 +4,7 @@ go 1.26
require ( require (
github.com/glebarez/go-sqlite v1.22.0 github.com/glebarez/go-sqlite v1.22.0
github.com/go-webauthn/webauthn v0.17.4
github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/pquerna/otp v1.5.0 github.com/pquerna/otp v1.5.0
@@ -15,9 +16,17 @@ require (
require ( require (
github.com/boombuler/barcode v1.1.0 // indirect github.com/boombuler/barcode v1.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.6 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect github.com/mattn/go-isatty v0.0.22 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/tdewolff/parse/v2 v2.8.13 // indirect github.com/tdewolff/parse/v2 v2.8.13 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/sys v0.46.0 // indirect golang.org/x/sys v0.46.0 // indirect
modernc.org/libc v1.73.0 // indirect modernc.org/libc v1.73.0 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect

78
go.sum
View File

@@ -1,28 +1,39 @@
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo= github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
@@ -30,43 +41,60 @@ github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tdewolff/minify/v2 v2.24.13 h1:xrcF7gKDnUszseEY9WX9mUlZII2v2Go/QAcAwRASw58= github.com/tdewolff/minify/v2 v2.24.13 h1:xrcF7gKDnUszseEY9WX9mUlZII2v2Go/QAcAwRASw58=
github.com/tdewolff/minify/v2 v2.24.13/go.mod h1:emvwoYeIl8bfAKqRU5ww95LX9Gpggpqv/naal9a8Yq0= github.com/tdewolff/minify/v2 v2.24.13/go.mod h1:emvwoYeIl8bfAKqRU5ww95LX9Gpggpqv/naal9a8Yq0=
github.com/tdewolff/parse/v2 v2.8.12 h1:5BBjfaCv482v3nltlS0u6wH1xJaxjR6ofDrWttNvROg=
github.com/tdewolff/parse/v2 v2.8.12/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/parse/v2 v2.8.13 h1:si/8rLw5BZZTWCCiMm9A3f6x+RmqYfrkEeXCgpX5ick= github.com/tdewolff/parse/v2 v2.8.13 h1:si/8rLw5BZZTWCCiMm9A3f6x+RmqYfrkEeXCgpX5ick=
github.com/tdewolff/parse/v2 v2.8.13/go.mod h1:XdsoSFThlVIRIajAuqz1evNY7bagZS8LBOPA3aVopwQ= github.com/tdewolff/parse/v2 v2.8.13/go.mod h1:XdsoSFThlVIRIajAuqz1evNY7bagZS8LBOPA3aVopwQ=
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk= github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.37.6 h1:orZH3c5wmhIQFTXF+Nt+eeauyd+ZIt2BX6ARe+kD+aw= modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
modernc.org/libc v1.37.6/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE= modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.73.0 h1:Y/KmTxbIN5T3x+NFjYOzV/+Ha7wKClfIecmTCTuYlqQ= modernc.org/libc v1.73.0 h1:Y/KmTxbIN5T3x+NFjYOzV/+Ha7wKClfIecmTCTuYlqQ=
modernc.org/libc v1.73.0/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= modernc.org/libc v1.73.0/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ= modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0= modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo= modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo=
modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@@ -672,6 +672,10 @@ func UserInfo(w http.ResponseWriter, r *http.Request) {
twoFactorStatus = "setup_pending" twoFactorStatus = "setup_pending"
} }
recoveryCodesWarning := user.TwoFactorEnabled && recoveryCodesRemaining <= recoveryCodeWarningThreshold recoveryCodesWarning := user.TwoFactorEnabled && recoveryCodesRemaining <= recoveryCodeWarningThreshold
passkeyCount := 0
if count, err := storage.CountPasskeyCredentials(user.ID); err == nil {
passkeyCount = count
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(map[string]interface{}{ err = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -683,6 +687,8 @@ func UserInfo(w http.ResponseWriter, r *http.Request) {
"recovery_codes_remaining": recoveryCodesRemaining, "recovery_codes_remaining": recoveryCodesRemaining,
"recovery_codes_warning": recoveryCodesWarning, "recovery_codes_warning": recoveryCodesWarning,
"recovery_codes_warning_at": recoveryCodeWarningThreshold, "recovery_codes_warning_at": recoveryCodeWarningThreshold,
"passkeys_enabled": passkeyCount > 0,
"passkey_count": passkeyCount,
}) })
if err != nil { if err != nil {
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": " + err.Error()) log.Println("GET [api/userinfo] " + r.RemoteAddr + ": " + err.Error())
@@ -732,6 +738,11 @@ func issueLoginSessionWithExtra(w http.ResponseWriter, r *http.Request, user mod
return return
} }
passkeyCount := 0
if count, err := storage.CountPasskeyCredentials(user.ID); err == nil {
passkeyCount = count
}
setAuthCookies(w, accessToken, refreshTokenPlain) setAuthCookies(w, accessToken, refreshTokenPlain)
response := map[string]interface{}{ response := map[string]interface{}{
"access_token": accessToken, "access_token": accessToken,
@@ -741,6 +752,8 @@ func issueLoginSessionWithExtra(w http.ResponseWriter, r *http.Request, user mod
"username": user.Username, "username": user.Username,
"role": user.Role, "role": user.Role,
"two_factor_enabled": user.TwoFactorEnabled, "two_factor_enabled": user.TwoFactorEnabled,
"passkeys_enabled": passkeyCount > 0,
"passkey_count": passkeyCount,
}, },
} }
for key, value := range extra { for key, value := range extra {

580
handlers/passkeys.go Normal file
View File

@@ -0,0 +1,580 @@
package handlers
import (
"MiauInv/auth"
"MiauInv/models"
"MiauInv/storage"
utils "MiauInv/util"
"bytes"
"encoding/json"
"errors"
"io"
"log"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
)
const passkeyChallengeTTL = 5 * time.Minute
type passkeyWebAuthnUser struct {
user models.User
credentials []webauthn.Credential
}
func (user passkeyWebAuthnUser) WebAuthnID() []byte {
return []byte(user.user.ID)
}
func (user passkeyWebAuthnUser) WebAuthnName() string {
return user.user.Username
}
func (user passkeyWebAuthnUser) WebAuthnDisplayName() string {
return user.user.Username
}
func (user passkeyWebAuthnUser) WebAuthnCredentials() []webauthn.Credential {
return user.credentials
}
func Passkeys(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
listPasskeys(w, r)
case http.MethodDelete:
deletePasskey(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func PasskeyRegisterOptions(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Name string `json:"name"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Println("POST [api/passkeys/register/options] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if strings.TrimSpace(req.Password) == "" {
http.Error(w, "Current password required", http.StatusBadRequest)
return
}
claims := r.Context().Value(auth.UserContextKey).(*auth.Claims)
user, err := storage.GetUserById(claims.UserID)
if err != nil {
log.Println("POST [api/passkeys/register/options] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "User not found", http.StatusNotFound)
return
}
if !auth.CheckPasswordHash(req.Password, user.Password) {
http.Error(w, "Invalid password", http.StatusUnauthorized)
return
}
waUser, err := loadPasskeyWebAuthnUser(user)
if err != nil {
log.Println("POST [api/passkeys/register/options] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not load passkeys", http.StatusInternalServerError)
return
}
wa, err := webAuthnForRequest(r)
if err != nil {
log.Println("POST [api/passkeys/register/options] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid WebAuthn origin", http.StatusBadRequest)
return
}
creation, sessionData, err := wa.BeginRegistration(waUser,
webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired),
webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{
RequireResidentKey: protocol.ResidentKeyRequired(),
ResidentKey: protocol.ResidentKeyRequirementRequired,
UserVerification: protocol.VerificationRequired,
}),
webauthn.WithExclusions(webauthn.Credentials(waUser.WebAuthnCredentials()).CredentialDescriptors()),
webauthn.WithExtensions(map[string]any{"credProps": true}),
)
if err != nil {
log.Println("POST [api/passkeys/register/options] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not create passkey registration challenge", http.StatusInternalServerError)
return
}
sessionToken, err := utils.GenerateOpaqueToken()
if err != nil {
log.Println("POST [api/passkeys/register/options] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not create passkey challenge", http.StatusInternalServerError)
return
}
if err := storage.SavePasskeyChallenge(sessionToken, user.ID, storage.PasskeyCeremonyRegister, *sessionData, passkeyChallengeTTL); err != nil {
log.Println("POST [api/passkeys/register/options] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not save passkey challenge", http.StatusInternalServerError)
return
}
_ = storage.CleanupExpiredPasskeyChallenges()
writeJSON(w, http.StatusOK, map[string]interface{}{
"session_token": sessionToken,
"options": creation,
})
log.Println("POST [api/passkeys/register/options] " + r.RemoteAddr + ": Created passkey registration challenge")
}
func PasskeyRegisterFinish(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
SessionToken string `json:"session_token"`
Name string `json:"name"`
Credential json.RawMessage `json:"credential"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Println("POST [api/passkeys/register/finish] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if req.SessionToken == "" || len(req.Credential) == 0 {
http.Error(w, "Session token and credential required", http.StatusBadRequest)
return
}
claims := r.Context().Value(auth.UserContextKey).(*auth.Claims)
user, err := storage.GetUserById(claims.UserID)
if err != nil {
log.Println("POST [api/passkeys/register/finish] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "User not found", http.StatusNotFound)
return
}
challenge, sessionData, err := storage.ConsumePasskeyChallenge(req.SessionToken, storage.PasskeyCeremonyRegister)
if err != nil {
log.Println("POST [api/passkeys/register/finish] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid or expired passkey registration challenge", http.StatusUnauthorized)
return
}
if challenge.UserID != user.ID {
http.Error(w, "Invalid passkey registration challenge", http.StatusUnauthorized)
return
}
waUser, err := loadPasskeyWebAuthnUser(user)
if err != nil {
log.Println("POST [api/passkeys/register/finish] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not load passkeys", http.StatusInternalServerError)
return
}
wa, err := webAuthnForRequest(r)
if err != nil {
log.Println("POST [api/passkeys/register/finish] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid WebAuthn origin", http.StatusBadRequest)
return
}
credentialRequest := cloneRequestWithJSONBody(r, req.Credential)
credential, err := wa.FinishRegistration(waUser, sessionData, credentialRequest)
if err != nil {
log.Println("POST [api/passkeys/register/finish] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not verify passkey registration", http.StatusUnauthorized)
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
name = "Passkey"
}
if _, err := storage.AddPasskeyCredential(user.ID, name, credential); err != nil {
log.Println("POST [api/passkeys/register/finish] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not save passkey", http.StatusInternalServerError)
return
}
if err := storage.RevokeAllRefreshTokensForUser(user.ID); err != nil {
log.Println("POST [api/passkeys/register/finish] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not revoke old sessions", http.StatusInternalServerError)
return
}
passkeys, _ := storage.ListPasskeyCredentials(user.ID)
issueLoginSessionWithExtra(w, r, user, map[string]interface{}{
"passkeys_enabled": true,
"passkey_count": len(passkeys),
"passkeys": publicPasskeys(passkeys),
})
log.Println("POST [api/passkeys/register/finish] " + r.RemoteAddr + ": Registered passkey and revoked old sessions")
}
func PasskeyLoginOptions(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
wa, err := webAuthnForRequest(r)
if err != nil {
log.Println("POST [api/passkeys/login/options] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid WebAuthn origin", http.StatusBadRequest)
return
}
assertion, sessionData, err := wa.BeginDiscoverableLogin(webauthn.WithUserVerification(protocol.VerificationRequired))
if err != nil {
log.Println("POST [api/passkeys/login/options] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not create passkey login challenge", http.StatusInternalServerError)
return
}
sessionToken, err := utils.GenerateOpaqueToken()
if err != nil {
log.Println("POST [api/passkeys/login/options] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not create passkey challenge", http.StatusInternalServerError)
return
}
if err := storage.SavePasskeyChallenge(sessionToken, "", storage.PasskeyCeremonyLogin, *sessionData, passkeyChallengeTTL); err != nil {
log.Println("POST [api/passkeys/login/options] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not save passkey challenge", http.StatusInternalServerError)
return
}
_ = storage.CleanupExpiredPasskeyChallenges()
writeJSON(w, http.StatusOK, map[string]interface{}{
"session_token": sessionToken,
"options": assertion,
})
log.Println("POST [api/passkeys/login/options] " + r.RemoteAddr + ": Created passkey login challenge")
}
func PasskeyLoginFinish(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
SessionToken string `json:"session_token"`
Credential json.RawMessage `json:"credential"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Println("POST [api/passkeys/login/finish] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if req.SessionToken == "" || len(req.Credential) == 0 {
http.Error(w, "Session token and credential required", http.StatusBadRequest)
return
}
challenge, sessionData, err := storage.ConsumePasskeyChallenge(req.SessionToken, storage.PasskeyCeremonyLogin)
if err != nil {
log.Println("POST [api/passkeys/login/finish] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid or expired passkey login challenge", http.StatusUnauthorized)
return
}
wa, err := webAuthnForRequest(r)
if err != nil {
log.Println("POST [api/passkeys/login/finish] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid WebAuthn origin", http.StatusBadRequest)
return
}
credentialRequest := cloneRequestWithJSONBody(r, req.Credential)
var user models.User
var credential *webauthn.Credential
if challenge.UserID == "" {
var webAuthnUser webauthn.User
webAuthnUser, credential, err = wa.FinishPasskeyLogin(passkeyDiscoverableUserHandler, sessionData, credentialRequest)
if err == nil {
resolvedUser, ok := webAuthnUser.(passkeyWebAuthnUser)
if !ok {
err = errors.New("invalid passkey user type")
} else {
user = resolvedUser.user
}
}
} else {
user, err = storage.GetUserById(challenge.UserID)
if err == nil {
waUser, loadErr := loadPasskeyWebAuthnUser(user)
if loadErr != nil {
err = loadErr
} else {
credential, err = wa.FinishLogin(waUser, sessionData, credentialRequest)
}
}
}
if err != nil {
log.Println("POST [api/passkeys/login/finish] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not verify passkey login", http.StatusUnauthorized)
return
}
if err := storage.UpdatePasskeyCredentialAfterLogin(user.ID, credential); err != nil {
log.Println("POST [api/passkeys/login/finish] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not update passkey", http.StatusInternalServerError)
return
}
issueLoginSession(w, r, user)
log.Println("POST [api/passkeys/login/finish] " + r.RemoteAddr + ": Successfully logged in with passkey")
}
func PasskeyDisable(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Println("POST [api/passkeys/disable] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
claims := r.Context().Value(auth.UserContextKey).(*auth.Claims)
user, err := storage.GetUserById(claims.UserID)
if err != nil {
log.Println("POST [api/passkeys/disable] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "User not found", http.StatusNotFound)
return
}
if !auth.CheckPasswordHash(req.Password, user.Password) {
http.Error(w, "Invalid password", http.StatusUnauthorized)
return
}
if err := storage.DeleteAllPasskeyCredentials(user.ID); err != nil {
log.Println("POST [api/passkeys/disable] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not disable passkeys", http.StatusInternalServerError)
return
}
if err := storage.RevokeAllRefreshTokensForUser(user.ID); err != nil {
log.Println("POST [api/passkeys/disable] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not revoke old sessions", http.StatusInternalServerError)
return
}
issueLoginSessionWithExtra(w, r, user, map[string]interface{}{
"passkeys_enabled": false,
"passkey_count": 0,
"passkeys": []map[string]interface{}{},
})
log.Println("POST [api/passkeys/disable] " + r.RemoteAddr + ": Disabled passkeys and revoked old sessions")
}
func listPasskeys(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value(auth.UserContextKey).(*auth.Claims)
passkeys, err := storage.ListPasskeyCredentials(claims.UserID)
if err != nil {
log.Println("GET [api/passkeys] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not load passkeys", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"passkeys": publicPasskeys(passkeys),
"passkeys_enabled": len(passkeys) > 0,
"passkey_count": len(passkeys),
})
}
func deletePasskey(w http.ResponseWriter, r *http.Request) {
var req struct {
ID string `json:"id"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Println("DELETE [api/passkeys] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if req.ID == "" || req.Password == "" {
http.Error(w, "Passkey ID and password required", http.StatusBadRequest)
return
}
claims := r.Context().Value(auth.UserContextKey).(*auth.Claims)
user, err := storage.GetUserById(claims.UserID)
if err != nil {
log.Println("DELETE [api/passkeys] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "User not found", http.StatusNotFound)
return
}
if !auth.CheckPasswordHash(req.Password, user.Password) {
http.Error(w, "Invalid password", http.StatusUnauthorized)
return
}
if _, err := storage.GetPasskeyCredentialByID(user.ID, req.ID); err != nil {
http.Error(w, "Passkey not found", http.StatusNotFound)
return
}
if err := storage.DeletePasskeyCredential(user.ID, req.ID); err != nil {
log.Println("DELETE [api/passkeys] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not delete passkey", http.StatusInternalServerError)
return
}
if err := storage.RevokeAllRefreshTokensForUser(user.ID); err != nil {
log.Println("DELETE [api/passkeys] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not revoke old sessions", http.StatusInternalServerError)
return
}
passkeys, _ := storage.ListPasskeyCredentials(user.ID)
issueLoginSessionWithExtra(w, r, user, map[string]interface{}{
"passkeys_enabled": len(passkeys) > 0,
"passkey_count": len(passkeys),
"passkeys": publicPasskeys(passkeys),
})
log.Println("DELETE [api/passkeys] " + r.RemoteAddr + ": Deleted passkey and revoked old sessions")
}
func loadPasskeyWebAuthnUser(user models.User) (passkeyWebAuthnUser, error) {
rows, err := storage.ListPasskeyCredentials(user.ID)
if err != nil {
return passkeyWebAuthnUser{}, err
}
credentials, err := storage.DecodeWebAuthnCredentials(rows)
if err != nil {
return passkeyWebAuthnUser{}, err
}
return passkeyWebAuthnUser{user: user, credentials: credentials}, nil
}
func passkeyDiscoverableUserHandler(rawID, userHandle []byte) (webauthn.User, error) {
row, err := storage.GetPasskeyCredentialByCredentialID(utils.EncodeBase64URL(rawID))
if err != nil {
return nil, err
}
if row.UserID != string(userHandle) {
return nil, errors.New("passkey user handle mismatch")
}
user, err := storage.GetUserById(row.UserID)
if err != nil {
return nil, err
}
return loadPasskeyWebAuthnUser(user)
}
func publicPasskeys(passkeys []models.PasskeyCredential) []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(passkeys))
for _, passkey := range passkeys {
item := map[string]interface{}{
"id": passkey.ID,
"name": passkey.Name,
"credential_id": passkey.CredentialID,
"created_at": passkey.CreatedAt,
}
if passkey.LastUsedAt > 0 {
item["last_used_at"] = passkey.LastUsedAt
}
out = append(out, item)
}
return out
}
func webAuthnForRequest(r *http.Request) (*webauthn.WebAuthn, error) {
origin, rpID, err := passkeyOriginAndRPID(r)
if err != nil {
return nil, err
}
return webauthn.New(&webauthn.Config{
RPID: rpID,
RPDisplayName: "MiauInv",
RPOrigins: []string{origin},
RPTopOrigins: []string{origin},
RPTopOriginVerificationMode: protocol.TopOriginExplicitVerificationMode,
AuthenticatorSelection: protocol.AuthenticatorSelection{
RequireResidentKey: protocol.ResidentKeyRequired(),
ResidentKey: protocol.ResidentKeyRequirementRequired,
UserVerification: protocol.VerificationRequired,
},
})
}
func passkeyOriginAndRPID(r *http.Request) (string, string, error) {
origin := strings.TrimSpace(r.Header.Get("Origin"))
if origin == "" {
scheme := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))
if scheme == "" {
scheme = "https"
}
host := requestHost(r)
if host == "" {
return "", "", errors.New("missing request host")
}
origin = scheme + "://" + host
}
parsedOrigin, err := url.Parse(origin)
if err != nil || parsedOrigin.Scheme == "" || parsedOrigin.Host == "" {
return "", "", errors.New("invalid origin")
}
if parsedOrigin.Scheme != "https" && parsedOrigin.Hostname() != "localhost" {
return "", "", errors.New("passkeys require HTTPS except for localhost")
}
originHost := strings.ToLower(parsedOrigin.Hostname())
allowedHost := strings.ToLower(stripPort(requestHost(r)))
if allowedHost != "" && originHost != allowedHost {
return "", "", errors.New("origin host does not match request host")
}
return parsedOrigin.Scheme + "://" + parsedOrigin.Host, originHost, nil
}
func requestHost(r *http.Request) string {
if forwardedHost := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")); forwardedHost != "" {
parts := strings.Split(forwardedHost, ",")
return strings.TrimSpace(parts[0])
}
return strings.TrimSpace(r.Host)
}
func stripPort(host string) string {
if host == "" {
return ""
}
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
return parsedHost
}
if strings.Count(host, ":") == 0 {
return host
}
return host
}
func cloneRequestWithJSONBody(r *http.Request, raw json.RawMessage) *http.Request {
clone := r.Clone(r.Context())
clone.Body = io.NopCloser(bytes.NewReader(raw))
clone.ContentLength = int64(len(raw))
clone.Header = r.Header.Clone()
clone.Header.Set("Content-Type", "application/json")
return clone
}

19
models/passkeys.go Normal file
View File

@@ -0,0 +1,19 @@
package models
type PasskeyCredential struct {
ID string `json:"id"`
UserID string `json:"user_id"`
CredentialID string `json:"credential_id"`
Name string `json:"name"`
CredentialData string `json:"-"`
CreatedAt int64 `json:"created_at"`
LastUsedAt int64 `json:"last_used_at,omitempty"`
}
type PasskeyChallenge struct {
Token string `json:"token"`
UserID string `json:"user_id"`
Ceremony string `json:"ceremony"`
SessionData string `json:"-"`
ExpiresAt int64 `json:"expires_at"`
}

View File

@@ -98,6 +98,12 @@ func (this *Server) Run() {
mux.Handle("/api/userinfo", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.UserInfo))) mux.Handle("/api/userinfo", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.UserInfo)))
mux.Handle("/api/account/username", accountLimiter.Middleware(auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.AccountUpdateUsername)))) mux.Handle("/api/account/username", accountLimiter.Middleware(auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.AccountUpdateUsername))))
mux.Handle("/api/account/password", loginLimiter.Middleware(auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.AccountUpdatePassword)))) mux.Handle("/api/account/password", loginLimiter.Middleware(auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.AccountUpdatePassword))))
mux.Handle("/api/passkeys", accountLimiter.Middleware(auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.Passkeys))))
mux.Handle("/api/passkeys/register/options", accountLimiter.Middleware(auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.PasskeyRegisterOptions))))
mux.Handle("/api/passkeys/register/finish", accountLimiter.Middleware(auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.PasskeyRegisterFinish))))
mux.Handle("/api/passkeys/disable", loginLimiter.Middleware(auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.PasskeyDisable))))
mux.Handle("/api/passkeys/login/options", loginLimiter.Middleware(http.HandlerFunc(handlers.PasskeyLoginOptions)))
mux.Handle("/api/passkeys/login/finish", loginLimiter.Middleware(http.HandlerFunc(handlers.PasskeyLoginFinish)))
if this.AllowRegistration { if this.AllowRegistration {
mux.Handle("/api/register", loginLimiter.Middleware(http.HandlerFunc(handlers.APIRegister))) mux.Handle("/api/register", loginLimiter.Middleware(http.HandlerFunc(handlers.APIRegister)))
} }

262
storage/passkeys.go Normal file
View File

@@ -0,0 +1,262 @@
package storage
import (
"MiauInv/models"
utils "MiauInv/util"
"database/sql"
"encoding/json"
"errors"
"time"
"github.com/go-webauthn/webauthn/webauthn"
)
const (
PasskeyCeremonyRegister = "register"
PasskeyCeremonyLogin = "login"
)
func AddPasskeyCredential(userID, name string, credential *webauthn.Credential) (models.PasskeyCredential, error) {
if DB == nil {
return models.PasskeyCredential{}, errors.New("db not initialized")
}
credentialJSON, err := json.Marshal(credential)
if err != nil {
return models.PasskeyCredential{}, err
}
row := models.PasskeyCredential{
ID: utils.GenerateUUID(),
UserID: userID,
CredentialID: utils.EncodeBase64URL(credential.ID),
Name: name,
CredentialData: string(credentialJSON),
CreatedAt: time.Now().Unix(),
}
if row.Name == "" {
row.Name = "Passkey"
}
_, err = DB.Exec(`
INSERT INTO passkey_credentials(id, user_id, credential_id, name, credential_data, created_at, last_used_at)
VALUES (?, ?, ?, ?, ?, ?, NULL)
`, row.ID, row.UserID, row.CredentialID, row.Name, row.CredentialData, row.CreatedAt)
if err != nil {
return models.PasskeyCredential{}, err
}
return row, nil
}
func ListPasskeyCredentials(userID string) ([]models.PasskeyCredential, error) {
if DB == nil {
return nil, errors.New("db not initialized")
}
rows, err := DB.Query(`
SELECT id, user_id, credential_id, name, credential_data, created_at, COALESCE(last_used_at, 0)
FROM passkey_credentials
WHERE user_id = ?
ORDER BY created_at DESC
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
credentials := make([]models.PasskeyCredential, 0)
for rows.Next() {
var credential models.PasskeyCredential
if err := rows.Scan(&credential.ID, &credential.UserID, &credential.CredentialID, &credential.Name, &credential.CredentialData, &credential.CreatedAt, &credential.LastUsedAt); err != nil {
return nil, err
}
credentials = append(credentials, credential)
}
return credentials, rows.Err()
}
func CountPasskeyCredentials(userID string) (int, error) {
if DB == nil {
return 0, errors.New("db not initialized")
}
var count int
err := DB.QueryRow("SELECT COUNT(*) FROM passkey_credentials WHERE user_id = ?", userID).Scan(&count)
return count, err
}
func GetPasskeyCredentialByCredentialID(credentialID string) (models.PasskeyCredential, error) {
if DB == nil {
return models.PasskeyCredential{}, errors.New("db not initialized")
}
row := DB.QueryRow(`
SELECT id, user_id, credential_id, name, credential_data, created_at, COALESCE(last_used_at, 0)
FROM passkey_credentials
WHERE credential_id = ?
`, credentialID)
return scanPasskeyCredential(row)
}
func GetPasskeyCredentialByID(userID, id string) (models.PasskeyCredential, error) {
if DB == nil {
return models.PasskeyCredential{}, errors.New("db not initialized")
}
row := DB.QueryRow(`
SELECT id, user_id, credential_id, name, credential_data, created_at, COALESCE(last_used_at, 0)
FROM passkey_credentials
WHERE user_id = ? AND id = ?
`, userID, id)
return scanPasskeyCredential(row)
}
func scanPasskeyCredential(row *sql.Row) (models.PasskeyCredential, error) {
var credential models.PasskeyCredential
err := row.Scan(&credential.ID, &credential.UserID, &credential.CredentialID, &credential.Name, &credential.CredentialData, &credential.CreatedAt, &credential.LastUsedAt)
return credential, err
}
func DecodeWebAuthnCredential(row models.PasskeyCredential) (webauthn.Credential, error) {
var credential webauthn.Credential
err := json.Unmarshal([]byte(row.CredentialData), &credential)
return credential, err
}
func DecodeWebAuthnCredentials(rows []models.PasskeyCredential) ([]webauthn.Credential, error) {
credentials := make([]webauthn.Credential, 0, len(rows))
for _, row := range rows {
credential, err := DecodeWebAuthnCredential(row)
if err != nil {
return nil, err
}
credentials = append(credentials, credential)
}
return credentials, nil
}
func UpdatePasskeyCredentialAfterLogin(userID string, credential *webauthn.Credential) error {
if DB == nil {
return errors.New("db not initialized")
}
credentialID := utils.EncodeBase64URL(credential.ID)
credentialJSON, err := json.Marshal(credential)
if err != nil {
return err
}
res, err := DB.Exec(`
UPDATE passkey_credentials
SET credential_data = ?, last_used_at = ?
WHERE user_id = ? AND credential_id = ?
`, string(credentialJSON), time.Now().Unix(), userID, credentialID)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
return nil
}
func DeletePasskeyCredential(userID, id string) error {
if DB == nil {
return errors.New("db not initialized")
}
res, err := DB.Exec("DELETE FROM passkey_credentials WHERE user_id = ? AND id = ?", userID, id)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
return nil
}
func DeleteAllPasskeyCredentials(userID string) error {
if DB == nil {
return errors.New("db not initialized")
}
_, err := DB.Exec("DELETE FROM passkey_credentials WHERE user_id = ?", userID)
return err
}
func SavePasskeyChallenge(token, userID, ceremony string, sessionData webauthn.SessionData, ttl time.Duration) error {
if DB == nil {
return errors.New("db not initialized")
}
encodedSession, err := json.Marshal(sessionData)
if err != nil {
return err
}
_, err = DB.Exec(`
INSERT INTO passkey_challenges(token, user_id, ceremony, session_data, expires_at)
VALUES (?, ?, ?, ?, ?)
`, token, userID, ceremony, string(encodedSession), time.Now().Add(ttl).Unix())
return err
}
func ConsumePasskeyChallenge(token, ceremony string) (models.PasskeyChallenge, webauthn.SessionData, error) {
if DB == nil {
return models.PasskeyChallenge{}, webauthn.SessionData{}, errors.New("db not initialized")
}
tx, err := DB.Begin()
if err != nil {
return models.PasskeyChallenge{}, webauthn.SessionData{}, err
}
defer tx.Rollback()
row := tx.QueryRow(`
SELECT token, user_id, ceremony, session_data, expires_at
FROM passkey_challenges
WHERE token = ? AND ceremony = ?
`, token, ceremony)
var challenge models.PasskeyChallenge
if err := row.Scan(&challenge.Token, &challenge.UserID, &challenge.Ceremony, &challenge.SessionData, &challenge.ExpiresAt); err != nil {
return models.PasskeyChallenge{}, webauthn.SessionData{}, err
}
if _, err := tx.Exec("DELETE FROM passkey_challenges WHERE token = ?", token); err != nil {
return models.PasskeyChallenge{}, webauthn.SessionData{}, err
}
if challenge.ExpiresAt < time.Now().Unix() {
return models.PasskeyChallenge{}, webauthn.SessionData{}, errors.New("passkey challenge expired")
}
var sessionData webauthn.SessionData
if err := json.Unmarshal([]byte(challenge.SessionData), &sessionData); err != nil {
return models.PasskeyChallenge{}, webauthn.SessionData{}, err
}
if err := tx.Commit(); err != nil {
return models.PasskeyChallenge{}, webauthn.SessionData{}, err
}
return challenge, sessionData, nil
}
func CleanupExpiredPasskeyChallenges() error {
if DB == nil {
return errors.New("db not initialized")
}
_, err := DB.Exec("DELETE FROM passkey_challenges WHERE expires_at < ?", time.Now().Unix())
return err
}

View File

@@ -55,6 +55,25 @@ func InitDB(filepath string) error {
UNIQUE(user_id, code_hash) UNIQUE(user_id, code_hash)
); );
CREATE TABLE IF NOT EXISTS passkey_credentials (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
credential_id TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
credential_data TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_used_at INTEGER DEFAULT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS passkey_challenges (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL DEFAULT '',
ceremony TEXT NOT NULL,
session_data TEXT NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS items ( CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, name TEXT NOT NULL,

View File

@@ -23,12 +23,19 @@ func GenerateSecret() string {
return base64.StdEncoding.EncodeToString(b) return base64.StdEncoding.EncodeToString(b)
} }
func GenerateRefreshToken() (string, error) { func GenerateRefreshToken() (string, error) {
return GenerateOpaqueToken()
}
func GenerateOpaqueToken() (string, error) {
b := make([]byte, 32) b := make([]byte, 32)
if _, err := rand.Read(b); err != nil { if _, err := rand.Read(b); err != nil {
return "", err return "", err
} }
return base64.RawURLEncoding.EncodeToString(b), nil return base64.RawURLEncoding.EncodeToString(b), nil
} }
func EncodeBase64URL(data []byte) string {
return base64.RawURLEncoding.EncodeToString(data)
}
func HashToken(token string) string { func HashToken(token string) string {
hash := sha256.Sum256([]byte(token)) hash := sha256.Sum256([]byte(token))
return hex.EncodeToString(hash[:]) return hex.EncodeToString(hash[:])