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>
31 lines
1.3 KiB
Python
31 lines
1.3 KiB
Python
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)
|