Compare commits
No commits in common. "51d98173a6561bdba3be2795d73802d8457eeb31" and "c5a309f4a1969fb7f112baca2c89d4110c22bc80" have entirely different histories.
51d98173a6
...
c5a309f4a1
@ -1,40 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@ -30,12 +30,6 @@ class Settings(BaseSettings):
|
|||||||
# Concurrent session limit per user (oldest evicted when exceeded)
|
# Concurrent session limit per user (oldest evicted when exceeded)
|
||||||
MAX_SESSIONS_PER_USER: int = 10
|
MAX_SESSIONS_PER_USER: int = 10
|
||||||
|
|
||||||
# WebAuthn / Passkey configuration
|
|
||||||
WEBAUTHN_RP_ID: str = "localhost" # eTLD+1 domain, e.g. "umbra.ghost6.xyz"
|
|
||||||
WEBAUTHN_RP_NAME: str = "UMBRA"
|
|
||||||
WEBAUTHN_ORIGIN: str = "http://localhost" # Full origin with scheme, e.g. "https://umbra.ghost6.xyz"
|
|
||||||
WEBAUTHN_CHALLENGE_TTL: int = 60 # Challenge token lifetime in seconds
|
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
env_file_encoding="utf-8",
|
env_file_encoding="utf-8",
|
||||||
@ -53,9 +47,6 @@ class Settings(BaseSettings):
|
|||||||
self.CORS_ORIGINS = "http://localhost:5173"
|
self.CORS_ORIGINS = "http://localhost:5173"
|
||||||
assert self.COOKIE_SECURE is not None # type narrowing
|
assert self.COOKIE_SECURE is not None # type narrowing
|
||||||
assert self.CORS_ORIGINS is not None
|
assert self.CORS_ORIGINS is not None
|
||||||
# Validate WebAuthn origin includes scheme (S-04)
|
|
||||||
if not self.WEBAUTHN_ORIGIN.startswith(("http://", "https://")):
|
|
||||||
raise ValueError("WEBAUTHN_ORIGIN must include scheme (http:// or https://)")
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import engine
|
from app.database import engine
|
||||||
from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates
|
from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates
|
||||||
from app.routers import totp, admin, notifications as notifications_router, connections as connections_router, shared_calendars as shared_calendars_router, event_invitations as event_invitations_router, passkeys as passkeys_router
|
from app.routers import totp, admin, notifications as notifications_router, connections as connections_router, shared_calendars as shared_calendars_router, event_invitations as event_invitations_router
|
||||||
from app.jobs.notifications import run_notification_dispatch
|
from app.jobs.notifications import run_notification_dispatch
|
||||||
|
|
||||||
# Import models so Alembic's autogenerate can discover them
|
# Import models so Alembic's autogenerate can discover them
|
||||||
@ -23,7 +23,6 @@ from app.models import user_connection as _user_connection_model # noqa: F401
|
|||||||
from app.models import calendar_member as _calendar_member_model # noqa: F401
|
from app.models import calendar_member as _calendar_member_model # noqa: F401
|
||||||
from app.models import event_lock as _event_lock_model # noqa: F401
|
from app.models import event_lock as _event_lock_model # noqa: F401
|
||||||
from app.models import event_invitation as _event_invitation_model # noqa: F401
|
from app.models import event_invitation as _event_invitation_model # noqa: F401
|
||||||
from app.models import passkey_credential as _passkey_credential_model # noqa: F401
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -50,8 +49,6 @@ class CSRFHeaderMiddleware:
|
|||||||
"/api/auth/totp-verify",
|
"/api/auth/totp-verify",
|
||||||
"/api/auth/totp/enforce-setup",
|
"/api/auth/totp/enforce-setup",
|
||||||
"/api/auth/totp/enforce-confirm",
|
"/api/auth/totp/enforce-confirm",
|
||||||
"/api/auth/passkeys/login/begin",
|
|
||||||
"/api/auth/passkeys/login/complete",
|
|
||||||
})
|
})
|
||||||
_MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
_MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
||||||
|
|
||||||
@ -137,7 +134,6 @@ app.include_router(dashboard.router, prefix="/api", tags=["Dashboard"])
|
|||||||
app.include_router(weather.router, prefix="/api/weather", tags=["Weather"])
|
app.include_router(weather.router, prefix="/api/weather", tags=["Weather"])
|
||||||
app.include_router(event_templates.router, prefix="/api/event-templates", tags=["Event Templates"])
|
app.include_router(event_templates.router, prefix="/api/event-templates", tags=["Event Templates"])
|
||||||
app.include_router(totp.router, prefix="/api/auth", tags=["TOTP MFA"])
|
app.include_router(totp.router, prefix="/api/auth", tags=["TOTP MFA"])
|
||||||
app.include_router(passkeys_router.router, prefix="/api/auth/passkeys", tags=["Passkeys"])
|
|
||||||
app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
|
app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
|
||||||
app.include_router(notifications_router.router, prefix="/api/notifications", tags=["Notifications"])
|
app.include_router(notifications_router.router, prefix="/api/notifications", tags=["Notifications"])
|
||||||
app.include_router(connections_router.router, prefix="/api/connections", tags=["Connections"])
|
app.include_router(connections_router.router, prefix="/api/connections", tags=["Connections"])
|
||||||
|
|||||||
@ -23,7 +23,6 @@ from app.models.event_lock import EventLock
|
|||||||
from app.models.event_invitation import EventInvitation, EventInvitationOverride
|
from app.models.event_invitation import EventInvitation, EventInvitationOverride
|
||||||
from app.models.project_member import ProjectMember
|
from app.models.project_member import ProjectMember
|
||||||
from app.models.project_task_assignment import ProjectTaskAssignment
|
from app.models.project_task_assignment import ProjectTaskAssignment
|
||||||
from app.models.passkey_credential import PasskeyCredential
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Settings",
|
"Settings",
|
||||||
@ -52,5 +51,4 @@ __all__ = [
|
|||||||
"EventInvitationOverride",
|
"EventInvitationOverride",
|
||||||
"ProjectMember",
|
"ProjectMember",
|
||||||
"ProjectTaskAssignment",
|
"ProjectTaskAssignment",
|
||||||
"PasskeyCredential",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,30 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text, func
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class PasskeyCredential(Base):
|
|
||||||
__tablename__ = "passkey_credentials"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
|
||||||
user_id: Mapped[int] = mapped_column(
|
|
||||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
|
||||||
)
|
|
||||||
# base64url-encoded credential ID (spec allows up to 1023 bytes → ~1363 chars)
|
|
||||||
credential_id: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
|
||||||
# base64url-encoded COSE public key
|
|
||||||
public_key: Mapped[str] = mapped_column(Text, nullable=False)
|
|
||||||
# Authenticator sign count for clone detection
|
|
||||||
sign_count: Mapped[int] = mapped_column(Integer, default=0)
|
|
||||||
# User-assigned label (e.g. "MacBook Pro — Chrome")
|
|
||||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
||||||
# JSON array of transport hints (e.g. '["usb","hybrid"]')
|
|
||||||
transports: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
# Whether the credential is backed up / synced across devices
|
|
||||||
backed_up: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
|
||||||
last_used_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
|
||||||
@ -16,6 +16,7 @@ Security layers:
|
|||||||
4. bcrypt→Argon2id transparent upgrade on first login
|
4. bcrypt→Argon2id transparent upgrade on first login
|
||||||
5. Role-based authorization via require_role() dependency factory
|
5. Role-based authorization via require_role() dependency factory
|
||||||
"""
|
"""
|
||||||
|
import uuid
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@ -29,7 +30,6 @@ from app.models.user import User
|
|||||||
from app.models.session import UserSession
|
from app.models.session import UserSession
|
||||||
from app.models.settings import Settings
|
from app.models.settings import Settings
|
||||||
from app.models.system_config import SystemConfig
|
from app.models.system_config import SystemConfig
|
||||||
from app.models.passkey_credential import PasskeyCredential
|
|
||||||
from app.models.calendar import Calendar
|
from app.models.calendar import Calendar
|
||||||
from app.schemas.auth import (
|
from app.schemas.auth import (
|
||||||
SetupRequest, LoginRequest, RegisterRequest,
|
SetupRequest, LoginRequest, RegisterRequest,
|
||||||
@ -49,13 +49,6 @@ from app.services.auth import (
|
|||||||
create_mfa_enforce_token,
|
create_mfa_enforce_token,
|
||||||
)
|
)
|
||||||
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.session import (
|
|
||||||
set_session_cookie,
|
|
||||||
check_account_lockout,
|
|
||||||
record_failed_login,
|
|
||||||
record_successful_login,
|
|
||||||
create_db_session,
|
|
||||||
)
|
|
||||||
from app.config import settings as app_settings
|
from app.config import settings as app_settings
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@ -66,6 +59,22 @@ router = APIRouter()
|
|||||||
# is indistinguishable from a wrong-password attempt.
|
# is indistinguishable from a wrong-password attempt.
|
||||||
_DUMMY_HASH = hash_password("timing-equalization-dummy")
|
_DUMMY_HASH = hash_password("timing-equalization-dummy")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cookie helper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _set_session_cookie(response: Response, token: str) -> None:
|
||||||
|
response.set_cookie(
|
||||||
|
key="session",
|
||||||
|
value=token,
|
||||||
|
httponly=True,
|
||||||
|
secure=app_settings.COOKIE_SECURE,
|
||||||
|
max_age=app_settings.SESSION_MAX_AGE_DAYS * 86400,
|
||||||
|
samesite="lax",
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Auth dependencies — export get_current_user and get_current_settings
|
# Auth dependencies — export get_current_user and get_current_settings
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -121,7 +130,7 @@ async def get_current_user(
|
|||||||
await db.flush()
|
await db.flush()
|
||||||
# Re-issue cookie with fresh signed token to reset browser max_age timer
|
# Re-issue cookie with fresh signed token to reset browser max_age timer
|
||||||
fresh_token = create_session_token(user_id, session_id)
|
fresh_token = create_session_token(user_id, session_id)
|
||||||
set_session_cookie(response, fresh_token)
|
_set_session_cookie(response, fresh_token)
|
||||||
|
|
||||||
# Stash session on request so lock/unlock endpoints can access it
|
# Stash session on request so lock/unlock endpoints can access it
|
||||||
request.state.db_session = db_session
|
request.state.db_session = db_session
|
||||||
@ -181,6 +190,82 @@ def require_role(*allowed_roles: str):
|
|||||||
require_admin = require_role("admin")
|
require_admin = require_role("admin")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Account lockout helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _check_account_lockout(user: User) -> None:
|
||||||
|
"""Raise HTTP 423 if the account is currently locked."""
|
||||||
|
if user.locked_until and datetime.now() < user.locked_until:
|
||||||
|
remaining = int((user.locked_until - datetime.now()).total_seconds() / 60) + 1
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=423,
|
||||||
|
detail=f"Account locked. Try again in {remaining} minutes.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _record_failed_login(db: AsyncSession, user: User) -> None:
|
||||||
|
"""Increment failure counter; lock account after 10 failures."""
|
||||||
|
user.failed_login_count += 1
|
||||||
|
if user.failed_login_count >= 10:
|
||||||
|
user.locked_until = datetime.now() + timedelta(minutes=30)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _record_successful_login(db: AsyncSession, user: User) -> None:
|
||||||
|
"""Reset failure counter and update last_login_at."""
|
||||||
|
user.failed_login_count = 0
|
||||||
|
user.locked_until = None
|
||||||
|
user.last_login_at = datetime.now()
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Session creation helper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _create_db_session(
|
||||||
|
db: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
ip: str,
|
||||||
|
user_agent: str | None,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""Insert a UserSession row and return (session_id, signed_cookie_token)."""
|
||||||
|
session_id = uuid.uuid4().hex
|
||||||
|
expires_at = datetime.now() + timedelta(days=app_settings.SESSION_MAX_AGE_DAYS)
|
||||||
|
db_session = UserSession(
|
||||||
|
id=session_id,
|
||||||
|
user_id=user.id,
|
||||||
|
expires_at=expires_at,
|
||||||
|
ip_address=ip[:45] if ip else None,
|
||||||
|
user_agent=(user_agent or "")[:255] if user_agent else None,
|
||||||
|
)
|
||||||
|
db.add(db_session)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Enforce concurrent session limit: revoke oldest sessions beyond the cap
|
||||||
|
active_sessions = (
|
||||||
|
await db.execute(
|
||||||
|
select(UserSession)
|
||||||
|
.where(
|
||||||
|
UserSession.user_id == user.id,
|
||||||
|
UserSession.revoked == False, # noqa: E712
|
||||||
|
UserSession.expires_at > datetime.now(),
|
||||||
|
)
|
||||||
|
.order_by(UserSession.created_at.asc())
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
max_sessions = app_settings.MAX_SESSIONS_PER_USER
|
||||||
|
if len(active_sessions) > max_sessions:
|
||||||
|
for old_session in active_sessions[: len(active_sessions) - max_sessions]:
|
||||||
|
old_session.revoked = True
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
token = create_session_token(user.id, session_id)
|
||||||
|
return session_id, token
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# User bootstrapping helper (Settings + default calendars)
|
# User bootstrapping helper (Settings + default calendars)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -236,8 +321,8 @@ async def setup(
|
|||||||
|
|
||||||
ip = get_client_ip(request)
|
ip = get_client_ip(request)
|
||||||
user_agent = request.headers.get("user-agent")
|
user_agent = request.headers.get("user-agent")
|
||||||
_, token = await create_db_session(db, new_user, ip, user_agent)
|
_, token = await _create_db_session(db, new_user, ip, user_agent)
|
||||||
set_session_cookie(response, token)
|
_set_session_cookie(response, token)
|
||||||
|
|
||||||
await log_audit_event(
|
await log_audit_event(
|
||||||
db, action="auth.setup_complete", actor_id=new_user.id, ip=ip,
|
db, action="auth.setup_complete", actor_id=new_user.id, ip=ip,
|
||||||
@ -281,10 +366,10 @@ async def login(
|
|||||||
# executes — prevents distinguishing "locked" from "wrong password" via timing.
|
# executes — prevents distinguishing "locked" from "wrong password" via timing.
|
||||||
valid, new_hash = await averify_password_with_upgrade(data.password, user.password_hash)
|
valid, new_hash = await averify_password_with_upgrade(data.password, user.password_hash)
|
||||||
|
|
||||||
await check_account_lockout(user)
|
await _check_account_lockout(user)
|
||||||
|
|
||||||
if not valid:
|
if not valid:
|
||||||
await record_failed_login(db, user)
|
await _record_failed_login(db, user)
|
||||||
await log_audit_event(
|
await log_audit_event(
|
||||||
db, action="auth.login_failed", actor_id=user.id,
|
db, action="auth.login_failed", actor_id=user.id,
|
||||||
detail={"reason": "invalid_password"}, ip=client_ip,
|
detail={"reason": "invalid_password"}, ip=client_ip,
|
||||||
@ -293,7 +378,7 @@ async def login(
|
|||||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||||
|
|
||||||
# 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.
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
await log_audit_event(
|
await log_audit_event(
|
||||||
@ -306,7 +391,7 @@ async def login(
|
|||||||
if new_hash:
|
if new_hash:
|
||||||
user.password_hash = new_hash
|
user.password_hash = new_hash
|
||||||
|
|
||||||
await record_successful_login(db, user)
|
await _record_successful_login(db, user)
|
||||||
|
|
||||||
# SEC-03: MFA enforcement — block login entirely until MFA setup completes
|
# SEC-03: MFA enforcement — block login entirely until MFA setup completes
|
||||||
if user.mfa_enforce_pending and not user.totp_enabled:
|
if user.mfa_enforce_pending and not user.totp_enabled:
|
||||||
@ -334,8 +419,8 @@ async def login(
|
|||||||
if user.must_change_password:
|
if user.must_change_password:
|
||||||
# Issue a session but flag the frontend to show password change
|
# Issue a session but flag the frontend to show password change
|
||||||
user_agent = request.headers.get("user-agent")
|
user_agent = request.headers.get("user-agent")
|
||||||
_, token = await create_db_session(db, user, client_ip, user_agent)
|
_, token = await _create_db_session(db, user, client_ip, user_agent)
|
||||||
set_session_cookie(response, token)
|
_set_session_cookie(response, token)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {
|
return {
|
||||||
"authenticated": True,
|
"authenticated": True,
|
||||||
@ -343,8 +428,8 @@ async def login(
|
|||||||
}
|
}
|
||||||
|
|
||||||
user_agent = request.headers.get("user-agent")
|
user_agent = request.headers.get("user-agent")
|
||||||
_, token = await create_db_session(db, user, client_ip, user_agent)
|
_, token = await _create_db_session(db, user, client_ip, user_agent)
|
||||||
set_session_cookie(response, token)
|
_set_session_cookie(response, token)
|
||||||
|
|
||||||
await log_audit_event(
|
await log_audit_event(
|
||||||
db, action="auth.login_success", actor_id=user.id, ip=client_ip,
|
db, action="auth.login_success", actor_id=user.id, ip=client_ip,
|
||||||
@ -426,8 +511,8 @@ async def register(
|
|||||||
"mfa_token": enforce_token,
|
"mfa_token": enforce_token,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, token = await create_db_session(db, new_user, ip, user_agent)
|
_, token = await _create_db_session(db, new_user, ip, user_agent)
|
||||||
set_session_cookie(response, token)
|
_set_session_cookie(response, token)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return {"message": "Registration successful", "authenticated": True}
|
return {"message": "Registration successful", "authenticated": True}
|
||||||
@ -479,31 +564,32 @@ async def auth_status(
|
|||||||
|
|
||||||
is_locked = False
|
is_locked = False
|
||||||
|
|
||||||
u = None
|
|
||||||
if not setup_required and session_cookie:
|
if not setup_required and session_cookie:
|
||||||
payload = verify_session_token(session_cookie)
|
payload = verify_session_token(session_cookie)
|
||||||
if payload:
|
if payload:
|
||||||
user_id = payload.get("uid")
|
user_id = payload.get("uid")
|
||||||
session_id = payload.get("sid")
|
session_id = payload.get("sid")
|
||||||
if user_id and session_id:
|
if user_id and session_id:
|
||||||
# Single JOIN query (was 2 sequential queries — P-01 fix)
|
session_result = await db.execute(
|
||||||
result = await db.execute(
|
select(UserSession).where(
|
||||||
select(UserSession, User)
|
|
||||||
.join(User, UserSession.user_id == User.id)
|
|
||||||
.where(
|
|
||||||
UserSession.id == session_id,
|
UserSession.id == session_id,
|
||||||
UserSession.user_id == user_id,
|
UserSession.user_id == user_id,
|
||||||
UserSession.revoked == False,
|
UserSession.revoked == False,
|
||||||
UserSession.expires_at > datetime.now(),
|
UserSession.expires_at > datetime.now(),
|
||||||
User.is_active == True,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
row = result.one_or_none()
|
db_sess = session_result.scalar_one_or_none()
|
||||||
if row is not None:
|
if db_sess is not None:
|
||||||
db_sess, u = row.tuple()
|
|
||||||
authenticated = True
|
authenticated = True
|
||||||
is_locked = db_sess.is_locked
|
is_locked = db_sess.is_locked
|
||||||
role = u.role
|
user_obj_result = await db.execute(
|
||||||
|
select(User).where(User.id == user_id, User.is_active == True)
|
||||||
|
)
|
||||||
|
u = user_obj_result.scalar_one_or_none()
|
||||||
|
if u:
|
||||||
|
role = u.role
|
||||||
|
else:
|
||||||
|
authenticated = False
|
||||||
|
|
||||||
# Check registration availability
|
# Check registration availability
|
||||||
registration_open = False
|
registration_open = False
|
||||||
@ -514,16 +600,6 @@ 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)
|
|
||||||
has_passkeys = False
|
|
||||||
if authenticated and u:
|
|
||||||
pk_result = await db.execute(
|
|
||||||
select(func.count()).select_from(PasskeyCredential).where(
|
|
||||||
PasskeyCredential.user_id == u.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
has_passkeys = pk_result.scalar_one() > 0
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"authenticated": authenticated,
|
"authenticated": authenticated,
|
||||||
"setup_required": setup_required,
|
"setup_required": setup_required,
|
||||||
@ -531,7 +607,6 @@ async def auth_status(
|
|||||||
"username": u.username if authenticated and u else None,
|
"username": u.username if authenticated and u else None,
|
||||||
"registration_open": registration_open,
|
"registration_open": registration_open,
|
||||||
"is_locked": is_locked,
|
"is_locked": is_locked,
|
||||||
"has_passkeys": has_passkeys,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -550,7 +625,7 @@ async def lock_session(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/verify-password")
|
@router.post("/verify-password")
|
||||||
async def verify_password_endpoint(
|
async def verify_password(
|
||||||
data: VerifyPasswordRequest,
|
data: VerifyPasswordRequest,
|
||||||
request: Request,
|
request: Request,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@ -560,11 +635,11 @@ async def verify_password_endpoint(
|
|||||||
Verify the current user's password without changing anything.
|
Verify the current user's password without changing anything.
|
||||||
Used by the frontend lock screen to re-authenticate without a full login.
|
Used by the frontend lock screen to re-authenticate without a full login.
|
||||||
"""
|
"""
|
||||||
await check_account_lockout(current_user)
|
await _check_account_lockout(current_user)
|
||||||
|
|
||||||
valid, new_hash = await averify_password_with_upgrade(data.password, current_user.password_hash)
|
valid, new_hash = await averify_password_with_upgrade(data.password, current_user.password_hash)
|
||||||
if not valid:
|
if not valid:
|
||||||
await record_failed_login(db, current_user)
|
await _record_failed_login(db, current_user)
|
||||||
raise HTTPException(status_code=401, detail="Invalid password")
|
raise HTTPException(status_code=401, detail="Invalid password")
|
||||||
|
|
||||||
if new_hash:
|
if new_hash:
|
||||||
@ -586,11 +661,11 @@ async def change_password(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Change the current user's password. Requires old password verification."""
|
"""Change the current user's password. Requires old password verification."""
|
||||||
await check_account_lockout(current_user)
|
await _check_account_lockout(current_user)
|
||||||
|
|
||||||
valid, _ = await averify_password_with_upgrade(data.old_password, current_user.password_hash)
|
valid, _ = await averify_password_with_upgrade(data.old_password, current_user.password_hash)
|
||||||
if not valid:
|
if not valid:
|
||||||
await record_failed_login(db, current_user)
|
await _record_failed_login(db, current_user)
|
||||||
raise HTTPException(status_code=401, detail="Invalid current password")
|
raise HTTPException(status_code=401, detail="Invalid current password")
|
||||||
|
|
||||||
if data.new_password == data.old_password:
|
if data.new_password == data.old_password:
|
||||||
|
|||||||
@ -1,451 +0,0 @@
|
|||||||
"""
|
|
||||||
Passkey (WebAuthn/FIDO2) router.
|
|
||||||
|
|
||||||
Endpoints (all under /api/auth/passkeys — registered in main.py):
|
|
||||||
|
|
||||||
POST /register/begin — Start passkey registration (auth + password required)
|
|
||||||
POST /register/complete — Complete registration ceremony (auth required)
|
|
||||||
POST /login/begin — Start passkey authentication (public, CSRF-exempt)
|
|
||||||
POST /login/complete — Complete authentication ceremony (public, CSRF-exempt)
|
|
||||||
GET / — List registered passkeys (auth required)
|
|
||||||
DELETE /{id} — Remove a passkey (auth + password required)
|
|
||||||
|
|
||||||
Security:
|
|
||||||
- Challenge tokens signed with itsdangerous (60s TTL, single-use nonce)
|
|
||||||
- Registration binds challenge to user_id, validated on complete (S-01)
|
|
||||||
- Registration requires password re-entry (V-02)
|
|
||||||
- Generic 401 on all auth failures (no credential enumeration)
|
|
||||||
- Constant-time response on login/begin (V-03)
|
|
||||||
- Failed passkey logins increment shared lockout counter
|
|
||||||
- Passkey login bypasses TOTP (passkey IS 2FA)
|
|
||||||
"""
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.database import get_db
|
|
||||||
from app.models.user import User
|
|
||||||
from app.models.passkey_credential import PasskeyCredential
|
|
||||||
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.session import (
|
|
||||||
create_db_session,
|
|
||||||
set_session_cookie,
|
|
||||||
check_account_lockout,
|
|
||||||
record_failed_login,
|
|
||||||
record_successful_login,
|
|
||||||
)
|
|
||||||
from app.services.passkey import (
|
|
||||||
create_challenge_token,
|
|
||||||
verify_challenge_token,
|
|
||||||
build_registration_options,
|
|
||||||
verify_registration as verify_registration_response_svc,
|
|
||||||
build_authentication_options,
|
|
||||||
verify_authentication as verify_authentication_response_svc,
|
|
||||||
)
|
|
||||||
from webauthn.helpers import bytes_to_base64url, base64url_to_bytes
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Request/Response schemas
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class PasskeyRegisterBeginRequest(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
password: str = Field(max_length=128)
|
|
||||||
|
|
||||||
|
|
||||||
class PasskeyRegisterCompleteRequest(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
credential: str = Field(max_length=8192)
|
|
||||||
challenge_token: str = Field(max_length=2048)
|
|
||||||
name: str = Field(min_length=1, max_length=100)
|
|
||||||
|
|
||||||
|
|
||||||
class PasskeyLoginBeginRequest(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
username: str | None = Field(None, max_length=50)
|
|
||||||
|
|
||||||
|
|
||||||
class PasskeyLoginCompleteRequest(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
credential: str = Field(max_length=8192)
|
|
||||||
challenge_token: str = Field(max_length=2048)
|
|
||||||
|
|
||||||
|
|
||||||
class PasskeyDeleteRequest(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
password: str = Field(max_length=128)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Registration endpoints (authenticated)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@router.post("/register/begin")
|
|
||||||
async def passkey_register_begin(
|
|
||||||
data: PasskeyRegisterBeginRequest,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Start passkey registration. Requires password re-entry (V-02)."""
|
|
||||||
# V-02: Verify password before allowing registration
|
|
||||||
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
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
# Load existing credential IDs for exclusion
|
|
||||||
result = await db.execute(
|
|
||||||
select(PasskeyCredential.credential_id).where(
|
|
||||||
PasskeyCredential.user_id == current_user.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
existing_ids = [
|
|
||||||
base64url_to_bytes(row[0]) for row in result.all()
|
|
||||||
]
|
|
||||||
|
|
||||||
options_json, challenge = build_registration_options(
|
|
||||||
user_id=current_user.id,
|
|
||||||
username=current_user.username,
|
|
||||||
existing_credential_ids=existing_ids,
|
|
||||||
)
|
|
||||||
token = create_challenge_token(challenge, user_id=current_user.id)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"options": json.loads(options_json),
|
|
||||||
"challenge_token": token,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/register/complete")
|
|
||||||
async def passkey_register_complete(
|
|
||||||
data: PasskeyRegisterCompleteRequest,
|
|
||||||
request: Request,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Complete passkey registration ceremony."""
|
|
||||||
# Verify challenge token — cross-check user binding (S-01) + single-use nonce (V-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")
|
|
||||||
|
|
||||||
try:
|
|
||||||
verified = verify_registration_response_svc(
|
|
||||||
credential_json=data.credential,
|
|
||||||
challenge=challenge,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Passkey registration verification failed: %s", e)
|
|
||||||
raise HTTPException(status_code=400, detail="Registration verification failed")
|
|
||||||
|
|
||||||
# Store credential
|
|
||||||
credential_id_b64 = bytes_to_base64url(verified.credential_id)
|
|
||||||
|
|
||||||
# Check for duplicate (race condition safety)
|
|
||||||
existing = await db.execute(
|
|
||||||
select(PasskeyCredential).where(
|
|
||||||
PasskeyCredential.credential_id == credential_id_b64
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if existing.scalar_one_or_none():
|
|
||||||
raise HTTPException(status_code=409, detail="Credential already registered")
|
|
||||||
|
|
||||||
# Extract transport hints if available
|
|
||||||
transports_json = None
|
|
||||||
if hasattr(verified, 'credential_device_type'):
|
|
||||||
pass # py_webauthn doesn't expose transports on VerifiedRegistration
|
|
||||||
# Transports come from the browser response — parse from credential JSON
|
|
||||||
try:
|
|
||||||
cred_data = json.loads(data.credential)
|
|
||||||
if "response" in cred_data and "transports" in cred_data["response"]:
|
|
||||||
transports_json = json.dumps(cred_data["response"]["transports"])
|
|
||||||
except (json.JSONDecodeError, KeyError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Determine backup state from py_webauthn flags
|
|
||||||
backed_up = getattr(verified, 'credential_backed_up', False)
|
|
||||||
|
|
||||||
new_credential = PasskeyCredential(
|
|
||||||
user_id=current_user.id,
|
|
||||||
credential_id=credential_id_b64,
|
|
||||||
public_key=bytes_to_base64url(verified.credential_public_key),
|
|
||||||
sign_count=verified.sign_count,
|
|
||||||
name=data.name,
|
|
||||||
transports=transports_json,
|
|
||||||
backed_up=backed_up,
|
|
||||||
)
|
|
||||||
db.add(new_credential)
|
|
||||||
|
|
||||||
# B-02: If user has mfa_enforce_pending, clear it (passkey = MFA)
|
|
||||||
if current_user.mfa_enforce_pending:
|
|
||||||
current_user.mfa_enforce_pending = False
|
|
||||||
|
|
||||||
# Extract response data BEFORE commit (ORM expiry rule)
|
|
||||||
response_data = {
|
|
||||||
"id": None, # will be set after flush
|
|
||||||
"name": new_credential.name,
|
|
||||||
"created_at": None,
|
|
||||||
"backed_up": backed_up,
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.flush()
|
|
||||||
response_data["id"] = new_credential.id
|
|
||||||
response_data["created_at"] = str(new_credential.created_at) if new_credential.created_at else None
|
|
||||||
|
|
||||||
await log_audit_event(
|
|
||||||
db, action="passkey.registered", actor_id=current_user.id,
|
|
||||||
detail={"credential_name": data.name},
|
|
||||||
ip=get_client_ip(request),
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
return response_data
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Authentication endpoints (unauthenticated — CSRF-exempt)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@router.post("/login/begin")
|
|
||||||
async def passkey_login_begin(
|
|
||||||
data: PasskeyLoginBeginRequest,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""Start passkey authentication. CSRF-exempt, public endpoint."""
|
|
||||||
credential_data = None
|
|
||||||
|
|
||||||
if data.username:
|
|
||||||
# Look up user's credentials for allowCredentials
|
|
||||||
result = await db.execute(
|
|
||||||
select(User).where(User.username == data.username.lower().strip())
|
|
||||||
)
|
|
||||||
user = result.scalar_one_or_none()
|
|
||||||
if user:
|
|
||||||
cred_result = await db.execute(
|
|
||||||
select(
|
|
||||||
PasskeyCredential.credential_id,
|
|
||||||
PasskeyCredential.transports,
|
|
||||||
).where(PasskeyCredential.user_id == user.id)
|
|
||||||
)
|
|
||||||
rows = cred_result.all()
|
|
||||||
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))
|
|
||||||
|
|
||||||
# V-03: Generate options regardless of whether user exists or has passkeys.
|
|
||||||
# Identical response shape prevents timing enumeration.
|
|
||||||
options_json, challenge = build_authentication_options(
|
|
||||||
credential_ids_and_transports=credential_data,
|
|
||||||
)
|
|
||||||
token = create_challenge_token(challenge)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"options": json.loads(options_json),
|
|
||||||
"challenge_token": token,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login/complete")
|
|
||||||
async def passkey_login_complete(
|
|
||||||
data: PasskeyLoginCompleteRequest,
|
|
||||||
request: Request,
|
|
||||||
response: Response,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""Complete passkey authentication. CSRF-exempt, public endpoint."""
|
|
||||||
# Verify challenge token (60s TTL, single-use nonce V-01)
|
|
||||||
challenge = verify_challenge_token(data.challenge_token)
|
|
||||||
if challenge is None:
|
|
||||||
raise HTTPException(status_code=401, detail="Authentication failed")
|
|
||||||
|
|
||||||
# Parse credential_id from browser response
|
|
||||||
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 by ID
|
|
||||||
result = await db.execute(
|
|
||||||
select(PasskeyCredential).where(
|
|
||||||
PasskeyCredential.credential_id == raw_id_b64
|
|
||||||
)
|
|
||||||
)
|
|
||||||
credential = result.scalar_one_or_none()
|
|
||||||
if not credential:
|
|
||||||
raise HTTPException(status_code=401, detail="Authentication failed")
|
|
||||||
|
|
||||||
# Load user
|
|
||||||
user_result = await db.execute(
|
|
||||||
select(User).where(User.id == credential.user_id)
|
|
||||||
)
|
|
||||||
user = user_result.scalar_one_or_none()
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(status_code=401, detail="Authentication failed")
|
|
||||||
|
|
||||||
# Check account lockout (C-03)
|
|
||||||
await check_account_lockout(user)
|
|
||||||
|
|
||||||
# Check active status (C-03)
|
|
||||||
if not user.is_active:
|
|
||||||
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("Passkey authentication verification failed for user %s: %s", user.id, e)
|
|
||||||
# Increment failed login counter (shared with password auth)
|
|
||||||
await record_failed_login(db, user)
|
|
||||||
await log_audit_event(
|
|
||||||
db, action="passkey.login_failed", actor_id=user.id,
|
|
||||||
detail={"reason": "verification_failed"},
|
|
||||||
ip=get_client_ip(request),
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
raise HTTPException(status_code=401, detail="Authentication failed")
|
|
||||||
|
|
||||||
# Update sign count (log anomaly but don't fail — S-05)
|
|
||||||
new_sign_count = verified.new_sign_count
|
|
||||||
if new_sign_count < credential.sign_count and credential.sign_count > 0:
|
|
||||||
logger.warning(
|
|
||||||
"Sign count anomaly for user %s credential %s: expected >= %d, got %d",
|
|
||||||
user.id, credential.id, credential.sign_count, new_sign_count,
|
|
||||||
)
|
|
||||||
await log_audit_event(
|
|
||||||
db, action="passkey.sign_count_anomaly", actor_id=user.id,
|
|
||||||
detail={
|
|
||||||
"credential_id": credential.id,
|
|
||||||
"expected": credential.sign_count,
|
|
||||||
"received": new_sign_count,
|
|
||||||
},
|
|
||||||
ip=get_client_ip(request),
|
|
||||||
)
|
|
||||||
|
|
||||||
credential.sign_count = new_sign_count
|
|
||||||
credential.last_used_at = datetime.now()
|
|
||||||
|
|
||||||
# Record successful login
|
|
||||||
await record_successful_login(db, user)
|
|
||||||
|
|
||||||
# Create session (shared service — enforces session cap)
|
|
||||||
client_ip = get_client_ip(request)
|
|
||||||
user_agent = request.headers.get("user-agent")
|
|
||||||
_, token = await create_db_session(db, user, client_ip, user_agent)
|
|
||||||
set_session_cookie(response, token)
|
|
||||||
|
|
||||||
await log_audit_event(
|
|
||||||
db, action="passkey.login_success", actor_id=user.id,
|
|
||||||
detail={"credential_name": credential.name},
|
|
||||||
ip=client_ip,
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
# B-01: Handle must_change_password and mfa_enforce_pending flags
|
|
||||||
result_data: dict = {"authenticated": True}
|
|
||||||
if user.must_change_password:
|
|
||||||
result_data["must_change_password"] = True
|
|
||||||
# Passkey satisfies MFA — if mfa_enforce_pending, clear it
|
|
||||||
if user.mfa_enforce_pending:
|
|
||||||
user.mfa_enforce_pending = False
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
return result_data
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Management endpoints (authenticated)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@router.get("/")
|
|
||||||
async def list_passkeys(
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""List all passkeys for the current user."""
|
|
||||||
result = await db.execute(
|
|
||||||
select(PasskeyCredential)
|
|
||||||
.where(PasskeyCredential.user_id == current_user.id)
|
|
||||||
.order_by(PasskeyCredential.created_at.desc())
|
|
||||||
)
|
|
||||||
credentials = result.scalars().all()
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"id": c.id,
|
|
||||||
"name": c.name,
|
|
||||||
"created_at": str(c.created_at) if c.created_at else None,
|
|
||||||
"last_used_at": str(c.last_used_at) if c.last_used_at else None,
|
|
||||||
"backed_up": c.backed_up,
|
|
||||||
}
|
|
||||||
for c in credentials
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{credential_id}")
|
|
||||||
async def delete_passkey(
|
|
||||||
credential_id: int,
|
|
||||||
data: PasskeyDeleteRequest,
|
|
||||||
request: Request,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Delete a passkey. Requires password confirmation (S-06)."""
|
|
||||||
# Verify password
|
|
||||||
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
|
|
||||||
|
|
||||||
# Look up credential — verify ownership (IDOR prevention)
|
|
||||||
result = await db.execute(
|
|
||||||
select(PasskeyCredential).where(
|
|
||||||
PasskeyCredential.id == credential_id,
|
|
||||||
PasskeyCredential.user_id == current_user.id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
credential = result.scalar_one_or_none()
|
|
||||||
if not credential:
|
|
||||||
raise HTTPException(status_code=404, detail="Passkey not found")
|
|
||||||
|
|
||||||
cred_name = credential.name
|
|
||||||
await db.delete(credential)
|
|
||||||
|
|
||||||
await log_audit_event(
|
|
||||||
db, action="passkey.deleted", actor_id=current_user.id,
|
|
||||||
detail={"credential_name": cred_name, "credential_db_id": credential_id},
|
|
||||||
ip=get_client_ip(request),
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
return {"message": "Passkey removed"}
|
|
||||||
@ -18,6 +18,7 @@ Security:
|
|||||||
- totp-verify uses mfa_token (not session cookie) — user is not yet authenticated
|
- totp-verify uses mfa_token (not session cookie) — user is not yet authenticated
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import uuid
|
||||||
import secrets
|
import secrets
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
@ -31,16 +32,17 @@ from sqlalchemy.exc import IntegrityError
|
|||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.models.session import UserSession
|
||||||
from app.models.totp_usage import TOTPUsage
|
from app.models.totp_usage import TOTPUsage
|
||||||
from app.models.backup_code import BackupCode
|
from app.models.backup_code import BackupCode
|
||||||
from app.routers.auth import get_current_user
|
from app.routers.auth import get_current_user, _set_session_cookie
|
||||||
from app.services.audit import get_client_ip
|
from app.services.audit import get_client_ip
|
||||||
from app.services.auth import (
|
from app.services.auth import (
|
||||||
averify_password_with_upgrade,
|
averify_password_with_upgrade,
|
||||||
verify_mfa_token,
|
verify_mfa_token,
|
||||||
verify_mfa_enforce_token,
|
verify_mfa_enforce_token,
|
||||||
|
create_session_token,
|
||||||
)
|
)
|
||||||
from app.services.session import create_db_session, set_session_cookie
|
|
||||||
from app.services.totp import (
|
from app.services.totp import (
|
||||||
generate_totp_secret,
|
generate_totp_secret,
|
||||||
encrypt_totp_secret,
|
encrypt_totp_secret,
|
||||||
@ -50,7 +52,7 @@ from app.services.totp import (
|
|||||||
generate_qr_base64,
|
generate_qr_base64,
|
||||||
generate_backup_codes,
|
generate_backup_codes,
|
||||||
)
|
)
|
||||||
|
from app.config import settings as app_settings
|
||||||
|
|
||||||
# Argon2id for backup code hashing — treat each code like a password
|
# Argon2id for backup code hashing — treat each code like a password
|
||||||
from argon2 import PasswordHasher
|
from argon2 import PasswordHasher
|
||||||
@ -160,6 +162,29 @@ async def _verify_backup_code(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_full_session(
|
||||||
|
db: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
request: Request,
|
||||||
|
) -> str:
|
||||||
|
"""Create a UserSession row and return the signed cookie token."""
|
||||||
|
session_id = uuid.uuid4().hex
|
||||||
|
expires_at = datetime.now() + timedelta(days=app_settings.SESSION_MAX_AGE_DAYS)
|
||||||
|
ip = get_client_ip(request)
|
||||||
|
user_agent = request.headers.get("user-agent")
|
||||||
|
|
||||||
|
db_session = UserSession(
|
||||||
|
id=session_id,
|
||||||
|
user_id=user.id,
|
||||||
|
expires_at=expires_at,
|
||||||
|
ip_address=ip[:45] if ip else None,
|
||||||
|
user_agent=(user_agent or "")[:255] if user_agent else None,
|
||||||
|
)
|
||||||
|
db.add(db_session)
|
||||||
|
await db.commit()
|
||||||
|
return create_session_token(user.id, session_id)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Routes
|
# Routes
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -287,10 +312,8 @@ async def totp_verify(
|
|||||||
user.last_login_at = datetime.now()
|
user.last_login_at = datetime.now()
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
ip = get_client_ip(request)
|
token = await _create_full_session(db, user, request)
|
||||||
user_agent = request.headers.get("user-agent")
|
_set_session_cookie(response, token)
|
||||||
_, token = await create_db_session(db, user, ip, user_agent)
|
|
||||||
set_session_cookie(response, token)
|
|
||||||
return {"authenticated": True}
|
return {"authenticated": True}
|
||||||
|
|
||||||
# --- TOTP code path ---
|
# --- TOTP code path ---
|
||||||
@ -317,10 +340,8 @@ async def totp_verify(
|
|||||||
user.last_login_at = datetime.now()
|
user.last_login_at = datetime.now()
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
ip = get_client_ip(request)
|
token = await _create_full_session(db, user, request)
|
||||||
user_agent = request.headers.get("user-agent")
|
_set_session_cookie(response, token)
|
||||||
_, token = await create_db_session(db, user, ip, user_agent)
|
|
||||||
set_session_cookie(response, token)
|
|
||||||
return {"authenticated": True}
|
return {"authenticated": True}
|
||||||
|
|
||||||
|
|
||||||
@ -492,11 +513,9 @@ async def enforce_confirm_totp(
|
|||||||
user.last_login_at = datetime.now()
|
user.last_login_at = datetime.now()
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
# Issue a full session (now uses shared session service with cap enforcement)
|
# Issue a full session
|
||||||
ip = get_client_ip(request)
|
token = await _create_full_session(db, user, request)
|
||||||
user_agent = request.headers.get("user-agent")
|
_set_session_cookie(response, token)
|
||||||
_, token = await create_db_session(db, user, ip, user_agent)
|
|
||||||
set_session_cookie(response, token)
|
|
||||||
|
|
||||||
return {"authenticated": True}
|
return {"authenticated": True}
|
||||||
|
|
||||||
|
|||||||
@ -1,220 +0,0 @@
|
|||||||
"""
|
|
||||||
Passkey (WebAuthn/FIDO2) service.
|
|
||||||
|
|
||||||
Handles challenge token creation/verification (itsdangerous + nonce replay protection)
|
|
||||||
and wraps py_webauthn library calls for registration and authentication ceremonies.
|
|
||||||
"""
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import secrets
|
|
||||||
import time
|
|
||||||
import threading
|
|
||||||
|
|
||||||
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
|
|
||||||
|
|
||||||
from webauthn import (
|
|
||||||
generate_registration_options,
|
|
||||||
verify_registration_response,
|
|
||||||
generate_authentication_options,
|
|
||||||
verify_authentication_response,
|
|
||||||
options_to_json,
|
|
||||||
)
|
|
||||||
from webauthn.helpers.structs import (
|
|
||||||
PublicKeyCredentialDescriptor,
|
|
||||||
AuthenticatorSelectionCriteria,
|
|
||||||
AuthenticatorTransport,
|
|
||||||
ResidentKeyRequirement,
|
|
||||||
UserVerificationRequirement,
|
|
||||||
AttestationConveyancePreference,
|
|
||||||
)
|
|
||||||
from webauthn.helpers import bytes_to_base64url, base64url_to_bytes
|
|
||||||
|
|
||||||
from app.config import settings as app_settings
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Challenge token management (itsdangerous + nonce replay protection V-01)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_challenge_serializer = URLSafeTimedSerializer(
|
|
||||||
secret_key=app_settings.SECRET_KEY,
|
|
||||||
salt="webauthn-challenge-v1",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Thread-safe nonce cache for single-use enforcement
|
|
||||||
# Keys: nonce string, Values: expiry timestamp
|
|
||||||
_used_nonces: dict[str, float] = {}
|
|
||||||
_nonce_lock = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def create_challenge_token(challenge: bytes, user_id: int | None = None) -> str:
|
|
||||||
"""Sign challenge + nonce + optional user_id. Returns opaque token string."""
|
|
||||||
nonce = secrets.token_urlsafe(16)
|
|
||||||
payload = {
|
|
||||||
"ch": base64.b64encode(challenge).decode(),
|
|
||||||
"n": nonce,
|
|
||||||
}
|
|
||||||
if user_id is not None:
|
|
||||||
payload["uid"] = user_id
|
|
||||||
return _challenge_serializer.dumps(payload)
|
|
||||||
|
|
||||||
|
|
||||||
def verify_challenge_token(token: str, expected_user_id: int | None = None) -> bytes | None:
|
|
||||||
"""Verify token (TTL from config), enforce single-use via nonce.
|
|
||||||
|
|
||||||
If expected_user_id provided, cross-check user binding (for registration).
|
|
||||||
Returns challenge bytes or None on failure.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
data = _challenge_serializer.loads(
|
|
||||||
token, max_age=app_settings.WEBAUTHN_CHALLENGE_TTL
|
|
||||||
)
|
|
||||||
except (BadSignature, SignatureExpired):
|
|
||||||
return None
|
|
||||||
|
|
||||||
nonce = data.get("n")
|
|
||||||
if not nonce:
|
|
||||||
return None
|
|
||||||
|
|
||||||
now = time.time()
|
|
||||||
with _nonce_lock:
|
|
||||||
# Lazy cleanup of expired nonces
|
|
||||||
expired = [k for k, v in _used_nonces.items() if v <= now]
|
|
||||||
for k in expired:
|
|
||||||
del _used_nonces[k]
|
|
||||||
|
|
||||||
# Check for replay
|
|
||||||
if nonce in _used_nonces:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Mark nonce as used
|
|
||||||
_used_nonces[nonce] = now + app_settings.WEBAUTHN_CHALLENGE_TTL
|
|
||||||
|
|
||||||
# Cross-check user binding for registration tokens
|
|
||||||
if expected_user_id is not None:
|
|
||||||
if data.get("uid") != expected_user_id:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return base64.b64decode(data["ch"])
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# py_webauthn wrappers
|
|
||||||
# All synchronous — ECDSA P-256 verification is ~0.1ms, faster than executor overhead.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def build_registration_options(
|
|
||||||
user_id: int,
|
|
||||||
username: str,
|
|
||||||
existing_credential_ids: list[bytes],
|
|
||||||
) -> tuple[str, bytes]:
|
|
||||||
"""Generate WebAuthn registration options.
|
|
||||||
|
|
||||||
Returns (options_json_str, challenge_bytes).
|
|
||||||
"""
|
|
||||||
exclude_credentials = [
|
|
||||||
PublicKeyCredentialDescriptor(id=cid)
|
|
||||||
for cid in existing_credential_ids
|
|
||||||
]
|
|
||||||
|
|
||||||
options = generate_registration_options(
|
|
||||||
rp_id=app_settings.WEBAUTHN_RP_ID,
|
|
||||||
rp_name=app_settings.WEBAUTHN_RP_NAME,
|
|
||||||
user_id=str(user_id).encode(),
|
|
||||||
user_name=username,
|
|
||||||
attestation=AttestationConveyancePreference.NONE,
|
|
||||||
authenticator_selection=AuthenticatorSelectionCriteria(
|
|
||||||
resident_key=ResidentKeyRequirement.PREFERRED,
|
|
||||||
user_verification=UserVerificationRequirement.PREFERRED,
|
|
||||||
),
|
|
||||||
exclude_credentials=exclude_credentials,
|
|
||||||
timeout=60000,
|
|
||||||
)
|
|
||||||
|
|
||||||
options_json = options_to_json(options)
|
|
||||||
return options_json, options.challenge
|
|
||||||
|
|
||||||
|
|
||||||
def verify_registration(
|
|
||||||
credential_json: str,
|
|
||||||
challenge: bytes,
|
|
||||||
) -> "VerifiedRegistration":
|
|
||||||
"""Verify a registration response from the browser.
|
|
||||||
|
|
||||||
Returns VerifiedRegistration on success, raises on failure.
|
|
||||||
"""
|
|
||||||
from webauthn.helpers.structs import RegistrationCredential
|
|
||||||
|
|
||||||
credential = RegistrationCredential.model_validate_json(credential_json)
|
|
||||||
return verify_registration_response(
|
|
||||||
credential=credential,
|
|
||||||
expected_challenge=challenge,
|
|
||||||
expected_rp_id=app_settings.WEBAUTHN_RP_ID,
|
|
||||||
expected_origin=app_settings.WEBAUTHN_ORIGIN,
|
|
||||||
require_user_verification=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_authentication_options(
|
|
||||||
credential_ids_and_transports: list[tuple[bytes, list[str] | None]] | None = None,
|
|
||||||
) -> tuple[str, bytes]:
|
|
||||||
"""Generate WebAuthn authentication options.
|
|
||||||
|
|
||||||
If credential_ids_and_transports provided, includes allowCredentials.
|
|
||||||
Otherwise, allows discoverable credential flow.
|
|
||||||
Returns (options_json_str, challenge_bytes).
|
|
||||||
"""
|
|
||||||
allow_credentials = None
|
|
||||||
if credential_ids_and_transports:
|
|
||||||
allow_credentials = []
|
|
||||||
for cid, transports in credential_ids_and_transports:
|
|
||||||
transport_list = None
|
|
||||||
if transports:
|
|
||||||
transport_list = [
|
|
||||||
AuthenticatorTransport(t)
|
|
||||||
for t in transports
|
|
||||||
if t in [e.value for e in AuthenticatorTransport]
|
|
||||||
]
|
|
||||||
allow_credentials.append(
|
|
||||||
PublicKeyCredentialDescriptor(
|
|
||||||
id=cid,
|
|
||||||
transports=transport_list or None,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
options = generate_authentication_options(
|
|
||||||
rp_id=app_settings.WEBAUTHN_RP_ID,
|
|
||||||
allow_credentials=allow_credentials,
|
|
||||||
user_verification=UserVerificationRequirement.PREFERRED,
|
|
||||||
timeout=60000,
|
|
||||||
)
|
|
||||||
|
|
||||||
options_json = options_to_json(options)
|
|
||||||
return options_json, options.challenge
|
|
||||||
|
|
||||||
|
|
||||||
def verify_authentication(
|
|
||||||
credential_json: str,
|
|
||||||
challenge: bytes,
|
|
||||||
credential_public_key: bytes,
|
|
||||||
credential_current_sign_count: int,
|
|
||||||
) -> "VerifiedAuthentication":
|
|
||||||
"""Verify an authentication response from the browser.
|
|
||||||
|
|
||||||
Returns VerifiedAuthentication on success, raises on failure.
|
|
||||||
Sign count anomalies are NOT hard-failed — caller should log and continue.
|
|
||||||
"""
|
|
||||||
from webauthn.helpers.structs import AuthenticationCredential
|
|
||||||
|
|
||||||
credential = AuthenticationCredential.model_validate_json(credential_json)
|
|
||||||
return verify_authentication_response(
|
|
||||||
credential=credential,
|
|
||||||
expected_challenge=challenge,
|
|
||||||
expected_rp_id=app_settings.WEBAUTHN_RP_ID,
|
|
||||||
expected_origin=app_settings.WEBAUTHN_ORIGIN,
|
|
||||||
credential_public_key=credential_public_key,
|
|
||||||
credential_current_sign_count=credential_current_sign_count,
|
|
||||||
require_user_verification=False,
|
|
||||||
)
|
|
||||||
@ -1,103 +0,0 @@
|
|||||||
"""
|
|
||||||
Shared session management service.
|
|
||||||
|
|
||||||
Consolidates session creation, cookie handling, and account lockout logic
|
|
||||||
that was previously duplicated between auth.py and totp.py routers.
|
|
||||||
All auth paths (password, TOTP, passkey) use these functions to ensure
|
|
||||||
consistent session cap enforcement and lockout behavior.
|
|
||||||
"""
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
from fastapi import HTTPException, Response
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.models.user import User
|
|
||||||
from app.models.session import UserSession
|
|
||||||
from app.services.auth import create_session_token
|
|
||||||
from app.config import settings as app_settings
|
|
||||||
|
|
||||||
|
|
||||||
def set_session_cookie(response: Response, token: str) -> None:
|
|
||||||
"""Set httpOnly secure signed cookie on response."""
|
|
||||||
response.set_cookie(
|
|
||||||
key="session",
|
|
||||||
value=token,
|
|
||||||
httponly=True,
|
|
||||||
secure=app_settings.COOKIE_SECURE,
|
|
||||||
max_age=app_settings.SESSION_MAX_AGE_DAYS * 86400,
|
|
||||||
samesite="lax",
|
|
||||||
path="/",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def check_account_lockout(user: User) -> None:
|
|
||||||
"""Raise HTTP 423 if the account is currently locked."""
|
|
||||||
if user.locked_until and datetime.now() < user.locked_until:
|
|
||||||
remaining = int((user.locked_until - datetime.now()).total_seconds() / 60) + 1
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=423,
|
|
||||||
detail=f"Account locked. Try again in {remaining} minutes.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def record_failed_login(db: AsyncSession, user: User) -> None:
|
|
||||||
"""Increment failure counter; lock account after 10 failures."""
|
|
||||||
user.failed_login_count += 1
|
|
||||||
if user.failed_login_count >= 10:
|
|
||||||
user.locked_until = datetime.now() + timedelta(minutes=30)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
async def record_successful_login(db: AsyncSession, user: User) -> None:
|
|
||||||
"""Reset failure counter and update last_login_at."""
|
|
||||||
user.failed_login_count = 0
|
|
||||||
user.locked_until = None
|
|
||||||
user.last_login_at = datetime.now()
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
async def create_db_session(
|
|
||||||
db: AsyncSession,
|
|
||||||
user: User,
|
|
||||||
ip: str,
|
|
||||||
user_agent: str | None,
|
|
||||||
) -> tuple[str, str]:
|
|
||||||
"""Insert a UserSession row and return (session_id, signed_cookie_token).
|
|
||||||
|
|
||||||
Enforces MAX_SESSIONS_PER_USER by revoking oldest sessions beyond the cap.
|
|
||||||
"""
|
|
||||||
session_id = uuid.uuid4().hex
|
|
||||||
expires_at = datetime.now() + timedelta(days=app_settings.SESSION_MAX_AGE_DAYS)
|
|
||||||
db_session = UserSession(
|
|
||||||
id=session_id,
|
|
||||||
user_id=user.id,
|
|
||||||
expires_at=expires_at,
|
|
||||||
ip_address=ip[:45] if ip else None,
|
|
||||||
user_agent=(user_agent or "")[:255] if user_agent else None,
|
|
||||||
)
|
|
||||||
db.add(db_session)
|
|
||||||
await db.flush()
|
|
||||||
|
|
||||||
# Enforce concurrent session limit: revoke oldest sessions beyond the cap
|
|
||||||
active_sessions = (
|
|
||||||
await db.execute(
|
|
||||||
select(UserSession)
|
|
||||||
.where(
|
|
||||||
UserSession.user_id == user.id,
|
|
||||||
UserSession.revoked == False, # noqa: E712
|
|
||||||
UserSession.expires_at > datetime.now(),
|
|
||||||
)
|
|
||||||
.order_by(UserSession.created_at.asc())
|
|
||||||
)
|
|
||||||
).scalars().all()
|
|
||||||
|
|
||||||
max_sessions = app_settings.MAX_SESSIONS_PER_USER
|
|
||||||
if len(active_sessions) > max_sessions:
|
|
||||||
for old_session in active_sessions[: len(active_sessions) - max_sessions]:
|
|
||||||
old_session.revoked = True
|
|
||||||
await db.flush()
|
|
||||||
|
|
||||||
token = create_session_token(user.id, session_id)
|
|
||||||
return session_id, token
|
|
||||||
@ -15,4 +15,3 @@ python-dateutil==2.9.0
|
|||||||
itsdangerous==2.2.0
|
itsdangerous==2.2.0
|
||||||
httpx==0.27.2
|
httpx==0.27.2
|
||||||
apscheduler==3.10.4
|
apscheduler==3.10.4
|
||||||
webauthn>=2.1.0
|
|
||||||
|
|||||||
@ -83,29 +83,6 @@ server {
|
|||||||
include /etc/nginx/proxy-params.conf;
|
include /etc/nginx/proxy-params.conf;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Passkey authentication — rate-limited (C-04)
|
|
||||||
location /api/auth/passkeys/login/begin {
|
|
||||||
limit_req zone=auth_limit burst=5 nodelay;
|
|
||||||
limit_req_status 429;
|
|
||||||
include /etc/nginx/proxy-params.conf;
|
|
||||||
}
|
|
||||||
location /api/auth/passkeys/login/complete {
|
|
||||||
limit_req zone=auth_limit burst=5 nodelay;
|
|
||||||
limit_req_status 429;
|
|
||||||
include /etc/nginx/proxy-params.conf;
|
|
||||||
}
|
|
||||||
# Passkey registration — authenticated, lower burst
|
|
||||||
location /api/auth/passkeys/register/begin {
|
|
||||||
limit_req zone=auth_limit burst=3 nodelay;
|
|
||||||
limit_req_status 429;
|
|
||||||
include /etc/nginx/proxy-params.conf;
|
|
||||||
}
|
|
||||||
location /api/auth/passkeys/register/complete {
|
|
||||||
limit_req zone=auth_limit burst=3 nodelay;
|
|
||||||
limit_req_status 429;
|
|
||||||
include /etc/nginx/proxy-params.conf;
|
|
||||||
}
|
|
||||||
|
|
||||||
# SEC-14: Rate-limit public registration endpoint
|
# SEC-14: Rate-limit public registration endpoint
|
||||||
location /api/auth/register {
|
location /api/auth/register {
|
||||||
limit_req zone=register_limit burst=3 nodelay;
|
limit_req zone=register_limit burst=3 nodelay;
|
||||||
@ -187,5 +164,5 @@ server {
|
|||||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';" always;
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';" always;
|
||||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
# PT-I03: Restrict unnecessary browser APIs
|
# PT-I03: Restrict unnecessary browser APIs
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=(), publickey-credentials-get=*, publickey-credentials-create=*" always;
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
|
||||||
}
|
}
|
||||||
|
|||||||
17
frontend/package-lock.json
generated
17
frontend/package-lock.json
generated
@ -16,7 +16,6 @@
|
|||||||
"@fullcalendar/interaction": "^6.1.15",
|
"@fullcalendar/interaction": "^6.1.15",
|
||||||
"@fullcalendar/react": "^6.1.15",
|
"@fullcalendar/react": "^6.1.15",
|
||||||
"@fullcalendar/timegrid": "^6.1.15",
|
"@fullcalendar/timegrid": "^6.1.15",
|
||||||
"@simplewebauthn/browser": "^10.0.0",
|
|
||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@ -1349,22 +1348,6 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@simplewebauthn/browser": {
|
|
||||||
"version": "10.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-10.0.0.tgz",
|
|
||||||
"integrity": "sha512-hG0JMZD+LiLUbpQcAjS4d+t4gbprE/dLYop/CkE01ugU/9sKXflxV5s0DRjdz3uNMFecatRfb4ZLG3XvF8m5zg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@simplewebauthn/types": "^10.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@simplewebauthn/types": {
|
|
||||||
"version": "10.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-10.0.0.tgz",
|
|
||||||
"integrity": "sha512-SFXke7xkgPRowY2E+8djKbdEznTVnD5R6GO7GPTthpHrokLvNKw8C3lFZypTxLI7KkCfGPfhtqB3d7OVGGa9jQ==",
|
|
||||||
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@tanstack/query-core": {
|
"node_modules/@tanstack/query-core": {
|
||||||
"version": "5.90.20",
|
"version": "5.90.20",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
|
||||||
|
|||||||
@ -17,7 +17,6 @@
|
|||||||
"@fullcalendar/interaction": "^6.1.15",
|
"@fullcalendar/interaction": "^6.1.15",
|
||||||
"@fullcalendar/react": "^6.1.15",
|
"@fullcalendar/react": "^6.1.15",
|
||||||
"@fullcalendar/timegrid": "^6.1.15",
|
"@fullcalendar/timegrid": "^6.1.15",
|
||||||
"@simplewebauthn/browser": "^10.0.0",
|
|
||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useState, FormEvent } from 'react';
|
import { useState, FormEvent } from 'react';
|
||||||
import { Navigate } from 'react-router-dom';
|
import { Navigate } from 'react-router-dom';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { AlertTriangle, Copy, Fingerprint, Lock, Loader2, ShieldCheck, UserPlus } from 'lucide-react';
|
import { AlertTriangle, Copy, Lock, Loader2, ShieldCheck, UserPlus } from 'lucide-react';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import api, { getErrorMessage } from '@/lib/api';
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@ -10,7 +10,6 @@ import { DatePicker } from '@/components/ui/date-picker';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import AmbientBackground from './AmbientBackground';
|
import AmbientBackground from './AmbientBackground';
|
||||||
import type { TotpSetupResponse } from '@/types';
|
import type { TotpSetupResponse } from '@/types';
|
||||||
|
|
||||||
@ -48,8 +47,6 @@ export default function LockScreen() {
|
|||||||
isRegisterPending,
|
isRegisterPending,
|
||||||
isSetupPending,
|
isSetupPending,
|
||||||
isTotpPending,
|
isTotpPending,
|
||||||
passkeyLogin,
|
|
||||||
isPasskeyLoginPending,
|
|
||||||
} = useAuth();
|
} = useAuth();
|
||||||
|
|
||||||
// ── Shared credential fields ──
|
// ── Shared credential fields ──
|
||||||
@ -86,31 +83,6 @@ export default function LockScreen() {
|
|||||||
const [forcedConfirmPassword, setForcedConfirmPassword] = useState('');
|
const [forcedConfirmPassword, setForcedConfirmPassword] = useState('');
|
||||||
const [isForcePwPending, setIsForcePwPending] = useState(false);
|
const [isForcePwPending, setIsForcePwPending] = useState(false);
|
||||||
|
|
||||||
// ── Passkey support (U-01: browser feature detection, not per-user) ──
|
|
||||||
const [supportsWebAuthn] = useState(() => !!window.PublicKeyCredential);
|
|
||||||
|
|
||||||
const handlePasskeyLogin = async () => {
|
|
||||||
setLoginError(null);
|
|
||||||
try {
|
|
||||||
const result = await passkeyLogin();
|
|
||||||
if (result?.must_change_password) {
|
|
||||||
setMode('force_pw');
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
if (error.name === 'NotAllowedError') {
|
|
||||||
toast.info('Passkey not recognized. Try your password.');
|
|
||||||
} else if (error.name === 'AbortError') {
|
|
||||||
// User cancelled — silent
|
|
||||||
} else {
|
|
||||||
toast.error(getErrorMessage(error, 'Passkey login failed. Try your password.'));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toast.error(getErrorMessage(error, 'Passkey login failed. Try your password.'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Redirect authenticated users (no pending MFA flows)
|
// Redirect authenticated users (no pending MFA flows)
|
||||||
if (!isLoading && authStatus?.authenticated && !mfaSetupRequired && mode !== 'force_pw') {
|
if (!isLoading && authStatus?.authenticated && !mfaSetupRequired && mode !== 'force_pw') {
|
||||||
return <Navigate to="/dashboard" replace />;
|
return <Navigate to="/dashboard" replace />;
|
||||||
@ -589,30 +561,6 @@ export default function LockScreen() {
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Passkey login — shown when browser supports WebAuthn (U-01) */}
|
|
||||||
{!isSetup && supportsWebAuthn && (
|
|
||||||
<>
|
|
||||||
<div className="relative my-4">
|
|
||||||
<Separator />
|
|
||||||
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card px-2 text-xs text-muted-foreground">
|
|
||||||
or
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="w-full gap-2"
|
|
||||||
onClick={handlePasskeyLogin}
|
|
||||||
disabled={isPasskeyLoginPending}
|
|
||||||
aria-label="Sign in with a passkey"
|
|
||||||
>
|
|
||||||
{isPasskeyLoginPending
|
|
||||||
? <Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
: <Fingerprint className="h-4 w-4" />}
|
|
||||||
Sign in with a passkey
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Open registration link — only shown on login screen when enabled */}
|
{/* Open registration link — only shown on login screen when enabled */}
|
||||||
{!isSetup && registrationOpen && (
|
{!isSetup && registrationOpen && (
|
||||||
<div className="mt-4 text-center">
|
<div className="mt-4 text-center">
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState } from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { Menu } from 'lucide-react';
|
import { Menu } from 'lucide-react';
|
||||||
import { useTheme } from '@/hooks/useTheme';
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
|
||||||
import { usePrefetch } from '@/hooks/usePrefetch';
|
import { usePrefetch } from '@/hooks/usePrefetch';
|
||||||
import { AlertsProvider } from '@/hooks/useAlerts';
|
import { AlertsProvider } from '@/hooks/useAlerts';
|
||||||
import { LockProvider, useLock } from '@/hooks/useLock';
|
import { LockProvider, useLock } from '@/hooks/useLock';
|
||||||
@ -19,20 +17,7 @@ function AppContent({ mobileOpen, setMobileOpen }: {
|
|||||||
setMobileOpen: (v: boolean) => void;
|
setMobileOpen: (v: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const { isLocked, isLockResolved } = useLock();
|
const { isLocked, isLockResolved } = useLock();
|
||||||
const { hasPasskeys } = useAuth();
|
|
||||||
usePrefetch(isLockResolved && !isLocked);
|
usePrefetch(isLockResolved && !isLocked);
|
||||||
|
|
||||||
// Post-login passkey prompt — show once per session if user has no passkeys
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
isLockResolved && !isLocked && !hasPasskeys &&
|
|
||||||
window.PublicKeyCredential &&
|
|
||||||
!sessionStorage.getItem('passkey-prompt-shown')
|
|
||||||
) {
|
|
||||||
sessionStorage.setItem('passkey-prompt-shown', '1');
|
|
||||||
toast.info('Simplify your login \u2014 set up a passkey in Settings', { duration: 8000 });
|
|
||||||
}
|
|
||||||
}, [isLockResolved, isLocked, hasPasskeys]);
|
|
||||||
const [collapsed, setCollapsed] = useState(() => {
|
const [collapsed, setCollapsed] = useState(() => {
|
||||||
try { return JSON.parse(localStorage.getItem('umbra-sidebar-collapsed') || 'false'); }
|
try { return JSON.parse(localStorage.getItem('umbra-sidebar-collapsed') || 'false'); }
|
||||||
catch { return false; }
|
catch { return false; }
|
||||||
|
|||||||
@ -1,415 +0,0 @@
|
|||||||
import { useState, useCallback } from 'react';
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { Fingerprint, Loader2, Trash2, Cloud } from 'lucide-react';
|
|
||||||
import api, { getErrorMessage } from '@/lib/api';
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import { useConfirmAction } from '@/hooks/useConfirmAction';
|
|
||||||
import type { PasskeyCredential } from '@/types';
|
|
||||||
|
|
||||||
function formatRelativeTime(dateStr: string | null): string {
|
|
||||||
if (!dateStr) return 'Never';
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
const now = new Date();
|
|
||||||
const diffMs = now.getTime() - date.getTime();
|
|
||||||
const diffMins = Math.floor(diffMs / 60000);
|
|
||||||
if (diffMins < 1) return 'Just now';
|
|
||||||
if (diffMins < 60) return `${diffMins}m ago`;
|
|
||||||
const diffHours = Math.floor(diffMins / 60);
|
|
||||||
if (diffHours < 24) return `${diffHours}h ago`;
|
|
||||||
const diffDays = Math.floor(diffHours / 24);
|
|
||||||
if (diffDays < 30) return `${diffDays}d ago`;
|
|
||||||
return date.toLocaleDateString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr: string | null): string {
|
|
||||||
if (!dateStr) return '';
|
|
||||||
return new Date(dateStr).toLocaleDateString(undefined, {
|
|
||||||
day: 'numeric', month: 'short', year: 'numeric',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectDeviceName(): string {
|
|
||||||
const ua = navigator.userAgent;
|
|
||||||
let browser = 'Browser';
|
|
||||||
if (ua.includes('Chrome') && !ua.includes('Edg')) browser = 'Chrome';
|
|
||||||
else if (ua.includes('Safari') && !ua.includes('Chrome')) browser = 'Safari';
|
|
||||||
else if (ua.includes('Firefox')) browser = 'Firefox';
|
|
||||||
else if (ua.includes('Edg')) browser = 'Edge';
|
|
||||||
|
|
||||||
let os = '';
|
|
||||||
if (ua.includes('Mac')) os = 'macOS';
|
|
||||||
else if (ua.includes('Windows')) os = 'Windows';
|
|
||||||
else if (ua.includes('Linux')) os = 'Linux';
|
|
||||||
else if (ua.includes('iPhone') || ua.includes('iPad')) os = 'iOS';
|
|
||||||
else if (ua.includes('Android')) os = 'Android';
|
|
||||||
|
|
||||||
return os ? `${os} \u2014 ${browser}` : browser;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DeleteConfirmProps {
|
|
||||||
credential: PasskeyCredential;
|
|
||||||
onDelete: (id: number, password: string) => void;
|
|
||||||
isDeleting: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function PasskeyDeleteButton({ credential, onDelete, isDeleting }: DeleteConfirmProps) {
|
|
||||||
const [showPasswordDialog, setShowPasswordDialog] = useState(false);
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
|
|
||||||
const { confirming, handleClick } = useConfirmAction(() => {
|
|
||||||
setShowPasswordDialog(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmitDelete = () => {
|
|
||||||
onDelete(credential.id, password);
|
|
||||||
setPassword('');
|
|
||||||
setShowPasswordDialog(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7 text-muted-foreground hover:text-red-400"
|
|
||||||
onClick={handleClick}
|
|
||||||
disabled={isDeleting}
|
|
||||||
aria-label={`Remove passkey ${credential.name}`}
|
|
||||||
>
|
|
||||||
{confirming ? (
|
|
||||||
<span className="text-[10px] font-medium text-red-400">Sure?</span>
|
|
||||||
) : (
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Dialog open={showPasswordDialog} onOpenChange={setShowPasswordDialog}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Remove passkey</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Enter your password to remove "{credential.name}".
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="delete-passkey-password">Password</Label>
|
|
||||||
<Input
|
|
||||||
id="delete-passkey-password"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' && password) handleSubmitDelete(); }}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setShowPasswordDialog(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={handleSubmitDelete}
|
|
||||||
disabled={!password || isDeleting}
|
|
||||||
>
|
|
||||||
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Remove'}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PasskeySection() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
|
|
||||||
const [ceremonyState, setCeremonyState] = useState<'password' | 'waiting' | 'naming'>('password');
|
|
||||||
const [registerPassword, setRegisterPassword] = useState('');
|
|
||||||
const [passkeyName, setPasskeyName] = useState('');
|
|
||||||
const [pendingCredential, setPendingCredential] = useState<{
|
|
||||||
credential: string;
|
|
||||||
challenge_token: string;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const passkeysQuery = useQuery({
|
|
||||||
queryKey: ['passkeys'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const { data } = await api.get<PasskeyCredential[]>('/auth/passkeys');
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const registerMutation = useMutation({
|
|
||||||
mutationFn: async ({ password }: { password: string }) => {
|
|
||||||
const { startRegistration } = await import('@simplewebauthn/browser');
|
|
||||||
|
|
||||||
// Step 1: Get registration options (requires password V-02)
|
|
||||||
const { data: beginResp } = await api.post('/auth/passkeys/register/begin', { password });
|
|
||||||
|
|
||||||
// Step 2: Browser WebAuthn ceremony
|
|
||||||
setCeremonyState('waiting');
|
|
||||||
const credential = await startRegistration(beginResp.options);
|
|
||||||
|
|
||||||
return {
|
|
||||||
credential: JSON.stringify(credential),
|
|
||||||
challenge_token: beginResp.challenge_token,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
onSuccess: (data) => {
|
|
||||||
setPendingCredential(data);
|
|
||||||
setPasskeyName(detectDeviceName());
|
|
||||||
setCeremonyState('naming');
|
|
||||||
},
|
|
||||||
onError: (error: unknown) => {
|
|
||||||
if (error instanceof Error && error.name === 'NotAllowedError') {
|
|
||||||
toast.info('Passkey setup cancelled');
|
|
||||||
} else if (error instanceof Error && error.name === 'AbortError') {
|
|
||||||
toast.info('Cancelled');
|
|
||||||
} else {
|
|
||||||
toast.error(getErrorMessage(error, 'Failed to create passkey'));
|
|
||||||
}
|
|
||||||
setRegisterDialogOpen(false);
|
|
||||||
resetRegisterState();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const completeMutation = useMutation({
|
|
||||||
mutationFn: async ({ credential, challenge_token, name }: {
|
|
||||||
credential: string; challenge_token: string; name: string;
|
|
||||||
}) => {
|
|
||||||
const { data } = await api.post('/auth/passkeys/register/complete', {
|
|
||||||
credential, challenge_token, name,
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success('Passkey registered');
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['passkeys'] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['auth'] });
|
|
||||||
setRegisterDialogOpen(false);
|
|
||||||
resetRegisterState();
|
|
||||||
},
|
|
||||||
onError: (error: unknown) => {
|
|
||||||
toast.error(getErrorMessage(error, 'Failed to save passkey'));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
|
||||||
mutationFn: async ({ id, password }: { id: number; password: string }) => {
|
|
||||||
await api.delete(`/auth/passkeys/${id}`, { data: { password } });
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success('Passkey removed');
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['passkeys'] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['auth'] });
|
|
||||||
},
|
|
||||||
onError: (error: unknown) => {
|
|
||||||
toast.error(getErrorMessage(error, 'Failed to remove passkey'));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const resetRegisterState = useCallback(() => {
|
|
||||||
setCeremonyState('password');
|
|
||||||
setRegisterPassword('');
|
|
||||||
setPasskeyName('');
|
|
||||||
setPendingCredential(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleStartRegister = () => {
|
|
||||||
resetRegisterState();
|
|
||||||
setRegisterDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePasswordSubmit = () => {
|
|
||||||
if (!registerPassword) return;
|
|
||||||
registerMutation.mutate({ password: registerPassword });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveName = () => {
|
|
||||||
if (!pendingCredential || !passkeyName.trim()) return;
|
|
||||||
completeMutation.mutate({
|
|
||||||
...pendingCredential,
|
|
||||||
name: passkeyName.trim(),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = (id: number, password: string) => {
|
|
||||||
deleteMutation.mutate({ id, password });
|
|
||||||
};
|
|
||||||
|
|
||||||
const passkeys = passkeysQuery.data ?? [];
|
|
||||||
const hasPasskeys = passkeys.length > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6 space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="p-1.5 rounded-md bg-accent/10">
|
|
||||||
<Fingerprint className="h-4 w-4 text-accent" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label>Passkeys</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Sign in with your fingerprint, face, or security key
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{hasPasskeys && (
|
|
||||||
<span className="inline-flex items-center rounded-full bg-emerald-500/10 px-2.5 py-0.5 text-xs font-semibold text-emerald-400">
|
|
||||||
{passkeys.length} registered
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasPasskeys && (
|
|
||||||
<ul className="space-y-1" aria-live="polite">
|
|
||||||
{passkeys.map((pk) => (
|
|
||||||
<li
|
|
||||||
key={pk.id}
|
|
||||||
className="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-card-elevated transition-colors duration-150"
|
|
||||||
>
|
|
||||||
<div className="p-1.5 rounded-md bg-accent/10">
|
|
||||||
<Fingerprint className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-medium truncate">{pk.name}</span>
|
|
||||||
{pk.backed_up && (
|
|
||||||
<Cloud className="h-3 w-3 text-muted-foreground shrink-0" aria-label="Synced" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="text-[11px] text-muted-foreground">
|
|
||||||
Added {formatDate(pk.created_at)} · Last used {formatRelativeTime(pk.last_used_at)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<PasskeyDeleteButton
|
|
||||||
credential={pk}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
isDeleting={deleteMutation.isPending}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="gap-2"
|
|
||||||
onClick={handleStartRegister}
|
|
||||||
>
|
|
||||||
<Fingerprint className="h-4 w-4" />
|
|
||||||
{hasPasskeys ? 'Add another passkey' : 'Add a passkey'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Registration ceremony dialog */}
|
|
||||||
<Dialog
|
|
||||||
open={registerDialogOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
setRegisterDialogOpen(false);
|
|
||||||
resetRegisterState();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<div className="flex items-center gap-3 mb-1">
|
|
||||||
<div className="p-2 rounded-lg bg-accent/10">
|
|
||||||
<Fingerprint className="h-5 w-5 text-accent" />
|
|
||||||
</div>
|
|
||||||
<DialogTitle>
|
|
||||||
{ceremonyState === 'password' && 'Add a passkey'}
|
|
||||||
{ceremonyState === 'waiting' && 'Creating passkey'}
|
|
||||||
{ceremonyState === 'naming' && 'Name your passkey'}
|
|
||||||
</DialogTitle>
|
|
||||||
</div>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{ceremonyState === 'password' && (
|
|
||||||
<>
|
|
||||||
<DialogDescription>
|
|
||||||
Enter your password to add a passkey to your account.
|
|
||||||
</DialogDescription>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="register-passkey-password">Password</Label>
|
|
||||||
<Input
|
|
||||||
id="register-passkey-password"
|
|
||||||
type="password"
|
|
||||||
value={registerPassword}
|
|
||||||
onChange={(e) => setRegisterPassword(e.target.value)}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' && registerPassword) handlePasswordSubmit(); }}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setRegisterDialogOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handlePasswordSubmit}
|
|
||||||
disabled={!registerPassword || registerMutation.isPending}
|
|
||||||
>
|
|
||||||
{registerMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Continue'}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{ceremonyState === 'waiting' && (
|
|
||||||
<div className="flex flex-col items-center gap-4 py-6">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-accent" />
|
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
|
||||||
Follow your browser's prompt to create a passkey
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{ceremonyState === 'naming' && (
|
|
||||||
<>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="passkey-name">Name</Label>
|
|
||||||
<Input
|
|
||||||
id="passkey-name"
|
|
||||||
value={passkeyName}
|
|
||||||
onChange={(e) => setPasskeyName(e.target.value)}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' && passkeyName.trim()) handleSaveName(); }}
|
|
||||||
maxLength={100}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Give this passkey a name to help you identify it later.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
onClick={handleSaveName}
|
|
||||||
disabled={!passkeyName.trim() || completeMutation.isPending}
|
|
||||||
>
|
|
||||||
{completeMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Save'}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -5,7 +5,6 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import TotpSetupSection from './TotpSetupSection';
|
import TotpSetupSection from './TotpSetupSection';
|
||||||
import PasskeySection from './PasskeySection';
|
|
||||||
import type { Settings } from '@/types';
|
import type { Settings } from '@/types';
|
||||||
|
|
||||||
interface SecurityTabProps {
|
interface SecurityTabProps {
|
||||||
@ -87,9 +86,6 @@ export default function SecurityTab({ settings, updateSettings, isUpdating }: Se
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Passkeys */}
|
|
||||||
<PasskeySection />
|
|
||||||
|
|
||||||
{/* Password + TOTP */}
|
{/* Password + TOTP */}
|
||||||
<TotpSetupSection bare />
|
<TotpSetupSection bare />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -96,30 +96,6 @@ export function useAuth() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const passkeyLoginMutation = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
const { startAuthentication } = await import('@simplewebauthn/browser');
|
|
||||||
const { data: beginResp } = await api.post('/auth/passkeys/login/begin', {});
|
|
||||||
const credential = await startAuthentication(beginResp.options);
|
|
||||||
const { data } = await api.post('/auth/passkeys/login/complete', {
|
|
||||||
credential: JSON.stringify(credential),
|
|
||||||
challenge_token: beginResp.challenge_token,
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
onSuccess: (data) => {
|
|
||||||
setMfaToken(null);
|
|
||||||
setMfaSetupRequired(false);
|
|
||||||
if (!data?.must_change_password) {
|
|
||||||
queryClient.setQueryData(['auth'], (old: AuthStatus | undefined) => {
|
|
||||||
if (!old) return old;
|
|
||||||
return { ...old, authenticated: true };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['auth'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const logoutMutation = useMutation({
|
const logoutMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const { data } = await api.post('/auth/logout');
|
const { data } = await api.post('/auth/logout');
|
||||||
@ -149,8 +125,5 @@ export function useAuth() {
|
|||||||
isRegisterPending: registerMutation.isPending,
|
isRegisterPending: registerMutation.isPending,
|
||||||
isTotpPending: totpVerifyMutation.isPending,
|
isTotpPending: totpVerifyMutation.isPending,
|
||||||
isSetupPending: setupMutation.isPending,
|
isSetupPending: setupMutation.isPending,
|
||||||
passkeyLogin: passkeyLoginMutation.mutateAsync,
|
|
||||||
isPasskeyLoginPending: passkeyLoginMutation.isPending,
|
|
||||||
hasPasskeys: authQuery.data?.has_passkeys ?? false,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,7 +15,7 @@ api.interceptors.response.use(
|
|||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
const url = error.config?.url || '';
|
const url = error.config?.url || '';
|
||||||
// Don't redirect on auth endpoints — they legitimately return 401
|
// Don't redirect on auth endpoints — they legitimately return 401
|
||||||
const authEndpoints = ['/auth/login', '/auth/register', '/auth/setup', '/auth/verify-password', '/auth/change-password', '/auth/passkeys/login/begin', '/auth/passkeys/login/complete'];
|
const authEndpoints = ['/auth/login', '/auth/register', '/auth/setup', '/auth/verify-password', '/auth/change-password'];
|
||||||
if (!authEndpoints.some(ep => url.startsWith(ep))) {
|
if (!authEndpoints.some(ep => url.startsWith(ep))) {
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -243,15 +243,6 @@ export interface AuthStatus {
|
|||||||
username: string | null;
|
username: string | null;
|
||||||
registration_open: boolean;
|
registration_open: boolean;
|
||||||
is_locked: boolean;
|
is_locked: boolean;
|
||||||
has_passkeys: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PasskeyCredential {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
created_at: string | null;
|
|
||||||
last_used_at: string | null;
|
|
||||||
backed_up: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login response discriminated union
|
// Login response discriminated union
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user