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>
This commit is contained in:
parent
eebb34aa77
commit
e8e3f62ff8
40
backend/alembic/versions/061_add_passkey_credentials.py
Normal file
40
backend/alembic/versions/061_add_passkey_credentials.py
Normal 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")
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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"])
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
30
backend/app/models/passkey_credential.py
Normal file
30
backend/app/models/passkey_credential.py
Normal 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)
|
||||
@ -29,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,
|
||||
@ -513,6 +514,16 @@ async def auth_status(
|
||||
config = config_result.scalar_one_or_none()
|
||||
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 {
|
||||
"authenticated": authenticated,
|
||||
"setup_required": setup_required,
|
||||
@ -520,6 +531,7 @@ 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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
451
backend/app/routers/passkeys.py
Normal file
451
backend/app/routers/passkeys.py
Normal 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"}
|
||||
220
backend/app/services/passkey.py
Normal file
220
backend/app/services/passkey.py
Normal 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,
|
||||
)
|
||||
@ -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
|
||||
|
||||
@ -83,6 +83,29 @@ 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;
|
||||
}
|
||||
|
||||
# SEC-14: Rate-limit public registration endpoint
|
||||
location /api/auth/register {
|
||||
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 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=*, publickey-credentials-create=*" always;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user