Compare commits

..

20 Commits

Author SHA1 Message Date
0f58edf607 Merge feature/passkey-authentication: WebAuthn passkeys + passwordless login
Major feature: Passkey authentication (WebAuthn/FIDO2) with passwordless
login support, passkey-based lock screen unlock, and full admin controls.

Includes:
- Session consolidation (shared services/session.py)
- Passkey registration, login, management (6 endpoints + py_webauthn)
- Passwordless login toggle (per-account, admin-gated, 2-key minimum)
- Passkey lock screen unlock
- Admin: per-user force-disable, system config toggle
- Pentest: 30+ attack vectors tested, 3 low findings remediated
- QA: 2 critical + 5 warnings + 6 suggestions actioned
- Performance: EXISTS over COUNT, bulk session cap, dynamic imports

19 commits, 35 files changed, 2 migrations (061-062).
2026-03-18 02:34:57 +08:00
ed98924716 Action remaining QA suggestions + performance optimizations
S-02: Extract extract_credential_raw_id() helper in services/passkey.py
  — replaces 2 inline rawId parsing blocks in passkeys.py
S-03: Add PasskeyLoginResponse type, use in useAuth passkeyLoginMutation
S-04: Add Cancel button to disable-passwordless dialog
W-03: Invalidate auth queries on disable ceremony error/cancel

Perf-2: Session cap uses ID-only query + bulk UPDATE instead of loading
  full ORM objects and flipping booleans individually
Perf-3: Remove passkey_count from /auth/status hot path (polled every
  15s). Use EXISTS for has_passkeys boolean. Count derived from passkeys
  list query in PasskeySection (passkeys.length).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 02:34:00 +08:00
0a8e163e47 Fix QA review findings: 2 critical, 3 warnings, 1 suggestion
C-01: Initialize config=None before conditional in auth/status to
prevent NameError on fresh instance (setup_required=True path)

C-02: Use generic "Authentication failed" on passkey lockout trigger
instead of leaking lockout state (consistent with F-02 remediation)

W-01: Add nginx rate limit for /api/auth/passkeys/passwordless
endpoints (enable accepts password — brute force protection)

W-02: Call record_successful_login in passkey unlock path to reset
failed_login_count (prevents unexpected lockout accumulation)

W-05: Auto-clear must_change_password on passkey login — user can't
provide old password in forced-change form after passkey auth

S-01: Pin webauthn to >=2.1.0,<3 (prevent major version breakage)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 02:27:16 +08:00
94891d8a70 Fix IAM actions dropdown rendering behind System Settings card
Add relative z-10 to the Users Card so its stacking context sits above
the sibling System Settings Card. Without this, the absolutely-positioned
dropdown menu was painted behind the later sibling in DOM order.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 02:04:41 +08:00
0f6e40a5ba Fix dropdown clipping: remove overflow constraints on parent containers
Revert fixed-positioning approach (caused z-index and placement issues).
Instead fix the root cause: parent containers with overflow that clipped
absolutely-positioned dropdowns.

- IAMPage: Remove overflow-x-auto on table wrapper (columns already
  hide via responsive classes, no horizontal scroll needed)
- AlertBanner: Remove max-h-48 overflow-y-auto on alerts list
  (alerts are naturally bounded, constraint clipped SnoozeDropdown)
- Revert UserActionsMenu and SnoozeDropdown to simple absolute positioning

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 01:20:57 +08:00
a327890b57 Fix dropdown clipping: use fixed positioning to escape overflow
UserActionsMenu and SnoozeDropdown were clipped by parent containers
with overflow-x-auto/overflow-y-auto. Switch from absolute to fixed
positioning — compute viewport-relative coordinates on open via
getBoundingClientRect. Dropdowns now render above all overflow
boundaries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 01:10:36 +08:00
44e6c8e3e5 Fix 3 pentest findings: lockout status disclosure, timing side-channel, XFF trust scope
F-01 (passkeys.py): Add constant-time DB no-op on login/begin when username not
found. Without it the absent credential-fetch query makes the "no user" path
measurably faster, leaking username existence via timing.

F-02 (session.py, auth.py, passkeys.py, totp.py): Change check_account_lockout
from HTTP 423 to 401 — status-code analysis can no longer distinguish a locked
account from an invalid credential. record_failed_login now returns remaining
attempt count; callers use it for progressive UX warnings (<=3 attempts left,
and on the locking attempt) without changing the 401 status code visible to
attackers. Session-lock 423 path in get_current_user is unaffected.

F-03 (nginx.conf): Replace set_real_ip_from 0.0.0.0/0 with RFC 1918 ranges
(172.16.0.0/12, 10.0.0.0/8) to prevent external clients from spoofing
X-Forwarded-For to bypass rate limiting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 01:01:19 +08:00
863e9e2c45 feat: improve account lockout UX with severity-aware error styling
Login errors now distinguish between wrong-password (red), progressive
lockout warnings (amber, Lock icon), and temporary lockout (amber, Lock
icon) based on the backend detail string. Removes the dead 423 branch
from handleCredentialSubmit — account lockout is now returned as 401
with a descriptive detail message.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 01:00:42 +08:00
1b868ba503 Fix: hide passwordless toggle when disabled, remove lock auto-trigger
1. Passwordless toggle in Settings is now hidden when admin hasn't
   enabled allow_passwordless in system config (or when user already
   has it enabled — so they can still disable it). Backend exposes
   allow_passwordless in /auth/status response.

2. Remove auto-trigger passkey ceremony on lock screen — previously
   fired immediately when session locked for passwordless users.
   Now waits for user to click "Unlock with passkey" button.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 00:32:03 +08:00
42d73526f5 feat(passkeys): implement passwordless login frontend (Phase 2)
- types/index.ts: add passkey_count, passwordless_enabled to AuthStatus; add allow_passwordless to SystemConfig; add passwordless_enabled to AdminUser
- useAuth: expose passwordlessEnabled and passkeyCount from auth query
- useLock: add unlockWithPasskey() — clears lock state without password verification
- LockOverlay: passkey unlock support with three modes: passwordless-primary (passkey only, auto-triggers), hybrid (password + "or use a passkey"), password-only (existing behaviour)
- PasskeySection: passwordless toggle below passkey list — enable via password dialog, disable via WebAuthn ceremony dialog; requires 2+ passkeys
- useAdmin: add useDisablePasswordless mutation (PUT /admin/users/{id}/passwordless)
- IAMPage: add allow_passwordless system config toggle
- UserActionsMenu: add "Disable Passwordless" two-click confirm item (shown when user.passwordless_enabled)
- UserDetailSection: add Passwordless badge in Security & Permissions card

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 00:16:48 +08:00
bcfebbc9ae feat(backend): Phase 1 passwordless login — migration, models, toggle endpoints, unlock, delete guard, admin controls
- Migration 062: adds users.passwordless_enabled and system_config.allow_passwordless (both default false)
- User model: passwordless_enabled field after must_change_password
- SystemConfig model: allow_passwordless field after enforce_mfa_new_users
- auth.py login(): block passwordless-enabled accounts from password login path (403) with audit log
- auth.py auth_status(): change has_passkeys query to full COUNT, add passkey_count + passwordless_enabled to response
- auth.py get_current_user(): add /api/auth/passkeys/login/begin and /login/complete to lock_exempt set
- passkeys.py: add PasswordlessEnableRequest + PasswordlessDisableRequest schemas
- passkeys.py: PUT /passwordless/enable — verify password, check system config, require >= 2 passkeys, set flag
- passkeys.py: POST /passwordless/disable/begin — generate user-bound challenge for passkey auth ceremony
- passkeys.py: PUT /passwordless/disable — verify passkey auth response, clear flag, update sign count
- passkeys.py: PasskeyLoginCompleteRequest.unlock field — passkey re-auth into locked session without new session
- passkeys.py: delete guard — 409 if passwordless user attempts to drop below 2 passkeys
- schemas/admin.py: add passwordless_enabled to UserListItem + UserDetailResponse; add allow_passwordless to SystemConfigResponse + SystemConfigUpdate; add TogglePasswordlessRequest
- admin.py: PUT /users/{user_id}/passwordless — admin-only disable (enabled=False only), revokes all sessions, audit log
- admin.py: update_system_config handles allow_passwordless field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 00:15:39 +08:00
fc1f8d5514 Fix passkey registration: use correct py_webauthn credential parsers
RegistrationCredential and AuthenticationCredential are plain dataclasses,
not Pydantic models — model_validate_json() does not exist on them.
Replace with parse_registration_credential_json() and
parse_authentication_credential_json() from webauthn.helpers, which
correctly parse the camelCase JSON from @simplewebauthn/browser and
convert base64url fields to bytes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 23:40:26 +08:00
57d400c6de Update .env.example and README.md for passkey authentication
- .env.example: Add WEBAUTHN_RP_ID, WEBAUTHN_RP_NAME, WEBAUTHN_ORIGIN,
  ENVIRONMENT, and UMBRA_URL with documentation comments
- README.md: Full rewrite — remove outdated PIN/bcrypt references, document
  current auth stack (Argon2id + TOTP + passkeys), all 17 API route groups,
  security features, and Docker deployment

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 23:25:27 +08:00
9234880648 Fix SyntaxError: reorder delete_passkey params
Move `request: Request` (no default) before parameters with defaults
to fix 'parameter without default follows parameter with default'.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 23:05:31 +08:00
53101d1401 Action deferred review items: TOTP lockout consolidation + toast nav
W-04: Replace inline lockout logic in totp.py (3 occurrences of
manual failed_login_count/locked_until manipulation) with shared
session service calls: check_account_lockout, record_failed_login,
record_successful_login. Also fix TOTP replay prevention to use
flush() not commit() for atomicity with session creation.

S-1: Add "Set up" action button to the post-login passkey prompt
toast, navigating to /settings?tab=security (already supported by
SettingsPage search params).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 23:02:59 +08:00
ab84c7bc53 Fix review findings: transaction atomicity, perf, and UI polish
Backend fixes:
- session.py: record_failed/successful_login use flush() not commit()
  — callers own transaction boundary (BUG-2 atomicity fix)
- auth.py: Add explicit commits after record_failed_login where callers
  raise immediately; add commit before TOTP mfa_token return path
- passkeys.py: JOIN credential+user lookup in login/complete (W-1 perf)
- passkeys.py: Move mfa_enforce_pending clear before main commit (S-2)
- passkeys.py: Add Path(ge=1, le=2147483647) on DELETE endpoint (BUG-3)
- auth.py: Switch has_passkeys from COUNT to EXISTS with LIMIT 1 (W-2)
- passkey.py: Add single-worker nonce cache comment (H-1)

Frontend fixes:
- PasskeySection: emerald→green badge colors (W-3 palette)
- PasskeySection: text-[11px]/text-[10px]→text-xs (W-4 a11y minimum)
- PasskeySection: Scope deleteMutation.isPending to per-item (W-5)
- nginx.conf: Permissions-Policy publickey-credentials use (self) (H-2)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 22:59:59 +08:00
51d98173a6 Phase 3: Post-login passkey prompt toast
Show a one-time toast suggesting passkey setup after login when:
- User has no passkeys registered
- Browser supports WebAuthn
- Prompt hasn't been shown this session (sessionStorage gate)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 22:51:06 +08:00
cc460df5d4 Phase 2: Add passkey frontend UI
New files:
- PasskeySection.tsx: Passkey management in Settings > Security with
  registration ceremony (password -> browser prompt -> name), credential
  list, two-click delete with password confirmation

Changes:
- types/index.ts: PasskeyCredential type, has_passkeys on AuthStatus
- api.ts: 401 interceptor exclusions for passkey login endpoints
- useAuth.ts: passkeyLoginMutation with dynamic import of
  @simplewebauthn/browser (~45KB saved from initial bundle)
- LockScreen.tsx: "Sign in with a passkey" button (browser feature
  detection, not per-user), Fingerprint icon, error handling
- SecurityTab.tsx: PasskeySection between Auto-lock and TOTP
- package.json: Add @simplewebauthn/browser ^10.0.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 22:50:06 +08:00
e8e3f62ff8 Phase 1: Add passkey (WebAuthn/FIDO2) backend
New files:
- models/passkey_credential.py: PasskeyCredential model with indexed credential_id
- alembic 061: Create passkey_credentials table
- services/passkey.py: Challenge token management (itsdangerous + nonce replay
  protection) and py_webauthn wrappers for registration/authentication
- routers/passkeys.py: 6 endpoints (register begin/complete, login begin/complete,
  list, delete) with full security hardening

Changes:
- config.py: WEBAUTHN_RP_ID, RP_NAME, ORIGIN, CHALLENGE_TTL settings
- main.py: Mount passkey router, add CSRF exemptions for login endpoints
- auth.py: Add has_passkeys to /auth/status response
- nginx.conf: Rate limiting on all passkey endpoints, Permissions-Policy
  updated for publickey-credentials-get/create
- requirements.txt: Add webauthn>=2.1.0

Security: password re-entry for registration (V-02), single-use nonce
challenges (V-01), constant-time login/begin (V-03), shared lockout
counter, generic 401 errors, audit logging on all events.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 22:46:00 +08:00
eebb34aa77 Phase 0: Consolidate session creation into shared service
Extract _create_db_session, _set_session_cookie, _check_account_lockout,
_record_failed_login, and _record_successful_login from auth.py into
services/session.py. Update totp.py to use shared service instead of
its duplicate _create_full_session (which lacked session cap enforcement).

Also fixes:
- auth/status N+1 query (2 sequential queries -> single JOIN)
- Rename verify_password route to verify_password_endpoint (shadow fix)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 22:40:46 +08:00
36 changed files with 2438 additions and 388 deletions

View File

@ -21,6 +21,15 @@ ENVIRONMENT=development
# Timezone (applied to backend + db containers via env_file)
TZ=Australia/Perth
# ──────────────────────────────────────
# WebAuthn / Passkeys
# ──────────────────────────────────────
# REQUIRED for passkeys to work. Must match the domain users access UMBRA on.
# RP_ID = eTLD+1 (no scheme, no port). ORIGIN = full origin with scheme.
WEBAUTHN_RP_ID=umbra.example.com
WEBAUTHN_RP_NAME=UMBRA
WEBAUTHN_ORIGIN=https://umbra.example.com
# ──────────────────────────────────────
# Integrations
# ──────────────────────────────────────

View File

@ -1,2 +1,14 @@
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/umbra
SECRET_KEY=your-secret-key-change-in-production
ENVIRONMENT=development
# Public-facing URL (used for ntfy click links, CORS derivation)
UMBRA_URL=http://localhost
# WebAuthn / Passkey authentication
# RP_ID must be the eTLD+1 domain of the live site (e.g. umbra.ghost6.xyz)
# ORIGIN must include the scheme (https://)
# These defaults work for local development; override in production .env
WEBAUTHN_RP_ID=localhost
WEBAUTHN_RP_NAME=UMBRA
WEBAUTHN_ORIGIN=http://localhost

View File

