added passkey support (closes #6)
All checks were successful
test-and-lint / test-and-lint (pull_request) Successful in 2m50s
All checks were successful
test-and-lint / test-and-lint (pull_request) Successful in 2m50s
This commit is contained in:
29
README.md
29
README.md
@@ -1,6 +1,6 @@
|
||||
# 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.
|
||||
|
||||
@@ -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.
|
||||
- HTTP-only secure cookies for access and refresh tokens.
|
||||
- Account settings page at `/profile/settings`.
|
||||
- Passkey registration, login, removal, and full passkey disable from account settings.
|
||||
- Username change with password confirmation.
|
||||
- Password change with old-password verification and session refresh.
|
||||
- Avatar placeholder in the account settings UI for a later avatar implementation.
|
||||
|
||||
### 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.
|
||||
- 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
|
||||
|
||||
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.
|
||||
- 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.
|
||||
@@ -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` |
|
||||
| Password hashing | bcrypt via `golang.org/x/crypto/bcrypt` |
|
||||
| 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 |
|
||||
| Styling | Custom CSS with dark theme variables |
|
||||
| Deployment | Docker / Docker Compose |
|
||||
@@ -155,7 +166,7 @@ openssl rand -base64 48
|
||||
| `/items` | `GET` | Yes | Item management view. |
|
||||
| `/locations` | `GET` | Yes | Location 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. |
|
||||
| `/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/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/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
|
||||
|
||||
@@ -304,10 +322,11 @@ For Docker deployments, place Caddy and MiauInv on the same Docker network and r
|
||||
- Access tokens expire after 15 minutes.
|
||||
- Refresh tokens expire after 7 days and are rotated on refresh.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Authentication Architecture
|
||||
|
||||
MiauInv uses signed JWT access tokens, database-backed refresh tokens, and optional TOTP-based two-factor authentication. The authentication flow is implemented in the `auth/`, `handlers/`, `storage/`, and `frontend/assets/js/` packages.
|
||||
MiauInv uses signed JWT access tokens, database-backed refresh tokens, optional TOTP-based two-factor authentication, and optional WebAuthn passkey authentication. The authentication flow is implemented in the `auth/`, `handlers/`, `storage/`, and `frontend/assets/js/` packages.
|
||||
|
||||
JWTs are signed with a symmetric secret from the `JWT_SECRET` environment variable. They are not encrypted. Access tokens and normal purpose tokens should therefore contain identity and authorization metadata only, not secrets. The short-lived 2FA setup token intentionally carries the not-yet-enabled TOTP secret because the same secret is already returned to the authenticated browser for QR/manual setup and is not stored server-side until confirmation.
|
||||
|
||||
@@ -12,8 +12,9 @@ JWTs are signed with a symmetric secret from the `JWT_SECRET` environment variab
|
||||
| Middleware | `auth/middleware.go` | Extracts access tokens from bearer headers or cookies and injects claims into the request context. |
|
||||
| Password helpers | `auth/password.go` | bcrypt hashing and verification. |
|
||||
| Login/account handlers | `handlers/account.go` | Register, login, 2FA, refresh, logout, account settings, and user metadata. |
|
||||
| Persistent session storage | `storage/storage.go` | Refresh tokens, 2FA state, TOTP secret, and recovery-code hashes. |
|
||||
| Frontend auth logic | `frontend/assets/js/auth.js`, `frontend/assets/js/login.js`, `frontend/assets/js/api.js` | Login UI, token refresh, account settings, and 2FA UI interactions. |
|
||||
| Passkey handlers | `handlers/passkeys.go` | WebAuthn/passkey registration, login, removal, and disable flows. |
|
||||
| Persistent session storage | `storage/storage.go`, `storage/passkeys.go` | Refresh tokens, 2FA state, TOTP secret, recovery-code hashes, passkey credentials, and WebAuthn challenge state. |
|
||||
| Frontend auth logic | `frontend/assets/js/auth.js`, `frontend/assets/js/login.js`, `frontend/assets/js/api.js` | Login UI, passkey login, token refresh, account settings, 2FA UI interactions, and passkey UI interactions. |
|
||||
|
||||
## Token Types
|
||||
|
||||
@@ -23,6 +24,7 @@ JWTs are signed with a symmetric secret from the `JWT_SECRET` environment variab
|
||||
| Refresh token | `refresh_token` HTTP-only secure cookie and JSON response body | 7 days | Rotates sessions after access-token expiry. Stored in the database only as a hash. |
|
||||
| 2FA challenge token | JSON response from `/api/login` | 5 minutes | Allows `/api/login/2fa` to complete login after password verification. It is purpose-bound to `2fa_login`. |
|
||||
| 2FA setup token | JSON response from `/api/2fa/setup` | 10 minutes | Carries the not-yet-enabled TOTP secret until `/api/2fa/enable` validates the first code. It is purpose-bound to `2fa_setup`. |
|
||||
| Passkey ceremony token | JSON response from `/api/passkeys/*/options` | 5 minutes | Opaque server-side reference to WebAuthn session data stored in `passkey_challenges`. Used for passkey registration and login. |
|
||||
|
||||
## Normal Login Flow Without 2FA
|
||||
|
||||
@@ -67,6 +69,47 @@ JWTs are signed with a symmetric secret from the `JWT_SECRET` environment variab
|
||||
11. If either check succeeds, the server issues the normal access/refresh token session.
|
||||
12. If a recovery code was used, it is marked as used and cannot be used again.
|
||||
|
||||
|
||||
## Passkey Registration Flow
|
||||
|
||||
Passkeys are managed from `/profile/settings`. Registration requires an authenticated session and current-password confirmation.
|
||||
|
||||
1. Client sends `POST /api/passkeys/register/options` with a passkey name and the current password.
|
||||
2. Server verifies the password.
|
||||
3. Server creates WebAuthn registration options with resident-key and user-verification requirements.
|
||||
4. Server stores the WebAuthn session data in `passkey_challenges` and returns only an opaque `session_token` plus the public registration options.
|
||||
5. Browser calls `navigator.credentials.create()` with the returned public-key options.
|
||||
6. Client sends the browser credential response to `POST /api/passkeys/register/finish`.
|
||||
7. Server consumes the one-time challenge, verifies the WebAuthn response, stores the credential, revokes existing refresh sessions, and issues a new current session.
|
||||
|
||||
The server stores credential metadata and public-key credential data. It does not store private keys.
|
||||
|
||||
## Passkey Login Flow
|
||||
|
||||
Passkey login is available from the normal login page.
|
||||
|
||||
1. Client sends `POST /api/passkeys/login/options`.
|
||||
2. Server creates a discoverable passkey login challenge without requiring a username.
|
||||
3. Server stores the WebAuthn session data in `passkey_challenges` and returns an opaque `session_token` plus assertion options.
|
||||
4. Browser calls `navigator.credentials.get()` with the returned public-key options.
|
||||
5. Client sends the browser assertion response to `POST /api/passkeys/login/finish`.
|
||||
6. Server consumes the one-time challenge and verifies the WebAuthn assertion.
|
||||
7. The stored credential data is updated after successful login.
|
||||
8. Server issues a normal access/refresh session.
|
||||
|
||||
Passkey login is treated as a complete phishing-resistant sign-in method. The application requires WebAuthn user verification for passkey registration and login, so a valid passkey assertion is not followed by a separate TOTP challenge.
|
||||
|
||||
## Passkey Management
|
||||
|
||||
The account settings page supports:
|
||||
|
||||
- listing registered passkeys,
|
||||
- adding a passkey,
|
||||
- removing a single passkey,
|
||||
- disabling all passkeys.
|
||||
|
||||
Adding, removing, or disabling passkeys revokes existing refresh sessions and issues a fresh session for the current browser. Removing or disabling passkeys requires current-password confirmation.
|
||||
|
||||
## TOTP Setup Flow
|
||||
|
||||
The account settings page at `/profile/settings` exposes the UI for TOTP setup.
|
||||
@@ -178,6 +221,9 @@ The account settings page currently supports:
|
||||
|
||||
- username changes,
|
||||
- password changes,
|
||||
- passkey registration,
|
||||
- passkey removal,
|
||||
- passkey disable,
|
||||
- TOTP 2FA setup,
|
||||
- TOTP 2FA disable,
|
||||
- recovery-code download after generation,
|
||||
@@ -187,7 +233,7 @@ Username changes require the current password.
|
||||
|
||||
Password changes require the current password, reject passwords longer than bcrypt's 72-byte effective limit, revoke existing refresh tokens, and issue a new session.
|
||||
|
||||
2FA activation also revokes existing refresh tokens and issues a new session for the current browser. Other devices must log in again and complete 2FA.
|
||||
2FA activation also revokes existing refresh tokens and issues a new session for the current browser. Passkey changes also revoke existing refresh tokens and issue a new current session. Other devices must log in again and complete the configured authentication flow.
|
||||
|
||||
## Middleware Behavior
|
||||
|
||||
@@ -215,5 +261,5 @@ The current implementation is usable for private/self-hosted deployments, but th
|
||||
- Replace the current in-memory rate limiter with persistent or distributed rate limiting if the app is deployed across multiple instances.
|
||||
- Add audit logging for account security changes.
|
||||
- Add optional session/device management UI.
|
||||
- Consider encrypting TOTP secrets at rest if the deployment threat model includes database disclosure.
|
||||
- Consider encrypting TOTP secrets and passkey credential data at rest if the deployment threat model includes database disclosure.
|
||||
- Expand tests for all authentication and account settings handlers.
|
||||
|
||||
@@ -6,13 +6,16 @@ MiauInv uses SQLite for persistent storage. The schema is initialized in `storag
|
||||
PRAGMA foreign_keys = ON;
|
||||
```
|
||||
|
||||
The database stores users, refresh tokens, 2FA recovery codes, inventory items, locations, projects, stock mappings, and project allocations.
|
||||
The database stores users, refresh tokens, 2FA recovery codes, passkey credentials, passkey challenge state, inventory items, locations, projects, stock mappings, and project allocations.
|
||||
|
||||
## Entity Overview
|
||||
|
||||
```text
|
||||
[users] 1 ──── N [refresh_tokens]
|
||||
[users] 1 ──── N [two_factor_recovery_codes]
|
||||
[users] 1 ──── N [passkey_credentials]
|
||||
|
||||
[passkey_challenges] stores short-lived WebAuthn ceremony state
|
||||
|
||||
[items] 1 ──── N [stock] N ──── 1 [locations]
|
||||
[items] 1 ──── N [project_items] N ──── 1 [projects]
|
||||
@@ -65,6 +68,34 @@ Stores recovery-code hashes for 2FA fallback login.
|
||||
|
||||
Recovery codes are deleted and replaced when the user regenerates them. A recovery code is consumed with an atomic update that only matches unused codes.
|
||||
|
||||
### `passkey_credentials`
|
||||
|
||||
Stores WebAuthn passkey credentials for account login. Private keys are not stored by MiauInv; they remain in the authenticator, platform passkey provider, browser, or security key.
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `id` | `TEXT` | Primary key | Passkey row UUID. |
|
||||
| `user_id` | `TEXT` | Not null, foreign key to `users(id)` with `ON DELETE CASCADE` | Owning user. |
|
||||
| `credential_id` | `TEXT` | Not null, unique | Base64url-encoded WebAuthn credential ID. |
|
||||
| `name` | `TEXT` | Not null | User-visible passkey name. |
|
||||
| `credential_data` | `TEXT` | Not null | Serialized WebAuthn credential data, including public-key material and authenticator metadata. |
|
||||
| `created_at` | `INTEGER` | Not null | Unix timestamp for creation. |
|
||||
| `last_used_at` | `INTEGER` | Nullable | Unix timestamp when the passkey was last used successfully. |
|
||||
|
||||
### `passkey_challenges`
|
||||
|
||||
Stores short-lived server-side WebAuthn session data for registration and login ceremonies. The browser receives only an opaque `session_token`.
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `token` | `TEXT` | Primary key | Opaque challenge token returned to the client. |
|
||||
| `user_id` | `TEXT` | Not null, default `''` | Owning user for registration challenges. Empty for discoverable login challenges. |
|
||||
| `ceremony` | `TEXT` | Not null | Challenge type, for example `register` or `login`. |
|
||||
| `session_data` | `TEXT` | Not null | Serialized WebAuthn session data. |
|
||||
| `expires_at` | `INTEGER` | Not null | Unix timestamp after which the challenge is rejected. |
|
||||
|
||||
Challenge rows are consumed once during the finish step and expired rows are cleaned up opportunistically.
|
||||
|
||||
### `items`
|
||||
|
||||
Stores tracked inventory items.
|
||||
@@ -124,7 +155,7 @@ Maps item quantities to projects.
|
||||
|
||||
Foreign keys are enabled per connection. Because most inventory foreign keys do not define explicit cascade behavior, SQLite blocks deletion of referenced items, locations, or projects while dependent rows exist.
|
||||
|
||||
`two_factor_recovery_codes.user_id` uses `ON DELETE CASCADE`, so deleting a user also deletes their recovery-code rows.
|
||||
`two_factor_recovery_codes.user_id` and `passkey_credentials.user_id` use `ON DELETE CASCADE`, so deleting a user also deletes their recovery-code and passkey rows.
|
||||
|
||||
### Uniqueness
|
||||
|
||||
@@ -134,6 +165,7 @@ The following values are unique:
|
||||
- `locations.name`
|
||||
- `projects.name`
|
||||
- `two_factor_recovery_codes(user_id, code_hash)`
|
||||
- `passkey_credentials.credential_id`
|
||||
|
||||
### Current schema limitations
|
||||
|
||||
@@ -162,6 +194,36 @@ When the password is updated:
|
||||
3. Existing refresh tokens for that user are revoked.
|
||||
4. A new session is issued.
|
||||
|
||||
### Passkey registration
|
||||
|
||||
When a passkey is registered:
|
||||
|
||||
1. The current password is verified.
|
||||
2. A WebAuthn registration challenge is stored in `passkey_challenges`.
|
||||
3. The finish step consumes the challenge.
|
||||
4. The WebAuthn credential is verified.
|
||||
5. A row is inserted into `passkey_credentials`.
|
||||
6. Existing refresh tokens for the user are revoked.
|
||||
7. A new session is issued for the current browser.
|
||||
|
||||
### Passkey login
|
||||
|
||||
When passkey login completes:
|
||||
|
||||
1. A discoverable WebAuthn login challenge is consumed from `passkey_challenges`.
|
||||
2. The credential assertion is verified against the stored credential data.
|
||||
3. The stored credential data and `last_used_at` value are updated.
|
||||
4. A normal access/refresh session is issued.
|
||||
|
||||
### Passkey removal
|
||||
|
||||
When a passkey is removed or all passkeys are disabled:
|
||||
|
||||
1. The current password is verified.
|
||||
2. The matching passkey row, or all rows for the user, are deleted.
|
||||
3. Existing refresh tokens for the user are revoked.
|
||||
4. A new session is issued for the current browser.
|
||||
|
||||
### 2FA enable
|
||||
|
||||
When 2FA is enabled:
|
||||
|
||||
@@ -4,7 +4,7 @@ This document summarizes the current security-relevant behavior of MiauInv. It i
|
||||
|
||||
## Authentication
|
||||
|
||||
MiauInv uses signed JWT access tokens, database-backed refresh tokens, and optional TOTP-based two-factor authentication.
|
||||
MiauInv uses signed JWT access tokens, database-backed refresh tokens, optional TOTP-based two-factor authentication, and optional WebAuthn passkeys.
|
||||
|
||||
JWTs are signed with `JWT_SECRET`. They are not encrypted. Normal access tokens and purpose tokens should therefore contain only identity and authorization metadata.
|
||||
|
||||
@@ -66,9 +66,23 @@ Recovery codes are single-use. During login, a submitted value is first checked
|
||||
|
||||
The account settings UI warns the user when the remaining unused recovery-code count is low.
|
||||
|
||||
## Passkeys
|
||||
|
||||
Passkeys use WebAuthn public-key credentials. The server stores credential metadata and public-key material, but not private keys. Private keys remain controlled by the authenticator, browser, operating system, or security key.
|
||||
|
||||
Passkey registration requires the current account password. Registration uses a server-side challenge stored in `passkey_challenges` and returned to the browser only as an opaque challenge token. The browser response is verified before the credential is stored.
|
||||
|
||||
Passkey login creates a one-time server-side challenge. The login flow uses discoverable passkeys and does not require entering a username first. User verification is required for passkey registration and login.
|
||||
|
||||
Passkey login is treated as a complete phishing-resistant sign-in method. Because passkey registration and login require WebAuthn user verification, the server issues a normal session after a valid passkey assertion instead of asking for an additional TOTP code.
|
||||
|
||||
When passkeys are added, removed, or disabled, existing refresh sessions are revoked and a fresh current session is issued.
|
||||
|
||||
Passkey ceremonies require HTTPS except for localhost. Reverse proxy deployments must preserve the correct public `Host`, `X-Forwarded-Host`, and `X-Forwarded-Proto` information so that the relying party origin and ID match the browser-visible origin.
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
Basic in-memory rate limiting protects login, 2FA, refresh, registration, and sensitive account endpoints.
|
||||
Basic in-memory rate limiting protects login, passkey ceremonies, 2FA, refresh, registration, and sensitive account endpoints.
|
||||
|
||||
This is suitable for a single-instance private deployment. It is not sufficient for multi-instance deployments because limiter state is process-local. A public or multi-instance deployment should use persistent or distributed rate limiting at the application, reverse proxy, or infrastructure layer.
|
||||
|
||||
@@ -76,8 +90,8 @@ This is suitable for a single-instance private deployment. It is not sufficient
|
||||
|
||||
- Automated testing is currently limited.
|
||||
- TOTP secrets are stored in the database after confirmation because the server must validate future codes.
|
||||
- Passkey credential metadata and public-key data are stored in the database after registration.
|
||||
- TOTP secrets are not encrypted at rest.
|
||||
- There is no dedicated session/device management UI yet.
|
||||
- There is no audit log for account security changes yet.
|
||||
- The current rate limiter is process-local and memory-only.
|
||||
- Passkeys/WebAuthn are intentionally not implemented yet.
|
||||
|
||||
@@ -130,6 +130,51 @@ input::placeholder {
|
||||
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 {
|
||||
margin-top: 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
|
||||
@@ -41,6 +41,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
if (document.getElementById('projects-table-body')) loadProjects();
|
||||
if (document.getElementById('account-settings-content')) loadAccountSettings();
|
||||
|
||||
setupPasswordVisibilityToggles();
|
||||
loadProfile();
|
||||
});
|
||||
|
||||
@@ -487,6 +488,7 @@ async function loadProfile() {
|
||||
// ---- ACCOUNT SETTINGS ----
|
||||
let latestRecoveryCodes = [];
|
||||
let pendingTwoFactorSetupToken = "";
|
||||
let currentPasskeys = [];
|
||||
|
||||
function showAccountSettingsMessage(message, type = 'success') {
|
||||
const box = document.getElementById('account-settings-message');
|
||||
@@ -496,6 +498,51 @@ function showAccountSettingsMessage(message, type = 'success') {
|
||||
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) {
|
||||
const badge = document.getElementById('two-factor-badge');
|
||||
const status = document.getElementById('two-factor-status');
|
||||
@@ -552,16 +599,17 @@ async function loadAccountSettings() {
|
||||
const data = await apiRequest('/api/profile');
|
||||
|
||||
const usernameInput = document.getElementById('settings-username');
|
||||
const avatarPreview = document.getElementById('settings-avatar-preview');
|
||||
const remaining = document.getElementById('recovery-codes-remaining');
|
||||
|
||||
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;
|
||||
updateRecoveryCodeWarning(data.recovery_codes_remaining || 0, !!data.recovery_codes_warning);
|
||||
|
||||
setTwoFactorPanels(!!data.two_factor_enabled);
|
||||
setPasskeyPanels(!!data.passkeys_enabled, data.passkey_count || 0);
|
||||
renderRecoveryCodes([]);
|
||||
loadPasskeys();
|
||||
setupPasswordVisibilityToggles(document.getElementById('account-settings-content') || document);
|
||||
} catch (err) {
|
||||
showAccountSettingsMessage(err.message || 'Failed to load account settings.', 'error');
|
||||
}
|
||||
@@ -581,10 +629,8 @@ async function saveAccountUsername(event) {
|
||||
|
||||
const username = document.getElementById('username');
|
||||
const avatar = document.getElementById('avatar');
|
||||
const avatarPreview = document.getElementById('settings-avatar-preview');
|
||||
if (username) username.innerText = data.username;
|
||||
if (avatar && data.username) avatar.innerText = data.username[0].toLocaleUpperCase();
|
||||
if (avatarPreview && data.username) avatarPreview.innerText = data.username[0].toLocaleUpperCase();
|
||||
} catch (err) {
|
||||
showAccountSettingsMessage(err.message || 'Could not update username.', 'error');
|
||||
}
|
||||
@@ -681,8 +727,6 @@ async function enableTwoFactor(event) {
|
||||
async function disableTwoFactor(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!confirm('Disable 2FA for your account?')) return;
|
||||
|
||||
try {
|
||||
await apiRequest('/api/2fa/disable', 'POST', {
|
||||
password: document.getElementById('two-factor-disable-password').value,
|
||||
@@ -707,8 +751,6 @@ async function disableTwoFactor(event) {
|
||||
async function regenerateRecoveryCodes(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!confirm('Generate new recovery codes? Existing unused codes will stop working.')) return;
|
||||
|
||||
try {
|
||||
const data = await apiRequest('/api/2fa/recovery-codes/regenerate', 'POST', {
|
||||
password: document.getElementById('recovery-password').value,
|
||||
@@ -752,3 +794,247 @@ function downloadRecoveryCodes() {
|
||||
link.remove();
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return escapeHTML(value).replace(/`/g, '`');
|
||||
}
|
||||
|
||||
@@ -7,16 +7,70 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const twoFactorInput = document.getElementById("two-factor-code");
|
||||
const twoFactorGroup = document.getElementById("two-factor-group");
|
||||
const submitButton = document.getElementById("login-submit");
|
||||
const passkeyLoginButton = document.getElementById("passkey-login-button");
|
||||
const passkeyLoginHint = document.getElementById("passkey-login-hint");
|
||||
|
||||
let pendingTwoFactorToken = null;
|
||||
|
||||
if (!form) return;
|
||||
|
||||
setupPasswordVisibilityToggles();
|
||||
|
||||
function showError(message) {
|
||||
errorBox.textContent = message || "Login failed.";
|
||||
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) {
|
||||
localStorage.setItem("access_token", data.access_token);
|
||||
localStorage.setItem("refresh_token", data.refresh_token);
|
||||
@@ -26,15 +80,131 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
pendingTwoFactorToken = token;
|
||||
usernameInput.disabled = true;
|
||||
passwordInput.disabled = true;
|
||||
if (passkeyLoginButton) passkeyLoginButton.disabled = true;
|
||||
twoFactorGroup.style.display = "block";
|
||||
twoFactorInput.required = true;
|
||||
twoFactorInput.focus();
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
errorBox.style.display = "none";
|
||||
clearError();
|
||||
submitButton.disabled = true;
|
||||
|
||||
try {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<div class="modal-split" style="align-items: start;">
|
||||
<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>
|
||||
<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)">
|
||||
<div class="form-group">
|
||||
@@ -22,17 +22,6 @@
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Save username</button>
|
||||
</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 class="card" style="max-width: 100%; text-align: left; padding: 1.5rem;">
|
||||
@@ -136,5 +125,48 @@
|
||||
</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>
|
||||
{{ end }}
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<div id="error" class="message error"></div>
|
||||
|
||||
9
go.mod
9
go.mod
@@ -4,6 +4,7 @@ go 1.26
|
||||
|
||||
require (
|
||||
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/google/uuid v1.6.0
|
||||
github.com/pquerna/otp v1.5.0
|
||||
@@ -15,9 +16,17 @@ require (
|
||||
require (
|
||||
github.com/boombuler/barcode v1.1.0 // 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/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/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
|
||||
modernc.org/libc v1.73.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
||||
78
go.sum
78
go.sum
@@ -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.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
|
||||
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.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/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/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/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
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/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
|
||||
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
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/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
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/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/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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
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/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
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.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/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/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/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||
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/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
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/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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
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/libc v1.37.6/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
|
||||
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
|
||||
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/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/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/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
|
||||
modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
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/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=
|
||||
|
||||
@@ -672,6 +672,10 @@ func UserInfo(w http.ResponseWriter, r *http.Request) {
|
||||
twoFactorStatus = "setup_pending"
|
||||
}
|
||||
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")
|
||||
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_warning": recoveryCodesWarning,
|
||||
"recovery_codes_warning_at": recoveryCodeWarningThreshold,
|
||||
"passkeys_enabled": passkeyCount > 0,
|
||||
"passkey_count": passkeyCount,
|
||||
})
|
||||
if err != nil {
|
||||
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": " + err.Error())
|
||||
@@ -732,6 +738,11 @@ func issueLoginSessionWithExtra(w http.ResponseWriter, r *http.Request, user mod
|
||||
return
|
||||
}
|
||||
|
||||
passkeyCount := 0
|
||||
if count, err := storage.CountPasskeyCredentials(user.ID); err == nil {
|
||||
passkeyCount = count
|
||||
}
|
||||
|
||||
setAuthCookies(w, accessToken, refreshTokenPlain)
|
||||
response := map[string]interface{}{
|
||||
"access_token": accessToken,
|
||||
@@ -741,6 +752,8 @@ func issueLoginSessionWithExtra(w http.ResponseWriter, r *http.Request, user mod
|
||||
"username": user.Username,
|
||||
"role": user.Role,
|
||||
"two_factor_enabled": user.TwoFactorEnabled,
|
||||
"passkeys_enabled": passkeyCount > 0,
|
||||
"passkey_count": passkeyCount,
|
||||
},
|
||||
}
|
||||
for key, value := range extra {
|
||||
|
||||
580
handlers/passkeys.go
Normal file
580
handlers/passkeys.go
Normal 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
19
models/passkeys.go
Normal 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"`
|
||||
}
|
||||
@@ -98,6 +98,12 @@ func (this *Server) Run() {
|
||||
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/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 {
|
||||
mux.Handle("/api/register", loginLimiter.Middleware(http.HandlerFunc(handlers.APIRegister)))
|
||||
}
|
||||
|
||||
262
storage/passkeys.go
Normal file
262
storage/passkeys.go
Normal 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
|
||||
}
|
||||
@@ -55,6 +55,25 @@ func InitDB(filepath string) error {
|
||||
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 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
|
||||
@@ -23,12 +23,19 @@ func GenerateSecret() string {
|
||||
return base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
func GenerateRefreshToken() (string, error) {
|
||||
return GenerateOpaqueToken()
|
||||
}
|
||||
|
||||
func GenerateOpaqueToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
func EncodeBase64URL(data []byte) string {
|
||||
return base64.RawURLEncoding.EncodeToString(data)
|
||||
}
|
||||
func HashToken(token string) string {
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(hash[:])
|
||||
|
||||
Reference in New Issue
Block a user