feat(backend): Phase 1 passwordless login — migration, models, toggle endpoints, unlock, delete guard, admin controls

- Migration 062: adds users.passwordless_enabled and system_config.allow_passwordless (both default false)
- User model: passwordless_enabled field after must_change_password
- SystemConfig model: allow_passwordless field after enforce_mfa_new_users
- auth.py login(): block passwordless-enabled accounts from password login path (403) with audit log
- auth.py auth_status(): change has_passkeys query to full COUNT, add passkey_count + passwordless_enabled to response
- auth.py get_current_user(): add /api/auth/passkeys/login/begin and /login/complete to lock_exempt set
- passkeys.py: add PasswordlessEnableRequest + PasswordlessDisableRequest schemas
- passkeys.py: PUT /passwordless/enable — verify password, check system config, require >= 2 passkeys, set flag
- passkeys.py: POST /passwordless/disable/begin — generate user-bound challenge for passkey auth ceremony
- passkeys.py: PUT /passwordless/disable — verify passkey auth response, clear flag, update sign count
- passkeys.py: PasskeyLoginCompleteRequest.unlock field — passkey re-auth into locked session without new session
- passkeys.py: delete guard — 409 if passwordless user attempts to drop below 2 passkeys
- schemas/admin.py: add passwordless_enabled to UserListItem + UserDetailResponse; add allow_passwordless to SystemConfigResponse + SystemConfigUpdate; add TogglePasswordlessRequest
- admin.py: PUT /users/{user_id}/passwordless — admin-only disable (enabled=False only), revokes all sessions, audit log
- admin.py: update_system_config handles allow_passwordless field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-18 00:15:39 +08:00
parent fc1f8d5514
commit bcfebbc9ae
7 changed files with 357 additions and 7 deletions

View File