@ -1,39 +1,37 @@
# UMBRA Backend
A complete FastAPI backend for the UMBRA application with async SQLAlchemy, PostgreSQL, authentication, and comprehensive CRUD operations.
FastAPI backend for the UMBRA life management application with async SQLAlchemy, PostgreSQL, multi-user RBAC, and comprehensive security.
## Features
- **FastAPI** with async/await support
- **SQLAlchemy 2.0** with async engine
- **PostgreSQL** with asyncpg driver
- **Alembic** for database migrations
- **bcrypt** for password hashing
- **itsdangerous** for session management
- **PIN-based authentication** with secure session cookies
- **Full CRUD operations** for all entities
- **Dashboard** with aggregated data
- **CORS enabled** for frontend integration
- **FastAPI** with async/await and Pydantic v2
- **SQLAlchemy 2.0** async engine with `Mapped[]` types
- **PostgreSQL 16** via asyncpg
- **Alembic** database migrations (001-061)
- **Authentication**: Argon2id passwords + signed httpOnly cookies + optional TOTP MFA + passkey (WebAuthn/FIDO2)
- **Multi-user RBAC**: admin/standard roles, per-user resource scoping
- **Session management**: DB-backed sessions, sliding window expiry, concurrent session cap
- **Account security**: Account lockout (10 failures = 30-min lock), CSRF protection, rate limiting
- **APScheduler** for background notification dispatch
## Project Structure
```
backend/
├── alembic/ # Database migrations
│ ├── versions/ # Migration files
│ ├── env.py # Alembic environment
│ └── script.py.mako # Migration template
├── alembic/versions/ # 61 database migrations
├── app/
│ ├── models/ # SQLAlchemy models
│ ├── schemas/ # Pydantic schemas
│ ├── routers/ # API route handlers
│ ├── config.py # Configuration
│ ├── database.py # Database setup
│ └── main.py # FastAPI application
├── requirements.txt # Python dependencies
├── Dockerfile # Docker configuration
├── alembic.ini # Alembic configuration
└── start.sh # Startup script
│ ├── models/ # 21 SQLAlchemy 2.0 models
│ ├── schemas/ # 14 Pydantic v2 schema modules
│ ├── routers/ # 17 API routers
│ ├── services/ # Auth, session, passkey, TOTP, audit, recurrence, etc.
│ ├── jobs/ # APScheduler notification dispatch
│ ├── config.py # Pydantic Settings (env vars)
│ ├── database.py # Async engine + session factory
│ └── main.py # FastAPI app + CSRF middleware
├── requirements.txt
├── Dockerfile
├── alembic.ini
└── start.sh
```
## Setup
@ -41,160 +39,87 @@ backend/
### 1. Install Dependencies
```bash
cd backend
pip install -r requirements.txt
```
### 2. Configure Environment
Create a `.env` file:
Copy `.env.example` to `.env` and configure:
```bash
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/umbra
SECRET_KEY=your-secret-key-change-in-production
DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/umbra
SECRET_KEY=generate-a-strong-random-key
ENVIRONMENT=production
# WebAuthn / Passkeys (required for passkey auth)
WEBAUTHN_RP_ID=your-domain.com
WEBAUTHN_RP_NAME=UMBRA
WEBAUTHN_ORIGIN=https://your-domain.com
```
### 3. Create Database
```bash
createdb umbra
```
### 4. Run Migrations
### 3. Run Migrations
```bash
alembic upgrade head
```
### 5. Start Server
### 4. Start Server
```bash
# Using the start script
chmod +x start.sh
./start.sh
# Or directly with uvicorn
uvicorn app.main:app --reload
uvicorn app.main:app --host 0.0.0.0 --port 8000
```
The API will be available at `http://localhost:8000`
## API Routes
## API Documentation
All routes require authentication (signed session cookie) except `/api/auth/*` and `/health`.
Interactive API documentation is available at:
- **Swagger UI**: http://localhost:8000/docs
- **ReDoc**: http://localhost:8000/redoc
| Prefix | Description |
|--------|-------------|
| `/api/auth` | Login, logout, register, setup, status, password, TOTP, passkeys |
| `/api/admin` | User management, system config, audit logs (admin only) |
| `/api/todos` | Task management with categories and priorities |
| `/api/events` | Calendar events with recurrence support |
| `/api/event-invitations` | Event invitation RSVP and management |
| `/api/event-templates` | Reusable event templates |
| `/api/calendars` | Calendar CRUD |
| `/api/shared-calendars` | Calendar sharing with permission levels |
| `/api/reminders` | Reminder management with snooze |
| `/api/projects` | Projects with tasks, comments, and collaboration |
| `/api/people` | Contact management |
| `/api/locations` | Location management |
| `/api/connections` | User connections (friend requests) |
| `/api/notifications` | In-app notification centre |
| `/api/settings` | User preferences and ntfy configuration |
| `/api/dashboard` | Aggregated dashboard data |
| `/api/weather` | Weather widget data |
## API Endpoints
## Authentication
### Authentication
- `POST /api/auth/setup` - Initial PIN setup
- `POST /api/auth/login` - Login with PIN
- `POST /api/auth/logout` - Logout
- `GET /api/auth/status` - Check auth status
UMBRA supports three authentication methods:
### Todos
- `GET /api/todos` - List todos (with filters)
- `POST /api/todos` - Create todo
- `GET /api/todos/{id}` - Get todo
- `PUT /api/todos/{id}` - Update todo
- `DELETE /api/todos/{id}` - Delete todo
- `PATCH /api/todos/{id}/toggle` - Toggle completion
1. **Password** (Argon2id) - Primary login method
2. **TOTP MFA** - Optional second factor via authenticator apps
3. **Passkeys** (WebAuthn/FIDO2) - Optional passwordless login via biometrics, security keys, or password managers
### Calendar Events
- `GET /api/events` - List events (with date range)
- `POST /api/events` - Create event
- `GET /api/events/{id}` - Get event
- `PUT /api/events/{id}` - Update event
- `DELETE /api/events/{id}` - Delete event
Passkey login bypasses TOTP (a passkey is inherently two-factor: possession + biometric/PIN).
### Reminders
- `GET /api/reminders` - List reminders (with filters)
- `POST /api/reminders` - Create reminder
- `GET /api/reminders/{id}` - Get reminder
- `PUT /api/reminders/{id}` - Update reminder
- `DELETE /api/reminders/{id}` - Delete reminder
- `PATCH /api/reminders/{id}/dismiss` - Dismiss reminder
## Security
### Projects
- `GET /api/projects` - List projects
- `POST /api/projects` - Create project
- `GET /api/projects/{id}` - Get project
- `PUT /api/projects/{id}` - Update project
- `DELETE /api/projects/{id}` - Delete project
- `GET /api/projects/{id}/tasks` - List project tasks
- `POST /api/projects/{id}/tasks` - Create project task
- `PUT /api/projects/{id}/tasks/{task_id}` - Update task
- `DELETE /api/projects/{id}/tasks/{task_id}` - Delete task
### People
- `GET /api/people` - List people (with search)
- `POST /api/people` - Create person
- `GET /api/people/{id}` - Get person
- `PUT /api/people/{id}` - Update person
- `DELETE /api/people/{id}` - Delete person
### Locations
- `GET /api/locations` - List locations (with category filter)
- `POST /api/locations` - Create location
- `GET /api/locations/{id}` - Get location
- `PUT /api/locations/{id}` - Update location
- `DELETE /api/locations/{id}` - Delete location
### Settings
- `GET /api/settings` - Get settings
- `PUT /api/settings` - Update settings
- `PUT /api/settings/pin` - Change PIN
### Dashboard
- `GET /api/dashboard` - Get dashboard data
- `GET /api/upcoming?days=7` - Get upcoming items
## Database Schema
The application uses the following tables:
- `settings` - Application settings and PIN
- `todos` - Task items
- `calendar_events` - Calendar events
- `reminders` - Reminders
- `projects` - Projects
- `project_tasks` - Tasks within projects
- `people` - Contacts/people
- `locations` - Physical locations
- CSRF protection via `X-Requested-With` header middleware
- All Pydantic schemas use `extra="forbid"` (mass-assignment prevention)
- Nginx rate limiting on auth, registration, and admin endpoints
- DB-backed account lockout after 10 failed attempts
- Timing-safe dummy hash for non-existent users (prevents enumeration)
- SSRF validation on ntfy webhook URLs
- Naive datetimes throughout (Docker runs UTC)
## Docker
Build and run with Docker:
The backend runs as non-root `appuser` in `python:3.12-slim`:
```bash
docker build -t umbra-backend .
docker run -p 8000:8000 -e DATABASE_URL=... -e SECRET_KEY=... umbra-backend
docker run -p 8000:8000 --env-file .env umbra-backend
```
## Development
### Create New Migration
```bash
alembic revision --autogenerate -m "Description of changes"
```
### Apply Migrations
```bash
alembic upgrade head
```
### Rollback Migration
```bash
alembic downgrade -1
```
## Security Notes
- Change `SECRET_KEY` in production
- Use strong PINs (minimum 4 digits recommended)
- Session cookies are httpOnly and last 30 days
- All API endpoints (except auth) require authentication
- PINs are hashed with bcrypt before storage
In production, use Docker Compose (see root `docker-compose.yaml`).

View File

@ -0,0 +1,40 @@
"""Add passkey_credentials table for WebAuthn/FIDO2 authentication
Revision ID: 061
Revises: 060
"""
import sqlalchemy as sa
from alembic import op
revision = "061"
down_revision = "060"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"passkey_credentials",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column(
"user_id",
sa.Integer,
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("credential_id", sa.Text, unique=True, nullable=False),
sa.Column("public_key", sa.Text, nullable=False),
sa.Column("sign_count", sa.Integer, nullable=False, server_default="0"),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("transports", sa.Text, nullable=True),
sa.Column("backed_up", sa.Boolean, nullable=False, server_default="false"),
sa.Column("created_at", sa.DateTime, server_default=sa.text("now()")),
sa.Column("last_used_at", sa.DateTime, nullable=True),
)
op.create_index(
"ix_passkey_credentials_user_id", "passkey_credentials", ["user_id"]
)
def downgrade():
op.drop_table("passkey_credentials")

View File

