Compare commits
46 Commits
feature/mi
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 373030b81a | |||
| c96004f91d | |||
| 3496cf0f26 | |||
| e5869e0b19 | |||
| 3075495d1c | |||
| d945de3837 | |||
| 329c057632 | |||
| ae3dc4e9db | |||
| 70cf033fdc | |||
| 618afeb336 | |||
| 76b19cd33a | |||
| c98e47a050 | |||
| fac953fcea | |||
| 7a9122c235 | |||
| 7f38df22db | |||
| 86c113c412 | |||
| 1f34da9199 | |||
| 507c841a92 | |||
| 3ad216ab0c | |||
| 3ca1a9af08 | |||
| d981b9346f | |||
| 571268c9b4 | |||
| 55891eb7b5 | |||
| 4f8b83ba87 | |||
| 5d64034bb6 | |||
| 0f58edf607 | |||
| ed98924716 | |||
| 0a8e163e47 | |||
| 94891d8a70 | |||
| 0f6e40a5ba | |||
| a327890b57 | |||
| 44e6c8e3e5 | |||
| 863e9e2c45 | |||
| 1b868ba503 | |||
| 42d73526f5 | |||
| bcfebbc9ae | |||
| fc1f8d5514 | |||
| 57d400c6de | |||
| 9234880648 | |||
| 53101d1401 | |||
| ab84c7bc53 | |||
| 51d98173a6 | |||
| cc460df5d4 | |||
| e8e3f62ff8 | |||
| eebb34aa77 | |||
| c5a309f4a1 |
@ -21,6 +21,15 @@ ENVIRONMENT=development
|
|||||||
# Timezone (applied to backend + db containers via env_file)
|
# Timezone (applied to backend + db containers via env_file)
|
||||||
TZ=Australia/Perth
|
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
|
# Integrations
|
||||||
# ──────────────────────────────────────
|
# ──────────────────────────────────────
|
||||||
|
|||||||
86
.gitea/workflows/deploy.yml
Normal file
86
.gitea/workflows/deploy.yml
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
name: Build and Deploy UMBRA
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: https://github.com/actions/checkout@v4
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ vars.REGISTRY_HOST }} -u ${{ secrets.REGISTRY_USER }} --password-stdin
|
||||||
|
|
||||||
|
- name: Build and push backend
|
||||||
|
run: |
|
||||||
|
docker build --pull \
|
||||||
|
-t ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-backend:main-latest \
|
||||||
|
-t ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-backend:${{ github.sha }} \
|
||||||
|
./backend
|
||||||
|
docker push ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-backend:main-latest
|
||||||
|
docker push ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-backend:${{ github.sha }}
|
||||||
|
|
||||||
|
- name: Build and push frontend
|
||||||
|
run: |
|
||||||
|
docker build --pull \
|
||||||
|
-t ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-frontend:main-latest \
|
||||||
|
-t ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-frontend:${{ github.sha }} \
|
||||||
|
./frontend
|
||||||
|
docker push ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-frontend:main-latest
|
||||||
|
docker push ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-frontend:${{ github.sha }}
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
run: |
|
||||||
|
# Spawn a short-lived container that mounts the host deploy path
|
||||||
|
# and runs compose commands against the host Docker daemon.
|
||||||
|
# DEPLOY_PATH is a Gitea variable — update it when moving hosts.
|
||||||
|
docker run --rm \
|
||||||
|
--network host \
|
||||||
|
--security-opt label:disable \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
-v ${{ vars.DEPLOY_PATH }}:/deploy \
|
||||||
|
-w /deploy \
|
||||||
|
docker:27-cli sh -c "
|
||||||
|
docker compose -p umbra --env-file stack.env pull backend frontend &&
|
||||||
|
docker compose -p umbra --env-file stack.env up -d db backend frontend
|
||||||
|
"
|
||||||
|
|
||||||
|
- name: Health check
|
||||||
|
run: |
|
||||||
|
echo "Waiting for services to start..."
|
||||||
|
sleep 30
|
||||||
|
curl -f http://localhost:${{ vars.DEPLOY_PORT }}/health || exit 1
|
||||||
|
|
||||||
|
- name: Prune old images
|
||||||
|
if: success()
|
||||||
|
run: docker image prune -f
|
||||||
|
|
||||||
|
- name: Notify success
|
||||||
|
if: success()
|
||||||
|
run: |
|
||||||
|
curl -s \
|
||||||
|
-H "Title: UMBRA Deploy Success" \
|
||||||
|
-H "Tags: white_check_mark" \
|
||||||
|
--data-binary @- https://ntfy.ghost6.xyz/claude <<'NTFY_EOF'
|
||||||
|
Build ${{ github.sha }} deployed successfully to umbra.ghost6.xyz.
|
||||||
|
Triggered by push to main.
|
||||||
|
NTFY_EOF
|
||||||
|
|
||||||
|
- name: Notify failure
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
curl -s \
|
||||||
|
-H "Title: UMBRA Deploy FAILED" \
|
||||||
|
-H "Tags: fire" \
|
||||||
|
-H "Priority: high" \
|
||||||
|
--data-binary @- https://ntfy.ghost6.xyz/claude <<'NTFY_EOF'
|
||||||
|
Deploy failed for commit ${{ github.sha }}.
|
||||||
|
Check Gitea Actions logs at git.sentinelforest.xyz.
|
||||||
|
NTFY_EOF
|
||||||
20
act_runner_config.yaml
Normal file
20
act_runner_config.yaml
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
log:
|
||||||
|
level: info
|
||||||
|
|
||||||
|
runner:
|
||||||
|
capacity: 1
|
||||||
|
timeout: 3h
|
||||||
|
insecure: false
|
||||||
|
|
||||||
|
cache:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
container:
|
||||||
|
network: host
|
||||||
|
privileged: false
|
||||||
|
options: "--security-opt label:disable"
|
||||||
|
valid_volumes:
|
||||||
|
- "**"
|
||||||
|
|
||||||
|
host:
|
||||||
|
workdir_parent: /tmp/act_runner
|
||||||
@ -1,2 +1,14 @@
|
|||||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/umbra
|
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/umbra
|
||||||
SECRET_KEY=your-secret-key-change-in-production
|
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
|
||||||
|
|||||||
@ -1,39 +1,37 @@
|
|||||||
# UMBRA Backend
|
# 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
|
## Features
|
||||||
|
|
||||||
- **FastAPI** with async/await support
|
- **FastAPI** with async/await and Pydantic v2
|
||||||
- **SQLAlchemy 2.0** with async engine
|
- **SQLAlchemy 2.0** async engine with `Mapped[]` types
|
||||||
- **PostgreSQL** with asyncpg driver
|
- **PostgreSQL 16** via asyncpg
|
||||||
- **Alembic** for database migrations
|
- **Alembic** database migrations (001-061)
|
||||||
- **bcrypt** for password hashing
|
- **Authentication**: Argon2id passwords + signed httpOnly cookies + optional TOTP MFA + passkey (WebAuthn/FIDO2)
|
||||||
- **itsdangerous** for session management
|
- **Multi-user RBAC**: admin/standard roles, per-user resource scoping
|
||||||
- **PIN-based authentication** with secure session cookies
|
- **Session management**: DB-backed sessions, sliding window expiry, concurrent session cap
|
||||||
- **Full CRUD operations** for all entities
|
- **Account security**: Account lockout (10 failures = 30-min lock), CSRF protection, rate limiting
|
||||||
- **Dashboard** with aggregated data
|
- **APScheduler** for background notification dispatch
|
||||||
- **CORS enabled** for frontend integration
|
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
backend/
|
backend/
|
||||||
├── alembic/ # Database migrations
|
├── alembic/versions/ # 61 database migrations
|
||||||
│ ├── versions/ # Migration files
|
|
||||||
│ ├── env.py # Alembic environment
|
|
||||||
│ └── script.py.mako # Migration template
|
|
||||||
├── app/
|
├── app/
|
||||||
│ ├── models/ # SQLAlchemy models
|
│ ├── models/ # 21 SQLAlchemy 2.0 models
|
||||||
│ ├── schemas/ # Pydantic schemas
|
│ ├── schemas/ # 14 Pydantic v2 schema modules
|
||||||
│ ├── routers/ # API route handlers
|
│ ├── routers/ # 17 API routers
|
||||||
│ ├── config.py # Configuration
|
│ ├── services/ # Auth, session, passkey, TOTP, audit, recurrence, etc.
|
||||||
│ ├── database.py # Database setup
|
│ ├── jobs/ # APScheduler notification dispatch
|
||||||
│ └── main.py # FastAPI application
|
│ ├── config.py # Pydantic Settings (env vars)
|
||||||
├── requirements.txt # Python dependencies
|
│ ├── database.py # Async engine + session factory
|
||||||
├── Dockerfile # Docker configuration
|
│ └── main.py # FastAPI app + CSRF middleware
|
||||||
├── alembic.ini # Alembic configuration
|
├── requirements.txt
|
||||||
└── start.sh # Startup script
|
├── Dockerfile
|
||||||
|
├── alembic.ini
|
||||||
|
└── start.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
@ -41,160 +39,87 @@ backend/
|
|||||||
### 1. Install Dependencies
|
### 1. Install Dependencies
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Configure Environment
|
### 2. Configure Environment
|
||||||
|
|
||||||
Create a `.env` file:
|
Copy `.env.example` to `.env` and configure:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/umbra
|
DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/umbra
|
||||||
SECRET_KEY=your-secret-key-change-in-production
|
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
|
### 3. Run Migrations
|
||||||
|
|
||||||
```bash
|
|
||||||
createdb umbra
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Run Migrations
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
alembic upgrade head
|
alembic upgrade head
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. Start Server
|
### 4. Start Server
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Using the start script
|
uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
chmod +x start.sh
|
|
||||||
./start.sh
|
|
||||||
|
|
||||||
# Or directly with uvicorn
|
|
||||||
uvicorn app.main:app --reload
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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:
|
| Prefix | Description |
|
||||||
- **Swagger UI**: http://localhost:8000/docs
|
|--------|-------------|
|
||||||
- **ReDoc**: http://localhost:8000/redoc
|
| `/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
|
UMBRA supports three authentication methods:
|
||||||
- `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
|
|
||||||
|
|
||||||
### Todos
|
1. **Password** (Argon2id) - Primary login method
|
||||||
- `GET /api/todos` - List todos (with filters)
|
2. **TOTP MFA** - Optional second factor via authenticator apps
|
||||||
- `POST /api/todos` - Create todo
|
3. **Passkeys** (WebAuthn/FIDO2) - Optional passwordless login via biometrics, security keys, or password managers
|
||||||
- `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
|
|
||||||
|
|
||||||
### Calendar Events
|
Passkey login bypasses TOTP (a passkey is inherently two-factor: possession + biometric/PIN).
|
||||||
- `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
|
|
||||||
|
|
||||||
### Reminders
|
## Security
|
||||||
- `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
|
|
||||||
|
|
||||||
### Projects
|
- CSRF protection via `X-Requested-With` header middleware
|
||||||
- `GET /api/projects` - List projects
|
- All Pydantic schemas use `extra="forbid"` (mass-assignment prevention)
|
||||||
- `POST /api/projects` - Create project
|
- Nginx rate limiting on auth, registration, and admin endpoints
|
||||||
- `GET /api/projects/{id}` - Get project
|
- DB-backed account lockout after 10 failed attempts
|
||||||
- `PUT /api/projects/{id}` - Update project
|
- Timing-safe dummy hash for non-existent users (prevents enumeration)
|
||||||
- `DELETE /api/projects/{id}` - Delete project
|
- SSRF validation on ntfy webhook URLs
|
||||||
- `GET /api/projects/{id}/tasks` - List project tasks
|
- Naive datetimes throughout (Docker runs UTC)
|
||||||
- `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
|
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
Build and run with Docker:
|
The backend runs as non-root `appuser` in `python:3.12-slim`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t umbra-backend .
|
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
|
In production, use Docker Compose (see root `docker-compose.yaml`).
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|||||||
40
backend/alembic/versions/061_add_passkey_credentials.py
Normal file
40
backend/alembic/versions/061_add_passkey_credentials.py
Normal file
@ -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")
|
||||||
40
backend/alembic/versions/062_passwordless_login.py
Normal file
40
backend/alembic/versions/062_passwordless_login.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
"""Passwordless login — add passwordless_enabled to users and allow_passwordless to system_config.
|
||||||
|
|
||||||
|
Revision ID: 062
|
||||||
|
Revises: 061
|
||||||
|
Create Date: 2026-03-18
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "062"
|
||||||
|
down_revision = "061"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"users",
|
||||||
|
sa.Column(
|
||||||
|
"passwordless_enabled",
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
server_default="false",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"system_config",
|
||||||
|
sa.Column(
|
||||||
|
"allow_passwordless",
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
server_default="false",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("users", "passwordless_enabled")
|
||||||
|
op.drop_column("system_config", "allow_passwordless")
|
||||||
@ -30,6 +30,12 @@ class Settings(BaseSettings):
|
|||||||
# Concurrent session limit per user (oldest evicted when exceeded)
|
# Concurrent session limit per user (oldest evicted when exceeded)
|
||||||
MAX_SESSIONS_PER_USER: int = 10
|
MAX_SESSIONS_PER_USER: int = 10
|
||||||
|
|
||||||
|
# WebAuthn / Passkey configuration
|
||||||
|
WEBAUTHN_RP_ID: str = "localhost" # eTLD+1 domain, e.g. "umbra.ghost6.xyz"
|
||||||
|
WEBAUTHN_RP_NAME: str = "UMBRA"
|
||||||
|
WEBAUTHN_ORIGIN: str = "http://localhost" # Full origin with scheme, e.g. "https://umbra.ghost6.xyz"
|
||||||
|
WEBAUTHN_CHALLENGE_TTL: int = 60 # Challenge token lifetime in seconds
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
env_file_encoding="utf-8",
|
env_file_encoding="utf-8",
|
||||||
@ -47,6 +53,9 @@ class Settings(BaseSettings):
|
|||||||
self.CORS_ORIGINS = "http://localhost:5173"
|
self.CORS_ORIGINS = "http://localhost:5173"
|
||||||
assert self.COOKIE_SECURE is not None # type narrowing
|
assert self.COOKIE_SECURE is not None # type narrowing
|
||||||
assert self.CORS_ORIGINS is not None
|
assert self.CORS_ORIGINS is not None
|
||||||
|
# Validate WebAuthn origin includes scheme (S-04)
|
||||||
|
if not self.WEBAUTHN_ORIGIN.startswith(("http://", "https://")):
|
||||||
|
raise ValueError("WEBAUTHN_ORIGIN must include scheme (http:// or https://)")
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import engine
|
from app.database import engine
|
||||||
from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates
|
from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates
|
||||||
from app.routers import totp, admin, notifications as notifications_router, connections as connections_router, shared_calendars as shared_calendars_router, event_invitations as event_invitations_router
|
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
|
from app.jobs.notifications import run_notification_dispatch
|
||||||
|
|
||||||
# Import models so Alembic's autogenerate can discover them
|
# Import models so Alembic's autogenerate can discover them
|
||||||
@ -23,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 calendar_member as _calendar_member_model # noqa: F401
|
||||||
from app.models import event_lock as _event_lock_model # noqa: F401
|
from app.models import event_lock as _event_lock_model # noqa: F401
|
||||||
from app.models import event_invitation as _event_invitation_model # noqa: F401
|
from app.models import event_invitation as _event_invitation_model # noqa: F401
|
||||||
|
from app.models import passkey_credential as _passkey_credential_model # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -49,6 +50,8 @@ class CSRFHeaderMiddleware:
|
|||||||
"/api/auth/totp-verify",
|
"/api/auth/totp-verify",
|
||||||
"/api/auth/totp/enforce-setup",
|
"/api/auth/totp/enforce-setup",
|
||||||
"/api/auth/totp/enforce-confirm",
|
"/api/auth/totp/enforce-confirm",
|
||||||
|
"/api/auth/passkeys/login/begin",
|
||||||
|
"/api/auth/passkeys/login/complete",
|
||||||
})
|
})
|
||||||
_MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
_MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
||||||
|
|
||||||
@ -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(weather.router, prefix="/api/weather", tags=["Weather"])
|
||||||
app.include_router(event_templates.router, prefix="/api/event-templates", tags=["Event Templates"])
|
app.include_router(event_templates.router, prefix="/api/event-templates", tags=["Event Templates"])
|
||||||
app.include_router(totp.router, prefix="/api/auth", tags=["TOTP MFA"])
|
app.include_router(totp.router, prefix="/api/auth", tags=["TOTP MFA"])
|
||||||
|
app.include_router(passkeys_router.router, prefix="/api/auth/passkeys", tags=["Passkeys"])
|
||||||
app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
|
app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
|
||||||
app.include_router(notifications_router.router, prefix="/api/notifications", tags=["Notifications"])
|
app.include_router(notifications_router.router, prefix="/api/notifications", tags=["Notifications"])
|
||||||
app.include_router(connections_router.router, prefix="/api/connections", tags=["Connections"])
|
app.include_router(connections_router.router, prefix="/api/connections", tags=["Connections"])
|
||||||
|
|||||||
@ -23,6 +23,7 @@ from app.models.event_lock import EventLock
|
|||||||
from app.models.event_invitation import EventInvitation, EventInvitationOverride
|
from app.models.event_invitation import EventInvitation, EventInvitationOverride
|
||||||
from app.models.project_member import ProjectMember
|
from app.models.project_member import ProjectMember
|
||||||
from app.models.project_task_assignment import ProjectTaskAssignment
|
from app.models.project_task_assignment import ProjectTaskAssignment
|
||||||
|
from app.models.passkey_credential import PasskeyCredential
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Settings",
|
"Settings",
|
||||||
@ -51,4 +52,5 @@ __all__ = [
|
|||||||
"EventInvitationOverride",
|
"EventInvitationOverride",
|
||||||
"ProjectMember",
|
"ProjectMember",
|
||||||
"ProjectTaskAssignment",
|
"ProjectTaskAssignment",
|
||||||
|
"PasskeyCredential",
|
||||||
]
|
]
|
||||||
|
|||||||
30
backend/app/models/passkey_credential.py
Normal file
30
backend/app/models/passkey_credential.py
Normal file
@ -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)
|
||||||
@ -21,6 +21,9 @@ class SystemConfig(Base):
|
|||||||
enforce_mfa_new_users: Mapped[bool] = mapped_column(
|
enforce_mfa_new_users: Mapped[bool] = mapped_column(
|
||||||
Boolean, default=False, server_default="false"
|
Boolean, default=False, server_default="false"
|
||||||
)
|
)
|
||||||
|
allow_passwordless: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, default=False, server_default="false"
|
||||||
|
)
|
||||||
created_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now())
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
default=func.now(), onupdate=func.now(), server_default=func.now()
|
default=func.now(), onupdate=func.now(), server_default=func.now()
|
||||||
|
|||||||
@ -43,6 +43,11 @@ class User(Base):
|
|||||||
Boolean, default=False, server_default="false"
|
Boolean, default=False, server_default="false"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Passwordless login — requires >= 2 passkeys registered
|
||||||
|
passwordless_enabled: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, default=False, server_default="false"
|
||||||
|
)
|
||||||
|
|
||||||
# Audit
|
# Audit
|
||||||
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||||
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
||||||
|
|||||||
@ -45,6 +45,7 @@ from app.schemas.admin import (
|
|||||||
SystemConfigUpdate,
|
SystemConfigUpdate,
|
||||||
ToggleActiveRequest,
|
ToggleActiveRequest,
|
||||||
ToggleMfaEnforceRequest,
|
ToggleMfaEnforceRequest,
|
||||||
|
TogglePasswordlessRequest,
|
||||||
UpdateUserRoleRequest,
|
UpdateUserRoleRequest,
|
||||||
UserDetailResponse,
|
UserDetailResponse,
|
||||||
UserListItem,
|
UserListItem,
|
||||||
@ -670,6 +671,56 @@ async def get_user_sharing_stats(
|
|||||||
"pending_invites_received": pending_received,
|
"pending_invites_received": pending_received,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PUT /users/{user_id}/passwordless
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.put("/users/{user_id}/passwordless")
|
||||||
|
async def admin_toggle_passwordless(
|
||||||
|
request: Request,
|
||||||
|
data: TogglePasswordlessRequest,
|
||||||
|
user_id: int = Path(ge=1, le=2147483647),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
actor: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Admin-only: disable passwordless login for a user.
|
||||||
|
Only enabled=False is allowed — admin cannot remotely enable passwordless.
|
||||||
|
Revokes all sessions so the user must re-authenticate.
|
||||||
|
"""
|
||||||
|
if data.enabled:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Admin can only disable passwordless login, not enable it",
|
||||||
|
)
|
||||||
|
|
||||||
|
_guard_self_action(actor, user_id, "toggle passwordless for")
|
||||||
|
|
||||||
|
result = await db.execute(sa.select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
if not user.passwordless_enabled:
|
||||||
|
raise HTTPException(status_code=409, detail="Passwordless login is not enabled for this user")
|
||||||
|
|
||||||
|
user.passwordless_enabled = False
|
||||||
|
|
||||||
|
revoked = await _revoke_all_sessions(db, user_id)
|
||||||
|
|
||||||
|
await log_audit_event(
|
||||||
|
db,
|
||||||
|
action="admin.passwordless_disabled",
|
||||||
|
actor_id=actor.id,
|
||||||
|
target_id=user_id,
|
||||||
|
detail={"sessions_revoked": revoked, "username": user.username},
|
||||||
|
ip=get_client_ip(request),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {"passwordless_enabled": False, "sessions_revoked": revoked}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# GET /config
|
# GET /config
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -716,6 +767,9 @@ async def update_system_config(
|
|||||||
if data.enforce_mfa_new_users is not None:
|
if data.enforce_mfa_new_users is not None:
|
||||||
changes["enforce_mfa_new_users"] = data.enforce_mfa_new_users
|
changes["enforce_mfa_new_users"] = data.enforce_mfa_new_users
|
||||||
config.enforce_mfa_new_users = data.enforce_mfa_new_users
|
config.enforce_mfa_new_users = data.enforce_mfa_new_users
|
||||||
|
if data.allow_passwordless is not None:
|
||||||
|
changes["allow_passwordless"] = data.allow_passwordless
|
||||||
|
config.allow_passwordless = data.allow_passwordless
|
||||||
|
|
||||||
if changes:
|
if changes:
|
||||||
await log_audit_event(
|
await log_audit_event(
|
||||||
|
|||||||
@ -16,7 +16,6 @@ Security layers:
|
|||||||
4. bcrypt→Argon2id transparent upgrade on first login
|
4. bcrypt→Argon2id transparent upgrade on first login
|
||||||
5. Role-based authorization via require_role() dependency factory
|
5. Role-based authorization via require_role() dependency factory
|
||||||
"""
|
"""
|
||||||
import uuid
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@ -30,6 +29,7 @@ from app.models.user import User
|
|||||||
from app.models.session import UserSession
|
from app.models.session import UserSession
|
||||||
from app.models.settings import Settings
|
from app.models.settings import Settings
|
||||||
from app.models.system_config import SystemConfig
|
from app.models.system_config import SystemConfig
|
||||||
|
from app.models.passkey_credential import PasskeyCredential
|
||||||
from app.models.calendar import Calendar
|
from app.models.calendar import Calendar
|
||||||
from app.schemas.auth import (
|
from app.schemas.auth import (
|
||||||
SetupRequest, LoginRequest, RegisterRequest,
|
SetupRequest, LoginRequest, RegisterRequest,
|
||||||
@ -49,6 +49,13 @@ from app.services.auth import (
|
|||||||
create_mfa_enforce_token,
|
create_mfa_enforce_token,
|
||||||
)
|
)
|
||||||
from app.services.audit import get_client_ip, log_audit_event
|
from app.services.audit import get_client_ip, log_audit_event
|
||||||
|
from app.services.session import (
|
||||||
|
set_session_cookie,
|
||||||
|
check_account_lockout,
|
||||||
|
record_failed_login,
|
||||||
|
record_successful_login,
|
||||||
|
create_db_session,
|
||||||
|
)
|
||||||
from app.config import settings as app_settings
|
from app.config import settings as app_settings
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@ -59,22 +66,6 @@ router = APIRouter()
|
|||||||
# is indistinguishable from a wrong-password attempt.
|
# is indistinguishable from a wrong-password attempt.
|
||||||
_DUMMY_HASH = hash_password("timing-equalization-dummy")
|
_DUMMY_HASH = hash_password("timing-equalization-dummy")
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Cookie helper
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _set_session_cookie(response: Response, token: str) -> None:
|
|
||||||
response.set_cookie(
|
|
||||||
key="session",
|
|
||||||
value=token,
|
|
||||||
httponly=True,
|
|
||||||
secure=app_settings.COOKIE_SECURE,
|
|
||||||
max_age=app_settings.SESSION_MAX_AGE_DAYS * 86400,
|
|
||||||
samesite="lax",
|
|
||||||
path="/",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Auth dependencies — export get_current_user and get_current_settings
|
# Auth dependencies — export get_current_user and get_current_settings
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -130,7 +121,7 @@ async def get_current_user(
|
|||||||
await db.flush()
|
await db.flush()
|
||||||
# Re-issue cookie with fresh signed token to reset browser max_age timer
|
# Re-issue cookie with fresh signed token to reset browser max_age timer
|
||||||
fresh_token = create_session_token(user_id, session_id)
|
fresh_token = create_session_token(user_id, session_id)
|
||||||
_set_session_cookie(response, fresh_token)
|
set_session_cookie(response, fresh_token)
|
||||||
|
|
||||||
# Stash session on request so lock/unlock endpoints can access it
|
# Stash session on request so lock/unlock endpoints can access it
|
||||||
request.state.db_session = db_session
|
request.state.db_session = db_session
|
||||||
@ -141,6 +132,7 @@ async def get_current_user(
|
|||||||
lock_exempt = {
|
lock_exempt = {
|
||||||
"/api/auth/lock", "/api/auth/verify-password",
|
"/api/auth/lock", "/api/auth/verify-password",
|
||||||
"/api/auth/status", "/api/auth/logout",
|
"/api/auth/status", "/api/auth/logout",
|
||||||
|
"/api/auth/passkeys/login/begin", "/api/auth/passkeys/login/complete",
|
||||||
}
|
}
|
||||||
if request.url.path not in lock_exempt:
|
if request.url.path not in lock_exempt:
|
||||||
raise HTTPException(status_code=423, detail="Session is locked")
|
raise HTTPException(status_code=423, detail="Session is locked")
|
||||||
@ -190,82 +182,6 @@ def require_role(*allowed_roles: str):
|
|||||||
require_admin = require_role("admin")
|
require_admin = require_role("admin")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Account lockout helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def _check_account_lockout(user: User) -> None:
|
|
||||||
"""Raise HTTP 423 if the account is currently locked."""
|
|
||||||
if user.locked_until and datetime.now() < user.locked_until:
|
|
||||||
remaining = int((user.locked_until - datetime.now()).total_seconds() / 60) + 1
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=423,
|
|
||||||
detail=f"Account locked. Try again in {remaining} minutes.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _record_failed_login(db: AsyncSession, user: User) -> None:
|
|
||||||
"""Increment failure counter; lock account after 10 failures."""
|
|
||||||
user.failed_login_count += 1
|
|
||||||
if user.failed_login_count >= 10:
|
|
||||||
user.locked_until = datetime.now() + timedelta(minutes=30)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
async def _record_successful_login(db: AsyncSession, user: User) -> None:
|
|
||||||
"""Reset failure counter and update last_login_at."""
|
|
||||||
user.failed_login_count = 0
|
|
||||||
user.locked_until = None
|
|
||||||
user.last_login_at = datetime.now()
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Session creation helper
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def _create_db_session(
|
|
||||||
db: AsyncSession,
|
|
||||||
user: User,
|
|
||||||
ip: str,
|
|
||||||
user_agent: str | None,
|
|
||||||
) -> tuple[str, str]:
|
|
||||||
"""Insert a UserSession row and return (session_id, signed_cookie_token)."""
|
|
||||||
session_id = uuid.uuid4().hex
|
|
||||||
expires_at = datetime.now() + timedelta(days=app_settings.SESSION_MAX_AGE_DAYS)
|
|
||||||
db_session = UserSession(
|
|
||||||
id=session_id,
|
|
||||||
user_id=user.id,
|
|
||||||
expires_at=expires_at,
|
|
||||||
ip_address=ip[:45] if ip else None,
|
|
||||||
user_agent=(user_agent or "")[:255] if user_agent else None,
|
|
||||||
)
|
|
||||||
db.add(db_session)
|
|
||||||
await db.flush()
|
|
||||||
|
|
||||||
# Enforce concurrent session limit: revoke oldest sessions beyond the cap
|
|
||||||
active_sessions = (
|
|
||||||
await db.execute(
|
|
||||||
select(UserSession)
|
|
||||||
.where(
|
|
||||||
UserSession.user_id == user.id,
|
|
||||||
UserSession.revoked == False, # noqa: E712
|
|
||||||
UserSession.expires_at > datetime.now(),
|
|
||||||
)
|
|
||||||
.order_by(UserSession.created_at.asc())
|
|
||||||
)
|
|
||||||
).scalars().all()
|
|
||||||
|
|
||||||
max_sessions = app_settings.MAX_SESSIONS_PER_USER
|
|
||||||
if len(active_sessions) > max_sessions:
|
|
||||||
for old_session in active_sessions[: len(active_sessions) - max_sessions]:
|
|
||||||
old_session.revoked = True
|
|
||||||
await db.flush()
|
|
||||||
|
|
||||||
token = create_session_token(user.id, session_id)
|
|
||||||
return session_id, token
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# User bootstrapping helper (Settings + default calendars)
|
# User bootstrapping helper (Settings + default calendars)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -321,8 +237,8 @@ async def setup(
|
|||||||
|
|
||||||
ip = get_client_ip(request)
|
ip = get_client_ip(request)
|
||||||
user_agent = request.headers.get("user-agent")
|
user_agent = request.headers.get("user-agent")
|
||||||
_, token = await _create_db_session(db, new_user, ip, user_agent)
|
_, token = await create_db_session(db, new_user, ip, user_agent)
|
||||||
_set_session_cookie(response, token)
|
set_session_cookie(response, token)
|
||||||
|
|
||||||
await log_audit_event(
|
await log_audit_event(
|
||||||
db, action="auth.setup_complete", actor_id=new_user.id, ip=ip,
|
db, action="auth.setup_complete", actor_id=new_user.id, ip=ip,
|
||||||
@ -366,19 +282,38 @@ async def login(
|
|||||||
# executes — prevents distinguishing "locked" from "wrong password" via timing.
|
# executes — prevents distinguishing "locked" from "wrong password" via timing.
|
||||||
valid, new_hash = await averify_password_with_upgrade(data.password, user.password_hash)
|
valid, new_hash = await averify_password_with_upgrade(data.password, user.password_hash)
|
||||||
|
|
||||||
await _check_account_lockout(user)
|
await check_account_lockout(user)
|
||||||
|
|
||||||
if not valid:
|
if not valid:
|
||||||
await _record_failed_login(db, user)
|
remaining = await record_failed_login(db, user)
|
||||||
await log_audit_event(
|
await log_audit_event(
|
||||||
db, action="auth.login_failed", actor_id=user.id,
|
db, action="auth.login_failed", actor_id=user.id,
|
||||||
detail={"reason": "invalid_password"}, ip=client_ip,
|
detail={"reason": "invalid_password", "attempts_remaining": remaining}, ip=client_ip,
|
||||||
)
|
)
|
||||||
await db.commit()
|
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
|
# Block disabled accounts — checked AFTER password verification to avoid
|
||||||
# leaking account-state info, and BEFORE _record_successful_login so
|
# leaking account-state info, and BEFORE record_successful_login so
|
||||||
# last_login_at and lockout counters are not reset for inactive users.
|
# last_login_at and lockout counters are not reset for inactive users.
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
await log_audit_event(
|
await log_audit_event(
|
||||||
@ -391,7 +326,7 @@ async def login(
|
|||||||
if new_hash:
|
if new_hash:
|
||||||
user.password_hash = new_hash
|
user.password_hash = new_hash
|
||||||
|
|
||||||
await _record_successful_login(db, user)
|
await record_successful_login(db, user)
|
||||||
|
|
||||||
# SEC-03: MFA enforcement — block login entirely until MFA setup completes
|
# SEC-03: MFA enforcement — block login entirely until MFA setup completes
|
||||||
if user.mfa_enforce_pending and not user.totp_enabled:
|
if user.mfa_enforce_pending and not user.totp_enabled:
|
||||||
@ -409,6 +344,7 @@ async def login(
|
|||||||
# If TOTP is enabled, issue a short-lived MFA challenge token
|
# If TOTP is enabled, issue a short-lived MFA challenge token
|
||||||
if user.totp_enabled:
|
if user.totp_enabled:
|
||||||
mfa_token = create_mfa_token(user.id)
|
mfa_token = create_mfa_token(user.id)
|
||||||
|
await db.commit()
|
||||||
return {
|
return {
|
||||||
"authenticated": False,
|
"authenticated": False,
|
||||||
"totp_required": True,
|
"totp_required": True,
|
||||||
@ -419,8 +355,8 @@ async def login(
|
|||||||
if user.must_change_password:
|
if user.must_change_password:
|
||||||
# Issue a session but flag the frontend to show password change
|
# Issue a session but flag the frontend to show password change
|
||||||
user_agent = request.headers.get("user-agent")
|
user_agent = request.headers.get("user-agent")
|
||||||
_, token = await _create_db_session(db, user, client_ip, user_agent)
|
_, token = await create_db_session(db, user, client_ip, user_agent)
|
||||||
_set_session_cookie(response, token)
|
set_session_cookie(response, token)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {
|
return {
|
||||||
"authenticated": True,
|
"authenticated": True,
|
||||||
@ -428,8 +364,8 @@ async def login(
|
|||||||
}
|
}
|
||||||
|
|
||||||
user_agent = request.headers.get("user-agent")
|
user_agent = request.headers.get("user-agent")
|
||||||
_, token = await _create_db_session(db, user, client_ip, user_agent)
|
_, token = await create_db_session(db, user, client_ip, user_agent)
|
||||||
_set_session_cookie(response, token)
|
set_session_cookie(response, token)
|
||||||
|
|
||||||
await log_audit_event(
|
await log_audit_event(
|
||||||
db, action="auth.login_success", actor_id=user.id, ip=client_ip,
|
db, action="auth.login_success", actor_id=user.id, ip=client_ip,
|
||||||
@ -511,8 +447,8 @@ async def register(
|
|||||||
"mfa_token": enforce_token,
|
"mfa_token": enforce_token,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, token = await _create_db_session(db, new_user, ip, user_agent)
|
_, token = await create_db_session(db, new_user, ip, user_agent)
|
||||||
_set_session_cookie(response, token)
|
set_session_cookie(response, token)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return {"message": "Registration successful", "authenticated": True}
|
return {"message": "Registration successful", "authenticated": True}
|
||||||
@ -564,34 +500,34 @@ async def auth_status(
|
|||||||
|
|
||||||
is_locked = False
|
is_locked = False
|
||||||
|
|
||||||
|
u = None
|
||||||
if not setup_required and session_cookie:
|
if not setup_required and session_cookie:
|
||||||
payload = verify_session_token(session_cookie)
|
payload = verify_session_token(session_cookie)
|
||||||
if payload:
|
if payload:
|
||||||
user_id = payload.get("uid")
|
user_id = payload.get("uid")
|
||||||
session_id = payload.get("sid")
|
session_id = payload.get("sid")
|
||||||
if user_id and session_id:
|
if user_id and session_id:
|
||||||
session_result = await db.execute(
|
# Single JOIN query (was 2 sequential queries — P-01 fix)
|
||||||
select(UserSession).where(
|
result = await db.execute(
|
||||||
|
select(UserSession, User)
|
||||||
|
.join(User, UserSession.user_id == User.id)
|
||||||
|
.where(
|
||||||
UserSession.id == session_id,
|
UserSession.id == session_id,
|
||||||
UserSession.user_id == user_id,
|
UserSession.user_id == user_id,
|
||||||
UserSession.revoked == False,
|
UserSession.revoked == False,
|
||||||
UserSession.expires_at > datetime.now(),
|
UserSession.expires_at > datetime.now(),
|
||||||
|
User.is_active == True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
db_sess = session_result.scalar_one_or_none()
|
row = result.one_or_none()
|
||||||
if db_sess is not None:
|
if row is not None:
|
||||||
|
db_sess, u = row.tuple()
|
||||||
authenticated = True
|
authenticated = True
|
||||||
is_locked = db_sess.is_locked
|
is_locked = db_sess.is_locked
|
||||||
user_obj_result = await db.execute(
|
role = u.role
|
||||||
select(User).where(User.id == user_id, User.is_active == True)
|
|
||||||
)
|
|
||||||
u = user_obj_result.scalar_one_or_none()
|
|
||||||
if u:
|
|
||||||
role = u.role
|
|
||||||
else:
|
|
||||||
authenticated = False
|
|
||||||
|
|
||||||
# Check registration availability
|
# Check registration availability
|
||||||
|
config = None
|
||||||
registration_open = False
|
registration_open = False
|
||||||
if not setup_required:
|
if not setup_required:
|
||||||
config_result = await db.execute(
|
config_result = await db.execute(
|
||||||
@ -600,6 +536,19 @@ async def auth_status(
|
|||||||
config = config_result.scalar_one_or_none()
|
config = config_result.scalar_one_or_none()
|
||||||
registration_open = config.allow_registration if config else False
|
registration_open = config.allow_registration if config else False
|
||||||
|
|
||||||
|
# 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 {
|
return {
|
||||||
"authenticated": authenticated,
|
"authenticated": authenticated,
|
||||||
"setup_required": setup_required,
|
"setup_required": setup_required,
|
||||||
@ -607,6 +556,9 @@ async def auth_status(
|
|||||||
"username": u.username if authenticated and u else None,
|
"username": u.username if authenticated and u else None,
|
||||||
"registration_open": registration_open,
|
"registration_open": registration_open,
|
||||||
"is_locked": is_locked,
|
"is_locked": is_locked,
|
||||||
|
"has_passkeys": has_passkeys,
|
||||||
|
"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")
|
@router.post("/verify-password")
|
||||||
async def verify_password(
|
async def verify_password_endpoint(
|
||||||
data: VerifyPasswordRequest,
|
data: VerifyPasswordRequest,
|
||||||
request: Request,
|
request: Request,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@ -635,11 +587,12 @@ async def verify_password(
|
|||||||
Verify the current user's password without changing anything.
|
Verify the current user's password without changing anything.
|
||||||
Used by the frontend lock screen to re-authenticate without a full login.
|
Used by the frontend lock screen to re-authenticate without a full login.
|
||||||
"""
|
"""
|
||||||
await _check_account_lockout(current_user)
|
await check_account_lockout(current_user)
|
||||||
|
|
||||||
valid, new_hash = await averify_password_with_upgrade(data.password, current_user.password_hash)
|
valid, new_hash = await averify_password_with_upgrade(data.password, current_user.password_hash)
|
||||||
if not valid:
|
if not valid:
|
||||||
await _record_failed_login(db, current_user)
|
await record_failed_login(db, current_user)
|
||||||
|
await db.commit()
|
||||||
raise HTTPException(status_code=401, detail="Invalid password")
|
raise HTTPException(status_code=401, detail="Invalid password")
|
||||||
|
|
||||||
if new_hash:
|
if new_hash:
|
||||||
@ -661,11 +614,12 @@ async def change_password(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Change the current user's password. Requires old password verification."""
|
"""Change the current user's password. Requires old password verification."""
|
||||||
await _check_account_lockout(current_user)
|
await check_account_lockout(current_user)
|
||||||
|
|
||||||
valid, _ = await averify_password_with_upgrade(data.old_password, current_user.password_hash)
|
valid, _ = await averify_password_with_upgrade(data.old_password, current_user.password_hash)
|
||||||
if not valid:
|
if not valid:
|
||||||
await _record_failed_login(db, current_user)
|
await record_failed_login(db, current_user)
|
||||||
|
await db.commit()
|
||||||
raise HTTPException(status_code=401, detail="Invalid current password")
|
raise HTTPException(status_code=401, detail="Invalid current password")
|
||||||
|
|
||||||
if data.new_password == data.old_password:
|
if data.new_password == data.old_password:
|
||||||
|
|||||||
675
backend/app/routers/passkeys.py
Normal file
675
backend/app/routers/passkeys.py
Normal file
@ -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"}
|
||||||
@ -18,10 +18,9 @@ Security:
|
|||||||
- totp-verify uses mfa_token (not session cookie) — user is not yet authenticated
|
- totp-verify uses mfa_token (not session cookie) — user is not yet authenticated
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import uuid
|
|
||||||
import secrets
|
import secrets
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
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.database import get_db
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.session import UserSession
|
|
||||||
from app.models.totp_usage import TOTPUsage
|
from app.models.totp_usage import TOTPUsage
|
||||||
from app.models.backup_code import BackupCode
|
from app.models.backup_code import BackupCode
|
||||||
from app.routers.auth import get_current_user, _set_session_cookie
|
from app.routers.auth import get_current_user
|
||||||
from app.services.audit import get_client_ip
|
from app.services.audit import get_client_ip
|
||||||
from app.services.auth import (
|
from app.services.auth import (
|
||||||
averify_password_with_upgrade,
|
averify_password_with_upgrade,
|
||||||
verify_mfa_token,
|
verify_mfa_token,
|
||||||
verify_mfa_enforce_token,
|
verify_mfa_enforce_token,
|
||||||
create_session_token,
|
)
|
||||||
|
from app.services.session import (
|
||||||
|
create_db_session,
|
||||||
|
set_session_cookie,
|
||||||
|
check_account_lockout,
|
||||||
|
record_failed_login,
|
||||||
|
record_successful_login,
|
||||||
)
|
)
|
||||||
from app.services.totp import (
|
from app.services.totp import (
|
||||||
generate_totp_secret,
|
generate_totp_secret,
|
||||||
@ -52,7 +56,7 @@ from app.services.totp import (
|
|||||||
generate_qr_base64,
|
generate_qr_base64,
|
||||||
generate_backup_codes,
|
generate_backup_codes,
|
||||||
)
|
)
|
||||||
from app.config import settings as app_settings
|
|
||||||
|
|
||||||
# Argon2id for backup code hashing — treat each code like a password
|
# Argon2id for backup code hashing — treat each code like a password
|
||||||
from argon2 import PasswordHasher
|
from argon2 import PasswordHasher
|
||||||
@ -162,29 +166,6 @@ async def _verify_backup_code(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def _create_full_session(
|
|
||||||
db: AsyncSession,
|
|
||||||
user: User,
|
|
||||||
request: Request,
|
|
||||||
) -> str:
|
|
||||||
"""Create a UserSession row and return the signed cookie token."""
|
|
||||||
session_id = uuid.uuid4().hex
|
|
||||||
expires_at = datetime.now() + timedelta(days=app_settings.SESSION_MAX_AGE_DAYS)
|
|
||||||
ip = get_client_ip(request)
|
|
||||||
user_agent = request.headers.get("user-agent")
|
|
||||||
|
|
||||||
db_session = UserSession(
|
|
||||||
id=session_id,
|
|
||||||
user_id=user.id,
|
|
||||||
expires_at=expires_at,
|
|
||||||
ip_address=ip[:45] if ip else None,
|
|
||||||
user_agent=(user_agent or "")[:255] if user_agent else None,
|
|
||||||
)
|
|
||||||
db.add(db_session)
|
|
||||||
await db.commit()
|
|
||||||
return create_session_token(user.id, session_id)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Routes
|
# Routes
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -288,60 +269,55 @@ async def totp_verify(
|
|||||||
raise HTTPException(status_code=400, detail="TOTP not configured for this account")
|
raise HTTPException(status_code=400, detail="TOTP not configured for this account")
|
||||||
|
|
||||||
# Check account lockout (shared counter with password failures)
|
# Check account lockout (shared counter with password failures)
|
||||||
if user.locked_until and datetime.now() < user.locked_until:
|
await check_account_lockout(user)
|
||||||
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.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- Backup code path ---
|
# --- Backup code path ---
|
||||||
if data.backup_code:
|
if data.backup_code:
|
||||||
normalized = data.backup_code.strip().upper()
|
normalized = data.backup_code.strip().upper()
|
||||||
valid = await _verify_backup_code(db, user.id, normalized)
|
valid = await _verify_backup_code(db, user.id, normalized)
|
||||||
if not valid:
|
if not valid:
|
||||||
user.failed_login_count += 1
|
remaining = await record_failed_login(db, user)
|
||||||
if user.failed_login_count >= 10:
|
|
||||||
user.locked_until = datetime.now() + timedelta(minutes=30)
|
|
||||||
await db.commit()
|
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")
|
raise HTTPException(status_code=401, detail="Invalid backup code")
|
||||||
|
|
||||||
# Backup code accepted — reset lockout counter and issue session
|
# Backup code accepted — reset lockout counter and issue session
|
||||||
user.failed_login_count = 0
|
await record_successful_login(db, user)
|
||||||
user.locked_until = None
|
|
||||||
user.last_login_at = datetime.now()
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
token = await _create_full_session(db, user, request)
|
ip = get_client_ip(request)
|
||||||
_set_session_cookie(response, token)
|
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}
|
return {"authenticated": True}
|
||||||
|
|
||||||
# --- TOTP code path ---
|
# --- TOTP code path ---
|
||||||
matched_window = verify_totp_code(user.totp_secret, data.code)
|
matched_window = verify_totp_code(user.totp_secret, data.code)
|
||||||
if matched_window is None:
|
if matched_window is None:
|
||||||
user.failed_login_count += 1
|
remaining = await record_failed_login(db, user)
|
||||||
if user.failed_login_count >= 10:
|
|
||||||
user.locked_until = datetime.now() + timedelta(minutes=30)
|
|
||||||
await db.commit()
|
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")
|
raise HTTPException(status_code=401, detail="Invalid code")
|
||||||
|
|
||||||
# Replay prevention — record (user_id, code, actual_matching_window)
|
# Replay prevention — record (user_id, code, actual_matching_window)
|
||||||
totp_record = TOTPUsage(user_id=user.id, code=data.code, window=matched_window)
|
totp_record = TOTPUsage(user_id=user.id, code=data.code, window=matched_window)
|
||||||
db.add(totp_record)
|
db.add(totp_record)
|
||||||
try:
|
try:
|
||||||
await db.commit()
|
await db.flush()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
raise HTTPException(status_code=401, detail="Code already used — wait for the next code")
|
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
|
# Success — reset lockout counter, update last_login_at, issue full session
|
||||||
user.failed_login_count = 0
|
await record_successful_login(db, user)
|
||||||
user.locked_until = None
|
|
||||||
user.last_login_at = datetime.now()
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
token = await _create_full_session(db, user, request)
|
ip = get_client_ip(request)
|
||||||
_set_session_cookie(response, token)
|
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}
|
return {"authenticated": True}
|
||||||
|
|
||||||
|
|
||||||
@ -513,9 +489,11 @@ async def enforce_confirm_totp(
|
|||||||
user.last_login_at = datetime.now()
|
user.last_login_at = datetime.now()
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
# Issue a full session
|
# Issue a full session (now uses shared session service with cap enforcement)
|
||||||
token = await _create_full_session(db, user, request)
|
ip = get_client_ip(request)
|
||||||
_set_session_cookie(response, token)
|
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}
|
return {"authenticated": True}
|
||||||
|
|
||||||
|
|||||||
@ -30,6 +30,7 @@ class UserListItem(BaseModel):
|
|||||||
last_password_change_at: Optional[datetime] = None
|
last_password_change_at: Optional[datetime] = None
|
||||||
totp_enabled: bool
|
totp_enabled: bool
|
||||||
mfa_enforce_pending: bool
|
mfa_enforce_pending: bool
|
||||||
|
passwordless_enabled: bool = False
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
active_sessions: int = 0
|
active_sessions: int = 0
|
||||||
|
|
||||||
@ -107,6 +108,7 @@ class ToggleMfaEnforceRequest(BaseModel):
|
|||||||
class SystemConfigResponse(BaseModel):
|
class SystemConfigResponse(BaseModel):
|
||||||
allow_registration: bool
|
allow_registration: bool
|
||||||
enforce_mfa_new_users: bool
|
enforce_mfa_new_users: bool
|
||||||
|
allow_passwordless: bool = False
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@ -115,6 +117,12 @@ class SystemConfigUpdate(BaseModel):
|
|||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
allow_registration: Optional[bool] = None
|
allow_registration: Optional[bool] = None
|
||||||
enforce_mfa_new_users: Optional[bool] = None
|
enforce_mfa_new_users: Optional[bool] = None
|
||||||
|
allow_passwordless: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TogglePasswordlessRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
240
backend/app/services/passkey.py
Normal file
240
backend/app/services/passkey.py
Normal file
@ -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,
|
||||||
|
)
|
||||||
121
backend/app/services/session.py
Normal file
121
backend/app/services/session.py
Normal file
@ -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
|
||||||
@ -15,3 +15,4 @@ python-dateutil==2.9.0
|
|||||||
itsdangerous==2.2.0
|
itsdangerous==2.2.0
|
||||||
httpx==0.27.2
|
httpx==0.27.2
|
||||||
apscheduler==3.10.4
|
apscheduler==3.10.4
|
||||||
|
webauthn>=2.1.0,<3
|
||||||
|
|||||||
@ -2,7 +2,10 @@ services:
|
|||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: .env
|
environment:
|
||||||
|
- POSTGRES_USER=${POSTGRES_USER}
|
||||||
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||||
|
- POSTGRES_DB=${POSTGRES_DB}
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
@ -19,9 +22,17 @@ services:
|
|||||||
cpus: "1.0"
|
cpus: "1.0"
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build: ./backend
|
image: git.sentinelforest.xyz/rohskiddo/umbra-backend:main-latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: .env
|
environment:
|
||||||
|
- DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
|
- SECRET_KEY=${SECRET_KEY}
|
||||||
|
- ENVIRONMENT=${ENVIRONMENT:-production}
|
||||||
|
- UMBRA_URL=${UMBRA_URL:-https://umbra.ghost6.xyz}
|
||||||
|
- OPENWEATHERMAP_API_KEY=${OPENWEATHERMAP_API_KEY:-}
|
||||||
|
- WEBAUTHN_RP_ID=${WEBAUTHN_RP_ID:-umbra.ghost6.xyz}
|
||||||
|
- WEBAUTHN_RP_NAME=${WEBAUTHN_RP_NAME:-UMBRA}
|
||||||
|
- WEBAUTHN_ORIGIN=${WEBAUTHN_ORIGIN:-https://umbra.ghost6.xyz}
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@ -30,7 +41,7 @@ services:
|
|||||||
- frontend_net
|
- frontend_net
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""]
|
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""]
|
||||||
interval: 10s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 30s
|
start_period: 30s
|
||||||
@ -41,7 +52,7 @@ services:
|
|||||||
cpus: "1.0"
|
cpus: "1.0"
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build: ./frontend
|
image: git.sentinelforest.xyz/rohskiddo/umbra-frontend:main-latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "80:8080"
|
- "80:8080"
|
||||||
@ -51,7 +62,7 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- frontend_net
|
- frontend_net
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--spider", "--quiet", "http://localhost:8080/"]
|
test: ["CMD", "wget", "--spider", "--quiet", "http://127.0.0.1:8080/"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@ -12,6 +12,8 @@ limit_req_zone $binary_remote_addr zone=conn_search_limit:10m rate=10r/m;
|
|||||||
limit_req_zone $binary_remote_addr zone=conn_request_limit:10m rate=3r/m;
|
limit_req_zone $binary_remote_addr zone=conn_request_limit:10m rate=3r/m;
|
||||||
# Event creation — recurrence amplification means 1 POST = up to 90-365 child rows
|
# Event creation — recurrence amplification means 1 POST = up to 90-365 child rows
|
||||||
limit_req_zone $binary_remote_addr zone=event_create_limit:10m rate=30r/m;
|
limit_req_zone $binary_remote_addr zone=event_create_limit:10m rate=30r/m;
|
||||||
|
# Health endpoint — lightweight but rate-limited for resilience
|
||||||
|
limit_req_zone $binary_remote_addr zone=health_limit:1m rate=30r/m;
|
||||||
|
|
||||||
# Use X-Forwarded-Proto from upstream proxy when present, fall back to $scheme for direct access
|
# Use X-Forwarded-Proto from upstream proxy when present, fall back to $scheme for direct access
|
||||||
map $http_x_forwarded_proto $forwarded_proto {
|
map $http_x_forwarded_proto $forwarded_proto {
|
||||||
@ -29,13 +31,14 @@ server {
|
|||||||
# Suppress nginx version in Server header
|
# Suppress nginx version in Server header
|
||||||
server_tokens off;
|
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.
|
# Pangolin (TLS-terminating reverse proxy) connects via Docker bridge.
|
||||||
# Restore the real client IP from X-Forwarded-For so that limit_req_zone
|
# 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.
|
# (which keys on $binary_remote_addr) throttles per-client, not per-proxy.
|
||||||
# Safe to trust all sources: nginx is only reachable via Docker networking,
|
# Restricted to RFC 1918 ranges only — trusting 0.0.0.0/0 would allow an
|
||||||
# never directly internet-facing. Tighten if deployment model changes.
|
# external client to spoof X-Forwarded-For and bypass rate limiting (F-03).
|
||||||
set_real_ip_from 0.0.0.0/0;
|
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_header X-Forwarded-For;
|
||||||
real_ip_recursive on;
|
real_ip_recursive on;
|
||||||
|
|
||||||
@ -83,6 +86,36 @@ server {
|
|||||||
include /etc/nginx/proxy-params.conf;
|
include /etc/nginx/proxy-params.conf;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Passkey authentication — rate-limited (C-04)
|
||||||
|
location /api/auth/passkeys/login/begin {
|
||||||
|
limit_req zone=auth_limit burst=5 nodelay;
|
||||||
|
limit_req_status 429;
|
||||||
|
include /etc/nginx/proxy-params.conf;
|
||||||
|
}
|
||||||
|
location /api/auth/passkeys/login/complete {
|
||||||
|
limit_req zone=auth_limit burst=5 nodelay;
|
||||||
|
limit_req_status 429;
|
||||||
|
include /etc/nginx/proxy-params.conf;
|
||||||
|
}
|
||||||
|
# Passkey registration — authenticated, lower burst
|
||||||
|
location /api/auth/passkeys/register/begin {
|
||||||
|
limit_req zone=auth_limit burst=3 nodelay;
|
||||||
|
limit_req_status 429;
|
||||||
|
include /etc/nginx/proxy-params.conf;
|
||||||
|
}
|
||||||
|
location /api/auth/passkeys/register/complete {
|
||||||
|
limit_req zone=auth_limit burst=3 nodelay;
|
||||||
|
limit_req_status 429;
|
||||||
|
include /etc/nginx/proxy-params.conf;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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
|
# SEC-14: Rate-limit public registration endpoint
|
||||||
location /api/auth/register {
|
location /api/auth/register {
|
||||||
limit_req zone=register_limit burst=3 nodelay;
|
limit_req zone=register_limit burst=3 nodelay;
|
||||||
@ -133,6 +166,13 @@ server {
|
|||||||
include /etc/nginx/proxy-params.conf;
|
include /etc/nginx/proxy-params.conf;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Health endpoint — proxied to backend for external uptime monitoring
|
||||||
|
location = /health {
|
||||||
|
limit_req zone=health_limit burst=5 nodelay;
|
||||||
|
limit_req_status 429;
|
||||||
|
include /etc/nginx/proxy-params.conf;
|
||||||
|
}
|
||||||
|
|
||||||
# API proxy (catch-all for non-rate-limited endpoints)
|
# API proxy (catch-all for non-rate-limited endpoints)
|
||||||
location /api {
|
location /api {
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
@ -153,7 +193,7 @@ server {
|
|||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';" always;
|
add_header 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'; frame-ancestors 'none'; upgrade-insecure-requests;" always;
|
||||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -161,8 +201,8 @@ server {
|
|||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';" always;
|
add_header 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'; frame-ancestors 'none'; upgrade-insecure-requests;" always;
|
||||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
# PT-I03: Restrict unnecessary browser APIs
|
# PT-I03: Restrict unnecessary browser APIs
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=(), publickey-credentials-get=(self), publickey-credentials-create=(self)" always;
|
||||||
}
|
}
|
||||||
|
|||||||
17
frontend/package-lock.json
generated
17
frontend/package-lock.json
generated
@ -16,6 +16,7 @@
|
|||||||
"@fullcalendar/interaction": "^6.1.15",
|
"@fullcalendar/interaction": "^6.1.15",
|
||||||
"@fullcalendar/react": "^6.1.15",
|
"@fullcalendar/react": "^6.1.15",
|
||||||
"@fullcalendar/timegrid": "^6.1.15",
|
"@fullcalendar/timegrid": "^6.1.15",
|
||||||
|
"@simplewebauthn/browser": "^10.0.0",
|
||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@ -1348,6 +1349,22 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@simplewebauthn/browser": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-hG0JMZD+LiLUbpQcAjS4d+t4gbprE/dLYop/CkE01ugU/9sKXflxV5s0DRjdz3uNMFecatRfb4ZLG3XvF8m5zg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@simplewebauthn/types": "^10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@simplewebauthn/types": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-SFXke7xkgPRowY2E+8djKbdEznTVnD5R6GO7GPTthpHrokLvNKw8C3lFZypTxLI7KkCfGPfhtqB3d7OVGGa9jQ==",
|
||||||
|
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@tanstack/query-core": {
|
"node_modules/@tanstack/query-core": {
|
||||||
"version": "5.90.20",
|
"version": "5.90.20",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
"@fullcalendar/interaction": "^6.1.15",
|
"@fullcalendar/interaction": "^6.1.15",
|
||||||
"@fullcalendar/react": "^6.1.15",
|
"@fullcalendar/react": "^6.1.15",
|
||||||
"@fullcalendar/timegrid": "^6.1.15",
|
"@fullcalendar/timegrid": "^6.1.15",
|
||||||
|
"@simplewebauthn/browser": "^10.0.0",
|
||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|||||||
@ -11,6 +11,6 @@ add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
|||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';" always;
|
add_header 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'; frame-ancestors 'none'; upgrade-insecure-requests;" always;
|
||||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
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;
|
||||||
|
|||||||
@ -81,7 +81,7 @@ export default function IAMPage() {
|
|||||||
);
|
);
|
||||||
}, [users, searchQuery]);
|
}, [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 {
|
try {
|
||||||
await updateConfig.mutateAsync({ [key]: value });
|
await updateConfig.mutateAsync({ [key]: value });
|
||||||
toast.success('System settings updated');
|
toast.success('System settings updated');
|
||||||
@ -123,8 +123,8 @@ export default function IAMPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User table */}
|
{/* User table — relative z-10 so action dropdowns render above sibling cards */}
|
||||||
<Card>
|
<Card className="relative z-10">
|
||||||
<CardHeader className="flex-row items-center justify-between flex-wrap gap-2 md:gap-3">
|
<CardHeader className="flex-row items-center justify-between flex-wrap gap-2 md:gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="p-1.5 rounded-md bg-accent/10">
|
<div className="p-1.5 rounded-md bg-accent/10">
|
||||||
@ -160,7 +160,7 @@ export default function IAMPage() {
|
|||||||
{searchQuery ? 'No users match your search.' : 'No users found.'}
|
{searchQuery ? 'No users match your search.' : 'No users found.'}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div>
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border bg-card-elevated/50">
|
<tr className="border-b border-border bg-card-elevated/50">
|
||||||
@ -320,6 +320,20 @@ export default function IAMPage() {
|
|||||||
disabled={updateConfig.isPending}
|
disabled={updateConfig.isPending}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label className="text-sm font-medium">Allow Passwordless Login</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Allow users to enable passkey-only login, skipping the password prompt entirely.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={config?.allow_passwordless ?? false}
|
||||||
|
onCheckedChange={(v) => handleConfigToggle('allow_passwordless', v)}
|
||||||
|
disabled={updateConfig.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
Loader2,
|
Loader2,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
ShieldOff,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useConfirmAction } from '@/hooks/useConfirmAction';
|
import { useConfirmAction } from '@/hooks/useConfirmAction';
|
||||||
@ -23,6 +24,7 @@ import {
|
|||||||
useToggleUserActive,
|
useToggleUserActive,
|
||||||
useRevokeSessions,
|
useRevokeSessions,
|
||||||
useDeleteUser,
|
useDeleteUser,
|
||||||
|
useDisablePasswordless,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
} from '@/hooks/useAdmin';
|
} from '@/hooks/useAdmin';
|
||||||
import type { AdminUserDetail, UserRole } from '@/types';
|
import type { AdminUserDetail, UserRole } from '@/types';
|
||||||
@ -53,6 +55,7 @@ export default function UserActionsMenu({ user, currentUsername }: UserActionsMe
|
|||||||
const toggleActive = useToggleUserActive();
|
const toggleActive = useToggleUserActive();
|
||||||
const revokeSessions = useRevokeSessions();
|
const revokeSessions = useRevokeSessions();
|
||||||
const deleteUser = useDeleteUser();
|
const deleteUser = useDeleteUser();
|
||||||
|
const disablePasswordless = useDisablePasswordless();
|
||||||
|
|
||||||
// Close on outside click
|
// Close on outside click
|
||||||
useEffect(() => {
|
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 =
|
const isLoading =
|
||||||
updateRole.isPending ||
|
updateRole.isPending ||
|
||||||
resetPassword.isPending ||
|
resetPassword.isPending ||
|
||||||
@ -110,7 +117,8 @@ export default function UserActionsMenu({ user, currentUsername }: UserActionsMe
|
|||||||
removeMfaEnforcement.isPending ||
|
removeMfaEnforcement.isPending ||
|
||||||
toggleActive.isPending ||
|
toggleActive.isPending ||
|
||||||
revokeSessions.isPending ||
|
revokeSessions.isPending ||
|
||||||
deleteUser.isPending;
|
deleteUser.isPending ||
|
||||||
|
disablePasswordless.isPending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={menuRef} className="relative">
|
<div ref={menuRef} className="relative">
|
||||||
@ -258,6 +266,21 @@ export default function UserActionsMenu({ user, currentUsername }: UserActionsMe
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{user.passwordless_enabled && (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors',
|
||||||
|
disablePasswordlessConfirm.confirming
|
||||||
|
? 'text-orange-400 bg-orange-500/10 hover:bg-orange-500/15'
|
||||||
|
: 'hover:bg-card-elevated'
|
||||||
|
)}
|
||||||
|
onClick={disablePasswordlessConfirm.handleClick}
|
||||||
|
>
|
||||||
|
<ShieldOff className="h-4 w-4" />
|
||||||
|
{disablePasswordlessConfirm.confirming ? 'Sure? Click to confirm' : 'Disable Passwordless'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="my-1 border-t border-border" />
|
<div className="my-1 border-t border-border" />
|
||||||
|
|
||||||
{/* Disable / Enable Account */}
|
{/* Disable / Enable Account */}
|
||||||
|
|||||||
@ -193,6 +193,18 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="Passwordless"
|
||||||
|
value={
|
||||||
|
user.passwordless_enabled ? (
|
||||||
|
<span className="text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide bg-green-500/15 text-green-400">
|
||||||
|
Enabled
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">Off</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
<DetailRow
|
<DetailRow
|
||||||
label="Must Change Pwd"
|
label="Must Change Pwd"
|
||||||
value={user.must_change_password ? 'Yes' : 'No'}
|
value={user.must_change_password ? 'Yes' : 'No'}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useState, FormEvent } from 'react';
|
import { useState, FormEvent } from 'react';
|
||||||
import { Navigate } from 'react-router-dom';
|
import { Navigate } from 'react-router-dom';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { AlertTriangle, Copy, Lock, Loader2, ShieldCheck, UserPlus } from 'lucide-react';
|
import { AlertTriangle, Copy, Fingerprint, Lock, Loader2, ShieldCheck, UserPlus } from 'lucide-react';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import api, { getErrorMessage } from '@/lib/api';
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@ -10,6 +10,7 @@ import { DatePicker } from '@/components/ui/date-picker';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
import AmbientBackground from './AmbientBackground';
|
import AmbientBackground from './AmbientBackground';
|
||||||
import type { TotpSetupResponse } from '@/types';
|
import type { TotpSetupResponse } from '@/types';
|
||||||
|
|
||||||
@ -47,6 +48,8 @@ export default function LockScreen() {
|
|||||||
isRegisterPending,
|
isRegisterPending,
|
||||||
isSetupPending,
|
isSetupPending,
|
||||||
isTotpPending,
|
isTotpPending,
|
||||||
|
passkeyLogin,
|
||||||
|
isPasskeyLoginPending,
|
||||||
} = useAuth();
|
} = useAuth();
|
||||||
|
|
||||||
// ── Shared credential fields ──
|
// ── Shared credential fields ──
|
||||||
@ -83,6 +86,31 @@ export default function LockScreen() {
|
|||||||
const [forcedConfirmPassword, setForcedConfirmPassword] = useState('');
|
const [forcedConfirmPassword, setForcedConfirmPassword] = useState('');
|
||||||
const [isForcePwPending, setIsForcePwPending] = useState(false);
|
const [isForcePwPending, setIsForcePwPending] = useState(false);
|
||||||
|
|
||||||
|
// ── Passkey support (U-01: browser feature detection, not per-user) ──
|
||||||
|
const [supportsWebAuthn] = useState(() => !!window.PublicKeyCredential);
|
||||||
|
|
||||||
|
const handlePasskeyLogin = async () => {
|
||||||
|
setLoginError(null);
|
||||||
|
try {
|
||||||
|
const result = await passkeyLogin();
|
||||||
|
if (result?.must_change_password) {
|
||||||
|
setMode('force_pw');
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.name === 'NotAllowedError') {
|
||||||
|
toast.info('Passkey not recognized. Try your password.');
|
||||||
|
} else if (error.name === 'AbortError') {
|
||||||
|
// User cancelled — silent
|
||||||
|
} else {
|
||||||
|
toast.error(getErrorMessage(error, 'Passkey login failed. Try your password.'));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error(getErrorMessage(error, 'Passkey login failed. Try your password.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Redirect authenticated users (no pending MFA flows)
|
// Redirect authenticated users (no pending MFA flows)
|
||||||
if (!isLoading && authStatus?.authenticated && !mfaSetupRequired && mode !== 'force_pw') {
|
if (!isLoading && authStatus?.authenticated && !mfaSetupRequired && mode !== 'force_pw') {
|
||||||
return <Navigate to="/dashboard" replace />;
|
return <Navigate to="/dashboard" replace />;
|
||||||
@ -127,11 +155,10 @@ export default function LockScreen() {
|
|||||||
// mfaSetupRequired / mfaRequired handled by hook state → activeMode switches automatically
|
// mfaSetupRequired / mfaRequired handled by hook state → activeMode switches automatically
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const status = error?.response?.status;
|
const status = error?.response?.status;
|
||||||
if (status === 423) {
|
if (status === 403) {
|
||||||
setLoginError(error.response.data?.detail || 'Account locked. Try again later.');
|
|
||||||
} else if (status === 403) {
|
|
||||||
setLoginError(error.response.data?.detail || 'Account is disabled. Contact an administrator.');
|
setLoginError(error.response.data?.detail || 'Account is disabled. Contact an administrator.');
|
||||||
} else {
|
} else {
|
||||||
|
// 401 covers both wrong password and account lockout (backend embeds detail string)
|
||||||
setLoginError(getErrorMessage(error, 'Invalid username or password'));
|
setLoginError(getErrorMessage(error, 'Invalid username or password'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -491,18 +518,28 @@ export default function LockScreen() {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loginError && (
|
{loginError && (() => {
|
||||||
<div
|
const isLockWarning =
|
||||||
role="alert"
|
loginError.includes('remaining') || loginError.includes('temporarily locked');
|
||||||
className={cn(
|
return (
|
||||||
'flex items-center gap-2 rounded-md border border-red-500/30',
|
<div
|
||||||
'bg-red-500/10 px-3 py-2 mb-4'
|
role="alert"
|
||||||
)}
|
className={cn(
|
||||||
>
|
'flex items-center gap-2 rounded-md border px-3 py-2 mb-4',
|
||||||
<AlertTriangle className="h-4 w-4 text-red-400 shrink-0" aria-hidden="true" />
|
isLockWarning
|
||||||
<p className="text-xs text-red-400">{loginError}</p>
|
? 'bg-amber-500/10 border-amber-500/30'
|
||||||
</div>
|
: 'bg-red-500/10 border-red-500/30'
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
{isLockWarning
|
||||||
|
? <Lock className="h-4 w-4 text-amber-400 shrink-0" aria-hidden="true" />
|
||||||
|
: <AlertTriangle className="h-4 w-4 text-red-400 shrink-0" aria-hidden="true" />}
|
||||||
|
<p className={cn('text-xs', isLockWarning ? 'text-amber-400' : 'text-red-400')}>
|
||||||
|
{loginError}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<form onSubmit={handleCredentialSubmit} className="space-y-4">
|
<form onSubmit={handleCredentialSubmit} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="username" required>Username</Label>
|
<Label htmlFor="username" required>Username</Label>
|
||||||
@ -561,6 +598,30 @@ export default function LockScreen() {
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* Passkey login — shown when browser supports WebAuthn (U-01) */}
|
||||||
|
{!isSetup && supportsWebAuthn && (
|
||||||
|
<>
|
||||||
|
<div className="relative my-4">
|
||||||
|
<Separator />
|
||||||
|
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card px-2 text-xs text-muted-foreground">
|
||||||
|
or
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full gap-2"
|
||||||
|
onClick={handlePasskeyLogin}
|
||||||
|
disabled={isPasskeyLoginPending}
|
||||||
|
aria-label="Sign in with a passkey"
|
||||||
|
>
|
||||||
|
{isPasskeyLoginPending
|
||||||
|
? <Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
: <Fingerprint className="h-4 w-4" />}
|
||||||
|
Sign in with a passkey
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Open registration link — only shown on login screen when enabled */}
|
{/* Open registration link — only shown on login screen when enabled */}
|
||||||
{!isSetup && registrationOpen && (
|
{!isSetup && registrationOpen && (
|
||||||
<div className="mt-4 text-center">
|
<div className="mt-4 text-center">
|
||||||
|
|||||||
@ -591,32 +591,32 @@ export default function EventDetailPanel({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-8 w-8"
|
||||||
onClick={() => setScopeStep(null)}
|
onClick={() => setScopeStep(null)}
|
||||||
title="Cancel"
|
title="Cancel"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
) : (isEditing || isCreating) ? (
|
) : (isEditing || isCreating) ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-green-400 hover:text-green-300"
|
className="h-8 w-8 text-green-400 hover:text-green-300"
|
||||||
onClick={handleEditSave}
|
onClick={handleEditSave}
|
||||||
disabled={saveMutation.isPending}
|
disabled={saveMutation.isPending}
|
||||||
title="Save"
|
title="Save"
|
||||||
>
|
>
|
||||||
<Save className="h-3.5 w-3.5" />
|
<Save className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-8 w-8"
|
||||||
onClick={handleEditCancel}
|
onClick={handleEditCancel}
|
||||||
title="Cancel"
|
title="Cancel"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@ -628,12 +628,12 @@ export default function EventDetailPanel({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-8 w-8"
|
||||||
onClick={handleEditStart}
|
onClick={handleEditStart}
|
||||||
disabled={isAcquiringLock || !!activeLockInfo}
|
disabled={isAcquiringLock || !!activeLockInfo}
|
||||||
title={activeLockInfo ? `Locked by ${activeLockInfo.locked_by_name || 'another user'}` : 'Edit event'}
|
title={activeLockInfo ? `Locked by ${activeLockInfo.locked_by_name || 'another user'}` : 'Edit event'}
|
||||||
>
|
>
|
||||||
{isAcquiringLock ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Pencil className="h-3.5 w-3.5" />}
|
{isAcquiringLock ? <Loader2 className="h-4 w-4 animate-spin" /> : <Pencil className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{/* Leave button for invited events */}
|
{/* Leave button for invited events */}
|
||||||
@ -641,11 +641,11 @@ export default function EventDetailPanel({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||||
onClick={() => setShowLeaveDialog(true)}
|
onClick={() => setShowLeaveDialog(true)}
|
||||||
title="Leave event"
|
title="Leave event"
|
||||||
>
|
>
|
||||||
<LogOut className="h-3.5 w-3.5" />
|
<LogOut className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{/* Delete button for own events */}
|
{/* Delete button for own events */}
|
||||||
@ -664,12 +664,12 @@ export default function EventDetailPanel({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||||
onClick={handleDeleteStart}
|
onClick={handleDeleteStart}
|
||||||
disabled={deleteMutation.isPending}
|
disabled={deleteMutation.isPending}
|
||||||
title="Delete event"
|
title="Delete event"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@ -678,11 +678,11 @@ export default function EventDetailPanel({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-8 w-8"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
title="Close panel"
|
title="Close panel"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -23,7 +23,7 @@ export default function AlertBanner({ alerts, onDismiss, onSnooze }: AlertBanner
|
|||||||
{alerts.length}
|
{alerts.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y divide-border max-h-48 overflow-y-auto">
|
<div className="divide-y divide-border">
|
||||||
{alerts.map((alert) => (
|
{alerts.map((alert) => (
|
||||||
<div
|
<div
|
||||||
key={alert.id}
|
key={alert.id}
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet, useNavigate } from 'react-router-dom';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { Menu } from 'lucide-react';
|
import { Menu } from 'lucide-react';
|
||||||
import { useTheme } from '@/hooks/useTheme';
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { usePrefetch } from '@/hooks/usePrefetch';
|
import { usePrefetch } from '@/hooks/usePrefetch';
|
||||||
import { AlertsProvider } from '@/hooks/useAlerts';
|
import { AlertsProvider } from '@/hooks/useAlerts';
|
||||||
import { LockProvider, useLock } from '@/hooks/useLock';
|
import { LockProvider, useLock } from '@/hooks/useLock';
|
||||||
@ -17,7 +19,27 @@ function AppContent({ mobileOpen, setMobileOpen }: {
|
|||||||
setMobileOpen: (v: boolean) => void;
|
setMobileOpen: (v: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const { isLocked, isLockResolved } = useLock();
|
const { isLocked, isLockResolved } = useLock();
|
||||||
|
const { hasPasskeys } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
usePrefetch(isLockResolved && !isLocked);
|
usePrefetch(isLockResolved && !isLocked);
|
||||||
|
|
||||||
|
// Post-login passkey prompt — show once per session if user has no passkeys
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
isLockResolved && !isLocked && !hasPasskeys &&
|
||||||
|
window.PublicKeyCredential &&
|
||||||
|
!sessionStorage.getItem('passkey-prompt-shown')
|
||||||
|
) {
|
||||||
|
sessionStorage.setItem('passkey-prompt-shown', '1');
|
||||||
|
toast.info('Simplify your login \u2014 set up a passkey in Settings', {
|
||||||
|
duration: 8000,
|
||||||
|
action: {
|
||||||
|
label: 'Set up',
|
||||||
|
onClick: () => navigate('/settings?tab=security'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isLockResolved, isLocked, hasPasskeys, navigate]);
|
||||||
const [collapsed, setCollapsed] = useState(() => {
|
const [collapsed, setCollapsed] = useState(() => {
|
||||||
try { return JSON.parse(localStorage.getItem('umbra-sidebar-collapsed') || 'false'); }
|
try { return JSON.parse(localStorage.getItem('umbra-sidebar-collapsed') || 'false'); }
|
||||||
catch { return false; }
|
catch { return false; }
|
||||||
|
|||||||
@ -1,34 +1,43 @@
|
|||||||
import { useState, FormEvent, useEffect, useRef } from 'react';
|
import { useState, FormEvent, useEffect, useRef } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Lock, Loader2 } from 'lucide-react';
|
import { Lock, Loader2, Fingerprint } from 'lucide-react';
|
||||||
import { useLock } from '@/hooks/useLock';
|
import { useLock } from '@/hooks/useLock';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useSettings } from '@/hooks/useSettings';
|
import { useSettings } from '@/hooks/useSettings';
|
||||||
import { getErrorMessage } from '@/lib/api';
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
import AmbientBackground from '@/components/auth/AmbientBackground';
|
import AmbientBackground from '@/components/auth/AmbientBackground';
|
||||||
|
|
||||||
export default function LockOverlay() {
|
export default function LockOverlay() {
|
||||||
const { isLocked, unlock } = useLock();
|
const { isLocked, unlock, unlockWithPasskey } = useLock();
|
||||||
const { logout } = useAuth();
|
const { logout, passwordlessEnabled, hasPasskeys, authStatus } = useAuth();
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [isUnlocking, setIsUnlocking] = useState(false);
|
const [isUnlocking, setIsUnlocking] = useState(false);
|
||||||
|
const [isPasskeyUnlocking, setIsPasskeyUnlocking] = useState(false);
|
||||||
|
const [supportsWebAuthn] = useState(() => !!window.PublicKeyCredential);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(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(() => {
|
useEffect(() => {
|
||||||
if (isLocked) {
|
if (isLocked && showPasswordForm) {
|
||||||
setPassword('');
|
setPassword('');
|
||||||
// Small delay to let the overlay render
|
|
||||||
const t = setTimeout(() => inputRef.current?.focus(), 100);
|
const t = setTimeout(() => inputRef.current?.focus(), 100);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}
|
}
|
||||||
}, [isLocked]);
|
}, [isLocked, showPasswordForm]);
|
||||||
|
|
||||||
if (!isLocked) return null;
|
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 () => {
|
const handleSwitchAccount = async () => {
|
||||||
await logout();
|
await logout();
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
@ -75,29 +107,87 @@ export default function LockOverlay() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Password form */}
|
{/* Passwordless-primary mode: passkey button only */}
|
||||||
<form onSubmit={handleUnlock} className="w-full space-y-4">
|
{passwordlessEnabled && showPasskeyButton && (
|
||||||
<Input
|
<Button
|
||||||
ref={inputRef}
|
type="button"
|
||||||
type="password"
|
className="w-full gap-2"
|
||||||
aria-label="Password"
|
onClick={handlePasskeyUnlock}
|
||||||
value={password}
|
disabled={isPasskeyUnlocking}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
aria-label="Unlock with passkey"
|
||||||
placeholder="Enter password to unlock"
|
>
|
||||||
autoComplete="current-password"
|
{isPasskeyUnlocking ? (
|
||||||
className="text-center"
|
|
||||||
/>
|
|
||||||
<Button type="submit" className="w-full" disabled={isUnlocking}>
|
|
||||||
{isUnlocking ? (
|
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
Unlocking
|
Verifying passkey
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Unlock'
|
<>
|
||||||
|
<Fingerprint className="h-4 w-4" />
|
||||||
|
Unlock with passkey
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
)}
|
||||||
|
|
||||||
|
{/* Password form — shown when passwordless is off */}
|
||||||
|
{showPasswordForm && (
|
||||||
|
<>
|
||||||
|
<form onSubmit={handleUnlock} className="w-full space-y-4">
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
type="password"
|
||||||
|
aria-label="Password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Enter password to unlock"
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="text-center"
|
||||||
|
/>
|
||||||
|
<Button type="submit" className="w-full" disabled={isUnlocking}>
|
||||||
|
{isUnlocking ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Unlocking
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Unlock'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Passkey secondary option */}
|
||||||
|
{showPasskeyButton && (
|
||||||
|
<div className="w-full flex flex-col items-center gap-4">
|
||||||
|
<div className="w-full flex items-center gap-3">
|
||||||
|
<Separator className="flex-1" />
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">or</span>
|
||||||
|
<Separator className="flex-1" />
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full gap-2"
|
||||||
|
onClick={handlePasskeyUnlock}
|
||||||
|
disabled={isPasskeyUnlocking}
|
||||||
|
aria-label="Unlock with passkey"
|
||||||
|
>
|
||||||
|
{isPasskeyUnlocking ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Verifying passkey
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Fingerprint className="h-4 w-4" />
|
||||||
|
Use a passkey
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Switch account link */}
|
{/* Switch account link */}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -285,21 +285,21 @@ export default function TaskDetailPanel({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-green-400 hover:text-green-300"
|
className="h-8 w-8 text-green-400 hover:text-green-300"
|
||||||
onClick={handleEditSave}
|
onClick={handleEditSave}
|
||||||
disabled={updateTaskMutation.isPending}
|
disabled={updateTaskMutation.isPending}
|
||||||
title="Save changes"
|
title="Save changes"
|
||||||
>
|
>
|
||||||
<Save className="h-3.5 w-3.5" />
|
<Save className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-8 w-8"
|
||||||
onClick={handleEditCancel}
|
onClick={handleEditCancel}
|
||||||
title="Cancel editing"
|
title="Cancel editing"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@ -307,30 +307,30 @@ export default function TaskDetailPanel({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-8 w-8"
|
||||||
onClick={handleEditStart}
|
onClick={handleEditStart}
|
||||||
title="Edit task"
|
title="Edit task"
|
||||||
>
|
>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||||
onClick={() => onDelete(task.id)}
|
onClick={() => onDelete(task.id)}
|
||||||
title="Delete task"
|
title="Delete task"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
{onClose && (
|
{onClose && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-8 w-8"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
title="Close panel"
|
title="Close panel"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@ -562,7 +562,7 @@ export default function TaskDetailPanel({
|
|||||||
{/* Comments */}
|
{/* Comments */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<MessageSquare className="h-3.5 w-3.5 text-muted-foreground" />
|
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
||||||
<h4 className="text-[11px] text-muted-foreground uppercase tracking-wider">
|
<h4 className="text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
Comments
|
Comments
|
||||||
{comments.length > 0 && (
|
{comments.length > 0 && (
|
||||||
|
|||||||
@ -236,21 +236,21 @@ export default function ReminderDetailPanel({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-green-400 hover:text-green-300"
|
className="h-8 w-8 text-green-400 hover:text-green-300"
|
||||||
onClick={handleEditSave}
|
onClick={handleEditSave}
|
||||||
disabled={saveMutation.isPending}
|
disabled={saveMutation.isPending}
|
||||||
title="Save"
|
title="Save"
|
||||||
>
|
>
|
||||||
<Save className="h-3.5 w-3.5" />
|
<Save className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-8 w-8"
|
||||||
onClick={handleEditCancel}
|
onClick={handleEditCancel}
|
||||||
title="Cancel"
|
title="Cancel"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@ -259,22 +259,22 @@ export default function ReminderDetailPanel({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 hover:bg-orange-500/10 hover:text-orange-400"
|
className="h-8 w-8 hover:bg-orange-500/10 hover:text-orange-400"
|
||||||
onClick={() => dismissMutation.mutate()}
|
onClick={() => dismissMutation.mutate()}
|
||||||
disabled={dismissMutation.isPending}
|
disabled={dismissMutation.isPending}
|
||||||
title="Dismiss reminder"
|
title="Dismiss reminder"
|
||||||
>
|
>
|
||||||
<BellOff className="h-3.5 w-3.5" />
|
<BellOff className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-8 w-8"
|
||||||
onClick={handleEditStart}
|
onClick={handleEditStart}
|
||||||
title="Edit reminder"
|
title="Edit reminder"
|
||||||
>
|
>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
{confirmingDelete ? (
|
{confirmingDelete ? (
|
||||||
<Button
|
<Button
|
||||||
@ -290,22 +290,22 @@ export default function ReminderDetailPanel({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||||
onClick={handleDeleteClick}
|
onClick={handleDeleteClick}
|
||||||
disabled={deleteMutation.isPending}
|
disabled={deleteMutation.isPending}
|
||||||
title="Delete reminder"
|
title="Delete reminder"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-8 w-8"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
title="Close panel"
|
title="Close panel"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
600
frontend/src/components/settings/PasskeySection.tsx
Normal file
600
frontend/src/components/settings/PasskeySection.tsx
Normal file
@ -0,0 +1,600 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Fingerprint, Loader2, Trash2, Cloud, ShieldOff } from 'lucide-react';
|
||||||
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { useConfirmAction } from '@/hooks/useConfirmAction';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import type { PasskeyCredential } from '@/types';
|
||||||
|
|
||||||
|
function formatRelativeTime(dateStr: string | null): string {
|
||||||
|
if (!dateStr) return 'Never';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
if (diffMins < 1) return 'Just now';
|
||||||
|
if (diffMins < 60) return `${diffMins}m ago`;
|
||||||
|
const diffHours = Math.floor(diffMins / 60);
|
||||||
|
if (diffHours < 24) return `${diffHours}h ago`;
|
||||||
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
|
if (diffDays < 30) return `${diffDays}d ago`;
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string | null): string {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
return new Date(dateStr).toLocaleDateString(undefined, {
|
||||||
|
day: 'numeric', month: 'short', year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectDeviceName(): string {
|
||||||
|
const ua = navigator.userAgent;
|
||||||
|
let browser = 'Browser';
|
||||||
|
if (ua.includes('Chrome') && !ua.includes('Edg')) browser = 'Chrome';
|
||||||
|
else if (ua.includes('Safari') && !ua.includes('Chrome')) browser = 'Safari';
|
||||||
|
else if (ua.includes('Firefox')) browser = 'Firefox';
|
||||||
|
else if (ua.includes('Edg')) browser = 'Edge';
|
||||||
|
|
||||||
|
let os = '';
|
||||||
|
if (ua.includes('Mac')) os = 'macOS';
|
||||||
|
else if (ua.includes('Windows')) os = 'Windows';
|
||||||
|
else if (ua.includes('Linux')) os = 'Linux';
|
||||||
|
else if (ua.includes('iPhone') || ua.includes('iPad')) os = 'iOS';
|
||||||
|
else if (ua.includes('Android')) os = 'Android';
|
||||||
|
|
||||||
|
return os ? `${os} \u2014 ${browser}` : browser;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeleteConfirmProps {
|
||||||
|
credential: PasskeyCredential;
|
||||||
|
onDelete: (id: number, password: string) => void;
|
||||||
|
isDeleting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PasskeyDeleteButton({ credential, onDelete, isDeleting }: DeleteConfirmProps) {
|
||||||
|
const [showPasswordDialog, setShowPasswordDialog] = useState(false);
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
|
||||||
|
const { confirming, handleClick } = useConfirmAction(() => {
|
||||||
|
setShowPasswordDialog(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmitDelete = () => {
|
||||||
|
onDelete(credential.id, password);
|
||||||
|
setPassword('');
|
||||||
|
setShowPasswordDialog(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-muted-foreground hover:text-red-400"
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={isDeleting}
|
||||||
|
aria-label={`Remove passkey ${credential.name}`}
|
||||||
|
>
|
||||||
|
{confirming ? (
|
||||||
|
<span className="text-xs font-medium text-red-400">Sure?</span>
|
||||||
|
) : (
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dialog open={showPasswordDialog} onOpenChange={setShowPasswordDialog}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Remove passkey</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Enter your password to remove "{credential.name}".
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="delete-passkey-password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="delete-passkey-password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' && password) handleSubmitDelete(); }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setShowPasswordDialog(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleSubmitDelete}
|
||||||
|
disabled={!password || isDeleting}
|
||||||
|
>
|
||||||
|
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Remove'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PasskeySection() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { 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<PasskeyCredential[]>('/auth/passkeys');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const registerMutation = useMutation({
|
||||||
|
mutationFn: async ({ password }: { password: string }) => {
|
||||||
|
const { startRegistration } = await import('@simplewebauthn/browser');
|
||||||
|
|
||||||
|
// Step 1: Get registration options (requires password V-02)
|
||||||
|
const { data: beginResp } = await api.post('/auth/passkeys/register/begin', { password });
|
||||||
|
|
||||||
|
// Step 2: Browser WebAuthn ceremony
|
||||||
|
setCeremonyState('waiting');
|
||||||
|
const credential = await startRegistration(beginResp.options);
|
||||||
|
|
||||||
|
return {
|
||||||
|
credential: JSON.stringify(credential),
|
||||||
|
challenge_token: beginResp.challenge_token,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setPendingCredential(data);
|
||||||
|
setPasskeyName(detectDeviceName());
|
||||||
|
setCeremonyState('naming');
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
if (error instanceof Error && error.name === 'NotAllowedError') {
|
||||||
|
toast.info('Passkey setup cancelled');
|
||||||
|
} else if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
toast.info('Cancelled');
|
||||||
|
} else {
|
||||||
|
toast.error(getErrorMessage(error, 'Failed to create passkey'));
|
||||||
|
}
|
||||||
|
setRegisterDialogOpen(false);
|
||||||
|
resetRegisterState();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const completeMutation = useMutation({
|
||||||
|
mutationFn: async ({ credential, challenge_token, name }: {
|
||||||
|
credential: string; challenge_token: string; name: string;
|
||||||
|
}) => {
|
||||||
|
const { data } = await api.post('/auth/passkeys/register/complete', {
|
||||||
|
credential, challenge_token, name,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Passkey registered');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['passkeys'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['auth'] });
|
||||||
|
setRegisterDialogOpen(false);
|
||||||
|
resetRegisterState();
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
toast.error(getErrorMessage(error, 'Failed to save passkey'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: async ({ id, password }: { id: number; password: string }) => {
|
||||||
|
await api.delete(`/auth/passkeys/${id}`, { data: { password } });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Passkey removed');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['passkeys'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['auth'] });
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
toast.error(getErrorMessage(error, 'Failed to remove passkey'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const 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 (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="p-1.5 rounded-md bg-accent/10">
|
||||||
|
<Fingerprint className="h-4 w-4 text-accent" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label>Passkeys</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Sign in with your fingerprint, face, or security key
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{hasPasskeys && (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-green-500/10 px-2.5 py-0.5 text-xs font-semibold text-green-400">
|
||||||
|
{passkeys.length} registered
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasPasskeys && (
|
||||||
|
<ul className="space-y-1" aria-live="polite">
|
||||||
|
{passkeys.map((pk) => (
|
||||||
|
<li
|
||||||
|
key={pk.id}
|
||||||
|
className="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-card-elevated transition-colors duration-150"
|
||||||
|
>
|
||||||
|
<div className="p-1.5 rounded-md bg-accent/10">
|
||||||
|
<Fingerprint className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium truncate">{pk.name}</span>
|
||||||
|
{pk.backed_up && (
|
||||||
|
<Cloud className="h-3 w-3 text-muted-foreground shrink-0" aria-label="Synced" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Added {formatDate(pk.created_at)} · Last used {formatRelativeTime(pk.last_used_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<PasskeyDeleteButton
|
||||||
|
credential={pk}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
isDeleting={deleteMutation.isPending && (deleteMutation.variables as { id: number } | undefined)?.id === pk.id}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
onClick={handleStartRegister}
|
||||||
|
>
|
||||||
|
<Fingerprint className="h-4 w-4" />
|
||||||
|
{hasPasskeys ? 'Add another passkey' : 'Add a passkey'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Passwordless login section — hidden when admin hasn't enabled the feature */}
|
||||||
|
{(allowPasswordless || passwordlessEnabled) && <Separator />}
|
||||||
|
|
||||||
|
{(allowPasswordless || passwordlessEnabled) && (
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Fingerprint className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<Label className="text-sm font-medium">Passwordless Login</Label>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Skip the password prompt and unlock the app using a passkey only.
|
||||||
|
</p>
|
||||||
|
{passkeys.length < 2 && !passwordlessEnabled && (
|
||||||
|
<p className="text-xs text-amber-400">
|
||||||
|
Requires at least 2 registered passkeys as a fallback.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={passwordlessEnabled}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (checked) {
|
||||||
|
setEnablePassword('');
|
||||||
|
setEnableDialogOpen(true);
|
||||||
|
} else {
|
||||||
|
setDisableDialogOpen(true);
|
||||||
|
disablePasswordlessMutation.mutate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={(!passwordlessEnabled && passkeys.length < 2) || enablePasswordlessMutation.isPending || disablePasswordlessMutation.isPending}
|
||||||
|
aria-label="Toggle passwordless login"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Enable passwordless dialog */}
|
||||||
|
<Dialog open={enableDialogOpen} onOpenChange={(open) => {
|
||||||
|
if (!open) { setEnableDialogOpen(false); setEnablePassword(''); }
|
||||||
|
}}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<div className="p-2 rounded-lg bg-accent/10">
|
||||||
|
<Fingerprint className="h-5 w-5 text-accent" />
|
||||||
|
</div>
|
||||||
|
<DialogTitle>Enable Passwordless Login</DialogTitle>
|
||||||
|
</div>
|
||||||
|
<DialogDescription>
|
||||||
|
Confirm your password to enable passkey-only login.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="enable-passwordless-password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="enable-passwordless-password"
|
||||||
|
type="password"
|
||||||
|
value={enablePassword}
|
||||||
|
onChange={(e) => setEnablePassword(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && enablePassword) {
|
||||||
|
enablePasswordlessMutation.mutate({ password: enablePassword });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => { setEnableDialogOpen(false); setEnablePassword(''); }}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => enablePasswordlessMutation.mutate({ password: enablePassword })}
|
||||||
|
disabled={!enablePassword || enablePasswordlessMutation.isPending}
|
||||||
|
>
|
||||||
|
{enablePasswordlessMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Enable'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Disable passwordless dialog */}
|
||||||
|
<Dialog open={disableDialogOpen} onOpenChange={(open) => {
|
||||||
|
if (!open && !disablePasswordlessMutation.isPending) {
|
||||||
|
setDisableDialogOpen(false);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<div className="p-2 rounded-lg bg-orange-500/10">
|
||||||
|
<ShieldOff className="h-5 w-5 text-orange-400" />
|
||||||
|
</div>
|
||||||
|
<DialogTitle>Disable Passwordless Login</DialogTitle>
|
||||||
|
</div>
|
||||||
|
<DialogDescription>
|
||||||
|
Verify with your passkey to disable passwordless login.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col items-center gap-4 py-4">
|
||||||
|
{disablePasswordlessMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-accent" />
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
Follow your browser's prompt to verify your passkey
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
Ready to verify your passkey
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDisableDialogOpen(false)}
|
||||||
|
disabled={disablePasswordlessMutation.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Registration ceremony dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={registerDialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setRegisterDialogOpen(false);
|
||||||
|
resetRegisterState();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<div className="p-2 rounded-lg bg-accent/10">
|
||||||
|
<Fingerprint className="h-5 w-5 text-accent" />
|
||||||
|
</div>
|
||||||
|
<DialogTitle>
|
||||||
|
{ceremonyState === 'password' && 'Add a passkey'}
|
||||||
|
{ceremonyState === 'waiting' && 'Creating passkey'}
|
||||||
|
{ceremonyState === 'naming' && 'Name your passkey'}
|
||||||
|
</DialogTitle>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{ceremonyState === 'password' && (
|
||||||
|
<>
|
||||||
|
<DialogDescription>
|
||||||
|
Enter your password to add a passkey to your account.
|
||||||
|
</DialogDescription>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="register-passkey-password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="register-passkey-password"
|
||||||
|
type="password"
|
||||||
|
value={registerPassword}
|
||||||
|
onChange={(e) => setRegisterPassword(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' && registerPassword) handlePasswordSubmit(); }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setRegisterDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handlePasswordSubmit}
|
||||||
|
disabled={!registerPassword || registerMutation.isPending}
|
||||||
|
>
|
||||||
|
{registerMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Continue'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ceremonyState === 'waiting' && (
|
||||||
|
<div className="flex flex-col items-center gap-4 py-6">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-accent" />
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
Follow your browser's prompt to create a passkey
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ceremonyState === 'naming' && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="passkey-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="passkey-name"
|
||||||
|
value={passkeyName}
|
||||||
|
onChange={(e) => setPasskeyName(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' && passkeyName.trim()) handleSaveName(); }}
|
||||||
|
maxLength={100}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Give this passkey a name to help you identify it later.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveName}
|
||||||
|
disabled={!passkeyName.trim() || completeMutation.isPending}
|
||||||
|
>
|
||||||
|
{completeMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import TotpSetupSection from './TotpSetupSection';
|
import TotpSetupSection from './TotpSetupSection';
|
||||||
|
import PasskeySection from './PasskeySection';
|
||||||
import type { Settings } from '@/types';
|
import type { Settings } from '@/types';
|
||||||
|
|
||||||
interface SecurityTabProps {
|
interface SecurityTabProps {
|
||||||
@ -86,6 +87,9 @@ export default function SecurityTab({ settings, updateSettings, isUpdating }: Se
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Passkeys */}
|
||||||
|
<PasskeySection />
|
||||||
|
|
||||||
{/* Password + TOTP */}
|
{/* Password + TOTP */}
|
||||||
<TotpSetupSection bare />
|
<TotpSetupSection bare />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -61,7 +61,7 @@ export function EntityDetailPanel<T>({
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={onToggleFavourite}
|
onClick={onToggleFavourite}
|
||||||
aria-label={isFavourite ? `Remove from ${favouriteLabel}s` : `Add to ${favouriteLabel}s`}
|
aria-label={isFavourite ? `Remove from ${favouriteLabel}s` : `Add to ${favouriteLabel}s`}
|
||||||
className={`h-7 w-7 ${isFavourite ? 'text-yellow-400' : 'text-muted-foreground'}`}
|
className={`h-8 w-8 ${isFavourite ? 'text-yellow-400' : 'text-muted-foreground'}`}
|
||||||
>
|
>
|
||||||
{isFavourite ? (
|
{isFavourite ? (
|
||||||
<Star className="h-4 w-4 fill-yellow-400" />
|
<Star className="h-4 w-4 fill-yellow-400" />
|
||||||
@ -75,7 +75,7 @@ export function EntityDetailPanel<T>({
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label="Close panel"
|
aria-label="Close panel"
|
||||||
className="h-7 w-7"
|
className="h-8 w-8"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -272,21 +272,21 @@ export default function TodoDetailPanel({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-green-400 hover:text-green-300"
|
className="h-8 w-8 text-green-400 hover:text-green-300"
|
||||||
onClick={handleEditSave}
|
onClick={handleEditSave}
|
||||||
disabled={saveMutation.isPending}
|
disabled={saveMutation.isPending}
|
||||||
title="Save"
|
title="Save"
|
||||||
>
|
>
|
||||||
<Save className="h-3.5 w-3.5" />
|
<Save className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-8 w-8"
|
||||||
onClick={handleEditCancel}
|
onClick={handleEditCancel}
|
||||||
title="Cancel"
|
title="Cancel"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@ -294,11 +294,11 @@ export default function TodoDetailPanel({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-8 w-8"
|
||||||
onClick={handleEditStart}
|
onClick={handleEditStart}
|
||||||
title="Edit todo"
|
title="Edit todo"
|
||||||
>
|
>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
{confirmingDelete ? (
|
{confirmingDelete ? (
|
||||||
<Button
|
<Button
|
||||||
@ -314,22 +314,22 @@ export default function TodoDetailPanel({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||||
onClick={handleDeleteClick}
|
onClick={handleDeleteClick}
|
||||||
disabled={deleteMutation.isPending}
|
disabled={deleteMutation.isPending}
|
||||||
title="Delete todo"
|
title="Delete todo"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-8 w-8"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
title="Close panel"
|
title="Close panel"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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
|
// Re-export getErrorMessage for convenience in admin components
|
||||||
export { getErrorMessage };
|
export { getErrorMessage };
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import type { AuthStatus, LoginResponse } from '@/types';
|
import type { AuthStatus, LoginResponse, PasskeyLoginResponse } from '@/types';
|
||||||
|
|
||||||
export function useAuth() {
|
export function useAuth() {
|
||||||
const queryClient = useQueryClient();
|
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<PasskeyLoginResponse>('/auth/passkeys/login/complete', {
|
||||||
|
credential: JSON.stringify(credential),
|
||||||
|
challenge_token: beginResp.challenge_token,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setMfaToken(null);
|
||||||
|
setMfaSetupRequired(false);
|
||||||
|
if (!data?.must_change_password) {
|
||||||
|
queryClient.setQueryData(['auth'], (old: AuthStatus | undefined) => {
|
||||||
|
if (!old) return old;
|
||||||
|
return { ...old, authenticated: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['auth'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const logoutMutation = useMutation({
|
const logoutMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const { data } = await api.post('/auth/logout');
|
const { data } = await api.post('/auth/logout');
|
||||||
@ -125,5 +149,11 @@ export function useAuth() {
|
|||||||
isRegisterPending: registerMutation.isPending,
|
isRegisterPending: registerMutation.isPending,
|
||||||
isTotpPending: totpVerifyMutation.isPending,
|
isTotpPending: totpVerifyMutation.isPending,
|
||||||
isSetupPending: setupMutation.isPending,
|
isSetupPending: setupMutation.isPending,
|
||||||
|
passkeyLogin: passkeyLoginMutation.mutateAsync,
|
||||||
|
isPasskeyLoginPending: passkeyLoginMutation.isPending,
|
||||||
|
hasPasskeys: authQuery.data?.has_passkeys ?? false,
|
||||||
|
passkeyCount: 0, // Derived from passkeys list query in PasskeySection, not auth/status
|
||||||
|
passwordlessEnabled: authQuery.data?.passwordless_enabled ?? false,
|
||||||
|
allowPasswordless: authQuery.data?.allow_passwordless ?? false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,7 @@ interface LockContextValue {
|
|||||||
isLockResolved: boolean;
|
isLockResolved: boolean;
|
||||||
lock: () => Promise<void>;
|
lock: () => Promise<void>;
|
||||||
unlock: (password: string) => Promise<void>;
|
unlock: (password: string) => Promise<void>;
|
||||||
|
unlockWithPasskey: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LockContext = createContext<LockContextValue | null>(null);
|
const LockContext = createContext<LockContextValue | null>(null);
|
||||||
@ -94,6 +95,14 @@ export function LockProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, [queryClient]);
|
}, [queryClient]);
|
||||||
|
|
||||||
|
const unlockWithPasskey = useCallback(() => {
|
||||||
|
setIsLocked(false);
|
||||||
|
lastActivityRef.current = Date.now();
|
||||||
|
queryClient.setQueryData<AuthStatus>(['auth'], (old) =>
|
||||||
|
old ? { ...old, is_locked: false } : old
|
||||||
|
);
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
// Auto-lock idle timer
|
// Auto-lock idle timer
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const enabled = settings?.auto_lock_enabled ?? false;
|
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]);
|
}, [settings?.auto_lock_enabled, settings?.auto_lock_minutes, isLocked, lock]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LockContext.Provider value={{ isLocked, isLockResolved, lock, unlock }}>
|
<LockContext.Provider value={{ isLocked, isLockResolved, lock, unlock, unlockWithPasskey }}>
|
||||||
{children}
|
{children}
|
||||||
</LockContext.Provider>
|
</LockContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -15,7 +15,7 @@ api.interceptors.response.use(
|
|||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
const url = error.config?.url || '';
|
const url = error.config?.url || '';
|
||||||
// Don't redirect on auth endpoints — they legitimately return 401
|
// Don't redirect on auth endpoints — they legitimately return 401
|
||||||
const authEndpoints = ['/auth/login', '/auth/register', '/auth/setup', '/auth/verify-password', '/auth/change-password'];
|
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))) {
|
if (!authEndpoints.some(ep => url.startsWith(ep))) {
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -243,6 +243,23 @@ export interface AuthStatus {
|
|||||||
username: string | null;
|
username: string | null;
|
||||||
registration_open: boolean;
|
registration_open: boolean;
|
||||||
is_locked: 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
|
// Login response discriminated union
|
||||||
@ -279,6 +296,7 @@ export interface AdminUser {
|
|||||||
last_password_change_at: string | null;
|
last_password_change_at: string | null;
|
||||||
totp_enabled: boolean;
|
totp_enabled: boolean;
|
||||||
mfa_enforce_pending: boolean;
|
mfa_enforce_pending: boolean;
|
||||||
|
passwordless_enabled: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -293,6 +311,7 @@ export interface AdminUserDetail extends AdminUser {
|
|||||||
export interface SystemConfig {
|
export interface SystemConfig {
|
||||||
allow_registration: boolean;
|
allow_registration: boolean;
|
||||||
enforce_mfa_new_users: boolean;
|
enforce_mfa_new_users: boolean;
|
||||||
|
allow_passwordless: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuditLogEntry {
|
export interface AuditLogEntry {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user