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>
41 lines
1.2 KiB
Python
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")
|