UMBRA/backend/alembic/versions/061_add_passkey_credentials.py
Kyle Pope 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

41 lines
1.2 KiB
Python

"""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")