From bcfebbc9aebd1aa3d1450b9d05b9434c162ffee2 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Wed, 18 Mar 2026 00:15:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(backend):=20Phase=201=20passwordless=20log?= =?UTF-8?q?in=20=E2=80=94=20migration,=20models,=20toggle=20endpoints,=20u?= =?UTF-8?q?nlock,=20delete=20guard,=20admin=20controls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../versions/062_passwordless_login.py | 40 ++++ backend/app/models/system_config.py | 3 + backend/app/models/user.py | 5 + backend/app/routers/admin.py | 54 +++++ backend/app/routers/auth.py | 28 ++- backend/app/routers/passkeys.py | 226 +++++++++++++++++- backend/app/schemas/admin.py | 8 + 7 files changed, 357 insertions(+), 7 deletions(-) create mode 100644 backend/alembic/versions/062_passwordless_login.py diff --git a/backend/alembic/versions/062_passwordless_login.py b/backend/alembic/versions/062_passwordless_login.py new file mode 100644 index 0000000..930326a --- /dev/null +++ b/backend/alembic/versions/062_passwordless_login.py @@ -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") diff --git a/backend/app/models/system_config.py b/backend/app/models/system_config.py index 3e801b2..b16c1e6 100644 --- a/backend/app/models/system_config.py +++ b/backend/app/models/system_config.py @@ -21,6 +21,9 @@ class SystemConfig(Base): enforce_mfa_new_users: Mapped[bool] = mapped_column( 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()) updated_at: Mapped[datetime] = mapped_column( default=func.now(), onupdate=func.now(), server_default=func.now() diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 64e36e0..9499ed4 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -43,6 +43,11 @@ class User(Base): 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 created_at: Mapped[datetime] = mapped_column(default=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 8e9feef..9e79631 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -45,6 +45,7 @@ from app.schemas.admin import ( SystemConfigUpdate, ToggleActiveRequest, ToggleMfaEnforceRequest, + TogglePasswordlessRequest, UpdateUserRoleRequest, UserDetailResponse, UserListItem, @@ -670,6 +671,56 @@ async def get_user_sharing_stats( "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 # --------------------------------------------------------------------------- @@ -716,6 +767,9 @@ async def update_system_config( if data.enforce_mfa_new_users is not None: changes["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: await log_audit_event( diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index b75dce7..afb7289 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -132,6 +132,7 @@ async def get_current_user( lock_exempt = { "/api/auth/lock", "/api/auth/verify-password", "/api/auth/status", "/api/auth/logout", + "/api/auth/passkeys/login/begin", "/api/auth/passkeys/login/complete", } if request.url.path not in lock_exempt: raise HTTPException(status_code=423, detail="Session is locked") @@ -292,6 +293,19 @@ async def login( await db.commit() 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 # leaking account-state info, and BEFORE record_successful_login so # 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() 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 + passwordless_enabled = False if authenticated and u: pk_result = await db.execute( - select(PasskeyCredential.id).where( + select(func.count()).select_from(PasskeyCredential).where( 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 { "authenticated": authenticated, @@ -533,6 +551,8 @@ async def auth_status( "registration_open": registration_open, "is_locked": is_locked, "has_passkeys": has_passkeys, + "passkey_count": passkey_count, + "passwordless_enabled": passwordless_enabled, } diff --git a/backend/app/routers/passkeys.py b/backend/app/routers/passkeys.py index 9b8d899..8266f6d 100644 --- a/backend/app/routers/passkeys.py +++ b/backend/app/routers/passkeys.py @@ -27,14 +27,15 @@ from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Path, Request, Response from pydantic import BaseModel, ConfigDict, Field from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select +from sqlalchemy import select, func from app.database import get_db -from app.models.user import User 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.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 ( create_db_session, set_session_cookie, @@ -50,6 +51,7 @@ from app.services.passkey import ( build_authentication_options, verify_authentication as verify_authentication_response_svc, ) +from app.models.session import UserSession from webauthn.helpers import bytes_to_base64url, base64url_to_bytes logger = logging.getLogger(__name__) @@ -82,6 +84,7 @@ class PasskeyLoginCompleteRequest(BaseModel): model_config = ConfigDict(extra="forbid") credential: str = Field(max_length=8192) challenge_token: str = Field(max_length=2048) + unlock: bool = False class PasskeyDeleteRequest(BaseModel): @@ -89,6 +92,17 @@ class PasskeyDeleteRequest(BaseModel): 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) # --------------------------------------------------------------------------- @@ -345,6 +359,31 @@ async def passkey_login_complete( credential.sign_count = new_sign_count 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 await record_successful_login(db, user) @@ -372,6 +411,173 @@ async def passkey_login_complete( 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) # --------------------------------------------------------------------------- @@ -430,6 +636,20 @@ async def delete_passkey( if not credential: 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 await db.delete(credential) diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py index bfc4441..305e347 100644 --- a/backend/app/schemas/admin.py +++ b/backend/app/schemas/admin.py @@ -30,6 +30,7 @@ class UserListItem(BaseModel): last_password_change_at: Optional[datetime] = None totp_enabled: bool mfa_enforce_pending: bool + passwordless_enabled: bool = False created_at: datetime active_sessions: int = 0 @@ -107,6 +108,7 @@ class ToggleMfaEnforceRequest(BaseModel): class SystemConfigResponse(BaseModel): allow_registration: bool enforce_mfa_new_users: bool + allow_passwordless: bool = False model_config = ConfigDict(from_attributes=True) @@ -115,6 +117,12 @@ class SystemConfigUpdate(BaseModel): model_config = ConfigDict(extra="forbid") allow_registration: 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 # ---------------------------------------------------------------------------