diff --git a/.env.example b/.env.example index 9ac77b4..9ba0e21 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,15 @@ ENVIRONMENT=development # Timezone (applied to backend + db containers via env_file) TZ=Australia/Perth +# ────────────────────────────────────── +# WebAuthn / Passkeys +# ────────────────────────────────────── +# REQUIRED for passkeys to work. Must match the domain users access UMBRA on. +# RP_ID = eTLD+1 (no scheme, no port). ORIGIN = full origin with scheme. +WEBAUTHN_RP_ID=umbra.example.com +WEBAUTHN_RP_NAME=UMBRA +WEBAUTHN_ORIGIN=https://umbra.example.com + # ────────────────────────────────────── # Integrations # ────────────────────────────────────── diff --git a/backend/.env.example b/backend/.env.example index 396bfaa..e90dbc3 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,2 +1,14 @@ DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/umbra SECRET_KEY=your-secret-key-change-in-production +ENVIRONMENT=development + +# Public-facing URL (used for ntfy click links, CORS derivation) +UMBRA_URL=http://localhost + +# WebAuthn / Passkey authentication +# RP_ID must be the eTLD+1 domain of the live site (e.g. umbra.ghost6.xyz) +# ORIGIN must include the scheme (https://) +# These defaults work for local development; override in production .env +WEBAUTHN_RP_ID=localhost +WEBAUTHN_RP_NAME=UMBRA +WEBAUTHN_ORIGIN=http://localhost diff --git a/backend/README.md b/backend/README.md index 99cce58..0f51c54 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,39 +1,37 @@ # UMBRA Backend -A complete FastAPI backend for the UMBRA application with async SQLAlchemy, PostgreSQL, authentication, and comprehensive CRUD operations. +FastAPI backend for the UMBRA life management application with async SQLAlchemy, PostgreSQL, multi-user RBAC, and comprehensive security. ## Features -- **FastAPI** with async/await support -- **SQLAlchemy 2.0** with async engine -- **PostgreSQL** with asyncpg driver -- **Alembic** for database migrations -- **bcrypt** for password hashing -- **itsdangerous** for session management -- **PIN-based authentication** with secure session cookies -- **Full CRUD operations** for all entities -- **Dashboard** with aggregated data -- **CORS enabled** for frontend integration +- **FastAPI** with async/await and Pydantic v2 +- **SQLAlchemy 2.0** async engine with `Mapped[]` types +- **PostgreSQL 16** via asyncpg +- **Alembic** database migrations (001-061) +- **Authentication**: Argon2id passwords + signed httpOnly cookies + optional TOTP MFA + passkey (WebAuthn/FIDO2) +- **Multi-user RBAC**: admin/standard roles, per-user resource scoping +- **Session management**: DB-backed sessions, sliding window expiry, concurrent session cap +- **Account security**: Account lockout (10 failures = 30-min lock), CSRF protection, rate limiting +- **APScheduler** for background notification dispatch ## Project Structure ``` backend/ -├── alembic/ # Database migrations -│ ├── versions/ # Migration files -│ ├── env.py # Alembic environment -│ └── script.py.mako # Migration template +├── alembic/versions/ # 61 database migrations ├── app/ -│ ├── models/ # SQLAlchemy models -│ ├── schemas/ # Pydantic schemas -│ ├── routers/ # API route handlers -│ ├── config.py # Configuration -│ ├── database.py # Database setup -│ └── main.py # FastAPI application -├── requirements.txt # Python dependencies -├── Dockerfile # Docker configuration -├── alembic.ini # Alembic configuration -└── start.sh # Startup script +│ ├── models/ # 21 SQLAlchemy 2.0 models +│ ├── schemas/ # 14 Pydantic v2 schema modules +│ ├── routers/ # 17 API routers +│ ├── services/ # Auth, session, passkey, TOTP, audit, recurrence, etc. +│ ├── jobs/ # APScheduler notification dispatch +│ ├── config.py # Pydantic Settings (env vars) +│ ├── database.py # Async engine + session factory +│ └── main.py # FastAPI app + CSRF middleware +├── requirements.txt +├── Dockerfile +├── alembic.ini +└── start.sh ``` ## Setup @@ -41,160 +39,87 @@ backend/ ### 1. Install Dependencies ```bash -cd backend pip install -r requirements.txt ``` ### 2. Configure Environment -Create a `.env` file: +Copy `.env.example` to `.env` and configure: ```bash -DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/umbra -SECRET_KEY=your-secret-key-change-in-production +DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/umbra +SECRET_KEY=generate-a-strong-random-key +ENVIRONMENT=production + +# WebAuthn / Passkeys (required for passkey auth) +WEBAUTHN_RP_ID=your-domain.com +WEBAUTHN_RP_NAME=UMBRA +WEBAUTHN_ORIGIN=https://your-domain.com ``` -### 3. Create Database - -```bash -createdb umbra -``` - -### 4. Run Migrations +### 3. Run Migrations ```bash alembic upgrade head ``` -### 5. Start Server +### 4. Start Server ```bash -# Using the start script -chmod +x start.sh -./start.sh - -# Or directly with uvicorn -uvicorn app.main:app --reload +uvicorn app.main:app --host 0.0.0.0 --port 8000 ``` -The API will be available at `http://localhost:8000` +## API Routes -## API Documentation +All routes require authentication (signed session cookie) except `/api/auth/*` and `/health`. -Interactive API documentation is available at: -- **Swagger UI**: http://localhost:8000/docs -- **ReDoc**: http://localhost:8000/redoc +| Prefix | Description | +|--------|-------------| +| `/api/auth` | Login, logout, register, setup, status, password, TOTP, passkeys | +| `/api/admin` | User management, system config, audit logs (admin only) | +| `/api/todos` | Task management with categories and priorities | +| `/api/events` | Calendar events with recurrence support | +| `/api/event-invitations` | Event invitation RSVP and management | +| `/api/event-templates` | Reusable event templates | +| `/api/calendars` | Calendar CRUD | +| `/api/shared-calendars` | Calendar sharing with permission levels | +| `/api/reminders` | Reminder management with snooze | +| `/api/projects` | Projects with tasks, comments, and collaboration | +| `/api/people` | Contact management | +| `/api/locations` | Location management | +| `/api/connections` | User connections (friend requests) | +| `/api/notifications` | In-app notification centre | +| `/api/settings` | User preferences and ntfy configuration | +| `/api/dashboard` | Aggregated dashboard data | +| `/api/weather` | Weather widget data | -## API Endpoints +## Authentication -### Authentication -- `POST /api/auth/setup` - Initial PIN setup -- `POST /api/auth/login` - Login with PIN -- `POST /api/auth/logout` - Logout -- `GET /api/auth/status` - Check auth status +UMBRA supports three authentication methods: -### Todos -- `GET /api/todos` - List todos (with filters) -- `POST /api/todos` - Create todo -- `GET /api/todos/{id}` - Get todo -- `PUT /api/todos/{id}` - Update todo -- `DELETE /api/todos/{id}` - Delete todo -- `PATCH /api/todos/{id}/toggle` - Toggle completion +1. **Password** (Argon2id) - Primary login method +2. **TOTP MFA** - Optional second factor via authenticator apps +3. **Passkeys** (WebAuthn/FIDO2) - Optional passwordless login via biometrics, security keys, or password managers -### Calendar Events -- `GET /api/events` - List events (with date range) -- `POST /api/events` - Create event -- `GET /api/events/{id}` - Get event -- `PUT /api/events/{id}` - Update event -- `DELETE /api/events/{id}` - Delete event +Passkey login bypasses TOTP (a passkey is inherently two-factor: possession + biometric/PIN). -### Reminders -- `GET /api/reminders` - List reminders (with filters) -- `POST /api/reminders` - Create reminder -- `GET /api/reminders/{id}` - Get reminder -- `PUT /api/reminders/{id}` - Update reminder -- `DELETE /api/reminders/{id}` - Delete reminder -- `PATCH /api/reminders/{id}/dismiss` - Dismiss reminder +## Security -### Projects -- `GET /api/projects` - List projects -- `POST /api/projects` - Create project -- `GET /api/projects/{id}` - Get project -- `PUT /api/projects/{id}` - Update project -- `DELETE /api/projects/{id}` - Delete project -- `GET /api/projects/{id}/tasks` - List project tasks -- `POST /api/projects/{id}/tasks` - Create project task -- `PUT /api/projects/{id}/tasks/{task_id}` - Update task -- `DELETE /api/projects/{id}/tasks/{task_id}` - Delete task - -### People -- `GET /api/people` - List people (with search) -- `POST /api/people` - Create person -- `GET /api/people/{id}` - Get person -- `PUT /api/people/{id}` - Update person -- `DELETE /api/people/{id}` - Delete person - -### Locations -- `GET /api/locations` - List locations (with category filter) -- `POST /api/locations` - Create location -- `GET /api/locations/{id}` - Get location -- `PUT /api/locations/{id}` - Update location -- `DELETE /api/locations/{id}` - Delete location - -### Settings -- `GET /api/settings` - Get settings -- `PUT /api/settings` - Update settings -- `PUT /api/settings/pin` - Change PIN - -### Dashboard -- `GET /api/dashboard` - Get dashboard data -- `GET /api/upcoming?days=7` - Get upcoming items - -## Database Schema - -The application uses the following tables: -- `settings` - Application settings and PIN -- `todos` - Task items -- `calendar_events` - Calendar events -- `reminders` - Reminders -- `projects` - Projects -- `project_tasks` - Tasks within projects -- `people` - Contacts/people -- `locations` - Physical locations +- CSRF protection via `X-Requested-With` header middleware +- All Pydantic schemas use `extra="forbid"` (mass-assignment prevention) +- Nginx rate limiting on auth, registration, and admin endpoints +- DB-backed account lockout after 10 failed attempts +- Timing-safe dummy hash for non-existent users (prevents enumeration) +- SSRF validation on ntfy webhook URLs +- Naive datetimes throughout (Docker runs UTC) ## Docker -Build and run with Docker: +The backend runs as non-root `appuser` in `python:3.12-slim`: ```bash docker build -t umbra-backend . -docker run -p 8000:8000 -e DATABASE_URL=... -e SECRET_KEY=... umbra-backend +docker run -p 8000:8000 --env-file .env umbra-backend ``` -## Development - -### Create New Migration - -```bash -alembic revision --autogenerate -m "Description of changes" -``` - -### Apply Migrations - -```bash -alembic upgrade head -``` - -### Rollback Migration - -```bash -alembic downgrade -1 -``` - -## Security Notes - -- Change `SECRET_KEY` in production -- Use strong PINs (minimum 4 digits recommended) -- Session cookies are httpOnly and last 30 days -- All API endpoints (except auth) require authentication -- PINs are hashed with bcrypt before storage +In production, use Docker Compose (see root `docker-compose.yaml`). diff --git a/backend/alembic/versions/061_add_passkey_credentials.py b/backend/alembic/versions/061_add_passkey_credentials.py new file mode 100644 index 0000000..0a8b749 --- /dev/null +++ b/backend/alembic/versions/061_add_passkey_credentials.py @@ -0,0 +1,40 @@ +"""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") diff --git a/backend/alembic/versions/062_passwordless_login.py b/backend/alembic/versions/062_passwordless_login.py new file mode 100644 index 0000000..930326a --- /dev/null +++ b/backend/alembic/versions/062_passwordless_login.py @@ -0,0 +1,40 @@ +"""Passwordless login — add passwordless_enabled to users and allow_passwordless to system_config. + +Revision ID: 062 +Revises: 061 +Create Date: 2026-03-18 +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "062" +down_revision = "061" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "users", + sa.Column( + "passwordless_enabled", + sa.Boolean(), + nullable=False, + server_default="false", + ), + ) + op.add_column( + "system_config", + sa.Column( + "allow_passwordless", + sa.Boolean(), + nullable=False, + server_default="false", + ), + ) + + +def downgrade() -> None: + op.drop_column("users", "passwordless_enabled") + op.drop_column("system_config", "allow_passwordless") diff --git a/backend/app/config.py b/backend/app/config.py index 592af82..4c2fab9 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -30,6 +30,12 @@ class Settings(BaseSettings): # Concurrent session limit per user (oldest evicted when exceeded) 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( env_file=".env", env_file_encoding="utf-8", @@ -47,6 +53,9 @@ class Settings(BaseSettings): self.CORS_ORIGINS = "http://localhost:5173" assert self.COOKIE_SECURE is not None # type narrowing 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 diff --git a/backend/app/main.py b/backend/app/main.py index 6c30d36..d177d05 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,7 +7,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler from app.config import settings 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 totp, admin, notifications as notifications_router, connections as connections_router, shared_calendars as shared_calendars_router, event_invitations as event_invitations_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, passkeys as passkeys_router from app.jobs.notifications import run_notification_dispatch # Import models so Alembic's autogenerate can discover them @@ -23,6 +23,7 @@ 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 event_lock as _event_lock_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 # --------------------------------------------------------------------------- @@ -49,6 +50,8 @@ class CSRFHeaderMiddleware: "/api/auth/totp-verify", "/api/auth/totp/enforce-setup", "/api/auth/totp/enforce-confirm", + "/api/auth/passkeys/login/begin", + "/api/auth/passkeys/login/complete", }) _MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"}) @@ -134,6 +137,7 @@ app.include_router(dashboard.router, prefix="/api", tags=["Dashboard"]) 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(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(notifications_router.router, prefix="/api/notifications", tags=["Notifications"]) app.include_router(connections_router.router, prefix="/api/connections", tags=["Connections"]) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 05f7a00..da2607d 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -23,6 +23,7 @@ from app.models.event_lock import EventLock from app.models.event_invitation import EventInvitation, EventInvitationOverride from app.models.project_member import ProjectMember from app.models.project_task_assignment import ProjectTaskAssignment +from app.models.passkey_credential import PasskeyCredential __all__ = [ "Settings", @@ -51,4 +52,5 @@ __all__ = [ "EventInvitationOverride", "ProjectMember", "ProjectTaskAssignment", + "PasskeyCredential", ] diff --git a/backend/app/models/passkey_credential.py b/backend/app/models/passkey_credential.py new file mode 100644 index 0000000..4d03042 --- /dev/null +++ b/backend/app/models/passkey_credential.py @@ -0,0 +1,30 @@ +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) diff --git a/backend/app/models/system_config.py b/backend/app/models/system_config.py index 3e801b2..b16c1e6 100644 --- a/backend/app/models/system_config.py +++ b/backend/app/models/system_config.py @@ -21,6 +21,9 @@ class SystemConfig(Base): enforce_mfa_new_users: Mapped[bool] = mapped_column( Boolean, default=False, server_default="false" ) + allow_passwordless: Mapped[bool] = mapped_column( + Boolean, default=False, server_default="false" + ) created_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now()) updated_at: Mapped[datetime] = mapped_column( default=func.now(), onupdate=func.now(), server_default=func.now() diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 64e36e0..9499ed4 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -43,6 +43,11 @@ class User(Base): Boolean, default=False, server_default="false" ) + # Passwordless login — requires >= 2 passkeys registered + passwordless_enabled: Mapped[bool] = mapped_column( + Boolean, default=False, server_default="false" + ) + # Audit created_at: Mapped[datetime] = mapped_column(default=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 8e9feef..9e79631 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -45,6 +45,7 @@ from app.schemas.admin import ( SystemConfigUpdate, ToggleActiveRequest, ToggleMfaEnforceRequest, + TogglePasswordlessRequest, UpdateUserRoleRequest, UserDetailResponse, UserListItem, @@ -670,6 +671,56 @@ async def get_user_sharing_stats( "pending_invites_received": pending_received, } +# --------------------------------------------------------------------------- +# PUT /users/{user_id}/passwordless +# --------------------------------------------------------------------------- + +@router.put("/users/{user_id}/passwordless") +async def admin_toggle_passwordless( + request: Request, + data: TogglePasswordlessRequest, + user_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + actor: User = Depends(get_current_user), +): + """ + Admin-only: disable passwordless login for a user. + Only enabled=False is allowed — admin cannot remotely enable passwordless. + Revokes all sessions so the user must re-authenticate. + """ + if data.enabled: + raise HTTPException( + status_code=400, + detail="Admin can only disable passwordless login, not enable it", + ) + + _guard_self_action(actor, user_id, "toggle passwordless for") + + result = await db.execute(sa.select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + if not user.passwordless_enabled: + raise HTTPException(status_code=409, detail="Passwordless login is not enabled for this user") + + user.passwordless_enabled = False + + revoked = await _revoke_all_sessions(db, user_id) + + await log_audit_event( + db, + action="admin.passwordless_disabled", + actor_id=actor.id, + target_id=user_id, + detail={"sessions_revoked": revoked, "username": user.username}, + ip=get_client_ip(request), + ) + await db.commit() + + return {"passwordless_enabled": False, "sessions_revoked": revoked} + + # --------------------------------------------------------------------------- # GET /config # --------------------------------------------------------------------------- @@ -716,6 +767,9 @@ async def update_system_config( if data.enforce_mfa_new_users is not None: changes["enforce_mfa_new_users"] = data.enforce_mfa_new_users config.enforce_mfa_new_users = data.enforce_mfa_new_users + if data.allow_passwordless is not None: + changes["allow_passwordless"] = data.allow_passwordless + config.allow_passwordless = data.allow_passwordless if changes: await log_audit_event( diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 6a85c6b..261754e 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -16,7 +16,6 @@ Security layers: 4. bcrypt→Argon2id transparent upgrade on first login 5. Role-based authorization via require_role() dependency factory """ -import uuid from datetime import datetime, timedelta from typing import Optional @@ -30,6 +29,7 @@ from app.models.user import User from app.models.session import UserSession from app.models.settings import Settings from app.models.system_config import SystemConfig +from app.models.passkey_credential import PasskeyCredential from app.models.calendar import Calendar from app.schemas.auth import ( SetupRequest, LoginRequest, RegisterRequest, @@ -49,6 +49,13 @@ from app.services.auth import ( create_mfa_enforce_token, ) 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 router = APIRouter() @@ -59,22 +66,6 @@ router = APIRouter() # is indistinguishable from a wrong-password attempt. _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 # --------------------------------------------------------------------------- @@ -130,7 +121,7 @@ async def get_current_user( await db.flush() # Re-issue cookie with fresh signed token to reset browser max_age timer 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 request.state.db_session = db_session @@ -141,6 +132,7 @@ async def get_current_user( lock_exempt = { "/api/auth/lock", "/api/auth/verify-password", "/api/auth/status", "/api/auth/logout", + "/api/auth/passkeys/login/begin", "/api/auth/passkeys/login/complete", } if request.url.path not in lock_exempt: raise HTTPException(status_code=423, detail="Session is locked") @@ -190,82 +182,6 @@ def require_role(*allowed_roles: str): 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) # --------------------------------------------------------------------------- @@ -321,8 +237,8 @@ async def setup( ip = get_client_ip(request) user_agent = request.headers.get("user-agent") - _, token = await _create_db_session(db, new_user, ip, user_agent) - _set_session_cookie(response, token) + _, token = await create_db_session(db, new_user, ip, user_agent) + set_session_cookie(response, token) await log_audit_event( db, action="auth.setup_complete", actor_id=new_user.id, ip=ip, @@ -366,19 +282,38 @@ async def login( # executes — prevents distinguishing "locked" from "wrong password" via timing. 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: - await _record_failed_login(db, user) + remaining = await record_failed_login(db, user) await log_audit_event( db, action="auth.login_failed", actor_id=user.id, - detail={"reason": "invalid_password"}, ip=client_ip, + detail={"reason": "invalid_password", "attempts_remaining": remaining}, ip=client_ip, ) await db.commit() - raise HTTPException(status_code=401, detail="Invalid username or password") + if remaining == 0: + detail = "Account temporarily locked. Try again in 30 minutes." + elif remaining <= 3: + detail = f"Invalid username or password. {remaining} attempt(s) remaining before account locks." + else: + detail = "Invalid username or password" + raise HTTPException(status_code=401, detail=detail) + + # Block passwordless-only accounts from using the password login path. + # Checked after password verification to avoid leaking account existence via timing. + if user.passwordless_enabled: + await log_audit_event( + db, action="auth.login_blocked_passwordless", actor_id=user.id, + detail={"reason": "passwordless_enabled"}, ip=client_ip, + ) + await db.commit() + raise HTTPException( + status_code=403, + detail="This account uses passwordless login. Sign in with a passkey.", + ) # Block disabled accounts — checked AFTER password verification to avoid - # leaking account-state info, and BEFORE _record_successful_login so + # leaking account-state info, and BEFORE record_successful_login so # last_login_at and lockout counters are not reset for inactive users. if not user.is_active: await log_audit_event( @@ -391,7 +326,7 @@ async def login( if 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 if user.mfa_enforce_pending and not user.totp_enabled: @@ -409,6 +344,7 @@ async def login( # If TOTP is enabled, issue a short-lived MFA challenge token if user.totp_enabled: mfa_token = create_mfa_token(user.id) + await db.commit() return { "authenticated": False, "totp_required": True, @@ -419,8 +355,8 @@ async def login( if user.must_change_password: # Issue a session but flag the frontend to show password change user_agent = request.headers.get("user-agent") - _, token = await _create_db_session(db, user, client_ip, user_agent) - _set_session_cookie(response, token) + _, token = await create_db_session(db, user, client_ip, user_agent) + set_session_cookie(response, token) await db.commit() return { "authenticated": True, @@ -428,8 +364,8 @@ async def login( } user_agent = request.headers.get("user-agent") - _, token = await _create_db_session(db, user, client_ip, user_agent) - _set_session_cookie(response, token) + _, token = await create_db_session(db, user, client_ip, user_agent) + set_session_cookie(response, token) await log_audit_event( db, action="auth.login_success", actor_id=user.id, ip=client_ip, @@ -511,8 +447,8 @@ async def register( "mfa_token": enforce_token, } - _, token = await _create_db_session(db, new_user, ip, user_agent) - _set_session_cookie(response, token) + _, token = await create_db_session(db, new_user, ip, user_agent) + set_session_cookie(response, token) await db.commit() return {"message": "Registration successful", "authenticated": True} @@ -564,34 +500,34 @@ async def auth_status( is_locked = False + u = None if not setup_required and session_cookie: payload = verify_session_token(session_cookie) if payload: user_id = payload.get("uid") session_id = payload.get("sid") if user_id and session_id: - session_result = await db.execute( - select(UserSession).where( + # Single JOIN query (was 2 sequential queries — P-01 fix) + result = await db.execute( + select(UserSession, User) + .join(User, UserSession.user_id == User.id) + .where( UserSession.id == session_id, UserSession.user_id == user_id, UserSession.revoked == False, UserSession.expires_at > datetime.now(), + User.is_active == True, ) ) - db_sess = session_result.scalar_one_or_none() - if db_sess is not None: + row = result.one_or_none() + if row is not None: + db_sess, u = row.tuple() authenticated = True is_locked = db_sess.is_locked - 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 + role = u.role # Check registration availability + config = None registration_open = False if not setup_required: config_result = await db.execute( @@ -600,6 +536,19 @@ async def auth_status( config = config_result.scalar_one_or_none() registration_open = config.allow_registration if config else False + # Perf-3: Check passkey existence with EXISTS (not COUNT) — this endpoint + # is polled every 15s. Count is derived from GET /auth/passkeys list instead. + has_passkeys = False + passwordless_enabled = False + if authenticated and u: + pk_result = await db.execute( + select(PasskeyCredential.id).where( + PasskeyCredential.user_id == u.id + ).limit(1) + ) + has_passkeys = pk_result.scalar_one_or_none() is not None + passwordless_enabled = u.passwordless_enabled + return { "authenticated": authenticated, "setup_required": setup_required, @@ -607,6 +556,9 @@ async def auth_status( "username": u.username if authenticated and u else None, "registration_open": registration_open, "is_locked": is_locked, + "has_passkeys": has_passkeys, + "passwordless_enabled": passwordless_enabled, + "allow_passwordless": config.allow_passwordless if config else False, } @@ -625,7 +577,7 @@ async def lock_session( @router.post("/verify-password") -async def verify_password( +async def verify_password_endpoint( data: VerifyPasswordRequest, request: Request, db: AsyncSession = Depends(get_db), @@ -635,11 +587,12 @@ async def verify_password( Verify the current user's password without changing anything. 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) if not valid: - await _record_failed_login(db, current_user) + await record_failed_login(db, current_user) + await db.commit() raise HTTPException(status_code=401, detail="Invalid password") if new_hash: @@ -661,11 +614,12 @@ async def change_password( current_user: User = Depends(get_current_user), ): """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) if not valid: - await _record_failed_login(db, current_user) + await record_failed_login(db, current_user) + await db.commit() raise HTTPException(status_code=401, detail="Invalid current password") if data.new_password == data.old_password: diff --git a/backend/app/routers/passkeys.py b/backend/app/routers/passkeys.py new file mode 100644 index 0000000..6de8076 --- /dev/null +++ b/backend/app/routers/passkeys.py @@ -0,0 +1,675 @@ +""" +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, Path, Request, Response +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func + +from app.database import get_db +from app.models.passkey_credential import PasskeyCredential +from app.models.system_config import SystemConfig +from app.models.user import User +from app.routers.auth import get_current_user +from app.services.audit import get_client_ip, log_audit_event +from app.services.auth import averify_password_with_upgrade, verify_session_token +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, + extract_credential_raw_id, +) +from app.models.session import UserSession +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) + unlock: bool = False + + +class PasskeyDeleteRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + password: str = Field(max_length=128) + + +class PasswordlessEnableRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + password: str = Field(max_length=128) + + +class PasswordlessDisableRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + credential: str = Field(max_length=8192) + challenge_token: str = Field(max_length=2048) + + +# --------------------------------------------------------------------------- +# Registration endpoints (authenticated) +# --------------------------------------------------------------------------- + +@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)) + else: + # F-01: User not found — run a no-op DB query to equalize timing with + # the credential fetch that executes for existing users. Without this, + # the absence of the second query makes the "no user" path measurably + # faster, leaking whether the username exists. + await db.execute( + select(PasskeyCredential.credential_id).where( + PasskeyCredential.user_id == 0 + ).limit(1) + ) + + # 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 (S-02: shared helper) + raw_id_b64 = extract_credential_raw_id(data.credential) + if not raw_id_b64: + raise HTTPException(status_code=401, detail="Authentication failed") + + # Look up credential + user in a single JOIN query (W-1 perf fix) + result = await db.execute( + select(PasskeyCredential, User) + .join(User, User.id == PasskeyCredential.user_id) + .where(PasskeyCredential.credential_id == raw_id_b64) + ) + row = result.one_or_none() + if not row: + raise HTTPException(status_code=401, detail="Authentication failed") + credential, user = row.tuple() + + # 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) + remaining = await record_failed_login(db, user) + await log_audit_event( + db, action="passkey.login_failed", actor_id=user.id, + detail={"reason": "verification_failed", "attempts_remaining": remaining}, + ip=get_client_ip(request), + ) + await db.commit() + # Generic message for all failures — don't leak lockout state (C-02/F-02) + 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() + + # Passkey unlock — re-authenticate into a locked session instead of creating a new one + if data.unlock: + session_cookie = request.cookies.get("session") + payload = verify_session_token(session_cookie) if session_cookie else None + if not payload or payload.get("uid") != user.id: + raise HTTPException(status_code=401, detail="Authentication failed") + sess_result = await db.execute( + select(UserSession).where( + UserSession.id == payload["sid"], + UserSession.user_id == user.id, + UserSession.revoked == False, + ) + ) + db_sess = sess_result.scalar_one_or_none() + if not db_sess: + raise HTTPException(status_code=401, detail="Authentication failed") + db_sess.is_locked = False + db_sess.locked_at = None + # Reset failed login counter on successful passkey unlock (W-02) + await record_successful_login(db, user) + await log_audit_event( + db, action="passkey.unlock_success", actor_id=user.id, + ip=get_client_ip(request), + ) + await db.commit() + return {"unlocked": True} + + # Record successful login + await record_successful_login(db, user) + + # 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) + + # Handle special flags for passkey login + result_data: dict = {"authenticated": True} + # W-05: Passkey login auto-clears must_change_password — user can't provide + # old password in the forced-change form since they authenticated via passkey. + if user.must_change_password: + user.must_change_password = False + # Passkey satisfies MFA — if mfa_enforce_pending, clear it (before commit) + if user.mfa_enforce_pending: + user.mfa_enforce_pending = False + + await log_audit_event( + db, action="passkey.login_success", actor_id=user.id, + detail={"credential_name": credential.name}, + ip=client_ip, + ) + await db.commit() + + return result_data + + +# --------------------------------------------------------------------------- +# Passwordless toggle endpoints (authenticated) +# --------------------------------------------------------------------------- + +@router.put("/passwordless/enable") +async def passwordless_enable( + data: PasswordlessEnableRequest, + request: Request, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Enable passwordless login for the current user. + + Requirements: + - System config must have allow_passwordless = True + - User must have >= 2 registered passkeys + - Password confirmation required + """ + # Verify password first + valid, new_hash = await averify_password_with_upgrade( + data.password, current_user.password_hash + ) + if not valid: + raise HTTPException(status_code=401, detail="Invalid password") + if new_hash: + current_user.password_hash = new_hash + + # Check system config + config_result = await db.execute( + select(SystemConfig).where(SystemConfig.id == 1) + ) + config = config_result.scalar_one_or_none() + if not config or not config.allow_passwordless: + raise HTTPException( + status_code=403, + detail="Passwordless login is not enabled on this system", + ) + + # Require >= 2 passkeys as safety net (can't get locked out) + pk_count_result = await db.execute( + select(func.count()).select_from(PasskeyCredential).where( + PasskeyCredential.user_id == current_user.id + ) + ) + pk_count = pk_count_result.scalar_one() + if pk_count < 2: + raise HTTPException( + status_code=400, + detail="At least 2 passkeys must be registered before enabling passwordless login", + ) + + current_user.passwordless_enabled = True + + await log_audit_event( + db, action="passkey.passwordless_enabled", actor_id=current_user.id, + ip=get_client_ip(request), + ) + await db.commit() + + return {"passwordless_enabled": True} + + +@router.post("/passwordless/disable/begin") +async def passwordless_disable_begin( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Begin the passkey authentication ceremony to disable passwordless login. + Returns challenge options for the browser to present to the authenticator. + """ + # Load user's credentials for allowCredentials + cred_result = await db.execute( + select( + PasskeyCredential.credential_id, + PasskeyCredential.transports, + ).where(PasskeyCredential.user_id == current_user.id) + ) + rows = cred_result.all() + + credential_data = None + if rows: + credential_data = [] + for row in rows: + cid_bytes = base64url_to_bytes(row[0]) + transports = json.loads(row[1]) if row[1] else None + credential_data.append((cid_bytes, transports)) + + options_json, challenge = build_authentication_options( + credential_ids_and_transports=credential_data, + ) + # Bind challenge to this user so complete endpoint can cross-check + token = create_challenge_token(challenge, user_id=current_user.id) + + return { + "options": json.loads(options_json), + "challenge_token": token, + } + + +@router.put("/passwordless/disable") +async def passwordless_disable( + data: PasswordlessDisableRequest, + request: Request, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Complete passkey authentication to disable passwordless login. + Verifies the credential belongs to the current user. + """ + # Verify challenge token — user-bound (single-use nonce V-01, cross-user binding S-01) + challenge = verify_challenge_token( + data.challenge_token, expected_user_id=current_user.id + ) + if challenge is None: + raise HTTPException(status_code=401, detail="Invalid or expired challenge") + + # Parse rawId from credential (S-02: shared helper) + raw_id_b64 = extract_credential_raw_id(data.credential) + if not raw_id_b64: + raise HTTPException(status_code=401, detail="Authentication failed") + + # Look up credential — verify ownership (IDOR prevention) + cred_result = await db.execute( + select(PasskeyCredential).where( + PasskeyCredential.credential_id == raw_id_b64, + PasskeyCredential.user_id == current_user.id, + ) + ) + credential = cred_result.scalar_one_or_none() + if not credential: + raise HTTPException(status_code=401, detail="Authentication failed") + + # Verify the authentication response + try: + verified = verify_authentication_response_svc( + credential_json=data.credential, + challenge=challenge, + credential_public_key=base64url_to_bytes(credential.public_key), + credential_current_sign_count=credential.sign_count, + ) + except Exception as e: + logger.warning( + "Passwordless disable: auth verification failed for user %s: %s", + current_user.id, e, + ) + raise HTTPException(status_code=401, detail="Authentication failed") + + # Update sign count + credential.sign_count = verified.new_sign_count + credential.last_used_at = datetime.now() + + current_user.passwordless_enabled = False + + await log_audit_event( + db, action="passkey.passwordless_disabled", actor_id=current_user.id, + ip=get_client_ip(request), + ) + await db.commit() + + return {"passwordless_enabled": False} + + +# --------------------------------------------------------------------------- +# Management endpoints (authenticated) +# --------------------------------------------------------------------------- + +@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( + request: Request, + credential_id: int = Path(ge=1, le=2147483647), + data: PasskeyDeleteRequest = ..., + 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") + + # Guard: passwordless users must retain at least 2 passkeys + if current_user.passwordless_enabled: + pk_count_result = await db.execute( + select(func.count()).select_from(PasskeyCredential).where( + PasskeyCredential.user_id == current_user.id + ) + ) + pk_count = pk_count_result.scalar_one() + if pk_count <= 2: + raise HTTPException( + status_code=409, + detail="Cannot delete: passwordless requires at least 2 passkeys", + ) + + cred_name = credential.name + await db.delete(credential) + + 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"} diff --git a/backend/app/routers/totp.py b/backend/app/routers/totp.py index 9839b18..2dcb396 100644 --- a/backend/app/routers/totp.py +++ b/backend/app/routers/totp.py @@ -18,10 +18,9 @@ Security: - totp-verify uses mfa_token (not session cookie) — user is not yet authenticated """ import asyncio -import uuid import secrets import logging -from datetime import datetime, timedelta +from datetime import datetime from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Request, Response @@ -32,16 +31,21 @@ from sqlalchemy.exc import IntegrityError from app.database import get_db from app.models.user import User -from app.models.session import UserSession from app.models.totp_usage import TOTPUsage from app.models.backup_code import BackupCode -from app.routers.auth import get_current_user, _set_session_cookie +from app.routers.auth import get_current_user from app.services.audit import get_client_ip from app.services.auth import ( averify_password_with_upgrade, verify_mfa_token, verify_mfa_enforce_token, - create_session_token, +) +from app.services.session import ( + create_db_session, + set_session_cookie, + check_account_lockout, + record_failed_login, + record_successful_login, ) from app.services.totp import ( generate_totp_secret, @@ -52,7 +56,7 @@ from app.services.totp import ( generate_qr_base64, generate_backup_codes, ) -from app.config import settings as app_settings + # Argon2id for backup code hashing — treat each code like a password from argon2 import PasswordHasher @@ -162,29 +166,6 @@ async def _verify_backup_code( 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 # --------------------------------------------------------------------------- @@ -288,60 +269,55 @@ async def totp_verify( raise HTTPException(status_code=400, detail="TOTP not configured for this account") # Check account lockout (shared counter with password failures) - 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.", - ) + await check_account_lockout(user) # --- Backup code path --- if data.backup_code: normalized = data.backup_code.strip().upper() valid = await _verify_backup_code(db, user.id, normalized) if not valid: - user.failed_login_count += 1 - if user.failed_login_count >= 10: - user.locked_until = datetime.now() + timedelta(minutes=30) + remaining = await record_failed_login(db, user) await db.commit() + if remaining == 0: + raise HTTPException(status_code=401, detail="Account temporarily locked. Try again in 30 minutes.") raise HTTPException(status_code=401, detail="Invalid backup code") # Backup code accepted — reset lockout counter and issue session - user.failed_login_count = 0 - user.locked_until = None - user.last_login_at = datetime.now() - await db.commit() + await record_successful_login(db, user) - token = await _create_full_session(db, user, request) - _set_session_cookie(response, token) + ip = get_client_ip(request) + user_agent = request.headers.get("user-agent") + _, token = await create_db_session(db, user, ip, user_agent) + set_session_cookie(response, token) + await db.commit() return {"authenticated": True} # --- TOTP code path --- matched_window = verify_totp_code(user.totp_secret, data.code) if matched_window is None: - user.failed_login_count += 1 - if user.failed_login_count >= 10: - user.locked_until = datetime.now() + timedelta(minutes=30) + remaining = await record_failed_login(db, user) await db.commit() + if remaining == 0: + raise HTTPException(status_code=401, detail="Account temporarily locked. Try again in 30 minutes.") raise HTTPException(status_code=401, detail="Invalid code") # Replay prevention — record (user_id, code, actual_matching_window) totp_record = TOTPUsage(user_id=user.id, code=data.code, window=matched_window) db.add(totp_record) try: - await db.commit() + await db.flush() except IntegrityError: await db.rollback() raise HTTPException(status_code=401, detail="Code already used — wait for the next code") # Success — reset lockout counter, update last_login_at, issue full session - user.failed_login_count = 0 - user.locked_until = None - user.last_login_at = datetime.now() - await db.commit() + await record_successful_login(db, user) - token = await _create_full_session(db, user, request) - _set_session_cookie(response, token) + ip = get_client_ip(request) + user_agent = request.headers.get("user-agent") + _, token = await create_db_session(db, user, ip, user_agent) + set_session_cookie(response, token) + await db.commit() return {"authenticated": True} @@ -513,9 +489,11 @@ async def enforce_confirm_totp( user.last_login_at = datetime.now() await db.commit() - # Issue a full session - token = await _create_full_session(db, user, request) - _set_session_cookie(response, token) + # Issue a full session (now uses shared session service with cap enforcement) + ip = get_client_ip(request) + user_agent = request.headers.get("user-agent") + _, token = await create_db_session(db, user, ip, user_agent) + set_session_cookie(response, token) return {"authenticated": True} diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py index bfc4441..305e347 100644 --- a/backend/app/schemas/admin.py +++ b/backend/app/schemas/admin.py @@ -30,6 +30,7 @@ class UserListItem(BaseModel): last_password_change_at: Optional[datetime] = None totp_enabled: bool mfa_enforce_pending: bool + passwordless_enabled: bool = False created_at: datetime active_sessions: int = 0 @@ -107,6 +108,7 @@ class ToggleMfaEnforceRequest(BaseModel): class SystemConfigResponse(BaseModel): allow_registration: bool enforce_mfa_new_users: bool + allow_passwordless: bool = False model_config = ConfigDict(from_attributes=True) @@ -115,6 +117,12 @@ class SystemConfigUpdate(BaseModel): model_config = ConfigDict(extra="forbid") allow_registration: Optional[bool] = None enforce_mfa_new_users: Optional[bool] = None + allow_passwordless: Optional[bool] = None + + +class TogglePasswordlessRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + enabled: bool # --------------------------------------------------------------------------- diff --git a/backend/app/services/passkey.py b/backend/app/services/passkey.py new file mode 100644 index 0000000..b8ad4bc --- /dev/null +++ b/backend/app/services/passkey.py @@ -0,0 +1,240 @@ +""" +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, + parse_registration_credential_json, + parse_authentication_credential_json, +) + +from app.config import settings as app_settings + +# --------------------------------------------------------------------------- +# Credential JSON helpers +# --------------------------------------------------------------------------- + + +def extract_credential_raw_id(credential_json: str) -> str | None: + """Extract the base64url-encoded rawId from a WebAuthn credential JSON string. + + Returns None if parsing fails. + """ + try: + cred_data = json.loads(credential_json) + return cred_data.get("rawId") or cred_data.get("id") or None + except (json.JSONDecodeError, KeyError, TypeError): + return None + +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. +# NOTE: This is process-local. If scaling to multiple uvicorn workers, +# move nonce tracking to Redis or a DB table with unique constraint. +# Current deployment: single worker (Dockerfile --workers 1). +_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. + """ + credential = parse_registration_credential_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. + """ + credential = parse_authentication_credential_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, + ) diff --git a/backend/app/services/session.py b/backend/app/services/session.py new file mode 100644 index 0000000..7e70290 --- /dev/null +++ b/backend/app/services/session.py @@ -0,0 +1,121 @@ +""" +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, update + +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 401 if the account is currently locked. + + Uses 401 (same status as wrong-password) so that status-code analysis + cannot distinguish a locked account from an invalid credential (F-02). + """ + 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=401, + detail=f"Account temporarily locked. Try again in {remaining} minutes.", + ) + + +async def record_failed_login(db: AsyncSession, user: User) -> int: + """Increment failure counter; lock account after 10 failures. + + Returns the number of attempts remaining before lockout (0 = just locked). + Does NOT commit — caller owns the transaction boundary. + """ + user.failed_login_count += 1 + remaining = max(0, 10 - user.failed_login_count) + if user.failed_login_count >= 10: + user.locked_until = datetime.now() + timedelta(minutes=30) + await db.flush() + return remaining + + +async def record_successful_login(db: AsyncSession, user: User) -> None: + """Reset failure counter and update last_login_at. + + Does NOT commit — caller owns the transaction boundary. + """ + user.failed_login_count = 0 + user.locked_until = None + user.last_login_at = datetime.now() + await db.flush() + + +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. + # Perf-2: Query IDs only, bulk-update instead of loading full ORM objects. + max_sessions = app_settings.MAX_SESSIONS_PER_USER + active_ids = ( + await db.execute( + select(UserSession.id) + .where( + UserSession.user_id == user.id, + UserSession.revoked == False, # noqa: E712 + UserSession.expires_at > datetime.now(), + ) + .order_by(UserSession.created_at.asc()) + ) + ).scalars().all() + + if len(active_ids) > max_sessions: + ids_to_revoke = active_ids[: len(active_ids) - max_sessions] + await db.execute( + update(UserSession) + .where(UserSession.id.in_(ids_to_revoke)) + .values(revoked=True) + ) + await db.flush() + + token = create_session_token(user.id, session_id) + return session_id, token diff --git a/backend/requirements.txt b/backend/requirements.txt index 0f6463b..f920556 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -15,3 +15,4 @@ python-dateutil==2.9.0 itsdangerous==2.2.0 httpx==0.27.2 apscheduler==3.10.4 +webauthn>=2.1.0,<3 diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 85d709c..d7948ed 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -29,13 +29,14 @@ server { # Suppress nginx version in Server header server_tokens off; - # ── Real client IP restoration (PT-01) ──────────────────────────── + # ── Real client IP restoration (PT-01 / F-03) ───────────────────── # Pangolin (TLS-terminating reverse proxy) connects via Docker bridge. # Restore the real client IP from X-Forwarded-For so that limit_req_zone # (which keys on $binary_remote_addr) throttles per-client, not per-proxy. - # Safe to trust all sources: nginx is only reachable via Docker networking, - # never directly internet-facing. Tighten if deployment model changes. - set_real_ip_from 0.0.0.0/0; + # Restricted to RFC 1918 ranges only — trusting 0.0.0.0/0 would allow an + # external client to spoof X-Forwarded-For and bypass rate limiting (F-03). + set_real_ip_from 172.16.0.0/12; + set_real_ip_from 10.0.0.0/8; real_ip_header X-Forwarded-For; real_ip_recursive on; @@ -83,6 +84,36 @@ server { 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; + } + + # Passwordless toggle — enable accepts password, rate-limit against brute force + location /api/auth/passkeys/passwordless { + 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 location /api/auth/register { limit_req zone=register_limit burst=3 nodelay; @@ -164,5 +195,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 Strict-Transport-Security "max-age=31536000; includeSubDomains" always; # PT-I03: Restrict unnecessary browser APIs - add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=(), publickey-credentials-get=(self), publickey-credentials-create=(self)" always; } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 83aa677..a31f85e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,6 +16,7 @@ "@fullcalendar/interaction": "^6.1.15", "@fullcalendar/react": "^6.1.15", "@fullcalendar/timegrid": "^6.1.15", + "@simplewebauthn/browser": "^10.0.0", "@tanstack/react-query": "^5.62.0", "axios": "^1.7.9", "class-variance-authority": "^0.7.1", @@ -1348,6 +1349,22 @@ "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": { "version": "5.90.20", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4e34964..1a4a5f8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "@fullcalendar/interaction": "^6.1.15", "@fullcalendar/react": "^6.1.15", "@fullcalendar/timegrid": "^6.1.15", + "@simplewebauthn/browser": "^10.0.0", "@tanstack/react-query": "^5.62.0", "axios": "^1.7.9", "class-variance-authority": "^0.7.1", diff --git a/frontend/src/components/admin/IAMPage.tsx b/frontend/src/components/admin/IAMPage.tsx index 2c83957..6b92b79 100644 --- a/frontend/src/components/admin/IAMPage.tsx +++ b/frontend/src/components/admin/IAMPage.tsx @@ -81,7 +81,7 @@ export default function IAMPage() { ); }, [users, searchQuery]); - const handleConfigToggle = async (key: 'allow_registration' | 'enforce_mfa_new_users', value: boolean) => { + const handleConfigToggle = async (key: 'allow_registration' | 'enforce_mfa_new_users' | 'allow_passwordless', value: boolean) => { try { await updateConfig.mutateAsync({ [key]: value }); toast.success('System settings updated'); @@ -123,8 +123,8 @@ export default function IAMPage() { /> - {/* User table */} - + {/* User table — relative z-10 so action dropdowns render above sibling cards */} +
@@ -160,7 +160,7 @@ export default function IAMPage() { {searchQuery ? 'No users match your search.' : 'No users found.'}

) : ( -
+
@@ -320,6 +320,20 @@ export default function IAMPage() { disabled={updateConfig.isPending} /> + +
+
+ +

+ Allow users to enable passkey-only login, skipping the password prompt entirely. +

+
+ handleConfigToggle('allow_passwordless', v)} + disabled={updateConfig.isPending} + /> +
)} diff --git a/frontend/src/components/admin/UserActionsMenu.tsx b/frontend/src/components/admin/UserActionsMenu.tsx index 056e5a7..60c99d5 100644 --- a/frontend/src/components/admin/UserActionsMenu.tsx +++ b/frontend/src/components/admin/UserActionsMenu.tsx @@ -11,6 +11,7 @@ import { ChevronRight, Loader2, Trash2, + ShieldOff, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { useConfirmAction } from '@/hooks/useConfirmAction'; @@ -23,6 +24,7 @@ import { useToggleUserActive, useRevokeSessions, useDeleteUser, + useDisablePasswordless, getErrorMessage, } from '@/hooks/useAdmin'; import type { AdminUserDetail, UserRole } from '@/types'; @@ -53,6 +55,7 @@ export default function UserActionsMenu({ user, currentUsername }: UserActionsMe const toggleActive = useToggleUserActive(); const revokeSessions = useRevokeSessions(); const deleteUser = useDeleteUser(); + const disablePasswordless = useDisablePasswordless(); // Close on outside click useEffect(() => { @@ -102,6 +105,10 @@ export default function UserActionsMenu({ user, currentUsername }: UserActionsMe } }); + const disablePasswordlessConfirm = useConfirmAction(() => { + handleAction(() => disablePasswordless.mutateAsync(user.id), 'Passwordless login disabled'); + }); + const isLoading = updateRole.isPending || resetPassword.isPending || @@ -110,7 +117,8 @@ export default function UserActionsMenu({ user, currentUsername }: UserActionsMe removeMfaEnforcement.isPending || toggleActive.isPending || revokeSessions.isPending || - deleteUser.isPending; + deleteUser.isPending || + disablePasswordless.isPending; return (
@@ -258,6 +266,21 @@ export default function UserActionsMenu({ user, currentUsername }: UserActionsMe )} + {user.passwordless_enabled && ( + + )} +
{/* Disable / Enable Account */} diff --git a/frontend/src/components/admin/UserDetailSection.tsx b/frontend/src/components/admin/UserDetailSection.tsx index f5b8e09..49d7de8 100644 --- a/frontend/src/components/admin/UserDetailSection.tsx +++ b/frontend/src/components/admin/UserDetailSection.tsx @@ -193,6 +193,18 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection /> } /> + + Enabled + + ) : ( + Off + ) + } + /> !!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) if (!isLoading && authStatus?.authenticated && !mfaSetupRequired && mode !== 'force_pw') { return ; @@ -127,11 +155,10 @@ export default function LockScreen() { // mfaSetupRequired / mfaRequired handled by hook state → activeMode switches automatically } catch (error: any) { const status = error?.response?.status; - if (status === 423) { - setLoginError(error.response.data?.detail || 'Account locked. Try again later.'); - } else if (status === 403) { + if (status === 403) { setLoginError(error.response.data?.detail || 'Account is disabled. Contact an administrator.'); } else { + // 401 covers both wrong password and account lockout (backend embeds detail string) setLoginError(getErrorMessage(error, 'Invalid username or password')); } } @@ -491,18 +518,28 @@ export default function LockScreen() {
- {loginError && ( -
-
- )} + {loginError && (() => { + const isLockWarning = + loginError.includes('remaining') || loginError.includes('temporarily locked'); + return ( +
+ {isLockWarning + ?
+ ); + })()}
@@ -561,6 +598,30 @@ export default function LockScreen() { + {/* Passkey login — shown when browser supports WebAuthn (U-01) */} + {!isSetup && supportsWebAuthn && ( + <> +
+ + + or + +
+ + + )} + {/* Open registration link — only shown on login screen when enabled */} {!isSetup && registrationOpen && (
diff --git a/frontend/src/components/dashboard/AlertBanner.tsx b/frontend/src/components/dashboard/AlertBanner.tsx index f9f946c..1373cf9 100644 --- a/frontend/src/components/dashboard/AlertBanner.tsx +++ b/frontend/src/components/dashboard/AlertBanner.tsx @@ -23,7 +23,7 @@ export default function AlertBanner({ alerts, onDismiss, onSnooze }: AlertBanner {alerts.length}
-
+
{alerts.map((alert) => (
void; }) { const { isLocked, isLockResolved } = useLock(); + const { hasPasskeys } = useAuth(); + const navigate = useNavigate(); 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, + action: { + label: 'Set up', + onClick: () => navigate('/settings?tab=security'), + }, + }); + } + }, [isLockResolved, isLocked, hasPasskeys, navigate]); const [collapsed, setCollapsed] = useState(() => { try { return JSON.parse(localStorage.getItem('umbra-sidebar-collapsed') || 'false'); } catch { return false; } diff --git a/frontend/src/components/layout/LockOverlay.tsx b/frontend/src/components/layout/LockOverlay.tsx index e48bc7b..9f39013 100644 --- a/frontend/src/components/layout/LockOverlay.tsx +++ b/frontend/src/components/layout/LockOverlay.tsx @@ -1,34 +1,43 @@ import { useState, FormEvent, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { toast } from 'sonner'; -import { Lock, Loader2 } from 'lucide-react'; +import { Lock, Loader2, Fingerprint } from 'lucide-react'; import { useLock } from '@/hooks/useLock'; import { useAuth } from '@/hooks/useAuth'; import { useSettings } from '@/hooks/useSettings'; -import { getErrorMessage } from '@/lib/api'; +import api, { getErrorMessage } from '@/lib/api'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; +import { Separator } from '@/components/ui/separator'; import AmbientBackground from '@/components/auth/AmbientBackground'; export default function LockOverlay() { - const { isLocked, unlock } = useLock(); - const { logout } = useAuth(); + const { isLocked, unlock, unlockWithPasskey } = useLock(); + const { logout, passwordlessEnabled, hasPasskeys, authStatus } = useAuth(); const { settings } = useSettings(); const navigate = useNavigate(); const [password, setPassword] = useState(''); const [isUnlocking, setIsUnlocking] = useState(false); + const [isPasskeyUnlocking, setIsPasskeyUnlocking] = useState(false); + const [supportsWebAuthn] = useState(() => !!window.PublicKeyCredential); const inputRef = useRef(null); - // Focus password input when lock activates + // Derive from auth query — has_passkeys covers both owners and any registered passkey + const userHasPasskeys = authStatus?.has_passkeys ?? hasPasskeys; + const showPasskeyButton = userHasPasskeys && supportsWebAuthn; + // When passwordless is enabled: passkey is the primary unlock method + // When passwordless is disabled: show password form, optionally with passkey secondary + const showPasswordForm = !passwordlessEnabled; + + // Focus password input when lock activates (only when password form is visible) useEffect(() => { - if (isLocked) { + if (isLocked && showPasswordForm) { setPassword(''); - // Small delay to let the overlay render const t = setTimeout(() => inputRef.current?.focus(), 100); return () => clearTimeout(t); } - }, [isLocked]); + }, [isLocked, showPasswordForm]); if (!isLocked) return null; @@ -50,6 +59,29 @@ export default function LockOverlay() { } }; + const handlePasskeyUnlock = async () => { + setIsPasskeyUnlocking(true); + try { + const { startAuthentication } = await import('@simplewebauthn/browser'); + const { data: beginResp } = await api.post('/auth/passkeys/login/begin', {}); + const credential = await startAuthentication(beginResp.options); + await api.post('/auth/passkeys/login/complete', { + credential: JSON.stringify(credential), + challenge_token: beginResp.challenge_token, + unlock: true, + }); + unlockWithPasskey(); + } catch (error) { + if (error instanceof Error && error.name === 'NotAllowedError') { + toast.error('Passkey not recognized'); + } else if (error instanceof Error && error.name !== 'AbortError') { + toast.error(getErrorMessage(error, 'Unlock failed')); + } + } finally { + setIsPasskeyUnlocking(false); + } + }; + const handleSwitchAccount = async () => { await logout(); navigate('/login'); @@ -75,29 +107,87 @@ export default function LockOverlay() { )}
- {/* Password form */} -
- setPassword(e.target.value)} - placeholder="Enter password to unlock" - autoComplete="current-password" - className="text-center" - /> - - + )} + + {/* Password form — shown when passwordless is off */} + {showPasswordForm && ( + <> +
+ setPassword(e.target.value)} + placeholder="Enter password to unlock" + autoComplete="current-password" + className="text-center" + /> + + + + {/* Passkey secondary option */} + {showPasskeyButton && ( +
+
+ + or + +
+ +
+ )} + + )} {/* Switch account link */} + + + + + Remove passkey + + Enter your password to remove "{credential.name}". + + +
+ + setPassword(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter' && password) handleSubmitDelete(); }} + autoFocus + /> +
+ + + + +
+
+ + ); +} + +export default function PasskeySection() { + const queryClient = useQueryClient(); + const { passwordlessEnabled, allowPasswordless } = useAuth(); + + // Registration state + 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); + + // Passwordless enable state + const [enableDialogOpen, setEnableDialogOpen] = useState(false); + const [enablePassword, setEnablePassword] = useState(''); + + // Passwordless disable state + const [disableDialogOpen, setDisableDialogOpen] = useState(false); + + const passkeysQuery = useQuery({ + queryKey: ['passkeys'], + queryFn: async () => { + const { data } = await api.get('/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 enablePasswordlessMutation = useMutation({ + mutationFn: async ({ password }: { password: string }) => { + const { data } = await api.put('/auth/passkeys/passwordless/enable', { password }); + return data; + }, + onSuccess: () => { + toast.success('Passwordless login enabled'); + queryClient.invalidateQueries({ queryKey: ['auth'] }); + queryClient.invalidateQueries({ queryKey: ['passkeys'] }); + setEnableDialogOpen(false); + setEnablePassword(''); + }, + onError: (error: unknown) => { + toast.error(getErrorMessage(error, 'Failed to enable passwordless login')); + }, + }); + + const disablePasswordlessMutation = useMutation({ + mutationFn: async () => { + const { startAuthentication } = await import('@simplewebauthn/browser'); + const { data: beginResp } = await api.post('/auth/passkeys/passwordless/disable/begin', {}); + const credential = await startAuthentication(beginResp.options); + const { data } = await api.put('/auth/passkeys/passwordless/disable', { + credential: JSON.stringify(credential), + challenge_token: beginResp.challenge_token, + }); + return data; + }, + onSuccess: () => { + toast.success('Passwordless login disabled'); + queryClient.invalidateQueries({ queryKey: ['auth'] }); + queryClient.invalidateQueries({ queryKey: ['passkeys'] }); + setDisableDialogOpen(false); + }, + onError: (error: unknown) => { + if (error instanceof Error && error.name === 'NotAllowedError') { + toast.error('Passkey not recognized'); + } else if (error instanceof Error && error.name === 'AbortError') { + toast.info('Cancelled'); + } else { + toast.error(getErrorMessage(error, 'Failed to disable passwordless login')); + } + setDisableDialogOpen(false); + // W-03: Invalidate to resync switch state after failed/cancelled ceremony + queryClient.invalidateQueries({ queryKey: ['auth'] }); + }, + }); + + 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 ( + + +
+
+
+ +
+
+ +

+ Sign in with your fingerprint, face, or security key +

+
+
+ {hasPasskeys && ( + + {passkeys.length} registered + + )} +
+ + {hasPasskeys && ( +
    + {passkeys.map((pk) => ( +
  • +
    + +
    +
    +
    + {pk.name} + {pk.backed_up && ( + + )} +
    + + Added {formatDate(pk.created_at)} · Last used {formatRelativeTime(pk.last_used_at)} + +
    + +
  • + ))} +
+ )} + + + + + + {/* Passwordless login section — hidden when admin hasn't enabled the feature */} + {(allowPasswordless || passwordlessEnabled) && } + + {(allowPasswordless || passwordlessEnabled) && ( +
+
+
+ + +
+

+ Skip the password prompt and unlock the app using a passkey only. +

+ {passkeys.length < 2 && !passwordlessEnabled && ( +

+ Requires at least 2 registered passkeys as a fallback. +

+ )} +
+ { + if (checked) { + setEnablePassword(''); + setEnableDialogOpen(true); + } else { + setDisableDialogOpen(true); + disablePasswordlessMutation.mutate(); + } + }} + disabled={(!passwordlessEnabled && passkeys.length < 2) || enablePasswordlessMutation.isPending || disablePasswordlessMutation.isPending} + aria-label="Toggle passwordless login" + /> +
+ )} + + {/* Enable passwordless dialog */} + { + if (!open) { setEnableDialogOpen(false); setEnablePassword(''); } + }}> + + +
+
+ +
+ Enable Passwordless Login +
+ + Confirm your password to enable passkey-only login. + +
+
+ + setEnablePassword(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && enablePassword) { + enablePasswordlessMutation.mutate({ password: enablePassword }); + } + }} + autoFocus + /> +
+ + + + +
+
+ + {/* Disable passwordless dialog */} + { + if (!open && !disablePasswordlessMutation.isPending) { + setDisableDialogOpen(false); + } + }}> + + +
+
+ +
+ Disable Passwordless Login +
+ + Verify with your passkey to disable passwordless login. + +
+
+ {disablePasswordlessMutation.isPending ? ( + <> + +

+ Follow your browser's prompt to verify your passkey +

+ + ) : ( +

+ Ready to verify your passkey +

+ )} +
+ + + +
+
+ + {/* Registration ceremony dialog */} + { + if (!open) { + setRegisterDialogOpen(false); + resetRegisterState(); + } + }} + > + + +
+
+ +
+ + {ceremonyState === 'password' && 'Add a passkey'} + {ceremonyState === 'waiting' && 'Creating passkey'} + {ceremonyState === 'naming' && 'Name your passkey'} + +
+
+ + {ceremonyState === 'password' && ( + <> + + Enter your password to add a passkey to your account. + +
+ + setRegisterPassword(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter' && registerPassword) handlePasswordSubmit(); }} + autoFocus + /> +
+ + + + + + )} + + {ceremonyState === 'waiting' && ( +
+ +

+ Follow your browser's prompt to create a passkey +

+
+ )} + + {ceremonyState === 'naming' && ( + <> +
+ + setPasskeyName(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter' && passkeyName.trim()) handleSaveName(); }} + maxLength={100} + autoFocus + /> +

+ Give this passkey a name to help you identify it later. +

+
+ + + + + )} +
+
+
+
+ ); +} diff --git a/frontend/src/components/settings/SecurityTab.tsx b/frontend/src/components/settings/SecurityTab.tsx index 22c9627..f8ccca3 100644 --- a/frontend/src/components/settings/SecurityTab.tsx +++ b/frontend/src/components/settings/SecurityTab.tsx @@ -5,6 +5,7 @@ import { Label } from '@/components/ui/label'; import { Card, CardContent } from '@/components/ui/card'; import { Switch } from '@/components/ui/switch'; import TotpSetupSection from './TotpSetupSection'; +import PasskeySection from './PasskeySection'; import type { Settings } from '@/types'; interface SecurityTabProps { @@ -86,6 +87,9 @@ export default function SecurityTab({ settings, updateSettings, isUpdating }: Se + {/* Passkeys */} + + {/* Password + TOTP */}
diff --git a/frontend/src/hooks/useAdmin.ts b/frontend/src/hooks/useAdmin.ts index 8a394fa..a4c6929 100644 --- a/frontend/src/hooks/useAdmin.ts +++ b/frontend/src/hooks/useAdmin.ts @@ -203,5 +203,12 @@ export function useUpdateConfig() { }); } +export function useDisablePasswordless() { + return useAdminMutation(async (userId: number) => { + const { data } = await api.put(`/admin/users/${userId}/passwordless`, { enabled: false }); + return data; + }); +} + // Re-export getErrorMessage for convenience in admin components export { getErrorMessage }; diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index a7acc78..6d32eba 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -1,7 +1,7 @@ import { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import api from '@/lib/api'; -import type { AuthStatus, LoginResponse } from '@/types'; +import type { AuthStatus, LoginResponse, PasskeyLoginResponse } from '@/types'; export function useAuth() { const queryClient = useQueryClient(); @@ -96,6 +96,30 @@ 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({ mutationFn: async () => { const { data } = await api.post('/auth/logout'); @@ -125,5 +149,11 @@ export function useAuth() { isRegisterPending: registerMutation.isPending, isTotpPending: totpVerifyMutation.isPending, isSetupPending: setupMutation.isPending, + passkeyLogin: passkeyLoginMutation.mutateAsync, + isPasskeyLoginPending: passkeyLoginMutation.isPending, + hasPasskeys: authQuery.data?.has_passkeys ?? false, + passkeyCount: 0, // Derived from passkeys list query in PasskeySection, not auth/status + passwordlessEnabled: authQuery.data?.passwordless_enabled ?? false, + allowPasswordless: authQuery.data?.allow_passwordless ?? false, }; } diff --git a/frontend/src/hooks/useLock.tsx b/frontend/src/hooks/useLock.tsx index cdb2e1e..bb91a2b 100644 --- a/frontend/src/hooks/useLock.tsx +++ b/frontend/src/hooks/useLock.tsx @@ -17,6 +17,7 @@ interface LockContextValue { isLockResolved: boolean; lock: () => Promise; unlock: (password: string) => Promise; + unlockWithPasskey: () => void; } const LockContext = createContext(null); @@ -94,6 +95,14 @@ export function LockProvider({ children }: { children: ReactNode }) { } }, [queryClient]); + const unlockWithPasskey = useCallback(() => { + setIsLocked(false); + lastActivityRef.current = Date.now(); + queryClient.setQueryData(['auth'], (old) => + old ? { ...old, is_locked: false } : old + ); + }, [queryClient]); + // Auto-lock idle timer useEffect(() => { const enabled = settings?.auto_lock_enabled ?? false; @@ -147,7 +156,7 @@ export function LockProvider({ children }: { children: ReactNode }) { }, [settings?.auto_lock_enabled, settings?.auto_lock_minutes, isLocked, lock]); return ( - + {children} ); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 60d29c8..c43ecc4 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -15,7 +15,7 @@ api.interceptors.response.use( if (error.response?.status === 401) { const url = error.config?.url || ''; // Don't redirect on auth endpoints — they legitimately return 401 - const authEndpoints = ['/auth/login', '/auth/register', '/auth/setup', '/auth/verify-password', '/auth/change-password']; + const authEndpoints = ['/auth/login', '/auth/register', '/auth/setup', '/auth/verify-password', '/auth/change-password', '/auth/passkeys/login/begin', '/auth/passkeys/login/complete']; if (!authEndpoints.some(ep => url.startsWith(ep))) { window.location.href = '/login'; } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 949a6ce..c961e5c 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -243,6 +243,23 @@ export interface AuthStatus { username: string | null; registration_open: boolean; is_locked: boolean; + has_passkeys: boolean; + passwordless_enabled: boolean; + allow_passwordless: boolean; +} + +export interface PasskeyCredential { + id: number; + name: string; + created_at: string | null; + last_used_at: string | null; + backed_up: boolean; +} + +export interface PasskeyLoginResponse { + authenticated?: true; + must_change_password?: boolean; + unlocked?: boolean; } // Login response discriminated union @@ -279,6 +296,7 @@ export interface AdminUser { last_password_change_at: string | null; totp_enabled: boolean; mfa_enforce_pending: boolean; + passwordless_enabled: boolean; created_at: string; } @@ -293,6 +311,7 @@ export interface AdminUserDetail extends AdminUser { export interface SystemConfig { allow_registration: boolean; enforce_mfa_new_users: boolean; + allow_passwordless: boolean; } export interface AuditLogEntry {