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:
parent
fc1f8d5514
commit
bcfebbc9ae
40
backend/alembic/versions/062_passwordless_login.py
Normal file
40
backend/alembic/versions/062_passwordless_login.py
Normal 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")
|
||||||
@ -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()
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user