Compare commits

..

4 Commits

Author SHA1 Message Date
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
21 changed files with 1492 additions and 163 deletions

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

@ -30,6 +30,12 @@ class Settings(BaseSettings):
# Concurrent session limit per user (oldest evicted when exceeded) # Concurrent session limit per user (oldest evicted when exceeded)
MAX_SESSIONS_PER_USER: int = 10 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( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",
env_file_encoding="utf-8", env_file_encoding="utf-8",
@ -47,6 +53,9 @@ class Settings(BaseSettings):
self.CORS_ORIGINS = "http://localhost:5173" self.CORS_ORIGINS = "http://localhost:5173"
assert self.COOKIE_SECURE is not None # type narrowing assert self.COOKIE_SECURE is not None # type narrowing
assert self.CORS_ORIGINS is not None 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 return self

View File

@ -7,7 +7,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from app.config import settings from app.config import settings
from app.database import engine 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 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 from app.jobs.notifications import run_notification_dispatch
# Import models so Alembic's autogenerate can discover them # 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 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_lock as _event_lock_model # noqa: F401
from app.models import event_invitation as _event_invitation_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-verify",
"/api/auth/totp/enforce-setup", "/api/auth/totp/enforce-setup",
"/api/auth/totp/enforce-confirm", "/api/auth/totp/enforce-confirm",
"/api/auth/passkeys/login/begin",
"/api/auth/passkeys/login/complete",
}) })
_MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"}) _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(weather.router, prefix="/api/weather", tags=["Weather"])
app.include_router(event_templates.router, prefix="/api/event-templates", tags=["Event Templates"]) 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(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(admin.router, prefix="/api/admin", tags=["Admin"])
app.include_router(notifications_router.router, prefix="/api/notifications", tags=["Notifications"]) app.include_router(notifications_router.router, prefix="/api/notifications", tags=["Notifications"])
app.include_router(connections_router.router, prefix="/api/connections", tags=["Connections"]) 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.event_invitation import EventInvitation, EventInvitationOverride
from app.models.project_member import ProjectMember from app.models.project_member import ProjectMember
from app.models.project_task_assignment import ProjectTaskAssignment from app.models.project_task_assignment import ProjectTaskAssignment
from app.models.passkey_credential import PasskeyCredential
__all__ = [ __all__ = [
"Settings", "Settings",
@ -51,4 +52,5 @@ __all__ = [
"EventInvitationOverride", "EventInvitationOverride",
"ProjectMember", "ProjectMember",
"ProjectTaskAssignment", "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

@ -16,7 +16,6 @@ Security layers:
4. bcryptArgon2id transparent upgrade on first login 4. bcryptArgon2id transparent upgrade on first login
5. Role-based authorization via require_role() dependency factory 5. Role-based authorization via require_role() dependency factory
""" """
import uuid
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional from typing import Optional
@ -30,6 +29,7 @@ from app.models.user import User
from app.models.session import UserSession from app.models.session import UserSession
from app.models.settings import Settings from app.models.settings import Settings
from app.models.system_config import SystemConfig from app.models.system_config import SystemConfig
from app.models.passkey_credential import PasskeyCredential
from app.models.calendar import Calendar from app.models.calendar import Calendar
from app.schemas.auth import ( from app.schemas.auth import (
SetupRequest, LoginRequest, RegisterRequest, SetupRequest, LoginRequest, RegisterRequest,
@ -49,6 +49,13 @@ from app.services.auth import (
create_mfa_enforce_token, create_mfa_enforce_token,
) )
from app.services.audit import get_client_ip, log_audit_event 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 from app.config import settings as app_settings
router = APIRouter() router = APIRouter()
@ -59,22 +66,6 @@ router = APIRouter()
# is indistinguishable from a wrong-password attempt. # is indistinguishable from a wrong-password attempt.
_DUMMY_HASH = hash_password("timing-equalization-dummy") _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 # Auth dependencies — export get_current_user and get_current_settings
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -130,7 +121,7 @@ async def get_current_user(
await db.flush() await db.flush()
# Re-issue cookie with fresh signed token to reset browser max_age timer # Re-issue cookie with fresh signed token to reset browser max_age timer
fresh_token = create_session_token(user_id, session_id) 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 # Stash session on request so lock/unlock endpoints can access it
request.state.db_session = db_session request.state.db_session = db_session
@ -190,82 +181,6 @@ def require_role(*allowed_roles: str):
require_admin = require_role("admin") 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) # User bootstrapping helper (Settings + default calendars)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -321,8 +236,8 @@ async def setup(
ip = get_client_ip(request) ip = get_client_ip(request)
user_agent = request.headers.get("user-agent") user_agent = request.headers.get("user-agent")
_, token = await _create_db_session(db, new_user, ip, user_agent) _, token = await create_db_session(db, new_user, ip, user_agent)
_set_session_cookie(response, token) set_session_cookie(response, token)
await log_audit_event( await log_audit_event(
db, action="auth.setup_complete", actor_id=new_user.id, ip=ip, db, action="auth.setup_complete", actor_id=new_user.id, ip=ip,
@ -366,10 +281,10 @@ async def login(
# executes — prevents distinguishing "locked" from "wrong password" via timing. # executes — prevents distinguishing "locked" from "wrong password" via timing.
valid, new_hash = await averify_password_with_upgrade(data.password, user.password_hash) 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: if not valid:
await _record_failed_login(db, user) await record_failed_login(db, user)
await log_audit_event( await log_audit_event(
db, action="auth.login_failed", actor_id=user.id, db, action="auth.login_failed", actor_id=user.id,
detail={"reason": "invalid_password"}, ip=client_ip, detail={"reason": "invalid_password"}, ip=client_ip,
@ -378,7 +293,7 @@ async def login(
raise HTTPException(status_code=401, detail="Invalid username or password") raise HTTPException(status_code=401, detail="Invalid username or password")
# Block disabled accounts — checked AFTER password verification to avoid # 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. # last_login_at and lockout counters are not reset for inactive users.
if not user.is_active: if not user.is_active:
await log_audit_event( await log_audit_event(
@ -391,7 +306,7 @@ async def login(
if new_hash: if new_hash:
user.password_hash = 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 # SEC-03: MFA enforcement — block login entirely until MFA setup completes
if user.mfa_enforce_pending and not user.totp_enabled: if user.mfa_enforce_pending and not user.totp_enabled:
@ -419,8 +334,8 @@ async def login(
if user.must_change_password: if user.must_change_password:
# Issue a session but flag the frontend to show password change # Issue a session but flag the frontend to show password change
user_agent = request.headers.get("user-agent") user_agent = request.headers.get("user-agent")
_, token = await _create_db_session(db, user, client_ip, user_agent) _, token = await create_db_session(db, user, client_ip, user_agent)
_set_session_cookie(response, token) set_session_cookie(response, token)
await db.commit() await db.commit()
return { return {
"authenticated": True, "authenticated": True,
@ -428,8 +343,8 @@ async def login(
} }
user_agent = request.headers.get("user-agent") user_agent = request.headers.get("user-agent")
_, token = await _create_db_session(db, user, client_ip, user_agent) _, token = await create_db_session(db, user, client_ip, user_agent)
_set_session_cookie(response, token) set_session_cookie(response, token)
await log_audit_event( await log_audit_event(
db, action="auth.login_success", actor_id=user.id, ip=client_ip, db, action="auth.login_success", actor_id=user.id, ip=client_ip,
@ -511,8 +426,8 @@ async def register(
"mfa_token": enforce_token, "mfa_token": enforce_token,
} }
_, token = await _create_db_session(db, new_user, ip, user_agent) _, token = await create_db_session(db, new_user, ip, user_agent)
_set_session_cookie(response, token) set_session_cookie(response, token)
await db.commit() await db.commit()
return {"message": "Registration successful", "authenticated": True} return {"message": "Registration successful", "authenticated": True}
@ -564,32 +479,31 @@ async def auth_status(
is_locked = False is_locked = False
u = None
if not setup_required and session_cookie: if not setup_required and session_cookie:
payload = verify_session_token(session_cookie) payload = verify_session_token(session_cookie)
if payload: if payload:
user_id = payload.get("uid") user_id = payload.get("uid")
session_id = payload.get("sid") session_id = payload.get("sid")
if user_id and session_id: if user_id and session_id:
session_result = await db.execute( # Single JOIN query (was 2 sequential queries — P-01 fix)
select(UserSession).where( result = await db.execute(
select(UserSession, User)
.join(User, UserSession.user_id == User.id)
.where(
UserSession.id == session_id, UserSession.id == session_id,
UserSession.user_id == user_id, UserSession.user_id == user_id,
UserSession.revoked == False, UserSession.revoked == False,
UserSession.expires_at > datetime.now(), UserSession.expires_at > datetime.now(),
User.is_active == True,
) )
) )
db_sess = session_result.scalar_one_or_none() row = result.one_or_none()
if db_sess is not None: if row is not None:
db_sess, u = row.tuple()
authenticated = True authenticated = True
is_locked = db_sess.is_locked 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 role = u.role
else:
authenticated = False
# Check registration availability # Check registration availability
registration_open = False registration_open = False
@ -600,6 +514,16 @@ async def auth_status(
config = config_result.scalar_one_or_none() config = config_result.scalar_one_or_none()
registration_open = config.allow_registration if config else False registration_open = config.allow_registration if config else False
# Check if authenticated user has passkeys registered (Q-04)
has_passkeys = False
if authenticated and u:
pk_result = await db.execute(
select(func.count()).select_from(PasskeyCredential).where(
PasskeyCredential.user_id == u.id
)
)
has_passkeys = pk_result.scalar_one() > 0
return { return {
"authenticated": authenticated, "authenticated": authenticated,
"setup_required": setup_required, "setup_required": setup_required,
@ -607,6 +531,7 @@ async def auth_status(
"username": u.username if authenticated and u else None, "username": u.username if authenticated and u else None,
"registration_open": registration_open, "registration_open": registration_open,
"is_locked": is_locked, "is_locked": is_locked,
"has_passkeys": has_passkeys,
} }
@ -625,7 +550,7 @@ async def lock_session(
@router.post("/verify-password") @router.post("/verify-password")
async def verify_password( async def verify_password_endpoint(
data: VerifyPasswordRequest, data: VerifyPasswordRequest,
request: Request, request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@ -635,11 +560,11 @@ async def verify_password(
Verify the current user's password without changing anything. Verify the current user's password without changing anything.
Used by the frontend lock screen to re-authenticate without a full login. 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) valid, new_hash = await averify_password_with_upgrade(data.password, current_user.password_hash)
if not valid: if not valid:
await _record_failed_login(db, current_user) await record_failed_login(db, current_user)
raise HTTPException(status_code=401, detail="Invalid password") raise HTTPException(status_code=401, detail="Invalid password")
if new_hash: if new_hash:
@ -661,11 +586,11 @@ async def change_password(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""Change the current user's password. Requires old password verification.""" """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) valid, _ = await averify_password_with_upgrade(data.old_password, current_user.password_hash)
if not valid: if not valid:
await _record_failed_login(db, current_user) await record_failed_login(db, current_user)
raise HTTPException(status_code=401, detail="Invalid current password") raise HTTPException(status_code=401, detail="Invalid current password")
if data.new_password == data.old_password: if data.new_password == data.old_password:

View File

@ -0,0 +1,451 @@
"""
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, Request, Response
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.models.user import User
from app.models.passkey_credential import PasskeyCredential
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
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,
)
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)
class PasskeyDeleteRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
password: str = Field(max_length=128)
# ---------------------------------------------------------------------------
# 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))
# 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
try:
cred_data = json.loads(data.credential)
raw_id_b64 = cred_data.get("rawId") or cred_data.get("id", "")
except (json.JSONDecodeError, KeyError):
raise HTTPException(status_code=401, detail="Authentication failed")
# Look up credential by ID
result = await db.execute(
select(PasskeyCredential).where(
PasskeyCredential.credential_id == raw_id_b64
)
)
credential = result.scalar_one_or_none()
if not credential:
raise HTTPException(status_code=401, detail="Authentication failed")
# Load user
user_result = await db.execute(
select(User).where(User.id == credential.user_id)
)
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=401, detail="Authentication failed")
# 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)
await record_failed_login(db, user)
await log_audit_event(
db, action="passkey.login_failed", actor_id=user.id,
detail={"reason": "verification_failed"},
ip=get_client_ip(request),
)
await db.commit()
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()
# 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)
await log_audit_event(
db, action="passkey.login_success", actor_id=user.id,
detail={"credential_name": credential.name},
ip=client_ip,
)
await db.commit()
# B-01: Handle must_change_password and mfa_enforce_pending flags
result_data: dict = {"authenticated": True}
if user.must_change_password:
result_data["must_change_password"] = True
# Passkey satisfies MFA — if mfa_enforce_pending, clear it
if user.mfa_enforce_pending:
user.mfa_enforce_pending = False
await db.commit()
return result_data
# ---------------------------------------------------------------------------
# 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(
credential_id: int,
data: PasskeyDeleteRequest,
request: Request,
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")
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,7 +18,6 @@ Security:
- totp-verify uses mfa_token (not session cookie) user is not yet authenticated - totp-verify uses mfa_token (not session cookie) user is not yet authenticated
""" """
import asyncio import asyncio
import uuid
import secrets import secrets
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -32,17 +31,16 @@ from sqlalchemy.exc import IntegrityError
from app.database import get_db from app.database import get_db
from app.models.user import User from app.models.user import User
from app.models.session import UserSession
from app.models.totp_usage import TOTPUsage from app.models.totp_usage import TOTPUsage
from app.models.backup_code import BackupCode 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.audit import get_client_ip
from app.services.auth import ( from app.services.auth import (
averify_password_with_upgrade, averify_password_with_upgrade,
verify_mfa_token, verify_mfa_token,
verify_mfa_enforce_token, verify_mfa_enforce_token,
create_session_token,
) )
from app.services.session import create_db_session, set_session_cookie
from app.services.totp import ( from app.services.totp import (
generate_totp_secret, generate_totp_secret,
encrypt_totp_secret, encrypt_totp_secret,
@ -52,7 +50,7 @@ from app.services.totp import (
generate_qr_base64, generate_qr_base64,
generate_backup_codes, generate_backup_codes,
) )
from app.config import settings as app_settings
# Argon2id for backup code hashing — treat each code like a password # Argon2id for backup code hashing — treat each code like a password
from argon2 import PasswordHasher from argon2 import PasswordHasher
@ -162,29 +160,6 @@ async def _verify_backup_code(
return False 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 # Routes
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -312,8 +287,10 @@ async def totp_verify(
user.last_login_at = datetime.now() user.last_login_at = datetime.now()
await db.commit() await db.commit()
token = await _create_full_session(db, user, request) ip = get_client_ip(request)
_set_session_cookie(response, token) 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} return {"authenticated": True}
# --- TOTP code path --- # --- TOTP code path ---
@ -340,8 +317,10 @@ async def totp_verify(
user.last_login_at = datetime.now() user.last_login_at = datetime.now()
await db.commit() await db.commit()
token = await _create_full_session(db, user, request) ip = get_client_ip(request)
_set_session_cookie(response, token) 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} return {"authenticated": True}
@ -513,9 +492,11 @@ async def enforce_confirm_totp(
user.last_login_at = datetime.now() user.last_login_at = datetime.now()
await db.commit() await db.commit()
# Issue a full session # Issue a full session (now uses shared session service with cap enforcement)
token = await _create_full_session(db, user, request) ip = get_client_ip(request)
_set_session_cookie(response, token) 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} return {"authenticated": True}

View File

@ -0,0 +1,220 @@
"""
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
from app.config import settings as app_settings
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
_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.
"""
from webauthn.helpers.structs import RegistrationCredential
credential = RegistrationCredential.model_validate_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.
"""
from webauthn.helpers.structs import AuthenticationCredential
credential = AuthenticationCredential.model_validate_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,103 @@
"""
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
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 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()
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
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

View File

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

View File

@ -83,6 +83,29 @@ server {
include /etc/nginx/proxy-params.conf; 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;
}
# SEC-14: Rate-limit public registration endpoint # SEC-14: Rate-limit public registration endpoint
location /api/auth/register { location /api/auth/register {
limit_req zone=register_limit burst=3 nodelay; limit_req zone=register_limit burst=3 nodelay;
@ -164,5 +187,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 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; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# PT-I03: Restrict unnecessary browser APIs # 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=*, publickey-credentials-create=*" always;
} }

View File

@ -16,6 +16,7 @@
"@fullcalendar/interaction": "^6.1.15", "@fullcalendar/interaction": "^6.1.15",
"@fullcalendar/react": "^6.1.15", "@fullcalendar/react": "^6.1.15",
"@fullcalendar/timegrid": "^6.1.15", "@fullcalendar/timegrid": "^6.1.15",
"@simplewebauthn/browser": "^10.0.0",
"@tanstack/react-query": "^5.62.0", "@tanstack/react-query": "^5.62.0",
"axios": "^1.7.9", "axios": "^1.7.9",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@ -1348,6 +1349,22 @@
"win32" "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": { "node_modules/@tanstack/query-core": {
"version": "5.90.20", "version": "5.90.20",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", "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/interaction": "^6.1.15",
"@fullcalendar/react": "^6.1.15", "@fullcalendar/react": "^6.1.15",
"@fullcalendar/timegrid": "^6.1.15", "@fullcalendar/timegrid": "^6.1.15",
"@simplewebauthn/browser": "^10.0.0",
"@tanstack/react-query": "^5.62.0", "@tanstack/react-query": "^5.62.0",
"axios": "^1.7.9", "axios": "^1.7.9",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",

View File

@ -1,7 +1,7 @@
import { useState, FormEvent } from 'react'; import { useState, FormEvent } from 'react';
import { Navigate } from 'react-router-dom'; import { Navigate } from 'react-router-dom';
import { toast } from 'sonner'; 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 { useAuth } from '@/hooks/useAuth';
import api, { getErrorMessage } from '@/lib/api'; import api, { getErrorMessage } from '@/lib/api';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; 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 { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Separator } from '@/components/ui/separator';
import AmbientBackground from './AmbientBackground'; import AmbientBackground from './AmbientBackground';
import type { TotpSetupResponse } from '@/types'; import type { TotpSetupResponse } from '@/types';
@ -47,6 +48,8 @@ export default function LockScreen() {
isRegisterPending, isRegisterPending,
isSetupPending, isSetupPending,
isTotpPending, isTotpPending,
passkeyLogin,
isPasskeyLoginPending,
} = useAuth(); } = useAuth();
// ── Shared credential fields ── // ── Shared credential fields ──
@ -83,6 +86,31 @@ export default function LockScreen() {
const [forcedConfirmPassword, setForcedConfirmPassword] = useState(''); const [forcedConfirmPassword, setForcedConfirmPassword] = useState('');
const [isForcePwPending, setIsForcePwPending] = useState(false); 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) // Redirect authenticated users (no pending MFA flows)
if (!isLoading && authStatus?.authenticated && !mfaSetupRequired && mode !== 'force_pw') { if (!isLoading && authStatus?.authenticated && !mfaSetupRequired && mode !== 'force_pw') {
return <Navigate to="/dashboard" replace />; return <Navigate to="/dashboard" replace />;
@ -561,6 +589,30 @@ export default function LockScreen() {
</Button> </Button>
</form> </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 */} {/* Open registration link — only shown on login screen when enabled */}
{!isSetup && registrationOpen && ( {!isSetup && registrationOpen && (
<div className="mt-4 text-center"> <div className="mt-4 text-center">

View File

@ -1,7 +1,9 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import { toast } from 'sonner';
import { Menu } from 'lucide-react'; import { Menu } from 'lucide-react';
import { useTheme } from '@/hooks/useTheme'; import { useTheme } from '@/hooks/useTheme';
import { useAuth } from '@/hooks/useAuth';
import { usePrefetch } from '@/hooks/usePrefetch'; import { usePrefetch } from '@/hooks/usePrefetch';
import { AlertsProvider } from '@/hooks/useAlerts'; import { AlertsProvider } from '@/hooks/useAlerts';
import { LockProvider, useLock } from '@/hooks/useLock'; import { LockProvider, useLock } from '@/hooks/useLock';
@ -17,7 +19,20 @@ function AppContent({ mobileOpen, setMobileOpen }: {
setMobileOpen: (v: boolean) => void; setMobileOpen: (v: boolean) => void;
}) { }) {
const { isLocked, isLockResolved } = useLock(); const { isLocked, isLockResolved } = useLock();
const { hasPasskeys } = useAuth();
usePrefetch(isLockResolved && !isLocked); 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 });
}
}, [isLockResolved, isLocked, hasPasskeys]);
const [collapsed, setCollapsed] = useState(() => { const [collapsed, setCollapsed] = useState(() => {
try { return JSON.parse(localStorage.getItem('umbra-sidebar-collapsed') || 'false'); } try { return JSON.parse(localStorage.getItem('umbra-sidebar-collapsed') || 'false'); }
catch { return false; } catch { return false; }

View File

@ -0,0 +1,415 @@
import { useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Fingerprint, Loader2, Trash2, Cloud } 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 {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Separator } from '@/components/ui/separator';
import { useConfirmAction } from '@/hooks/useConfirmAction';
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-[10px] 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 [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);
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 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-emerald-500/10 px-2.5 py-0.5 text-xs font-semibold text-emerald-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-[11px] 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}
/>
</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>
{/* 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 { Card, CardContent } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import TotpSetupSection from './TotpSetupSection'; import TotpSetupSection from './TotpSetupSection';
import PasskeySection from './PasskeySection';
import type { Settings } from '@/types'; import type { Settings } from '@/types';
interface SecurityTabProps { interface SecurityTabProps {
@ -86,6 +87,9 @@ export default function SecurityTab({ settings, updateSettings, isUpdating }: Se
</CardContent> </CardContent>
</Card> </Card>
{/* Passkeys */}
<PasskeySection />
{/* Password + TOTP */} {/* Password + TOTP */}
<TotpSetupSection bare /> <TotpSetupSection bare />
</div> </div>

View File

@ -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('/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({ const logoutMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
const { data } = await api.post('/auth/logout'); const { data } = await api.post('/auth/logout');
@ -125,5 +149,8 @@ export function useAuth() {
isRegisterPending: registerMutation.isPending, isRegisterPending: registerMutation.isPending,
isTotpPending: totpVerifyMutation.isPending, isTotpPending: totpVerifyMutation.isPending,
isSetupPending: setupMutation.isPending, isSetupPending: setupMutation.isPending,
passkeyLogin: passkeyLoginMutation.mutateAsync,
isPasskeyLoginPending: passkeyLoginMutation.isPending,
hasPasskeys: authQuery.data?.has_passkeys ?? false,
}; };
} }

View File

@ -15,7 +15,7 @@ api.interceptors.response.use(
if (error.response?.status === 401) { if (error.response?.status === 401) {
const url = error.config?.url || ''; const url = error.config?.url || '';
// Don't redirect on auth endpoints — they legitimately return 401 // 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))) { if (!authEndpoints.some(ep => url.startsWith(ep))) {
window.location.href = '/login'; window.location.href = '/login';
} }

View File

@ -243,6 +243,15 @@ export interface AuthStatus {
username: string | null; username: string | null;
registration_open: boolean; registration_open: boolean;
is_locked: boolean; is_locked: boolean;
has_passkeys: boolean;
}
export interface PasskeyCredential {
id: number;
name: string;
created_at: string | null;
last_used_at: string | null;
backed_up: boolean;
} }
// Login response discriminated union // Login response discriminated union