@ -0,0 +1,40 @@
"""Passwordless login — add passwordless_enabled to users and allow_passwordless to system_config.
Revision ID: 062
Revises: 061
Create Date: 2026-03-18
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "062"
down_revision = "061"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column(
"passwordless_enabled",
sa.Boolean(),
nullable=False,
server_default="false",
),
)
op.add_column(
"system_config",
sa.Column(
"allow_passwordless",
sa.Boolean(),
nullable=False,
server_default="false",
),
)
def downgrade() -> None:
op.drop_column("users", "passwordless_enabled")
op.drop_column("system_config", "allow_passwordless")

View File

@ -30,6 +30,12 @@ class Settings(BaseSettings):
# Concurrent session limit per user (oldest evicted when exceeded)
MAX_SESSIONS_PER_USER: int = 10
# WebAuthn / Passkey configuration
WEBAUTHN_RP_ID: str = "localhost" # eTLD+1 domain, e.g. "umbra.ghost6.xyz"
WEBAUTHN_RP_NAME: str = "UMBRA"
WEBAUTHN_ORIGIN: str = "http://localhost" # Full origin with scheme, e.g. "https://umbra.ghost6.xyz"
WEBAUTHN_CHALLENGE_TTL: int = 60 # Challenge token lifetime in seconds
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
@ -47,6 +53,9 @@ class Settings(BaseSettings):
self.CORS_ORIGINS = "http://localhost:5173"
assert self.COOKIE_SECURE is not None # type narrowing
assert self.CORS_ORIGINS is not None
# Validate WebAuthn origin includes scheme (S-04)
if not self.WEBAUTHN_ORIGIN.startswith(("http://", "https://")):
raise ValueError("WEBAUTHN_ORIGIN must include scheme (http:// or https://)")
return self

View File

@ -7,7 +7,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from app.config import settings
from app.database import engine
from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates
from app.routers import totp, admin, notifications as notifications_router, connections as connections_router, shared_calendars as shared_calendars_router, event_invitations as event_invitations_router
from app.routers import totp, admin, notifications as notifications_router, connections as connections_router, shared_calendars as shared_calendars_router, event_invitations as event_invitations_router, passkeys as passkeys_router
from app.jobs.notifications import run_notification_dispatch
# Import models so Alembic's autogenerate can discover them
@ -23,6 +23,7 @@ from app.models import user_connection as _user_connection_model # noqa: F401
from app.models import calendar_member as _calendar_member_model # noqa: F401
from app.models import event_lock as _event_lock_model # noqa: F401
from app.models import event_invitation as _event_invitation_model # noqa: F401
from app.models import passkey_credential as _passkey_credential_model # noqa: F401
# ---------------------------------------------------------------------------
@ -49,6 +50,8 @@ class CSRFHeaderMiddleware:
"/api/auth/totp-verify",
"/api/auth/totp/enforce-setup",
"/api/auth/totp/enforce-confirm",
"/api/auth/passkeys/login/begin",
"/api/auth/passkeys/login/complete",
})
_MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
@ -134,6 +137,7 @@ app.include_router(dashboard.router, prefix="/api", tags=["Dashboard"])
app.include_router(weather.router, prefix="/api/weather", tags=["Weather"])
app.include_router(event_templates.router, prefix="/api/event-templates", tags=["Event Templates"])
app.include_router(totp.router, prefix="/api/auth", tags=["TOTP MFA"])
app.include_router(passkeys_router.router, prefix="/api/auth/passkeys", tags=["Passkeys"])
app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
app.include_router(notifications_router.router, prefix="/api/notifications", tags=["Notifications"])
app.include_router(connections_router.router, prefix="/api/connections", tags=["Connections"])

View File

@ -23,6 +23,7 @@ from app.models.event_lock import EventLock
from app.models.event_invitation import EventInvitation, EventInvitationOverride
from app.models.project_member import ProjectMember
from app.models.project_task_assignment import ProjectTaskAssignment
from app.models.passkey_credential import PasskeyCredential
__all__ = [
"Settings",
@ -51,4 +52,5 @@ __all__ = [
"EventInvitationOverride",
"ProjectMember",
"ProjectTaskAssignment",
"PasskeyCredential",
]

View File

@ -0,0 +1,30 @@
from datetime import datetime
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class PasskeyCredential(Base):
__tablename__ = "passkey_credentials"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
# base64url-encoded credential ID (spec allows up to 1023 bytes → ~1363 chars)
credential_id: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
# base64url-encoded COSE public key
public_key: Mapped[str] = mapped_column(Text, nullable=False)
# Authenticator sign count for clone detection
sign_count: Mapped[int] = mapped_column(Integer, default=0)
# User-assigned label (e.g. "MacBook Pro — Chrome")
name: Mapped[str] = mapped_column(String(100), nullable=False)
# JSON array of transport hints (e.g. '["usb","hybrid"]')
transports: Mapped[str | None] = mapped_column(Text, nullable=True)
# Whether the credential is backed up / synced across devices
backed_up: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(default=func.now())
last_used_at: Mapped[datetime | None] = mapped_column(nullable=True)

View File

@ -21,6 +21,9 @@ class SystemConfig(Base):
enforce_mfa_new_users: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false"
)
allow_passwordless: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false"
)
created_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
default=func.now(), onupdate=func.now(), server_default=func.now()

View File

@ -43,6 +43,11 @@ class User(Base):
Boolean, default=False, server_default="false"
)
# Passwordless login — requires >= 2 passkeys registered
passwordless_enabled: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false"
)
# Audit
created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())

View File

@ -45,6 +45,7 @@ from app.schemas.admin import (
SystemConfigUpdate,
ToggleActiveRequest,
ToggleMfaEnforceRequest,
TogglePasswordlessRequest,
UpdateUserRoleRequest,
UserDetailResponse,
UserListItem,
@ -670,6 +671,56 @@ async def get_user_sharing_stats(
"pending_invites_received": pending_received,
}
# ---------------------------------------------------------------------------
# PUT /users/{user_id}/passwordless
# ---------------------------------------------------------------------------
@router.put("/users/{user_id}/passwordless")
async def admin_toggle_passwordless(
request: Request,
data: TogglePasswordlessRequest,
user_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
actor: User = Depends(get_current_user),
):
"""
Admin-only: disable passwordless login for a user.
Only enabled=False is allowed admin cannot remotely enable passwordless.
Revokes all sessions so the user must re-authenticate.
"""
if data.enabled:
raise HTTPException(
status_code=400,
detail="Admin can only disable passwordless login, not enable it",
)
_guard_self_action(actor, user_id, "toggle passwordless for")
result = await db.execute(sa.select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if not user.passwordless_enabled:
raise HTTPException(status_code=409, detail="Passwordless login is not enabled for this user")
user.passwordless_enabled = False
revoked = await _revoke_all_sessions(db, user_id)
await log_audit_event(
db,
action="admin.passwordless_disabled",
actor_id=actor.id,
target_id=user_id,
detail={"sessions_revoked": revoked, "username": user.username},
ip=get_client_ip(request),
)
await db.commit()
return {"passwordless_enabled": False, "sessions_revoked": revoked}
# ---------------------------------------------------------------------------
# GET /config
# ---------------------------------------------------------------------------
@ -716,6 +767,9 @@ async def update_system_config(
if data.enforce_mfa_new_users is not None:
changes["enforce_mfa_new_users"] = data.enforce_mfa_new_users
config.enforce_mfa_new_users = data.enforce_mfa_new_users
if data.allow_passwordless is not None:
changes["allow_passwordless"] = data.allow_passwordless
config.allow_passwordless = data.allow_passwordless
if changes:
await log_audit_event(

View File

@ -16,7 +16,6 @@ Security layers:
4. bcryptArgon2id transparent upgrade on first login
5. Role-based authorization via require_role() dependency factory
"""
import uuid
from datetime import datetime, timedelta
from typing import Optional
@ -30,6 +29,7 @@ from app.models.user import User
from app.models.session import UserSession
from app.models.settings import Settings
from app.models.system_config import SystemConfig
from app.models.passkey_credential import PasskeyCredential
from app.models.calendar import Calendar
from app.schemas.auth import (
SetupRequest, LoginRequest, RegisterRequest,
@ -49,6 +49,13 @@ from app.services.auth import (
create_mfa_enforce_token,
)
from app.services.audit import get_client_ip, log_audit_event
from app.services.session import (
set_session_cookie,
check_account_lockout,
record_failed_login,
record_successful_login,
create_db_session,
)
from app.config import settings as app_settings
router = APIRouter()
@ -59,22 +66,6 @@ router = APIRouter()
# is indistinguishable from a wrong-password attempt.
_DUMMY_HASH = hash_password("timing-equalization-dummy")
# ---------------------------------------------------------------------------
# Cookie helper
# ---------------------------------------------------------------------------
def _set_session_cookie(response: Response, token: str) -> None:
response.set_cookie(
key="session",
value=token,
httponly=True,
secure=app_settings.COOKIE_SECURE,
max_age=app_settings.SESSION_MAX_AGE_DAYS * 86400,
samesite="lax",
path="/",
)
# ---------------------------------------------------------------------------
# Auth dependencies — export get_current_user and get_current_settings
# ---------------------------------------------------------------------------
@ -130,7 +121,7 @@ async def get_current_user(
await db.flush()
# Re-issue cookie with fresh signed token to reset browser max_age timer
fresh_token = create_session_token(user_id, session_id)
_set_session_cookie(response, fresh_token)
set_session_cookie(response, fresh_token)
# Stash session on request so lock/unlock endpoints can access it
request.state.db_session = db_session
@ -141,6 +132,7 @@ async def get_current_user(
lock_exempt = {
"/api/auth/lock", "/api/auth/verify-password",
"/api/auth/status", "/api/auth/logout",
"/api/auth/passkeys/login/begin", "/api/auth/passkeys/login/complete",
}
if request.url.path not in lock_exempt:
raise HTTPException(status_code=423, detail="Session is locked")
@ -190,82 +182,6 @@ def require_role(*allowed_roles: str):
require_admin = require_role("admin")
# ---------------------------------------------------------------------------
# Account lockout helpers
# ---------------------------------------------------------------------------
async def _check_account_lockout(user: User) -> None:
"""Raise HTTP 423 if the account is currently locked."""
if user.locked_until and datetime.now() < user.locked_until:
remaining = int((user.locked_until - datetime.now()).total_seconds() / 60) + 1
raise HTTPException(
status_code=423,
detail=f"Account locked. Try again in {remaining} minutes.",
)
async def _record_failed_login(db: AsyncSession, user: User) -> None:
"""Increment failure counter; lock account after 10 failures."""
user.failed_login_count += 1
if user.failed_login_count >= 10:
user.locked_until = datetime.now() + timedelta(minutes=30)
await db.commit()
async def _record_successful_login(db: AsyncSession, user: User) -> None:
"""Reset failure counter and update last_login_at."""
user.failed_login_count = 0
user.locked_until = None
user.last_login_at = datetime.now()
await db.commit()
# ---------------------------------------------------------------------------
# Session creation helper
# ---------------------------------------------------------------------------
async def _create_db_session(
db: AsyncSession,
user: User,
ip: str,
user_agent: str | None,
) -> tuple[str, str]:
"""Insert a UserSession row and return (session_id, signed_cookie_token)."""
session_id = uuid.uuid4().hex
expires_at = datetime.now() + timedelta(days=app_settings.SESSION_MAX_AGE_DAYS)
db_session = UserSession(
id=session_id,
user_id=user.id,
expires_at=expires_at,
ip_address=ip[:45] if ip else None,
user_agent=(user_agent or "")[:255] if user_agent else None,
)
db.add(db_session)
await db.flush()
# Enforce concurrent session limit: revoke oldest sessions beyond the cap
active_sessions = (
await db.execute(
select(UserSession)
.where(
UserSession.user_id == user.id,
UserSession.revoked == False, # noqa: E712
UserSession.expires_at > datetime.now(),
)
.order_by(UserSession.created_at.asc())
)
).scalars().all()
max_sessions = app_settings.MAX_SESSIONS_PER_USER
if len(active_sessions) > max_sessions:
for old_session in active_sessions[: len(active_sessions) - max_sessions]:
old_session.revoked = True
await db.flush()
token = create_session_token(user.id, session_id)
return session_id, token
# ---------------------------------------------------------------------------
# User bootstrapping helper (Settings + default calendars)
# ---------------------------------------------------------------------------
@ -321,8 +237,8 @@ async def setup(
ip = get_client_ip(request)
user_agent = request.headers.get("user-agent")
_, token = await _create_db_session(db, new_user, ip, user_agent)
_set_session_cookie(response, token)
_, token = await create_db_session(db, new_user, ip, user_agent)
set_session_cookie(response, token)
await log_audit_event(
db, action="auth.setup_complete", actor_id=new_user.id, ip=ip,
@ -366,19 +282,38 @@ async def login(
# executes — prevents distinguishing "locked" from "wrong password" via timing.
valid, new_hash = await averify_password_with_upgrade(data.password, user.password_hash)
await _check_account_lockout(user)
await check_account_lockout(user)
if not valid:
await _record_failed_login(db, user)
remaining = await record_failed_login(db, user)
await log_audit_event(
db, action="auth.login_failed", actor_id=user.id,
detail={"reason": "invalid_password"}, ip=client_ip,
detail={"reason": "invalid_password", "attempts_remaining": remaining}, ip=client_ip,
)
await db.commit()
raise HTTPException(status_code=401, detail="Invalid username or password")
if remaining == 0:
detail = "Account temporarily locked. Try again in 30 minutes."
elif remaining <= 3:
detail = f"Invalid username or password. {remaining} attempt(s) remaining before account locks."
else:
detail = "Invalid username or password"
raise HTTPException(status_code=401, detail=detail)
# Block passwordless-only accounts from using the password login path.
# Checked after password verification to avoid leaking account existence via timing.
if user.passwordless_enabled:
await log_audit_event(
db, action="auth.login_blocked_passwordless", actor_id=user.id,
detail={"reason": "passwordless_enabled"}, ip=client_ip,
)
await db.commit()
raise HTTPException(
status_code=403,
detail="This account uses passwordless login. Sign in with a passkey.",
)
# Block disabled accounts — checked AFTER password verification to avoid
# leaking account-state info, and BEFORE _record_successful_login so
# leaking account-state info, and BEFORE record_successful_login so
# last_login_at and lockout counters are not reset for inactive users.
if not user.is_active:
await log_audit_event(
@ -391,7 +326,7 @@ async def login(
if new_hash:
user.password_hash = new_hash
await _record_successful_login(db, user)
await record_successful_login(db, user)
# SEC-03: MFA enforcement — block login entirely until MFA setup completes
if user.mfa_enforce_pending and not user.totp_enabled:
@ -409,6 +344,7 @@ async def login(
# If TOTP is enabled, issue a short-lived MFA challenge token
if user.totp_enabled:
mfa_token = create_mfa_token(user.id)
await db.commit()
return {
"authenticated": False,
"totp_required": True,
@ -419,8 +355,8 @@ async def login(
if user.must_change_password:
# Issue a session but flag the frontend to show password change
user_agent = request.headers.get("user-agent")
_, token = await _create_db_session(db, user, client_ip, user_agent)
_set_session_cookie(response, token)
_, token = await create_db_session(db, user, client_ip, user_agent)
set_session_cookie(response, token)
await db.commit()
return {
"authenticated": True,
@ -428,8 +364,8 @@ async def login(
}
user_agent = request.headers.get("user-agent")
_, token = await _create_db_session(db, user, client_ip, user_agent)
_set_session_cookie(response, token)
_, token = await create_db_session(db, user, client_ip, user_agent)
set_session_cookie(response, token)
await log_audit_event(
db, action="auth.login_success", actor_id=user.id, ip=client_ip,
@ -511,8 +447,8 @@ async def register(
"mfa_token": enforce_token,
}
_, token = await _create_db_session(db, new_user, ip, user_agent)
_set_session_cookie(response, token)
_, token = await create_db_session(db, new_user, ip, user_agent)
set_session_cookie(response, token)
await db.commit()
return {"message": "Registration successful", "authenticated": True}
@ -564,34 +500,34 @@ async def auth_status(
is_locked = False
u = None
if not setup_required and session_cookie:
payload = verify_session_token(session_cookie)
if payload:
user_id = payload.get("uid")
session_id = payload.get("sid")
if user_id and session_id:
session_result = await db.execute(
select(UserSession).where(
# Single JOIN query (was 2 sequential queries — P-01 fix)
result = await db.execute(
select(UserSession, User)
.join(User, UserSession.user_id == User.id)
.where(
UserSession.id == session_id,
UserSession.user_id == user_id,
UserSession.revoked == False,
UserSession.expires_at > datetime.now(),
User.is_active == True,
)
)
db_sess = session_result.scalar_one_or_none()
if db_sess is not None:
row = result.one_or_none()
if row is not None:
db_sess, u = row.tuple()
authenticated = True
is_locked = db_sess.is_locked
user_obj_result = await db.execute(
select(User).where(User.id == user_id, User.is_active == True)
)
u = user_obj_result.scalar_one_or_none()
if u:
role = u.role
else:
authenticated = False
role = u.role
# Check registration availability
config = None
registration_open = False
if not setup_required:
config_result = await db.execute(
@ -600,6 +536,19 @@ async def auth_status(
config = config_result.scalar_one_or_none()
registration_open = config.allow_registration if config else False
# Perf-3: Check passkey existence with EXISTS (not COUNT) — this endpoint
# is polled every 15s. Count is derived from GET /auth/passkeys list instead.
has_passkeys = False
passwordless_enabled = False
if authenticated and u:
pk_result = await db.execute(
select(PasskeyCredential.id).where(
PasskeyCredential.user_id == u.id
).limit(1)
)
has_passkeys = pk_result.scalar_one_or_none() is not None
passwordless_enabled = u.passwordless_enabled
return {
"authenticated": authenticated,
"setup_required": setup_required,
@ -607,6 +556,9 @@ async def auth_status(
"username": u.username if authenticated and u else None,
"registration_open": registration_open,
"is_locked": is_locked,
"has_passkeys": has_passkeys,
"passwordless_enabled": passwordless_enabled,
"allow_passwordless": config.allow_passwordless if config else False,
}
@ -625,7 +577,7 @@ async def lock_session(
@router.post("/verify-password")
async def verify_password(
async def verify_password_endpoint(
data: VerifyPasswordRequest,
request: Request,
db: AsyncSession = Depends(get_db),
@ -635,11 +587,12 @@ async def verify_password(
Verify the current user's password without changing anything.
Used by the frontend lock screen to re-authenticate without a full login.
"""
await _check_account_lockout(current_user)
await check_account_lockout(current_user)
valid, new_hash = await averify_password_with_upgrade(data.password, current_user.password_hash)
if not valid:
await _record_failed_login(db, current_user)
await record_failed_login(db, current_user)
await db.commit()
raise HTTPException(status_code=401, detail="Invalid password")
if new_hash:
@ -661,11 +614,12 @@ async def change_password(
current_user: User = Depends(get_current_user),
):
"""Change the current user's password. Requires old password verification."""
await _check_account_lockout(current_user)
await check_account_lockout(current_user)
valid, _ = await averify_password_with_upgrade(data.old_password, current_user.password_hash)
if not valid:
await _record_failed_login(db, current_user)
await record_failed_login(db, current_user)
await db.commit()
raise HTTPException(status_code=401, detail="Invalid current password")
if data.new_password == data.old_password:

View File

@ -0,0 +1,675 @@
"""
Passkey (WebAuthn/FIDO2) router.
Endpoints (all under /api/auth/passkeys registered in main.py):
POST /register/begin Start passkey registration (auth + password required)
POST /register/complete Complete registration ceremony (auth required)
POST /login/begin Start passkey authentication (public, CSRF-exempt)
POST /login/complete Complete authentication ceremony (public, CSRF-exempt)
GET / List registered passkeys (auth required)
DELETE /{id} Remove a passkey (auth + password required)
Security:
- Challenge tokens signed with itsdangerous (60s TTL, single-use nonce)
- Registration binds challenge to user_id, validated on complete (S-01)
- Registration requires password re-entry (V-02)
- Generic 401 on all auth failures (no credential enumeration)
- Constant-time response on login/begin (V-03)
- Failed passkey logins increment shared lockout counter
- Passkey login bypasses TOTP (passkey IS 2FA)
"""
import asyncio
import json
import logging
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Path, Request, Response
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.database import get_db
from app.models.passkey_credential import PasskeyCredential
from app.models.system_config import SystemConfig
from app.models.user import User
from app.routers.auth import get_current_user
from app.services.audit import get_client_ip, log_audit_event
from app.services.auth import averify_password_with_upgrade, verify_session_token
from app.services.session import (
create_db_session,
set_session_cookie,
check_account_lockout,
record_failed_login,
record_successful_login,
)
from app.services.passkey import (
create_challenge_token,
verify_challenge_token,
build_registration_options,
verify_registration as verify_registration_response_svc,
build_authentication_options,
verify_authentication as verify_authentication_response_svc,
extract_credential_raw_id,
)
from app.models.session import UserSession
from webauthn.helpers import bytes_to_base64url, base64url_to_bytes
logger = logging.getLogger(__name__)
router = APIRouter()
# ---------------------------------------------------------------------------
# Request/Response schemas
# ---------------------------------------------------------------------------
class PasskeyRegisterBeginRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
password: str = Field(max_length=128)
class PasskeyRegisterCompleteRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
credential: str = Field(max_length=8192)
challenge_token: str = Field(max_length=2048)
name: str = Field(min_length=1, max_length=100)
class PasskeyLoginBeginRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
username: str | None = Field(None, max_length=50)
class PasskeyLoginCompleteRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
credential: str = Field(max_length=8192)
challenge_token: str = Field(max_length=2048)
unlock: bool = False
class PasskeyDeleteRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
password: str = Field(max_length=128)
class PasswordlessEnableRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
password: str = Field(max_length=128)
class PasswordlessDisableRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
credential: str = Field(max_length=8192)
challenge_token: str = Field(max_length=2048)
# ---------------------------------------------------------------------------
# Registration endpoints (authenticated)
# ---------------------------------------------------------------------------
@router.post("/register/begin")
async def passkey_register_begin(
data: PasskeyRegisterBeginRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Start passkey registration. Requires password re-entry (V-02)."""
# V-02: Verify password before allowing registration
valid, new_hash = await averify_password_with_upgrade(
data.password, current_user.password_hash
)
if not valid:
raise HTTPException(status_code=401, detail="Invalid password")
if new_hash:
current_user.password_hash = new_hash
await db.commit()
# Load existing credential IDs for exclusion
result = await db.execute(
select(PasskeyCredential.credential_id).where(
PasskeyCredential.user_id == current_user.id
)
)
existing_ids = [
base64url_to_bytes(row[0]) for row in result.all()
]
options_json, challenge = build_registration_options(
user_id=current_user.id,
username=current_user.username,
existing_credential_ids=existing_ids,
)
token = create_challenge_token(challenge, user_id=current_user.id)
return {
"options": json.loads(options_json),
"challenge_token": token,
}
@router.post("/register/complete")
async def passkey_register_complete(
data: PasskeyRegisterCompleteRequest,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Complete passkey registration ceremony."""
# Verify challenge token — cross-check user binding (S-01) + single-use nonce (V-01)
challenge = verify_challenge_token(
data.challenge_token, expected_user_id=current_user.id
)
if challenge is None:
raise HTTPException(status_code=401, detail="Invalid or expired challenge")
try:
verified = verify_registration_response_svc(
credential_json=data.credential,
challenge=challenge,
)
except Exception as e:
logger.warning("Passkey registration verification failed: %s", e)
raise HTTPException(status_code=400, detail="Registration verification failed")
# Store credential
credential_id_b64 = bytes_to_base64url(verified.credential_id)
# Check for duplicate (race condition safety)
existing = await db.execute(
select(PasskeyCredential).where(
PasskeyCredential.credential_id == credential_id_b64
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Credential already registered")
# Extract transport hints if available
transports_json = None
if hasattr(verified, 'credential_device_type'):
pass # py_webauthn doesn't expose transports on VerifiedRegistration
# Transports come from the browser response — parse from credential JSON
try:
cred_data = json.loads(data.credential)
if "response" in cred_data and "transports" in cred_data["response"]:
transports_json = json.dumps(cred_data["response"]["transports"])
except (json.JSONDecodeError, KeyError):
pass
# Determine backup state from py_webauthn flags
backed_up = getattr(verified, 'credential_backed_up', False)
new_credential = PasskeyCredential(
user_id=current_user.id,
credential_id=credential_id_b64,
public_key=bytes_to_base64url(verified.credential_public_key),
sign_count=verified.sign_count,
name=data.name,
transports=transports_json,
backed_up=backed_up,
)
db.add(new_credential)
# B-02: If user has mfa_enforce_pending, clear it (passkey = MFA)
if current_user.mfa_enforce_pending:
current_user.mfa_enforce_pending = False
# Extract response data BEFORE commit (ORM expiry rule)
response_data = {
"id": None, # will be set after flush
"name": new_credential.name,
"created_at": None,
"backed_up": backed_up,
}
await db.flush()
response_data["id"] = new_credential.id
response_data["created_at"] = str(new_credential.created_at) if new_credential.created_at else None
await log_audit_event(
db, action="passkey.registered", actor_id=current_user.id,
detail={"credential_name": data.name},
ip=get_client_ip(request),
)
await db.commit()
return response_data
# ---------------------------------------------------------------------------
# Authentication endpoints (unauthenticated — CSRF-exempt)
# ---------------------------------------------------------------------------
@router.post("/login/begin")
async def passkey_login_begin(
data: PasskeyLoginBeginRequest,
db: AsyncSession = Depends(get_db),
):
"""Start passkey authentication. CSRF-exempt, public endpoint."""
credential_data = None
if data.username:
# Look up user's credentials for allowCredentials
result = await db.execute(
select(User).where(User.username == data.username.lower().strip())
)
user = result.scalar_one_or_none()
if user:
cred_result = await db.execute(
select(
PasskeyCredential.credential_id,
PasskeyCredential.transports,
).where(PasskeyCredential.user_id == user.id)
)
rows = cred_result.all()
if rows:
credential_data = []
for row in rows:
cid_bytes = base64url_to_bytes(row[0])
transports = json.loads(row[1]) if row[1] else None
credential_data.append((cid_bytes, transports))
else:
# F-01: User not found — run a no-op DB query to equalize timing with
# the credential fetch that executes for existing users. Without this,
# the absence of the second query makes the "no user" path measurably
# faster, leaking whether the username exists.
await db.execute(
select(PasskeyCredential.credential_id).where(
PasskeyCredential.user_id == 0
).limit(1)
)
# V-03: Generate options regardless of whether user exists or has passkeys.
# Identical response shape prevents timing enumeration.
options_json, challenge = build_authentication_options(
credential_ids_and_transports=credential_data,
)
token = create_challenge_token(challenge)
return {
"options": json.loads(options_json),
"challenge_token": token,
}
@router.post("/login/complete")
async def passkey_login_complete(
data: PasskeyLoginCompleteRequest,
request: Request,
response: Response,
db: AsyncSession = Depends(get_db),
):
"""Complete passkey authentication. CSRF-exempt, public endpoint."""
# Verify challenge token (60s TTL, single-use nonce V-01)
challenge = verify_challenge_token(data.challenge_token)
if challenge is None:
raise HTTPException(status_code=401, detail="Authentication failed")
# Parse credential_id from browser response (S-02: shared helper)
raw_id_b64 = extract_credential_raw_id(data.credential)
if not raw_id_b64:
raise HTTPException(status_code=401, detail="Authentication failed")
# Look up credential + user in a single JOIN query (W-1 perf fix)
result = await db.execute(
select(PasskeyCredential, User)
.join(User, User.id == PasskeyCredential.user_id)
.where(PasskeyCredential.credential_id == raw_id_b64)
)
row = result.one_or_none()
if not row:
raise HTTPException(status_code=401, detail="Authentication failed")
credential, user = row.tuple()
# Check account lockout (C-03)
await check_account_lockout(user)
# Check active status (C-03)
if not user.is_active:
raise HTTPException(status_code=401, detail="Authentication failed")
# Verify the authentication response
try:
verified = verify_authentication_response_svc(
credential_json=data.credential,
challenge=challenge,
credential_public_key=base64url_to_bytes(credential.public_key),
credential_current_sign_count=credential.sign_count,
)
except Exception as e:
logger.warning("Passkey authentication verification failed for user %s: %s", user.id, e)
# Increment failed login counter (shared with password auth)
remaining = await record_failed_login(db, user)
await log_audit_event(
db, action="passkey.login_failed", actor_id=user.id,
detail={"reason": "verification_failed", "attempts_remaining": remaining},
ip=get_client_ip(request),
)
await db.commit()
# Generic message for all failures — don't leak lockout state (C-02/F-02)
raise HTTPException(status_code=401, detail="Authentication failed")
# Update sign count (log anomaly but don't fail — S-05)
new_sign_count = verified.new_sign_count
if new_sign_count < credential.sign_count and credential.sign_count > 0:
logger.warning(
"Sign count anomaly for user %s credential %s: expected >= %d, got %d",
user.id, credential.id, credential.sign_count, new_sign_count,
)
await log_audit_event(
db, action="passkey.sign_count_anomaly", actor_id=user.id,
detail={
"credential_id": credential.id,
"expected": credential.sign_count,
"received": new_sign_count,
},
ip=get_client_ip(request),
)
credential.sign_count = new_sign_count
credential.last_used_at = datetime.now()
# Passkey unlock — re-authenticate into a locked session instead of creating a new one
if data.unlock:
session_cookie = request.cookies.get("session")
payload = verify_session_token(session_cookie) if session_cookie else None
if not payload or payload.get("uid") != user.id:
raise HTTPException(status_code=401, detail="Authentication failed")
sess_result = await db.execute(
select(UserSession).where(
UserSession.id == payload["sid"],
UserSession.user_id == user.id,
UserSession.revoked == False,
)
)
db_sess = sess_result.scalar_one_or_none()
if not db_sess:
raise HTTPException(status_code=401, detail="Authentication failed")
db_sess.is_locked = False
db_sess.locked_at = None
# Reset failed login counter on successful passkey unlock (W-02)
await record_successful_login(db, user)
await log_audit_event(
db, action="passkey.unlock_success", actor_id=user.id,
ip=get_client_ip(request),
)
await db.commit()
return {"unlocked": True}
# Record successful login
await record_successful_login(db, user)
# Create session (shared service — enforces session cap)
client_ip = get_client_ip(request)
user_agent = request.headers.get("user-agent")
_, token = await create_db_session(db, user, client_ip, user_agent)
set_session_cookie(response, token)
# Handle special flags for passkey login
result_data: dict = {"authenticated": True}
# W-05: Passkey login auto-clears must_change_password — user can't provide
# old password in the forced-change form since they authenticated via passkey.
if user.must_change_password:
user.must_change_password = False
# Passkey satisfies MFA — if mfa_enforce_pending, clear it (before commit)
if user.mfa_enforce_pending:
user.mfa_enforce_pending = False
await log_audit_event(
db, action="passkey.login_success", actor_id=user.id,
detail={"credential_name": credential.name},
ip=client_ip,
)
await db.commit()
return result_data
# ---------------------------------------------------------------------------
# Passwordless toggle endpoints (authenticated)
# ---------------------------------------------------------------------------
@router.put("/passwordless/enable")
async def passwordless_enable(
data: PasswordlessEnableRequest,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Enable passwordless login for the current user.
Requirements:
- System config must have allow_passwordless = True
- User must have >= 2 registered passkeys
- Password confirmation required
"""
# Verify password first
valid, new_hash = await averify_password_with_upgrade(
data.password, current_user.password_hash
)
if not valid:
raise HTTPException(status_code=401, detail="Invalid password")
if new_hash:
current_user.password_hash = new_hash
# Check system config
config_result = await db.execute(
select(SystemConfig).where(SystemConfig.id == 1)
)
config = config_result.scalar_one_or_none()
if not config or not config.allow_passwordless:
raise HTTPException(
status_code=403,
detail="Passwordless login is not enabled on this system",
)
# Require >= 2 passkeys as safety net (can't get locked out)
pk_count_result = await db.execute(
select(func.count()).select_from(PasskeyCredential).where(
PasskeyCredential.user_id == current_user.id
)
)
pk_count = pk_count_result.scalar_one()
if pk_count < 2:
raise HTTPException(
status_code=400,
detail="At least 2 passkeys must be registered before enabling passwordless login",
)
current_user.passwordless_enabled = True
await log_audit_event(
db, action="passkey.passwordless_enabled", actor_id=current_user.id,
ip=get_client_ip(request),
)
await db.commit()
return {"passwordless_enabled": True}
@router.post("/passwordless/disable/begin")
async def passwordless_disable_begin(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Begin the passkey authentication ceremony to disable passwordless login.
Returns challenge options for the browser to present to the authenticator.
"""
# Load user's credentials for allowCredentials
cred_result = await db.execute(
select(
PasskeyCredential.credential_id,
PasskeyCredential.transports,
).where(PasskeyCredential.user_id == current_user.id)
)
rows = cred_result.all()
credential_data = None
if rows:
credential_data = []
for row in rows:
cid_bytes = base64url_to_bytes(row[0])
transports = json.loads(row[1]) if row[1] else None
credential_data.append((cid_bytes, transports))
options_json, challenge = build_authentication_options(
credential_ids_and_transports=credential_data,
)
# Bind challenge to this user so complete endpoint can cross-check
token = create_challenge_token(challenge, user_id=current_user.id)
return {
"options": json.loads(options_json),
"challenge_token": token,
}
@router.put("/passwordless/disable")
async def passwordless_disable(
data: PasswordlessDisableRequest,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Complete passkey authentication to disable passwordless login.
Verifies the credential belongs to the current user.
"""
# Verify challenge token — user-bound (single-use nonce V-01, cross-user binding S-01)
challenge = verify_challenge_token(
data.challenge_token, expected_user_id=current_user.id
)
if challenge is None:
raise HTTPException(status_code=401, detail="Invalid or expired challenge")
# Parse rawId from credential (S-02: shared helper)
raw_id_b64 = extract_credential_raw_id(data.credential)
if not raw_id_b64:
raise HTTPException(status_code=401, detail="Authentication failed")
# Look up credential — verify ownership (IDOR prevention)
cred_result = await db.execute(
select(PasskeyCredential).where(
PasskeyCredential.credential_id == raw_id_b64,
PasskeyCredential.user_id == current_user.id,
)
)
credential = cred_result.scalar_one_or_none()
if not credential:
raise HTTPException(status_code=401, detail="Authentication failed")
# Verify the authentication response
try:
verified = verify_authentication_response_svc(
credential_json=data.credential,
challenge=challenge,
credential_public_key=base64url_to_bytes(credential.public_key),
credential_current_sign_count=credential.sign_count,
)
except Exception as e:
logger.warning(
"Passwordless disable: auth verification failed for user %s: %s",
current_user.id, e,
)
raise HTTPException(status_code=401, detail="Authentication failed")
# Update sign count
credential.sign_count = verified.new_sign_count
credential.last_used_at = datetime.now()
current_user.passwordless_enabled = False
await log_audit_event(
db, action="passkey.passwordless_disabled", actor_id=current_user.id,
ip=get_client_ip(request),
)
await db.commit()
return {"passwordless_enabled": False}
# ---------------------------------------------------------------------------
# Management endpoints (authenticated)
# ---------------------------------------------------------------------------
@router.get("/")
async def list_passkeys(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all passkeys for the current user."""
result = await db.execute(
select(PasskeyCredential)
.where(PasskeyCredential.user_id == current_user.id)
.order_by(PasskeyCredential.created_at.desc())
)
credentials = result.scalars().all()
return [
{
"id": c.id,
"name": c.name,
"created_at": str(c.created_at) if c.created_at else None,
"last_used_at": str(c.last_used_at) if c.last_used_at else None,
"backed_up": c.backed_up,
}
for c in credentials
]
@router.delete("/{credential_id}")
async def delete_passkey(
request: Request,
credential_id: int = Path(ge=1, le=2147483647),
data: PasskeyDeleteRequest = ...,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Delete a passkey. Requires password confirmation (S-06)."""
# Verify password
valid, new_hash = await averify_password_with_upgrade(
data.password, current_user.password_hash
)
if not valid:
raise HTTPException(status_code=401, detail="Invalid password")
if new_hash:
current_user.password_hash = new_hash
# Look up credential — verify ownership (IDOR prevention)
result = await db.execute(
select(PasskeyCredential).where(
PasskeyCredential.id == credential_id,
PasskeyCredential.user_id == current_user.id,
)
)
credential = result.scalar_one_or_none()
if not credential:
raise HTTPException(status_code=404, detail="Passkey not found")
# Guard: passwordless users must retain at least 2 passkeys
if current_user.passwordless_enabled:
pk_count_result = await db.execute(
select(func.count()).select_from(PasskeyCredential).where(
PasskeyCredential.user_id == current_user.id
)
)
pk_count = pk_count_result.scalar_one()
if pk_count <= 2:
raise HTTPException(
status_code=409,
detail="Cannot delete: passwordless requires at least 2 passkeys",
)
cred_name = credential.name
await db.delete(credential)
await log_audit_event(
db, action="passkey.deleted", actor_id=current_user.id,
detail={"credential_name": cred_name, "credential_db_id": credential_id},
ip=get_client_ip(request),
)
await db.commit()
return {"message": "Passkey removed"}

View File

@ -18,10 +18,9 @@ Security:
- totp-verify uses mfa_token (not session cookie) user is not yet authenticated
"""
import asyncio
import uuid
import secrets
import logging
from datetime import datetime, timedelta
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, Response
@ -32,16 +31,21 @@ from sqlalchemy.exc import IntegrityError
from app.database import get_db
from app.models.user import User
from app.models.session import UserSession
from app.models.totp_usage import TOTPUsage
from app.models.backup_code import BackupCode
from app.routers.auth import get_current_user, _set_session_cookie
from app.routers.auth import get_current_user
from app.services.audit import get_client_ip
from app.services.auth import (
averify_password_with_upgrade,
verify_mfa_token,
verify_mfa_enforce_token,
create_session_token,
)
from app.services.session import (
create_db_session,
set_session_cookie,
check_account_lockout,
record_failed_login,
record_successful_login,
)
from app.services.totp import (
generate_totp_secret,
@ -52,7 +56,7 @@ from app.services.totp import (
generate_qr_base64,
generate_backup_codes,
)
from app.config import settings as app_settings
# Argon2id for backup code hashing — treat each code like a password
from argon2 import PasswordHasher
@ -162,29 +166,6 @@ async def _verify_backup_code(
return False
async def _create_full_session(
db: AsyncSession,
user: User,
request: Request,
) -> str:
"""Create a UserSession row and return the signed cookie token."""
session_id = uuid.uuid4().hex
expires_at = datetime.now() + timedelta(days=app_settings.SESSION_MAX_AGE_DAYS)
ip = get_client_ip(request)
user_agent = request.headers.get("user-agent")
db_session = UserSession(
id=session_id,
user_id=user.id,
expires_at=expires_at,
ip_address=ip[:45] if ip else None,
user_agent=(user_agent or "")[:255] if user_agent else None,
)
db.add(db_session)
await db.commit()
return create_session_token(user.id, session_id)
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@ -288,60 +269,55 @@ async def totp_verify(
raise HTTPException(status_code=400, detail="TOTP not configured for this account")
# Check account lockout (shared counter with password failures)
if user.locked_until and datetime.now() < user.locked_until:
remaining = int((user.locked_until - datetime.now()).total_seconds() / 60) + 1
raise HTTPException(
status_code=423,
detail=f"Account locked. Try again in {remaining} minutes.",
)
await check_account_lockout(user)
# --- Backup code path ---
if data.backup_code:
normalized = data.backup_code.strip().upper()
valid = await _verify_backup_code(db, user.id, normalized)
if not valid:
user.failed_login_count += 1
if user.failed_login_count >= 10:
user.locked_until = datetime.now() + timedelta(minutes=30)
remaining = await record_failed_login(db, user)
await db.commit()
if remaining == 0:
raise HTTPException(status_code=401, detail="Account temporarily locked. Try again in 30 minutes.")
raise HTTPException(status_code=401, detail="Invalid backup code")
# Backup code accepted — reset lockout counter and issue session
user.failed_login_count = 0
user.locked_until = None
user.last_login_at = datetime.now()
await db.commit()
await record_successful_login(db, user)
token = await _create_full_session(db, user, request)
_set_session_cookie(response, token)
ip = get_client_ip(request)
user_agent = request.headers.get("user-agent")
_, token = await create_db_session(db, user, ip, user_agent)
set_session_cookie(response, token)
await db.commit()
return {"authenticated": True}
# --- TOTP code path ---
matched_window = verify_totp_code(user.totp_secret, data.code)
if matched_window is None:
user.failed_login_count += 1
if user.failed_login_count >= 10:
user.locked_until = datetime.now() + timedelta(minutes=30)
remaining = await record_failed_login(db, user)
await db.commit()
if remaining == 0:
raise HTTPException(status_code=401, detail="Account temporarily locked. Try again in 30 minutes.")
raise HTTPException(status_code=401, detail="Invalid code")
# Replay prevention — record (user_id, code, actual_matching_window)
totp_record = TOTPUsage(user_id=user.id, code=data.code, window=matched_window)
db.add(totp_record)
try:
await db.commit()
await db.flush()
except IntegrityError:
await db.rollback()
raise HTTPException(status_code=401, detail="Code already used — wait for the next code")
# Success — reset lockout counter, update last_login_at, issue full session
user.failed_login_count = 0
user.locked_until = None
user.last_login_at = datetime.now()
await db.commit()
await record_successful_login(db, user)
token = await _create_full_session(db, user, request)
_set_session_cookie(response, token)
ip = get_client_ip(request)
user_agent = request.headers.get("user-agent")
_, token = await create_db_session(db, user, ip, user_agent)
set_session_cookie(response, token)
await db.commit()
return {"authenticated": True}
@ -513,9 +489,11 @@ async def enforce_confirm_totp(
user.last_login_at = datetime.now()
await db.commit()
# Issue a full session
token = await _create_full_session(db, user, request)
_set_session_cookie(response, token)
# Issue a full session (now uses shared session service with cap enforcement)
ip = get_client_ip(request)
user_agent = request.headers.get("user-agent")
_, token = await create_db_session(db, user, ip, user_agent)
set_session_cookie(response, token)
return {"authenticated": True}

View File

@ -30,6 +30,7 @@ class UserListItem(BaseModel):
last_password_change_at: Optional[datetime] = None
totp_enabled: bool
mfa_enforce_pending: bool
passwordless_enabled: bool = False
created_at: datetime
active_sessions: int = 0
@ -107,6 +108,7 @@ class ToggleMfaEnforceRequest(BaseModel):
class SystemConfigResponse(BaseModel):
allow_registration: bool
enforce_mfa_new_users: bool
allow_passwordless: bool = False
model_config = ConfigDict(from_attributes=True)
@ -115,6 +117,12 @@ class SystemConfigUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
allow_registration: Optional[bool] = None
enforce_mfa_new_users: Optional[bool] = None
allow_passwordless: Optional[bool] = None
class TogglePasswordlessRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
enabled: bool
# ---------------------------------------------------------------------------

View File

@ -0,0 +1,240 @@
"""
Passkey (WebAuthn/FIDO2) service.
Handles challenge token creation/verification (itsdangerous + nonce replay protection)
and wraps py_webauthn library calls for registration and authentication ceremonies.
"""
import base64
import json
import logging
import secrets
import time
import threading
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
from webauthn import (
generate_registration_options,
verify_registration_response,
generate_authentication_options,
verify_authentication_response,
options_to_json,
)
from webauthn.helpers.structs import (
PublicKeyCredentialDescriptor,
AuthenticatorSelectionCriteria,
AuthenticatorTransport,
ResidentKeyRequirement,
UserVerificationRequirement,
AttestationConveyancePreference,
)
from webauthn.helpers import (
bytes_to_base64url,
base64url_to_bytes,
parse_registration_credential_json,
parse_authentication_credential_json,
)
from app.config import settings as app_settings
# ---------------------------------------------------------------------------
# Credential JSON helpers
# ---------------------------------------------------------------------------
def extract_credential_raw_id(credential_json: str) -> str | None:
"""Extract the base64url-encoded rawId from a WebAuthn credential JSON string.
Returns None if parsing fails.
"""
try:
cred_data = json.loads(credential_json)
return cred_data.get("rawId") or cred_data.get("id") or None
except (json.JSONDecodeError, KeyError, TypeError):
return None
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Challenge token management (itsdangerous + nonce replay protection V-01)
# ---------------------------------------------------------------------------
_challenge_serializer = URLSafeTimedSerializer(
secret_key=app_settings.SECRET_KEY,
salt="webauthn-challenge-v1",
)
# Thread-safe nonce cache for single-use enforcement.
# Keys: nonce string, Values: expiry timestamp.
# NOTE: This is process-local. If scaling to multiple uvicorn workers,
# move nonce tracking to Redis or a DB table with unique constraint.
# Current deployment: single worker (Dockerfile --workers 1).
_used_nonces: dict[str, float] = {}
_nonce_lock = threading.Lock()
def create_challenge_token(challenge: bytes, user_id: int | None = None) -> str:
"""Sign challenge + nonce + optional user_id. Returns opaque token string."""
nonce = secrets.token_urlsafe(16)
payload = {
"ch": base64.b64encode(challenge).decode(),
"n": nonce,
}
if user_id is not None:
payload["uid"] = user_id
return _challenge_serializer.dumps(payload)
def verify_challenge_token(token: str, expected_user_id: int | None = None) -> bytes | None:
"""Verify token (TTL from config), enforce single-use via nonce.
If expected_user_id provided, cross-check user binding (for registration).
Returns challenge bytes or None on failure.
"""
try:
data = _challenge_serializer.loads(
token, max_age=app_settings.WEBAUTHN_CHALLENGE_TTL
)
except (BadSignature, SignatureExpired):
return None
nonce = data.get("n")
if not nonce:
return None
now = time.time()
with _nonce_lock:
# Lazy cleanup of expired nonces
expired = [k for k, v in _used_nonces.items() if v <= now]
for k in expired:
del _used_nonces[k]
# Check for replay
if nonce in _used_nonces:
return None
# Mark nonce as used
_used_nonces[nonce] = now + app_settings.WEBAUTHN_CHALLENGE_TTL
# Cross-check user binding for registration tokens
if expected_user_id is not None:
if data.get("uid") != expected_user_id:
return None
return base64.b64decode(data["ch"])
# ---------------------------------------------------------------------------
# py_webauthn wrappers
# All synchronous — ECDSA P-256 verification is ~0.1ms, faster than executor overhead.
# ---------------------------------------------------------------------------
def build_registration_options(
user_id: int,
username: str,
existing_credential_ids: list[bytes],
) -> tuple[str, bytes]:
"""Generate WebAuthn registration options.
Returns (options_json_str, challenge_bytes).
"""
exclude_credentials = [
PublicKeyCredentialDescriptor(id=cid)
for cid in existing_credential_ids
]
options = generate_registration_options(
rp_id=app_settings.WEBAUTHN_RP_ID,
rp_name=app_settings.WEBAUTHN_RP_NAME,
user_id=str(user_id).encode(),
user_name=username,
attestation=AttestationConveyancePreference.NONE,
authenticator_selection=AuthenticatorSelectionCriteria(
resident_key=ResidentKeyRequirement.PREFERRED,
user_verification=UserVerificationRequirement.PREFERRED,
),
exclude_credentials=exclude_credentials,
timeout=60000,
)
options_json = options_to_json(options)
return options_json, options.challenge
def verify_registration(
credential_json: str,
challenge: bytes,
) -> "VerifiedRegistration":
"""Verify a registration response from the browser.
Returns VerifiedRegistration on success, raises on failure.
"""
credential = parse_registration_credential_json(credential_json)
return verify_registration_response(
credential=credential,
expected_challenge=challenge,
expected_rp_id=app_settings.WEBAUTHN_RP_ID,
expected_origin=app_settings.WEBAUTHN_ORIGIN,
require_user_verification=False,
)
def build_authentication_options(
credential_ids_and_transports: list[tuple[bytes, list[str] | None]] | None = None,
) -> tuple[str, bytes]:
"""Generate WebAuthn authentication options.
If credential_ids_and_transports provided, includes allowCredentials.
Otherwise, allows discoverable credential flow.
Returns (options_json_str, challenge_bytes).
"""
allow_credentials = None
if credential_ids_and_transports:
allow_credentials = []
for cid, transports in credential_ids_and_transports:
transport_list = None
if transports:
transport_list = [
AuthenticatorTransport(t)
for t in transports
if t in [e.value for e in AuthenticatorTransport]
]
allow_credentials.append(
PublicKeyCredentialDescriptor(
id=cid,
transports=transport_list or None,
)
)
options = generate_authentication_options(
rp_id=app_settings.WEBAUTHN_RP_ID,
allow_credentials=allow_credentials,
user_verification=UserVerificationRequirement.PREFERRED,
timeout=60000,
)
options_json = options_to_json(options)
return options_json, options.challenge
def verify_authentication(
credential_json: str,
challenge: bytes,
credential_public_key: bytes,
credential_current_sign_count: int,
) -> "VerifiedAuthentication":
"""Verify an authentication response from the browser.
Returns VerifiedAuthentication on success, raises on failure.
Sign count anomalies are NOT hard-failed caller should log and continue.
"""
credential = parse_authentication_credential_json(credential_json)
return verify_authentication_response(
credential=credential,
expected_challenge=challenge,
expected_rp_id=app_settings.WEBAUTHN_RP_ID,
expected_origin=app_settings.WEBAUTHN_ORIGIN,
credential_public_key=credential_public_key,
credential_current_sign_count=credential_current_sign_count,
require_user_verification=False,
)

View File

@ -0,0 +1,121 @@
"""
Shared session management service.
Consolidates session creation, cookie handling, and account lockout logic
that was previously duplicated between auth.py and totp.py routers.
All auth paths (password, TOTP, passkey) use these functions to ensure
consistent session cap enforcement and lockout behavior.
"""
import uuid
from datetime import datetime, timedelta
from fastapi import HTTPException, Response
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from app.models.user import User
from app.models.session import UserSession
from app.services.auth import create_session_token
from app.config import settings as app_settings
def set_session_cookie(response: Response, token: str) -> None:
"""Set httpOnly secure signed cookie on response."""
response.set_cookie(
key="session",
value=token,
httponly=True,
secure=app_settings.COOKIE_SECURE,
max_age=app_settings.SESSION_MAX_AGE_DAYS * 86400,
samesite="lax",
path="/",
)
async def check_account_lockout(user: User) -> None:
"""Raise HTTP 401 if the account is currently locked.
Uses 401 (same status as wrong-password) so that status-code analysis
cannot distinguish a locked account from an invalid credential (F-02).
"""
if user.locked_until and datetime.now() < user.locked_until:
remaining = int((user.locked_until - datetime.now()).total_seconds() / 60) + 1
raise HTTPException(
status_code=401,
detail=f"Account temporarily locked. Try again in {remaining} minutes.",
)
async def record_failed_login(db: AsyncSession, user: User) -> int:
"""Increment failure counter; lock account after 10 failures.
Returns the number of attempts remaining before lockout (0 = just locked).
Does NOT commit caller owns the transaction boundary.
"""
user.failed_login_count += 1
remaining = max(0, 10 - user.failed_login_count)
if user.failed_login_count >= 10:
user.locked_until = datetime.now() + timedelta(minutes=30)
await db.flush()
return remaining
async def record_successful_login(db: AsyncSession, user: User) -> None:
"""Reset failure counter and update last_login_at.
Does NOT commit caller owns the transaction boundary.
"""
user.failed_login_count = 0
user.locked_until = None
user.last_login_at = datetime.now()
await db.flush()
async def create_db_session(
db: AsyncSession,
user: User,
ip: str,
user_agent: str | None,
) -> tuple[str, str]:
"""Insert a UserSession row and return (session_id, signed_cookie_token).
Enforces MAX_SESSIONS_PER_USER by revoking oldest sessions beyond the cap.
"""
session_id = uuid.uuid4().hex
expires_at = datetime.now() + timedelta(days=app_settings.SESSION_MAX_AGE_DAYS)
db_session = UserSession(
id=session_id,
user_id=user.id,
expires_at=expires_at,
ip_address=ip[:45] if ip else None,
user_agent=(user_agent or "")[:255] if user_agent else None,
)
db.add(db_session)
await db.flush()
# Enforce concurrent session limit: revoke oldest sessions beyond the cap.
# Perf-2: Query IDs only, bulk-update instead of loading full ORM objects.
max_sessions = app_settings.MAX_SESSIONS_PER_USER
active_ids = (
await db.execute(
select(UserSession.id)
.where(
UserSession.user_id == user.id,
UserSession.revoked == False, # noqa: E712
UserSession.expires_at > datetime.now(),
)
.order_by(UserSession.created_at.asc())
)
).scalars().all()
if len(active_ids) > max_sessions:
ids_to_revoke = active_ids[: len(active_ids) - max_sessions]
await db.execute(
update(UserSession)
.where(UserSession.id.in_(ids_to_revoke))
.values(revoked=True)
)
await db.flush()
token = create_session_token(user.id, session_id)
return session_id, token

View File

@ -15,3 +15,4 @@ python-dateutil==2.9.0
itsdangerous==2.2.0
httpx==0.27.2
apscheduler==3.10.4
webauthn>=2.1.0,<3

View File

@ -29,13 +29,14 @@ server {
# Suppress nginx version in Server header
server_tokens off;
# ── Real client IP restoration (PT-01) ────────────────────────────
# ── Real client IP restoration (PT-01 / F-03) ─────────────────────
# Pangolin (TLS-terminating reverse proxy) connects via Docker bridge.
# Restore the real client IP from X-Forwarded-For so that limit_req_zone
# (which keys on $binary_remote_addr) throttles per-client, not per-proxy.
# Safe to trust all sources: nginx is only reachable via Docker networking,
# never directly internet-facing. Tighten if deployment model changes.
set_real_ip_from 0.0.0.0/0;
# Restricted to RFC 1918 ranges only trusting 0.0.0.0/0 would allow an
# external client to spoof X-Forwarded-For and bypass rate limiting (F-03).
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 10.0.0.0/8;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
@ -83,6 +84,36 @@ server {
include /etc/nginx/proxy-params.conf;
}
# Passkey authentication rate-limited (C-04)
location /api/auth/passkeys/login/begin {
limit_req zone=auth_limit burst=5 nodelay;
limit_req_status 429;
include /etc/nginx/proxy-params.conf;
}
location /api/auth/passkeys/login/complete {
limit_req zone=auth_limit burst=5 nodelay;
limit_req_status 429;
include /etc/nginx/proxy-params.conf;
}
# Passkey registration authenticated, lower burst
location /api/auth/passkeys/register/begin {
limit_req zone=auth_limit burst=3 nodelay;
limit_req_status 429;
include /etc/nginx/proxy-params.conf;
}
location /api/auth/passkeys/register/complete {
limit_req zone=auth_limit burst=3 nodelay;
limit_req_status 429;
include /etc/nginx/proxy-params.conf;
}
# Passwordless toggle enable accepts password, rate-limit against brute force
location /api/auth/passkeys/passwordless {
limit_req zone=auth_limit burst=3 nodelay;
limit_req_status 429;
include /etc/nginx/proxy-params.conf;
}
# SEC-14: Rate-limit public registration endpoint
location /api/auth/register {
limit_req zone=register_limit burst=3 nodelay;
@ -164,5 +195,5 @@ server {
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# PT-I03: Restrict unnecessary browser APIs
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=(), publickey-credentials-get=(self), publickey-credentials-create=(self)" always;
}

View File

@ -16,6 +16,7 @@
"@fullcalendar/interaction": "^6.1.15",
"@fullcalendar/react": "^6.1.15",
"@fullcalendar/timegrid": "^6.1.15",
"@simplewebauthn/browser": "^10.0.0",
"@tanstack/react-query": "^5.62.0",
"axios": "^1.7.9",
"class-variance-authority": "^0.7.1",
@ -1348,6 +1349,22 @@
"win32"
]
},
"node_modules/@simplewebauthn/browser": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-10.0.0.tgz",
"integrity": "sha512-hG0JMZD+LiLUbpQcAjS4d+t4gbprE/dLYop/CkE01ugU/9sKXflxV5s0DRjdz3uNMFecatRfb4ZLG3XvF8m5zg==",
"license": "MIT",
"dependencies": {
"@simplewebauthn/types": "^10.0.0"
}
},
"node_modules/@simplewebauthn/types": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-10.0.0.tgz",
"integrity": "sha512-SFXke7xkgPRowY2E+8djKbdEznTVnD5R6GO7GPTthpHrokLvNKw8C3lFZypTxLI7KkCfGPfhtqB3d7OVGGa9jQ==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT"
},
"node_modules/@tanstack/query-core": {
"version": "5.90.20",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",

View File

@ -17,6 +17,7 @@
"@fullcalendar/interaction": "^6.1.15",
"@fullcalendar/react": "^6.1.15",
"@fullcalendar/timegrid": "^6.1.15",
"@simplewebauthn/browser": "^10.0.0",
"@tanstack/react-query": "^5.62.0",
"axios": "^1.7.9",
"class-variance-authority": "^0.7.1",

View File

@ -81,7 +81,7 @@ export default function IAMPage() {
);
}, [users, searchQuery]);
const handleConfigToggle = async (key: 'allow_registration' | 'enforce_mfa_new_users', value: boolean) => {
const handleConfigToggle = async (key: 'allow_registration' | 'enforce_mfa_new_users' | 'allow_passwordless', value: boolean) => {
try {
await updateConfig.mutateAsync({ [key]: value });
toast.success('System settings updated');
@ -123,8 +123,8 @@ export default function IAMPage() {
/>
</div>
{/* User table */}
<Card>
{/* User table — relative z-10 so action dropdowns render above sibling cards */}
<Card className="relative z-10">
<CardHeader className="flex-row items-center justify-between flex-wrap gap-2 md:gap-3">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-accent/10">
@ -160,7 +160,7 @@ export default function IAMPage() {
{searchQuery ? 'No users match your search.' : 'No users found.'}
</p>
) : (
<div className="overflow-x-auto">
<div>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-card-elevated/50">
@ -320,6 +320,20 @@ export default function IAMPage() {
disabled={updateConfig.isPending}
/>
</div>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label className="text-sm font-medium">Allow Passwordless Login</Label>
<p className="text-xs text-muted-foreground">
Allow users to enable passkey-only login, skipping the password prompt entirely.
</p>
</div>
<Switch
checked={config?.allow_passwordless ?? false}
onCheckedChange={(v) => handleConfigToggle('allow_passwordless', v)}
disabled={updateConfig.isPending}
/>
</div>
</>
)}
</CardContent>

View File

@ -11,6 +11,7 @@ import {
ChevronRight,
Loader2,
Trash2,
ShieldOff,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useConfirmAction } from '@/hooks/useConfirmAction';
@ -23,6 +24,7 @@ import {
useToggleUserActive,
useRevokeSessions,
useDeleteUser,
useDisablePasswordless,
getErrorMessage,
} from '@/hooks/useAdmin';
import type { AdminUserDetail, UserRole } from '@/types';
@ -53,6 +55,7 @@ export default function UserActionsMenu({ user, currentUsername }: UserActionsMe
const toggleActive = useToggleUserActive();
const revokeSessions = useRevokeSessions();
const deleteUser = useDeleteUser();
const disablePasswordless = useDisablePasswordless();
// Close on outside click
useEffect(() => {
@ -102,6 +105,10 @@ export default function UserActionsMenu({ user, currentUsername }: UserActionsMe
}
});
const disablePasswordlessConfirm = useConfirmAction(() => {
handleAction(() => disablePasswordless.mutateAsync(user.id), 'Passwordless login disabled');
});
const isLoading =
updateRole.isPending ||
resetPassword.isPending ||
@ -110,7 +117,8 @@ export default function UserActionsMenu({ user, currentUsername }: UserActionsMe
removeMfaEnforcement.isPending ||
toggleActive.isPending ||
revokeSessions.isPending ||
deleteUser.isPending;
deleteUser.isPending ||
disablePasswordless.isPending;
return (
<div ref={menuRef} className="relative">
@ -258,6 +266,21 @@ export default function UserActionsMenu({ user, currentUsername }: UserActionsMe
</button>
)}
{user.passwordless_enabled && (
<button
className={cn(
'flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors',
disablePasswordlessConfirm.confirming
? 'text-orange-400 bg-orange-500/10 hover:bg-orange-500/15'
: 'hover:bg-card-elevated'
)}
onClick={disablePasswordlessConfirm.handleClick}
>
<ShieldOff className="h-4 w-4" />
{disablePasswordlessConfirm.confirming ? 'Sure? Click to confirm' : 'Disable Passwordless'}
</button>
)}
<div className="my-1 border-t border-border" />
{/* Disable / Enable Account */}

View File

@ -193,6 +193,18 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection
/>
}
/>
<DetailRow
label="Passwordless"
value={
user.passwordless_enabled ? (
<span className="text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide bg-green-500/15 text-green-400">
Enabled
</span>
) : (
<span className="text-xs text-muted-foreground">Off</span>
)
}
/>
<DetailRow
label="Must Change Pwd"
value={user.must_change_password ? 'Yes' : 'No'}

View File

@ -1,7 +1,7 @@
import { useState, FormEvent } from 'react';
import { Navigate } from 'react-router-dom';
import { toast } from 'sonner';
import { AlertTriangle, Copy, Lock, Loader2, ShieldCheck, UserPlus } from 'lucide-react';
import { AlertTriangle, Copy, Fingerprint, Lock, Loader2, ShieldCheck, UserPlus } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth';
import api, { getErrorMessage } from '@/lib/api';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@ -10,6 +10,7 @@ import { DatePicker } from '@/components/ui/date-picker';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
import { Separator } from '@/components/ui/separator';
import AmbientBackground from './AmbientBackground';
import type { TotpSetupResponse } from '@/types';
@ -47,6 +48,8 @@ export default function LockScreen() {
isRegisterPending,
isSetupPending,
isTotpPending,
passkeyLogin,
isPasskeyLoginPending,
} = useAuth();
// ── Shared credential fields ──
@ -83,6 +86,31 @@ export default function LockScreen() {
const [forcedConfirmPassword, setForcedConfirmPassword] = useState('');
const [isForcePwPending, setIsForcePwPending] = useState(false);
// ── Passkey support (U-01: browser feature detection, not per-user) ──
const [supportsWebAuthn] = useState(() => !!window.PublicKeyCredential);
const handlePasskeyLogin = async () => {
setLoginError(null);
try {
const result = await passkeyLogin();
if (result?.must_change_password) {
setMode('force_pw');
}
} catch (error: unknown) {
if (error instanceof Error) {
if (error.name === 'NotAllowedError') {
toast.info('Passkey not recognized. Try your password.');
} else if (error.name === 'AbortError') {
// User cancelled — silent
} else {
toast.error(getErrorMessage(error, 'Passkey login failed. Try your password.'));
}
} else {
toast.error(getErrorMessage(error, 'Passkey login failed. Try your password.'));
}
}
};
// Redirect authenticated users (no pending MFA flows)
if (!isLoading && authStatus?.authenticated && !mfaSetupRequired && mode !== 'force_pw') {
return <Navigate to="/dashboard" replace />;
@ -127,11 +155,10 @@ export default function LockScreen() {
// mfaSetupRequired / mfaRequired handled by hook state → activeMode switches automatically
} catch (error: any) {
const status = error?.response?.status;
if (status === 423) {
setLoginError(error.response.data?.detail || 'Account locked. Try again later.');
} else if (status === 403) {
if (status === 403) {
setLoginError(error.response.data?.detail || 'Account is disabled. Contact an administrator.');
} else {
// 401 covers both wrong password and account lockout (backend embeds detail string)
setLoginError(getErrorMessage(error, 'Invalid username or password'));
}
}
@ -491,18 +518,28 @@ export default function LockScreen() {
</div>
</CardHeader>
<CardContent>
{loginError && (
<div
role="alert"
className={cn(
'flex items-center gap-2 rounded-md border border-red-500/30',
'bg-red-500/10 px-3 py-2 mb-4'
)}
>
<AlertTriangle className="h-4 w-4 text-red-400 shrink-0" aria-hidden="true" />
<p className="text-xs text-red-400">{loginError}</p>
</div>
)}
{loginError && (() => {
const isLockWarning =
loginError.includes('remaining') || loginError.includes('temporarily locked');
return (
<div
role="alert"
className={cn(
'flex items-center gap-2 rounded-md border px-3 py-2 mb-4',
isLockWarning
? 'bg-amber-500/10 border-amber-500/30'
: 'bg-red-500/10 border-red-500/30'
)}
>
{isLockWarning
? <Lock className="h-4 w-4 text-amber-400 shrink-0" aria-hidden="true" />
: <AlertTriangle className="h-4 w-4 text-red-400 shrink-0" aria-hidden="true" />}
<p className={cn('text-xs', isLockWarning ? 'text-amber-400' : 'text-red-400')}>
{loginError}
</p>
</div>
);
})()}
<form onSubmit={handleCredentialSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username" required>Username</Label>
@ -561,6 +598,30 @@ export default function LockScreen() {
</Button>
</form>
{/* Passkey login — shown when browser supports WebAuthn (U-01) */}
{!isSetup && supportsWebAuthn && (
<>
<div className="relative my-4">
<Separator />
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card px-2 text-xs text-muted-foreground">
or
</span>
</div>
<Button
variant="outline"
className="w-full gap-2"
onClick={handlePasskeyLogin}
disabled={isPasskeyLoginPending}
aria-label="Sign in with a passkey"
>
{isPasskeyLoginPending
? <Loader2 className="h-4 w-4 animate-spin" />
: <Fingerprint className="h-4 w-4" />}
Sign in with a passkey
</Button>
</>
)}
{/* Open registration link — only shown on login screen when enabled */}
{!isSetup && registrationOpen && (
<div className="mt-4 text-center">

View File

@ -23,7 +23,7 @@ export default function AlertBanner({ alerts, onDismiss, onSnooze }: AlertBanner
{alerts.length}
</span>
</div>
<div className="divide-y divide-border max-h-48 overflow-y-auto">
<div className="divide-y divide-border">
{alerts.map((alert) => (
<div
key={alert.id}

View File

@ -1,7 +1,9 @@
import { useState } from 'react';
import { Outlet } from 'react-router-dom';
import { useState, useEffect } from 'react';
import { Outlet, useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { Menu } from 'lucide-react';
import { useTheme } from '@/hooks/useTheme';
import { useAuth } from '@/hooks/useAuth';
import { usePrefetch } from '@/hooks/usePrefetch';
import { AlertsProvider } from '@/hooks/useAlerts';
import { LockProvider, useLock } from '@/hooks/useLock';
@ -17,7 +19,27 @@ function AppContent({ mobileOpen, setMobileOpen }: {
setMobileOpen: (v: boolean) => void;
}) {
const { isLocked, isLockResolved } = useLock();
const { hasPasskeys } = useAuth();
const navigate = useNavigate();
usePrefetch(isLockResolved && !isLocked);
// Post-login passkey prompt — show once per session if user has no passkeys
useEffect(() => {
if (
isLockResolved && !isLocked && !hasPasskeys &&
window.PublicKeyCredential &&
!sessionStorage.getItem('passkey-prompt-shown')
) {
sessionStorage.setItem('passkey-prompt-shown', '1');
toast.info('Simplify your login \u2014 set up a passkey in Settings', {
duration: 8000,
action: {
label: 'Set up',
onClick: () => navigate('/settings?tab=security'),
},
});
}
}, [isLockResolved, isLocked, hasPasskeys, navigate]);
const [collapsed, setCollapsed] = useState(() => {
try { return JSON.parse(localStorage.getItem('umbra-sidebar-collapsed') || 'false'); }
catch { return false; }

View File

@ -1,34 +1,43 @@
import { useState, FormEvent, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { Lock, Loader2 } from 'lucide-react';
import { Lock, Loader2, Fingerprint } from 'lucide-react';
import { useLock } from '@/hooks/useLock';
import { useAuth } from '@/hooks/useAuth';
import { useSettings } from '@/hooks/useSettings';
import { getErrorMessage } from '@/lib/api';
import api, { getErrorMessage } from '@/lib/api';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import AmbientBackground from '@/components/auth/AmbientBackground';
export default function LockOverlay() {
const { isLocked, unlock } = useLock();
const { logout } = useAuth();
const { isLocked, unlock, unlockWithPasskey } = useLock();
const { logout, passwordlessEnabled, hasPasskeys, authStatus } = useAuth();
const { settings } = useSettings();
const navigate = useNavigate();
const [password, setPassword] = useState('');
const [isUnlocking, setIsUnlocking] = useState(false);
const [isPasskeyUnlocking, setIsPasskeyUnlocking] = useState(false);
const [supportsWebAuthn] = useState(() => !!window.PublicKeyCredential);
const inputRef = useRef<HTMLInputElement>(null);
// Focus password input when lock activates
// Derive from auth query — has_passkeys covers both owners and any registered passkey
const userHasPasskeys = authStatus?.has_passkeys ?? hasPasskeys;
const showPasskeyButton = userHasPasskeys && supportsWebAuthn;
// When passwordless is enabled: passkey is the primary unlock method
// When passwordless is disabled: show password form, optionally with passkey secondary
const showPasswordForm = !passwordlessEnabled;
// Focus password input when lock activates (only when password form is visible)
useEffect(() => {
if (isLocked) {
if (isLocked && showPasswordForm) {
setPassword('');
// Small delay to let the overlay render
const t = setTimeout(() => inputRef.current?.focus(), 100);
return () => clearTimeout(t);
}
}, [isLocked]);
}, [isLocked, showPasswordForm]);
if (!isLocked) return null;
@ -50,6 +59,29 @@ export default function LockOverlay() {
}
};
const handlePasskeyUnlock = async () => {
setIsPasskeyUnlocking(true);
try {
const { startAuthentication } = await import('@simplewebauthn/browser');
const { data: beginResp } = await api.post('/auth/passkeys/login/begin', {});
const credential = await startAuthentication(beginResp.options);
await api.post('/auth/passkeys/login/complete', {
credential: JSON.stringify(credential),
challenge_token: beginResp.challenge_token,
unlock: true,
});
unlockWithPasskey();
} catch (error) {
if (error instanceof Error && error.name === 'NotAllowedError') {
toast.error('Passkey not recognized');
} else if (error instanceof Error && error.name !== 'AbortError') {
toast.error(getErrorMessage(error, 'Unlock failed'));
}
} finally {
setIsPasskeyUnlocking(false);
}
};
const handleSwitchAccount = async () => {
await logout();
navigate('/login');
@ -75,29 +107,87 @@ export default function LockOverlay() {
)}
</div>
{/* Password form */}
<form onSubmit={handleUnlock} className="w-full space-y-4">
<Input
ref={inputRef}
type="password"
aria-label="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password to unlock"
autoComplete="current-password"
className="text-center"
/>
<Button type="submit" className="w-full" disabled={isUnlocking}>
{isUnlocking ? (
{/* Passwordless-primary mode: passkey button only */}
{passwordlessEnabled && showPasskeyButton && (
<Button
type="button"
className="w-full gap-2"
onClick={handlePasskeyUnlock}
disabled={isPasskeyUnlocking}
aria-label="Unlock with passkey"
>
{isPasskeyUnlocking ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Unlocking
Verifying passkey
</>
) : (
'Unlock'
<>
<Fingerprint className="h-4 w-4" />
Unlock with passkey
</>
)}
</Button>
</form>
)}
{/* Password form — shown when passwordless is off */}
{showPasswordForm && (
<>
<form onSubmit={handleUnlock} className="w-full space-y-4">
<Input
ref={inputRef}
type="password"
aria-label="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password to unlock"
autoComplete="current-password"
className="text-center"
/>
<Button type="submit" className="w-full" disabled={isUnlocking}>
{isUnlocking ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Unlocking
</>
) : (
'Unlock'
)}
</Button>
</form>
{/* Passkey secondary option */}
{showPasskeyButton && (
<div className="w-full flex flex-col items-center gap-4">
<div className="w-full flex items-center gap-3">
<Separator className="flex-1" />
<span className="text-xs text-muted-foreground shrink-0">or</span>
<Separator className="flex-1" />
</div>
<Button
type="button"
variant="outline"
className="w-full gap-2"
onClick={handlePasskeyUnlock}
disabled={isPasskeyUnlocking}
aria-label="Unlock with passkey"
>
{isPasskeyUnlocking ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Verifying passkey
</>
) : (
<>
<Fingerprint className="h-4 w-4" />
Use a passkey
</>
)}
</Button>
</div>
)}
</>
)}
{/* Switch account link */}
<button

View File

@ -0,0 +1,600 @@
import { useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Fingerprint, Loader2, Trash2, Cloud, ShieldOff } from 'lucide-react';
import api, { getErrorMessage } from '@/lib/api';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Separator } from '@/components/ui/separator';
import { useConfirmAction } from '@/hooks/useConfirmAction';
import { useAuth } from '@/hooks/useAuth';
import type { PasskeyCredential } from '@/types';
function formatRelativeTime(dateStr: string | null): string {
if (!dateStr) return 'Never';
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 30) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '';
return new Date(dateStr).toLocaleDateString(undefined, {
day: 'numeric', month: 'short', year: 'numeric',
});
}
function detectDeviceName(): string {
const ua = navigator.userAgent;
let browser = 'Browser';
if (ua.includes('Chrome') && !ua.includes('Edg')) browser = 'Chrome';
else if (ua.includes('Safari') && !ua.includes('Chrome')) browser = 'Safari';
else if (ua.includes('Firefox')) browser = 'Firefox';
else if (ua.includes('Edg')) browser = 'Edge';
let os = '';
if (ua.includes('Mac')) os = 'macOS';
else if (ua.includes('Windows')) os = 'Windows';
else if (ua.includes('Linux')) os = 'Linux';
else if (ua.includes('iPhone') || ua.includes('iPad')) os = 'iOS';
else if (ua.includes('Android')) os = 'Android';
return os ? `${os} \u2014 ${browser}` : browser;
}
interface DeleteConfirmProps {
credential: PasskeyCredential;
onDelete: (id: number, password: string) => void;
isDeleting: boolean;
}
function PasskeyDeleteButton({ credential, onDelete, isDeleting }: DeleteConfirmProps) {
const [showPasswordDialog, setShowPasswordDialog] = useState(false);
const [password, setPassword] = useState('');
const { confirming, handleClick } = useConfirmAction(() => {
setShowPasswordDialog(true);
});
const handleSubmitDelete = () => {
onDelete(credential.id, password);
setPassword('');
setShowPasswordDialog(false);
};
return (
<>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-red-400"
onClick={handleClick}
disabled={isDeleting}
aria-label={`Remove passkey ${credential.name}`}
>
{confirming ? (
<span className="text-xs font-medium text-red-400">Sure?</span>
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</Button>
<Dialog open={showPasswordDialog} onOpenChange={setShowPasswordDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Remove passkey</DialogTitle>
<DialogDescription>
Enter your password to remove "{credential.name}".
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="delete-passkey-password">Password</Label>
<Input
id="delete-passkey-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && password) handleSubmitDelete(); }}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowPasswordDialog(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleSubmitDelete}
disabled={!password || isDeleting}
>
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Remove'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
export default function PasskeySection() {
const queryClient = useQueryClient();
const { passwordlessEnabled, allowPasswordless } = useAuth();
// Registration state
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
const [ceremonyState, setCeremonyState] = useState<'password' | 'waiting' | 'naming'>('password');
const [registerPassword, setRegisterPassword] = useState('');
const [passkeyName, setPasskeyName] = useState('');
const [pendingCredential, setPendingCredential] = useState<{
credential: string;
challenge_token: string;
} | null>(null);
// Passwordless enable state
const [enableDialogOpen, setEnableDialogOpen] = useState(false);
const [enablePassword, setEnablePassword] = useState('');
// Passwordless disable state
const [disableDialogOpen, setDisableDialogOpen] = useState(false);
const passkeysQuery = useQuery({
queryKey: ['passkeys'],
queryFn: async () => {
const { data } = await api.get<PasskeyCredential[]>('/auth/passkeys');
return data;
},
});
const registerMutation = useMutation({
mutationFn: async ({ password }: { password: string }) => {
const { startRegistration } = await import('@simplewebauthn/browser');
// Step 1: Get registration options (requires password V-02)
const { data: beginResp } = await api.post('/auth/passkeys/register/begin', { password });
// Step 2: Browser WebAuthn ceremony
setCeremonyState('waiting');
const credential = await startRegistration(beginResp.options);
return {
credential: JSON.stringify(credential),
challenge_token: beginResp.challenge_token,
};
},
onSuccess: (data) => {
setPendingCredential(data);
setPasskeyName(detectDeviceName());
setCeremonyState('naming');
},
onError: (error: unknown) => {
if (error instanceof Error && error.name === 'NotAllowedError') {
toast.info('Passkey setup cancelled');
} else if (error instanceof Error && error.name === 'AbortError') {
toast.info('Cancelled');
} else {
toast.error(getErrorMessage(error, 'Failed to create passkey'));
}
setRegisterDialogOpen(false);
resetRegisterState();
},
});
const completeMutation = useMutation({
mutationFn: async ({ credential, challenge_token, name }: {
credential: string; challenge_token: string; name: string;
}) => {
const { data } = await api.post('/auth/passkeys/register/complete', {
credential, challenge_token, name,
});
return data;
},
onSuccess: () => {
toast.success('Passkey registered');
queryClient.invalidateQueries({ queryKey: ['passkeys'] });
queryClient.invalidateQueries({ queryKey: ['auth'] });
setRegisterDialogOpen(false);
resetRegisterState();
},
onError: (error: unknown) => {
toast.error(getErrorMessage(error, 'Failed to save passkey'));
},
});
const deleteMutation = useMutation({
mutationFn: async ({ id, password }: { id: number; password: string }) => {
await api.delete(`/auth/passkeys/${id}`, { data: { password } });
},
onSuccess: () => {
toast.success('Passkey removed');
queryClient.invalidateQueries({ queryKey: ['passkeys'] });
queryClient.invalidateQueries({ queryKey: ['auth'] });
},
onError: (error: unknown) => {
toast.error(getErrorMessage(error, 'Failed to remove passkey'));
},
});
const enablePasswordlessMutation = useMutation({
mutationFn: async ({ password }: { password: string }) => {
const { data } = await api.put('/auth/passkeys/passwordless/enable', { password });
return data;
},
onSuccess: () => {
toast.success('Passwordless login enabled');
queryClient.invalidateQueries({ queryKey: ['auth'] });
queryClient.invalidateQueries({ queryKey: ['passkeys'] });
setEnableDialogOpen(false);
setEnablePassword('');
},
onError: (error: unknown) => {
toast.error(getErrorMessage(error, 'Failed to enable passwordless login'));
},
});
const disablePasswordlessMutation = useMutation({
mutationFn: async () => {
const { startAuthentication } = await import('@simplewebauthn/browser');
const { data: beginResp } = await api.post('/auth/passkeys/passwordless/disable/begin', {});
const credential = await startAuthentication(beginResp.options);
const { data } = await api.put('/auth/passkeys/passwordless/disable', {
credential: JSON.stringify(credential),
challenge_token: beginResp.challenge_token,
});
return data;
},
onSuccess: () => {
toast.success('Passwordless login disabled');
queryClient.invalidateQueries({ queryKey: ['auth'] });
queryClient.invalidateQueries({ queryKey: ['passkeys'] });
setDisableDialogOpen(false);
},
onError: (error: unknown) => {
if (error instanceof Error && error.name === 'NotAllowedError') {
toast.error('Passkey not recognized');
} else if (error instanceof Error && error.name === 'AbortError') {
toast.info('Cancelled');
} else {
toast.error(getErrorMessage(error, 'Failed to disable passwordless login'));
}
setDisableDialogOpen(false);
// W-03: Invalidate to resync switch state after failed/cancelled ceremony
queryClient.invalidateQueries({ queryKey: ['auth'] });
},
});
const resetRegisterState = useCallback(() => {
setCeremonyState('password');
setRegisterPassword('');
setPasskeyName('');
setPendingCredential(null);
}, []);
const handleStartRegister = () => {
resetRegisterState();
setRegisterDialogOpen(true);
};
const handlePasswordSubmit = () => {
if (!registerPassword) return;
registerMutation.mutate({ password: registerPassword });
};
const handleSaveName = () => {
if (!pendingCredential || !passkeyName.trim()) return;
completeMutation.mutate({
...pendingCredential,
name: passkeyName.trim(),
});
};
const handleDelete = (id: number, password: string) => {
deleteMutation.mutate({ id, password });
};
const passkeys = passkeysQuery.data ?? [];
const hasPasskeys = passkeys.length > 0;
return (
<Card>
<CardContent className="pt-6 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-accent/10">
<Fingerprint className="h-4 w-4 text-accent" />
</div>
<div className="space-y-0.5">
<Label>Passkeys</Label>
<p className="text-xs text-muted-foreground">
Sign in with your fingerprint, face, or security key
</p>
</div>
</div>
{hasPasskeys && (
<span className="inline-flex items-center rounded-full bg-green-500/10 px-2.5 py-0.5 text-xs font-semibold text-green-400">
{passkeys.length} registered
</span>
)}
</div>
{hasPasskeys && (
<ul className="space-y-1" aria-live="polite">
{passkeys.map((pk) => (
<li
key={pk.id}
className="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-card-elevated transition-colors duration-150"
>
<div className="p-1.5 rounded-md bg-accent/10">
<Fingerprint className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{pk.name}</span>
{pk.backed_up && (
<Cloud className="h-3 w-3 text-muted-foreground shrink-0" aria-label="Synced" />
)}
</div>
<span className="text-xs text-muted-foreground">
Added {formatDate(pk.created_at)} · Last used {formatRelativeTime(pk.last_used_at)}
</span>
</div>
<PasskeyDeleteButton
credential={pk}
onDelete={handleDelete}
isDeleting={deleteMutation.isPending && (deleteMutation.variables as { id: number } | undefined)?.id === pk.id}
/>
</li>
))}
</ul>
)}
<Separator />
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={handleStartRegister}
>
<Fingerprint className="h-4 w-4" />
{hasPasskeys ? 'Add another passkey' : 'Add a passkey'}
</Button>
{/* Passwordless login section — hidden when admin hasn't enabled the feature */}
{(allowPasswordless || passwordlessEnabled) && <Separator />}
{(allowPasswordless || passwordlessEnabled) && (
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Fingerprint className="h-4 w-4 text-muted-foreground" />
<Label className="text-sm font-medium">Passwordless Login</Label>
</div>
<p className="text-xs text-muted-foreground">
Skip the password prompt and unlock the app using a passkey only.
</p>
{passkeys.length < 2 && !passwordlessEnabled && (
<p className="text-xs text-amber-400">
Requires at least 2 registered passkeys as a fallback.
</p>
)}
</div>
<Switch
checked={passwordlessEnabled}
onCheckedChange={(checked) => {
if (checked) {
setEnablePassword('');
setEnableDialogOpen(true);
} else {
setDisableDialogOpen(true);
disablePasswordlessMutation.mutate();
}
}}
disabled={(!passwordlessEnabled && passkeys.length < 2) || enablePasswordlessMutation.isPending || disablePasswordlessMutation.isPending}
aria-label="Toggle passwordless login"
/>
</div>
)}
{/* Enable passwordless dialog */}
<Dialog open={enableDialogOpen} onOpenChange={(open) => {
if (!open) { setEnableDialogOpen(false); setEnablePassword(''); }
}}>
<DialogContent>
<DialogHeader>
<div className="flex items-center gap-3 mb-1">
<div className="p-2 rounded-lg bg-accent/10">
<Fingerprint className="h-5 w-5 text-accent" />
</div>
<DialogTitle>Enable Passwordless Login</DialogTitle>
</div>
<DialogDescription>
Confirm your password to enable passkey-only login.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="enable-passwordless-password">Password</Label>
<Input
id="enable-passwordless-password"
type="password"
value={enablePassword}
onChange={(e) => setEnablePassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && enablePassword) {
enablePasswordlessMutation.mutate({ password: enablePassword });
}
}}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setEnableDialogOpen(false); setEnablePassword(''); }}>
Cancel
</Button>
<Button
onClick={() => enablePasswordlessMutation.mutate({ password: enablePassword })}
disabled={!enablePassword || enablePasswordlessMutation.isPending}
>
{enablePasswordlessMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Enable'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Disable passwordless dialog */}
<Dialog open={disableDialogOpen} onOpenChange={(open) => {
if (!open && !disablePasswordlessMutation.isPending) {
setDisableDialogOpen(false);
}
}}>
<DialogContent>
<DialogHeader>
<div className="flex items-center gap-3 mb-1">
<div className="p-2 rounded-lg bg-orange-500/10">
<ShieldOff className="h-5 w-5 text-orange-400" />
</div>
<DialogTitle>Disable Passwordless Login</DialogTitle>
</div>
<DialogDescription>
Verify with your passkey to disable passwordless login.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col items-center gap-4 py-4">
{disablePasswordlessMutation.isPending ? (
<>
<Loader2 className="h-8 w-8 animate-spin text-accent" />
<p className="text-sm text-muted-foreground text-center">
Follow your browser's prompt to verify your passkey
</p>
</>
) : (
<p className="text-sm text-muted-foreground text-center">
Ready to verify your passkey
</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDisableDialogOpen(false)}
disabled={disablePasswordlessMutation.isPending}
>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Registration ceremony dialog */}
<Dialog
open={registerDialogOpen}
onOpenChange={(open) => {
if (!open) {
setRegisterDialogOpen(false);
resetRegisterState();
}
}}
>
<DialogContent>
<DialogHeader>
<div className="flex items-center gap-3 mb-1">
<div className="p-2 rounded-lg bg-accent/10">
<Fingerprint className="h-5 w-5 text-accent" />
</div>
<DialogTitle>
{ceremonyState === 'password' && 'Add a passkey'}
{ceremonyState === 'waiting' && 'Creating passkey'}
{ceremonyState === 'naming' && 'Name your passkey'}
</DialogTitle>
</div>
</DialogHeader>
{ceremonyState === 'password' && (
<>
<DialogDescription>
Enter your password to add a passkey to your account.
</DialogDescription>
<div className="space-y-2">
<Label htmlFor="register-passkey-password">Password</Label>
<Input
id="register-passkey-password"
type="password"
value={registerPassword}
onChange={(e) => setRegisterPassword(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && registerPassword) handlePasswordSubmit(); }}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRegisterDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={handlePasswordSubmit}
disabled={!registerPassword || registerMutation.isPending}
>
{registerMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Continue'}
</Button>
</DialogFooter>
</>
)}
{ceremonyState === 'waiting' && (
<div className="flex flex-col items-center gap-4 py-6">
<Loader2 className="h-8 w-8 animate-spin text-accent" />
<p className="text-sm text-muted-foreground text-center">
Follow your browser's prompt to create a passkey
</p>
</div>
)}
{ceremonyState === 'naming' && (
<>
<div className="space-y-2">
<Label htmlFor="passkey-name">Name</Label>
<Input
id="passkey-name"
value={passkeyName}
onChange={(e) => setPasskeyName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && passkeyName.trim()) handleSaveName(); }}
maxLength={100}
autoFocus
/>
<p className="text-xs text-muted-foreground">
Give this passkey a name to help you identify it later.
</p>
</div>
<DialogFooter>
<Button
onClick={handleSaveName}
disabled={!passkeyName.trim() || completeMutation.isPending}
>
{completeMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Save'}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
</CardContent>
</Card>
);
}

View File

@ -5,6 +5,7 @@ import { Label } from '@/components/ui/label';
import { Card, CardContent } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import TotpSetupSection from './TotpSetupSection';
import PasskeySection from './PasskeySection';
import type { Settings } from '@/types';
interface SecurityTabProps {
@ -86,6 +87,9 @@ export default function SecurityTab({ settings, updateSettings, isUpdating }: Se
</CardContent>
</Card>
{/* Passkeys */}
<PasskeySection />
{/* Password + TOTP */}
<TotpSetupSection bare />
</div>

View File

@ -203,5 +203,12 @@ export function useUpdateConfig() {
});
}
export function useDisablePasswordless() {
return useAdminMutation(async (userId: number) => {
const { data } = await api.put(`/admin/users/${userId}/passwordless`, { enabled: false });
return data;
});
}
// Re-export getErrorMessage for convenience in admin components
export { getErrorMessage };

View File

@ -1,7 +1,7 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import type { AuthStatus, LoginResponse } from '@/types';
import type { AuthStatus, LoginResponse, PasskeyLoginResponse } from '@/types';
export function useAuth() {
const queryClient = useQueryClient();
@ -96,6 +96,30 @@ export function useAuth() {
},
});
const passkeyLoginMutation = useMutation({
mutationFn: async () => {
const { startAuthentication } = await import('@simplewebauthn/browser');
const { data: beginResp } = await api.post('/auth/passkeys/login/begin', {});
const credential = await startAuthentication(beginResp.options);
const { data } = await api.post<PasskeyLoginResponse>('/auth/passkeys/login/complete', {
credential: JSON.stringify(credential),
challenge_token: beginResp.challenge_token,
});
return data;
},
onSuccess: (data) => {
setMfaToken(null);
setMfaSetupRequired(false);
if (!data?.must_change_password) {
queryClient.setQueryData(['auth'], (old: AuthStatus | undefined) => {
if (!old) return old;
return { ...old, authenticated: true };
});
}
queryClient.invalidateQueries({ queryKey: ['auth'] });
},
});
const logoutMutation = useMutation({
mutationFn: async () => {
const { data } = await api.post('/auth/logout');
@ -125,5 +149,11 @@ export function useAuth() {
isRegisterPending: registerMutation.isPending,
isTotpPending: totpVerifyMutation.isPending,
isSetupPending: setupMutation.isPending,
passkeyLogin: passkeyLoginMutation.mutateAsync,
isPasskeyLoginPending: passkeyLoginMutation.isPending,
hasPasskeys: authQuery.data?.has_passkeys ?? false,
passkeyCount: 0, // Derived from passkeys list query in PasskeySection, not auth/status
passwordlessEnabled: authQuery.data?.passwordless_enabled ?? false,
allowPasswordless: authQuery.data?.allow_passwordless ?? false,
};
}

View File

@ -17,6 +17,7 @@ interface LockContextValue {
isLockResolved: boolean;
lock: () => Promise<void>;
unlock: (password: string) => Promise<void>;
unlockWithPasskey: () => void;
}
const LockContext = createContext<LockContextValue | null>(null);
@ -94,6 +95,14 @@ export function LockProvider({ children }: { children: ReactNode }) {
}
}, [queryClient]);
const unlockWithPasskey = useCallback(() => {
setIsLocked(false);
lastActivityRef.current = Date.now();
queryClient.setQueryData<AuthStatus>(['auth'], (old) =>
old ? { ...old, is_locked: false } : old
);
}, [queryClient]);
// Auto-lock idle timer
useEffect(() => {
const enabled = settings?.auto_lock_enabled ?? false;
@ -147,7 +156,7 @@ export function LockProvider({ children }: { children: ReactNode }) {
}, [settings?.auto_lock_enabled, settings?.auto_lock_minutes, isLocked, lock]);
return (
<LockContext.Provider value={{ isLocked, isLockResolved, lock, unlock }}>
<LockContext.Provider value={{ isLocked, isLockResolved, lock, unlock, unlockWithPasskey }}>
{children}
</LockContext.Provider>
);

View File

@ -15,7 +15,7 @@ api.interceptors.response.use(
if (error.response?.status === 401) {
const url = error.config?.url || '';
// Don't redirect on auth endpoints — they legitimately return 401
const authEndpoints = ['/auth/login', '/auth/register', '/auth/setup', '/auth/verify-password', '/auth/change-password'];
const authEndpoints = ['/auth/login', '/auth/register', '/auth/setup', '/auth/verify-password', '/auth/change-password', '/auth/passkeys/login/begin', '/auth/passkeys/login/complete'];
if (!authEndpoints.some(ep => url.startsWith(ep))) {
window.location.href = '/login';
}

View File

@ -243,6 +243,23 @@ export interface AuthStatus {
username: string | null;
registration_open: boolean;
is_locked: boolean;
has_passkeys: boolean;
passwordless_enabled: boolean;
allow_passwordless: boolean;
}
export interface PasskeyCredential {
id: number;
name: string;
created_at: string | null;
last_used_at: string | null;
backed_up: boolean;
}
export interface PasskeyLoginResponse {
authenticated?: true;
must_change_password?: boolean;
unlocked?: boolean;
}
// Login response discriminated union
@ -279,6 +296,7 @@ export interface AdminUser {
last_password_change_at: string | null;
totp_enabled: boolean;
mfa_enforce_pending: boolean;
passwordless_enabled: boolean;
created_at: string;
}
@ -293,6 +311,7 @@ export interface AdminUserDetail extends AdminUser {
export interface SystemConfig {
allow_registration: boolean;
enforce_mfa_new_users: boolean;
allow_passwordless: boolean;
}
export interface AuditLogEntry {