@ -0,0 +1,40 @@
"""Passwordless login — add passwordless_enabled to users and allow_passwordless to system_config.
Revision ID: 062
Revises: 061
Create Date: 2026-03-18
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "062"
down_revision = "061"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column(
"passwordless_enabled",
sa.Boolean(),
nullable=False,
server_default="false",
),
)
op.add_column(
"system_config",
sa.Column(
"allow_passwordless",
sa.Boolean(),
nullable=False,
server_default="false",
),
)
def downgrade() -> None:
op.drop_column("users", "passwordless_enabled")
op.drop_column("system_config", "allow_passwordless")

View File

@ -21,6 +21,9 @@ class SystemConfig(Base):
enforce_mfa_new_users: Mapped[bool] = mapped_column( enforce_mfa_new_users: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false" Boolean, default=False, server_default="false"
) )
allow_passwordless: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false"
)
created_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
default=func.now(), onupdate=func.now(), server_default=func.now() default=func.now(), onupdate=func.now(), server_default=func.now()

View File

@ -43,6 +43,11 @@ class User(Base):
Boolean, default=False, server_default="false" Boolean, default=False, server_default="false"
) )
# Passwordless login — requires >= 2 passkeys registered
passwordless_enabled: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false"
)
# Audit # Audit
created_at: Mapped[datetime] = mapped_column(default=func.now()) created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())

View File

@ -45,6 +45,7 @@ from app.schemas.admin import (
SystemConfigUpdate, SystemConfigUpdate,
ToggleActiveRequest, ToggleActiveRequest,
ToggleMfaEnforceRequest, ToggleMfaEnforceRequest,
TogglePasswordlessRequest,
UpdateUserRoleRequest, UpdateUserRoleRequest,
UserDetailResponse, UserDetailResponse,
UserListItem, UserListItem,
@ -670,6 +671,56 @@ async def get_user_sharing_stats(
"pending_invites_received": pending_received, "pending_invites_received": pending_received,
} }
# ---------------------------------------------------------------------------
# PUT /users/{user_id}/passwordless
# ---------------------------------------------------------------------------
@router.put("/users/{user_id}/passwordless")
async def admin_toggle_passwordless(
request: Request,
data: TogglePasswordlessRequest,
user_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
actor: User = Depends(get_current_user),
):
"""
Admin-only: disable passwordless login for a user.
Only enabled=False is allowed admin cannot remotely enable passwordless.
Revokes all sessions so the user must re-authenticate.
"""
if data.enabled:
raise HTTPException(
status_code=400,
detail="Admin can only disable passwordless login, not enable it",
)
_guard_self_action(actor, user_id, "toggle passwordless for")
result = await db.execute(sa.select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if not user.passwordless_enabled:
raise HTTPException(status_code=409, detail="Passwordless login is not enabled for this user")
user.passwordless_enabled = False
revoked = await _revoke_all_sessions(db, user_id)
await log_audit_event(
db,
action="admin.passwordless_disabled",
actor_id=actor.id,
target_id=user_id,
detail={"sessions_revoked": revoked, "username": user.username},
ip=get_client_ip(request),
)
await db.commit()
return {"passwordless_enabled": False, "sessions_revoked": revoked}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# GET /config # GET /config
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -716,6 +767,9 @@ async def update_system_config(
if data.enforce_mfa_new_users is not None: if data.enforce_mfa_new_users is not None:
changes["enforce_mfa_new_users"] = data.enforce_mfa_new_users changes["enforce_mfa_new_users"] = data.enforce_mfa_new_users
config.enforce_mfa_new_users = data.enforce_mfa_new_users config.enforce_mfa_new_users = data.enforce_mfa_new_users
if data.allow_passwordless is not None:
changes["allow_passwordless"] = data.allow_passwordless
config.allow_passwordless = data.allow_passwordless
if changes: if changes:
await log_audit_event( await log_audit_event(

View File

@ -132,6 +132,7 @@ async def get_current_user(
lock_exempt = { lock_exempt = {
"/api/auth/lock", "/api/auth/verify-password", "/api/auth/lock", "/api/auth/verify-password",
"/api/auth/status", "/api/auth/logout", "/api/auth/status", "/api/auth/logout",
"/api/auth/passkeys/login/begin", "/api/auth/passkeys/login/complete",
} }
if request.url.path not in lock_exempt: if request.url.path not in lock_exempt:
raise HTTPException(status_code=423, detail="Session is locked") raise HTTPException(status_code=423, detail="Session is locked")
@ -292,6 +293,19 @@ async def login(
await db.commit() await db.commit()
raise HTTPException(status_code=401, detail="Invalid username or password") raise HTTPException(status_code=401, detail="Invalid username or password")
# Block passwordless-only accounts from using the password login path.
# Checked after password verification to avoid leaking account existence via timing.
if user.passwordless_enabled:
await log_audit_event(
db, action="auth.login_blocked_passwordless", actor_id=user.id,
detail={"reason": "passwordless_enabled"}, ip=client_ip,
)
await db.commit()
raise HTTPException(
status_code=403,
detail="This account uses passwordless login. Sign in with a passkey.",
)
# 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.
@ -515,15 +529,19 @@ 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, W-2: EXISTS not COUNT) # Count passkeys for authenticated user — needed for passwordless toggle UX.
passkey_count = 0
has_passkeys = False has_passkeys = False
passwordless_enabled = False
if authenticated and u: if authenticated and u:
pk_result = await db.execute( pk_result = await db.execute(
select(PasskeyCredential.id).where( select(func.count()).select_from(PasskeyCredential).where(
PasskeyCredential.user_id == u.id PasskeyCredential.user_id == u.id
).limit(1) )
) )
has_passkeys = pk_result.scalar_one_or_none() is not None passkey_count = pk_result.scalar_one()
has_passkeys = passkey_count > 0
passwordless_enabled = u.passwordless_enabled
return { return {
"authenticated": authenticated, "authenticated": authenticated,
@ -533,6 +551,8 @@ async def auth_status(
"registration_open": registration_open, "registration_open": registration_open,
"is_locked": is_locked, "is_locked": is_locked,
"has_passkeys": has_passkeys, "has_passkeys": has_passkeys,
"passkey_count": passkey_count,
"passwordless_enabled": passwordless_enabled,
} }

View File

@ -27,14 +27,15 @@ from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Path, Request, Response from fastapi import APIRouter, Depends, HTTPException, Path, Request, Response
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select, func
from app.database import get_db from app.database import get_db
from app.models.user import User
from app.models.passkey_credential import PasskeyCredential from app.models.passkey_credential import PasskeyCredential
from app.models.system_config import SystemConfig
from app.models.user import User
from app.routers.auth import get_current_user from app.routers.auth import get_current_user
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.auth import averify_password_with_upgrade from app.services.auth import averify_password_with_upgrade, verify_session_token
from app.services.session import ( from app.services.session import (
create_db_session, create_db_session,
set_session_cookie, set_session_cookie,
@ -50,6 +51,7 @@ from app.services.passkey import (
build_authentication_options, build_authentication_options,
verify_authentication as verify_authentication_response_svc, verify_authentication as verify_authentication_response_svc,
) )
from app.models.session import UserSession
from webauthn.helpers import bytes_to_base64url, base64url_to_bytes from webauthn.helpers import bytes_to_base64url, base64url_to_bytes
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -82,6 +84,7 @@ class PasskeyLoginCompleteRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
credential: str = Field(max_length=8192) credential: str = Field(max_length=8192)
challenge_token: str = Field(max_length=2048) challenge_token: str = Field(max_length=2048)
unlock: bool = False
class PasskeyDeleteRequest(BaseModel): class PasskeyDeleteRequest(BaseModel):
@ -89,6 +92,17 @@ class PasskeyDeleteRequest(BaseModel):
password: str = Field(max_length=128) password: str = Field(max_length=128)
class PasswordlessEnableRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
password: str = Field(max_length=128)
class PasswordlessDisableRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
credential: str = Field(max_length=8192)
challenge_token: str = Field(max_length=2048)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Registration endpoints (authenticated) # Registration endpoints (authenticated)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -345,6 +359,31 @@ async def passkey_login_complete(
credential.sign_count = new_sign_count credential.sign_count = new_sign_count
credential.last_used_at = datetime.now() credential.last_used_at = datetime.now()
# Passkey unlock — re-authenticate into a locked session instead of creating a new one
if data.unlock:
session_cookie = request.cookies.get("session")
payload = verify_session_token(session_cookie) if session_cookie else None
if not payload or payload.get("uid") != user.id:
raise HTTPException(status_code=401, detail="Authentication failed")
sess_result = await db.execute(
select(UserSession).where(
UserSession.id == payload["sid"],
UserSession.user_id == user.id,
UserSession.revoked == False,
)
)
db_sess = sess_result.scalar_one_or_none()
if not db_sess:
raise HTTPException(status_code=401, detail="Authentication failed")
db_sess.is_locked = False
db_sess.locked_at = None
await log_audit_event(
db, action="passkey.unlock_success", actor_id=user.id,
ip=get_client_ip(request),
)
await db.commit()
return {"unlocked": True}
# Record successful login # Record successful login
await record_successful_login(db, user) await record_successful_login(db, user)
@ -372,6 +411,173 @@ async def passkey_login_complete(
return result_data return result_data
# ---------------------------------------------------------------------------
# Passwordless toggle endpoints (authenticated)
# ---------------------------------------------------------------------------
@router.put("/passwordless/enable")
async def passwordless_enable(
data: PasswordlessEnableRequest,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Enable passwordless login for the current user.
Requirements:
- System config must have allow_passwordless = True
- User must have >= 2 registered passkeys
- Password confirmation required
"""
# Verify password first
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
# Check system config
config_result = await db.execute(
select(SystemConfig).where(SystemConfig.id == 1)
)
config = config_result.scalar_one_or_none()
if not config or not config.allow_passwordless:
raise HTTPException(
status_code=403,
detail="Passwordless login is not enabled on this system",
)
# Require >= 2 passkeys as safety net (can't get locked out)
pk_count_result = await db.execute(
select(func.count()).select_from(PasskeyCredential).where(
PasskeyCredential.user_id == current_user.id
)
)
pk_count = pk_count_result.scalar_one()
if pk_count < 2:
raise HTTPException(
status_code=400,
detail="At least 2 passkeys must be registered before enabling passwordless login",
)
current_user.passwordless_enabled = True
await log_audit_event(
db, action="passkey.passwordless_enabled", actor_id=current_user.id,
ip=get_client_ip(request),
)
await db.commit()
return {"passwordless_enabled": True}
@router.post("/passwordless/disable/begin")
async def passwordless_disable_begin(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Begin the passkey authentication ceremony to disable passwordless login.
Returns challenge options for the browser to present to the authenticator.
"""
# Load user's credentials for allowCredentials
cred_result = await db.execute(
select(
PasskeyCredential.credential_id,
PasskeyCredential.transports,
).where(PasskeyCredential.user_id == current_user.id)
)
rows = cred_result.all()
credential_data = None
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))
options_json, challenge = build_authentication_options(
credential_ids_and_transports=credential_data,
)
# Bind challenge to this user so complete endpoint can cross-check
token = create_challenge_token(challenge, user_id=current_user.id)
return {
"options": json.loads(options_json),
"challenge_token": token,
}
@router.put("/passwordless/disable")
async def passwordless_disable(
data: PasswordlessDisableRequest,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Complete passkey authentication to disable passwordless login.
Verifies the credential belongs to the current user.
"""
# Verify challenge token — user-bound (single-use nonce V-01, cross-user binding S-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")
# Parse rawId from credential to look up the stored credential
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 — verify ownership (IDOR prevention)
cred_result = await db.execute(
select(PasskeyCredential).where(
PasskeyCredential.credential_id == raw_id_b64,
PasskeyCredential.user_id == current_user.id,
)
)
credential = cred_result.scalar_one_or_none()
if not credential:
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(
"Passwordless disable: auth verification failed for user %s: %s",
current_user.id, e,
)
raise HTTPException(status_code=401, detail="Authentication failed")
# Update sign count
credential.sign_count = verified.new_sign_count
credential.last_used_at = datetime.now()
current_user.passwordless_enabled = False
await log_audit_event(
db, action="passkey.passwordless_disabled", actor_id=current_user.id,
ip=get_client_ip(request),
)
await db.commit()
return {"passwordless_enabled": False}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Management endpoints (authenticated) # Management endpoints (authenticated)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -430,6 +636,20 @@ async def delete_passkey(
if not credential: if not credential:
raise HTTPException(status_code=404, detail="Passkey not found") raise HTTPException(status_code=404, detail="Passkey not found")
# Guard: passwordless users must retain at least 2 passkeys
if current_user.passwordless_enabled:
pk_count_result = await db.execute(
select(func.count()).select_from(PasskeyCredential).where(
PasskeyCredential.user_id == current_user.id
)
)
pk_count = pk_count_result.scalar_one()
if pk_count <= 2:
raise HTTPException(
status_code=409,
detail="Cannot delete: passwordless requires at least 2 passkeys",
)
cred_name = credential.name cred_name = credential.name
await db.delete(credential) await db.delete(credential)

View File

@ -30,6 +30,7 @@ class UserListItem(BaseModel):
last_password_change_at: Optional[datetime] = None last_password_change_at: Optional[datetime] = None
totp_enabled: bool totp_enabled: bool
mfa_enforce_pending: bool mfa_enforce_pending: bool
passwordless_enabled: bool = False
created_at: datetime created_at: datetime
active_sessions: int = 0 active_sessions: int = 0
@ -107,6 +108,7 @@ class ToggleMfaEnforceRequest(BaseModel):
class SystemConfigResponse(BaseModel): class SystemConfigResponse(BaseModel):
allow_registration: bool allow_registration: bool
enforce_mfa_new_users: bool enforce_mfa_new_users: bool
allow_passwordless: bool = False
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@ -115,6 +117,12 @@ class SystemConfigUpdate(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
allow_registration: Optional[bool] = None allow_registration: Optional[bool] = None
enforce_mfa_new_users: Optional[bool] = None enforce_mfa_new_users: Optional[bool] = None
allow_passwordless: Optional[bool] = None
class TogglePasswordlessRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
enabled: bool
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------