From fbc452a004a0bad659150ccb49bfd6c41d34852b Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Wed, 25 Feb 2026 04:12:37 +0800 Subject: [PATCH] =?UTF-8?q?Implement=20Stage=206=20Track=20A:=20PIN=20?= =?UTF-8?q?=E2=86=92=20Username/Password=20auth=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New User model (username, argon2id password_hash, totp fields, lockout) - New UserSession model (DB-backed revocation, replaces in-memory set) - New services/auth.py: Argon2id hashing, bcrypt→Argon2id upgrade path, URLSafeTimedSerializer session/MFA tokens - New schemas/auth.py: SetupRequest, LoginRequest, ChangePasswordRequest with OWASP password strength validation - Full rewrite of routers/auth.py: setup/login/logout/status/change-password with account lockout (10 failures → 30-min, HTTP 423), IP rate limiting retained as outer layer, get_current_user + get_current_settings dependencies replacing get_current_session - Settings model: drop pin_hash, add user_id FK (nullable for migration) - Schemas/settings.py: remove SettingsCreate, ChangePinRequest, _validate_pin_length - Settings router: rewrite to use get_current_user + get_current_settings, preserve ntfy test endpoint - All 11 consumer routers updated: auth-gate-only routers use get_current_user, routers reading Settings fields use get_current_settings - config.py: add SESSION_MAX_AGE_DAYS, MFA_TOKEN_MAX_AGE_SECONDS, TOTP_ISSUER - main.py: import User and UserSession models for Alembic discovery - requirements.txt: add argon2-cffi>=23.1.0 - Migration 023: create users + user_sessions tables, migrate pin_hash → User row (admin), backfill settings.user_id, drop pin_hash Co-Authored-By: Claude Opus 4.6 --- .../023_auth_migration_users_sessions.py | 143 +++++++ backend/app/config.py | 9 + backend/app/main.py | 4 + backend/app/models/session.py | 23 + backend/app/models/settings.py | 11 +- backend/app/models/user.py | 29 ++ backend/app/routers/auth.py | 401 +++++++++++++----- backend/app/routers/calendars.py | 12 +- backend/app/routers/dashboard.py | 6 +- backend/app/routers/event_templates.py | 11 +- backend/app/routers/events.py | 14 +- backend/app/routers/locations.py | 16 +- backend/app/routers/people.py | 14 +- backend/app/routers/projects.py | 30 +- backend/app/routers/reminders.py | 20 +- backend/app/routers/settings.py | 49 +-- backend/app/routers/todos.py | 15 +- backend/app/routers/weather.py | 16 +- backend/app/schemas/auth.py | 62 +++ backend/app/schemas/settings.py | 27 -- backend/app/services/auth.py | 128 ++++++ backend/requirements.txt | 1 + 22 files changed, 788 insertions(+), 253 deletions(-) create mode 100644 backend/alembic/versions/023_auth_migration_users_sessions.py create mode 100644 backend/app/models/session.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/schemas/auth.py create mode 100644 backend/app/services/auth.py diff --git a/backend/alembic/versions/023_auth_migration_users_sessions.py b/backend/alembic/versions/023_auth_migration_users_sessions.py new file mode 100644 index 0000000..5b7bb7a --- /dev/null +++ b/backend/alembic/versions/023_auth_migration_users_sessions.py @@ -0,0 +1,143 @@ +"""Auth migration: create users + user_sessions tables, migrate pin_hash to User row, +add user_id FK to settings, drop pin_hash from settings. + +Revision ID: 023 +Revises: 022 +Create Date: 2026-02-25 + +Data migration strategy (handles both fresh DB and existing single-user DB): + 1. Create users table + 2. Create user_sessions table + 3. If settings row exists with a pin_hash, create a User row with + username='admin' and password_hash = pin_hash (bcrypt hash is valid — + user will be prompted to change password on first login, hash upgrades + transparently to Argon2id on first successful login). + 4. Add user_id FK column to settings (nullable initially) + 5. Backfill settings.user_id to point at the migrated admin user (if any) + 6. Drop pin_hash from settings +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers +revision = '023' +down_revision = '022' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ------------------------------------------------------------------ + # 1. Create users table + # ------------------------------------------------------------------ + op.create_table( + 'users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=50), nullable=False), + sa.Column('password_hash', sa.String(length=255), nullable=False), + sa.Column('totp_secret', sa.String(length=500), nullable=True), + sa.Column('totp_enabled', sa.Boolean(), nullable=False, server_default='false'), + sa.Column('failed_login_count', sa.Integer(), nullable=False, server_default='0'), + sa.Column('locked_until', sa.DateTime(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('NOW()')), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('NOW()')), + sa.Column('last_login_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index('ix_users_id', 'users', ['id'], unique=False) + op.create_index('ix_users_username', 'users', ['username'], unique=True) + + # ------------------------------------------------------------------ + # 2. Create user_sessions table + # ------------------------------------------------------------------ + op.create_table( + 'user_sessions', + sa.Column('id', sa.String(length=64), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('NOW()')), + sa.Column('expires_at', sa.DateTime(), nullable=False), + sa.Column('revoked', sa.Boolean(), nullable=False, server_default='false'), + sa.Column('ip_address', sa.String(length=45), nullable=True), + sa.Column('user_agent', sa.String(length=255), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index('ix_user_sessions_user_id', 'user_sessions', ['user_id'], unique=False) + + # ------------------------------------------------------------------ + # 3. Data migration: create admin User from existing pin_hash (if any) + # Uses raw SQL to avoid any ORM dependency issues in the migration. + # ------------------------------------------------------------------ + bind = op.get_bind() + + # Check whether settings table has a row with a pin_hash + result = bind.execute( + sa.text("SELECT id, pin_hash FROM settings WHERE pin_hash IS NOT NULL LIMIT 1") + ) + settings_row = result.fetchone() + + admin_user_id = None + if settings_row is not None: + settings_id, existing_pin_hash = settings_row[0], settings_row[1] + + # Insert the migrated user — username defaults to 'admin', password_hash + # retains the existing bcrypt hash (will upgrade to Argon2id on first login) + insert_result = bind.execute( + sa.text( + "INSERT INTO users (username, password_hash, is_active, totp_enabled, " + "failed_login_count, created_at, updated_at) " + "VALUES ('admin', :ph, true, false, 0, NOW(), NOW()) RETURNING id" + ), + {"ph": existing_pin_hash}, + ) + admin_user_id = insert_result.fetchone()[0] + + # ------------------------------------------------------------------ + # 4. Add user_id FK column to settings (nullable during migration) + # ------------------------------------------------------------------ + op.add_column( + 'settings', + sa.Column( + 'user_id', + sa.Integer(), + sa.ForeignKey('users.id', ondelete='CASCADE'), + nullable=True, + ) + ) + op.create_index('ix_settings_user_id', 'settings', ['user_id'], unique=False) + + # ------------------------------------------------------------------ + # 5. Backfill settings.user_id for the migrated admin user + # ------------------------------------------------------------------ + if admin_user_id is not None: + bind.execute( + sa.text("UPDATE settings SET user_id = :uid"), + {"uid": admin_user_id}, + ) + + # ------------------------------------------------------------------ + # 6. Drop pin_hash from settings — data now lives in users.password_hash + # ------------------------------------------------------------------ + op.drop_column('settings', 'pin_hash') + + +def downgrade() -> None: + # Restore pin_hash column (empty — data cannot be recovered from users table + # because the column was dropped, not just migrated; acceptable for downgrade path) + op.add_column( + 'settings', + sa.Column('pin_hash', sa.String(length=255), nullable=True) + ) + + op.drop_index('ix_settings_user_id', table_name='settings') + op.drop_column('settings', 'user_id') + + op.drop_index('ix_user_sessions_user_id', table_name='user_sessions') + op.drop_table('user_sessions') + + op.drop_index('ix_users_username', table_name='users') + op.drop_index('ix_users_id', table_name='users') + op.drop_table('users') diff --git a/backend/app/config.py b/backend/app/config.py index b40d86e..cc794e0 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -9,6 +9,15 @@ class Settings(BaseSettings): COOKIE_SECURE: bool = False OPENWEATHERMAP_API_KEY: str = "" + # Session config + SESSION_MAX_AGE_DAYS: int = 30 + + # MFA token config (short-lived token bridging password OK → TOTP verification) + MFA_TOKEN_MAX_AGE_SECONDS: int = 300 # 5 minutes + + # TOTP issuer name shown in authenticator apps + TOTP_ISSUER: str = "UMBRA" + model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", diff --git a/backend/app/main.py b/backend/app/main.py index b0705b6..60a7d65 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,6 +8,10 @@ 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.jobs.notifications import run_notification_dispatch +# Import models so Alembic's autogenerate can discover them +from app.models import user as _user_model # noqa: F401 +from app.models import session as _session_model # noqa: F401 + @asynccontextmanager async def lifespan(app: FastAPI): diff --git a/backend/app/models/session.py b/backend/app/models/session.py new file mode 100644 index 0000000..33a2a21 --- /dev/null +++ b/backend/app/models/session.py @@ -0,0 +1,23 @@ +from sqlalchemy import String, Boolean, ForeignKey, func +from sqlalchemy.orm import Mapped, mapped_column +from datetime import datetime +from app.database import Base + + +class UserSession(Base): + __tablename__ = "user_sessions" + + # UUID4 hex — avoids integer primary key enumeration + id: Mapped[str] = mapped_column(String(64), primary_key=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + created_at: Mapped[datetime] = mapped_column(default=func.now()) + expires_at: Mapped[datetime] = mapped_column(nullable=False) + revoked: Mapped[bool] = mapped_column(Boolean, default=False) + + # Audit fields for security logging + ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True) + user_agent: Mapped[str | None] = mapped_column(String(255), nullable=True) diff --git a/backend/app/models/settings.py b/backend/app/models/settings.py index e0e6a47..57d95fd 100644 --- a/backend/app/models/settings.py +++ b/backend/app/models/settings.py @@ -1,4 +1,4 @@ -from sqlalchemy import String, Integer, Float, Boolean, func +from sqlalchemy import String, Integer, Float, Boolean, ForeignKey, func from sqlalchemy.orm import Mapped, mapped_column from datetime import datetime from typing import Optional @@ -9,7 +9,14 @@ class Settings(Base): __tablename__ = "settings" id: Mapped[int] = mapped_column(primary_key=True, index=True) - pin_hash: Mapped[str] = mapped_column(String(255), nullable=False) + + # FK to users table — nullable during migration, will be NOT NULL after data migration + user_id: Mapped[Optional[int]] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), + nullable=True, + index=True, + ) + accent_color: Mapped[str] = mapped_column(String(20), default="cyan") upcoming_days: Mapped[int] = mapped_column(Integer, default=7) preferred_name: Mapped[str | None] = mapped_column(String(100), nullable=True, default=None) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..8de4311 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,29 @@ +from sqlalchemy import String, Boolean, Integer, func +from sqlalchemy.orm import Mapped, mapped_column +from datetime import datetime +from app.database import Base + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + + # MFA — populated in Track B + # String(500) because Fernet-encrypted secrets are longer than raw base32 + totp_secret: Mapped[str | None] = mapped_column(String(500), nullable=True, default=None) + totp_enabled: Mapped[bool] = mapped_column(Boolean, default=False) + + # Account lockout + failed_login_count: Mapped[int] = mapped_column(Integer, default=0) + locked_until: Mapped[datetime | None] = mapped_column(nullable=True, default=None) + + # Account state + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + + # Audit + created_at: Mapped[datetime] = mapped_column(default=func.now()) + updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) + last_login_at: Mapped[datetime | None] = mapped_column(nullable=True, default=None) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index ac4213a..6448bd7 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,134 +1,239 @@ -from fastapi import APIRouter, Depends, HTTPException, Response, Cookie, Request +""" +Authentication router — username/password with DB-backed sessions and account lockout. + +Session flow: + POST /setup → create User + Settings row → issue session cookie + POST /login → verify credentials → check lockout → insert UserSession → issue cookie + → if TOTP enabled: return mfa_token instead of full session + POST /logout → mark session revoked in DB → delete cookie + GET /status → verify user exists + session valid + +Security layers: + 1. IP-based in-memory rate limit (5 attempts / 5 min) — outer guard, username enumeration + 2. DB-backed account lockout (10 failures → 30-min lock, HTTP 423) — per-user guard + 3. Session revocation stored in DB (survives container restarts) + 4. bcrypt→Argon2id transparent upgrade on first login with migrated hash +""" +import uuid +import time +from collections import defaultdict +from datetime import datetime, timedelta +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Request, Response, Cookie from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select -from typing import Optional -from collections import defaultdict -import time -import bcrypt -from itsdangerous import TimestampSigner, BadSignature from app.database import get_db +from app.models.user import User +from app.models.session import UserSession from app.models.settings import Settings -from app.schemas.settings import SettingsCreate +from app.schemas.auth import SetupRequest, LoginRequest, ChangePasswordRequest +from app.services.auth import ( + hash_password, + verify_password_with_upgrade, + create_session_token, + verify_session_token, + create_mfa_token, +) from app.config import settings as app_settings router = APIRouter() -# Initialize signer for session management -signer = TimestampSigner(app_settings.SECRET_KEY) - -# Brute-force protection: track failed login attempts per IP +# --------------------------------------------------------------------------- +# IP-based in-memory rate limit (retained as outer layer for all login attempts) +# --------------------------------------------------------------------------- _failed_attempts: dict[str, list[float]] = defaultdict(list) -_MAX_ATTEMPTS = 5 -_WINDOW_SECONDS = 300 # 5-minute lockout window - -# Server-side session revocation (in-memory, sufficient for single-user app) -_revoked_sessions: set[str] = set() +_MAX_IP_ATTEMPTS = 5 +_IP_WINDOW_SECONDS = 300 # 5 minutes -def _check_rate_limit(ip: str) -> None: - """Raise 429 if IP has exceeded failed login attempts.""" +def _check_ip_rate_limit(ip: str) -> None: + """Raise 429 if the IP has exceeded the failure window.""" now = time.time() - attempts = _failed_attempts[ip] - # Prune old entries outside the window - _failed_attempts[ip] = [t for t in attempts if now - t < _WINDOW_SECONDS] - # Remove the key entirely if no recent attempts remain + _failed_attempts[ip] = [t for t in _failed_attempts[ip] if now - t < _IP_WINDOW_SECONDS] if not _failed_attempts[ip]: - del _failed_attempts[ip] - elif len(_failed_attempts[ip]) >= _MAX_ATTEMPTS: + _failed_attempts.pop(ip, None) + elif len(_failed_attempts[ip]) >= _MAX_IP_ATTEMPTS: raise HTTPException( status_code=429, detail="Too many failed login attempts. Try again in a few minutes.", ) -def _record_failed_attempt(ip: str) -> None: - """Record a failed login attempt for the given IP.""" +def _record_ip_failure(ip: str) -> None: _failed_attempts[ip].append(time.time()) -def hash_pin(pin: str) -> str: - """Hash a PIN using bcrypt.""" - return bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode() - - -def verify_pin(pin: str, hashed: str) -> bool: - """Verify a PIN against its hash.""" - return bcrypt.checkpw(pin.encode(), hashed.encode()) - - -def create_session_token(user_id: int) -> str: - """Create a signed session token.""" - return signer.sign(str(user_id)).decode() - - -def verify_session_token(token: str) -> Optional[int]: - """Verify and extract user ID from session token.""" - try: - unsigned = signer.unsign(token, max_age=86400 * 30) # 30 days - return int(unsigned) - except (BadSignature, ValueError): - return None - +# --------------------------------------------------------------------------- +# Cookie helper +# --------------------------------------------------------------------------- def _set_session_cookie(response: Response, token: str) -> None: - """Set the session cookie with secure defaults.""" response.set_cookie( key="session", value=token, httponly=True, secure=app_settings.COOKIE_SECURE, - max_age=86400 * 30, # 30 days + max_age=app_settings.SESSION_MAX_AGE_DAYS * 86400, samesite="lax", ) -async def get_current_session( +# --------------------------------------------------------------------------- +# Auth dependencies — export get_current_user and get_current_settings +# --------------------------------------------------------------------------- + +async def get_current_user( + request: Request, session_cookie: Optional[str] = Cookie(None, alias="session"), - db: AsyncSession = Depends(get_db) -) -> Settings: - """Dependency to verify session and return current settings.""" + db: AsyncSession = Depends(get_db), +) -> User: + """ + Dependency that verifies the session cookie and returns the authenticated User. + Replaces the old get_current_session (which returned Settings). + Any router that hasn't been updated will get a compile-time type error. + """ if not session_cookie: raise HTTPException(status_code=401, detail="Not authenticated") - # Check if session has been revoked - if session_cookie in _revoked_sessions: - raise HTTPException(status_code=401, detail="Session has been revoked") - - user_id = verify_session_token(session_cookie) - if user_id is None: + payload = verify_session_token(session_cookie) + if payload is None: raise HTTPException(status_code=401, detail="Invalid or expired session") - result = await db.execute(select(Settings).where(Settings.id == user_id)) + user_id: int = payload.get("uid") + session_id: str = payload.get("sid") + if user_id is None or session_id is None: + raise HTTPException(status_code=401, detail="Malformed session token") + + # Verify session is active in DB (covers revocation + expiry) + session_result = await db.execute( + select(UserSession).where( + UserSession.id == session_id, + UserSession.user_id == user_id, + UserSession.revoked == False, + UserSession.expires_at > datetime.now(), + ) + ) + db_session = session_result.scalar_one_or_none() + if not db_session: + raise HTTPException(status_code=401, detail="Session has been revoked or expired") + + user_result = await db.execute( + select(User).where(User.id == user_id, User.is_active == True) + ) + user = user_result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=401, detail="User not found or inactive") + + return user + + +async def get_current_settings( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Settings: + """ + Convenience dependency for routers that need Settings access. + Always chain after get_current_user — never use standalone. + """ + result = await db.execute( + select(Settings).where(Settings.user_id == current_user.id) + ) settings_obj = result.scalar_one_or_none() - if not settings_obj: - raise HTTPException(status_code=401, detail="Session invalid") - + raise HTTPException(status_code=500, detail="Settings not found for user") return settings_obj -@router.post("/setup") -async def setup_pin( - data: SettingsCreate, - response: Response, - db: AsyncSession = Depends(get_db) -): - """Create initial PIN. Only works if no settings exist.""" - result = await db.execute(select(Settings).with_for_update()) - existing = result.scalar_one_or_none() +# --------------------------------------------------------------------------- +# Account lockout helpers +# --------------------------------------------------------------------------- - if existing: +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, # clamp to column width + user_agent=(user_agent or "")[:255] if user_agent else None, + ) + db.add(db_session) + await db.commit() + token = create_session_token(user.id, session_id) + return session_id, token + + +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- + +@router.post("/setup") +async def setup( + data: SetupRequest, + response: Response, + request: Request, + db: AsyncSession = Depends(get_db), +): + """ + First-time setup: create the User record and a linked Settings row. + Only works when no users exist (i.e., fresh install). + """ + existing = await db.execute(select(User)) + if existing.scalar_one_or_none(): raise HTTPException(status_code=400, detail="Setup already completed") - pin_hash = hash_pin(data.pin) - new_settings = Settings(pin_hash=pin_hash) + password_hash = hash_password(data.password) + new_user = User(username=data.username, password_hash=password_hash) + db.add(new_user) + await db.flush() # assign new_user.id before creating Settings + + # Create Settings row linked to this user with all defaults + new_settings = Settings(user_id=new_user.id) db.add(new_settings) await db.commit() - await db.refresh(new_settings) - # Create session - token = create_session_token(new_settings.id) + ip = request.client.host if request.client else "unknown" + user_agent = request.headers.get("user-agent") + _, token = await _create_db_session(db, new_user, ip, user_agent) _set_session_cookie(response, token) return {"message": "Setup completed successfully", "authenticated": True} @@ -136,48 +241,91 @@ async def setup_pin( @router.post("/login") async def login( - data: SettingsCreate, + data: LoginRequest, request: Request, response: Response, - db: AsyncSession = Depends(get_db) + db: AsyncSession = Depends(get_db), ): - """Verify PIN and create session.""" + """ + Authenticate with username + password. + + Returns: + { authenticated: true } — on success (no TOTP) + { authenticated: false, totp_required: true, mfa_token: "..." } — TOTP pending + HTTP 401 — wrong credentials (generic; never reveals which field is wrong) + HTTP 423 — account locked + HTTP 429 — IP rate limited + """ client_ip = request.client.host if request.client else "unknown" - _check_rate_limit(client_ip) + _check_ip_rate_limit(client_ip) - result = await db.execute(select(Settings)) - settings_obj = result.scalar_one_or_none() + # Lookup user — do NOT differentiate "user not found" from "wrong password" + result = await db.execute(select(User).where(User.username == data.username)) + user = result.scalar_one_or_none() - if not settings_obj: - raise HTTPException(status_code=400, detail="Setup required") + if not user: + _record_ip_failure(client_ip) + raise HTTPException(status_code=401, detail="Invalid username or password") - if not verify_pin(data.pin, settings_obj.pin_hash): - _record_failed_attempt(client_ip) - raise HTTPException(status_code=401, detail="Invalid PIN") + await _check_account_lockout(user) - # Clear failed attempts on successful login + # Transparent bcrypt→Argon2id upgrade + valid, new_hash = verify_password_with_upgrade(data.password, user.password_hash) + + if not valid: + _record_ip_failure(client_ip) + await _record_failed_login(db, user) + raise HTTPException(status_code=401, detail="Invalid username or password") + + # Persist upgraded hash if migration happened + if new_hash: + user.password_hash = new_hash + + # Clear IP failures and update user state _failed_attempts.pop(client_ip, None) + await _record_successful_login(db, user) - # Create session - token = create_session_token(settings_obj.id) + # If TOTP is enabled, issue a short-lived MFA challenge token instead of a full session + if user.totp_enabled: + mfa_token = create_mfa_token(user.id) + return { + "authenticated": False, + "totp_required": True, + "mfa_token": mfa_token, + } + + user_agent = request.headers.get("user-agent") + _, token = await _create_db_session(db, user, client_ip, user_agent) _set_session_cookie(response, token) - return {"message": "Login successful", "authenticated": True} + return {"authenticated": True} @router.post("/logout") async def logout( response: Response, - session_cookie: Optional[str] = Cookie(None, alias="session") + session_cookie: Optional[str] = Cookie(None, alias="session"), + db: AsyncSession = Depends(get_db), ): - """Clear session cookie and invalidate server-side session.""" + """Revoke the current session in DB and clear the cookie.""" if session_cookie: - _revoked_sessions.add(session_cookie) + payload = verify_session_token(session_cookie) + if payload: + session_id = payload.get("sid") + if session_id: + result = await db.execute( + select(UserSession).where(UserSession.id == session_id) + ) + db_session = result.scalar_one_or_none() + if db_session: + db_session.revoked = True + await db.commit() + response.delete_cookie( key="session", httponly=True, secure=app_settings.COOKIE_SECURE, - samesite="lax" + samesite="lax", ) return {"message": "Logout successful"} @@ -185,23 +333,48 @@ async def logout( @router.get("/status") async def auth_status( session_cookie: Optional[str] = Cookie(None, alias="session"), - db: AsyncSession = Depends(get_db) + db: AsyncSession = Depends(get_db), ): - """Check authentication status.""" - result = await db.execute(select(Settings)) - settings_obj = result.scalar_one_or_none() - - setup_required = settings_obj is None + """ + Check authentication status and whether initial setup has been performed. + Used by the frontend to decide whether to show login vs setup screen. + """ + user_result = await db.execute(select(User)) + existing_user = user_result.scalar_one_or_none() + setup_required = existing_user is None authenticated = False if not setup_required and session_cookie: - if session_cookie in _revoked_sessions: - authenticated = False - else: - user_id = verify_session_token(session_cookie) - authenticated = user_id is not None + payload = verify_session_token(session_cookie) + if payload: + user_id = payload.get("uid") + session_id = payload.get("sid") + if user_id and session_id: + session_result = await db.execute( + select(UserSession).where( + UserSession.id == session_id, + UserSession.user_id == user_id, + UserSession.revoked == False, + UserSession.expires_at > datetime.now(), + ) + ) + authenticated = session_result.scalar_one_or_none() is not None - return { - "authenticated": authenticated, - "setup_required": setup_required - } + return {"authenticated": authenticated, "setup_required": setup_required} + + +@router.post("/change-password") +async def change_password( + data: ChangePasswordRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Change the current user's password. Requires old password verification.""" + valid, _ = verify_password_with_upgrade(data.old_password, current_user.password_hash) + if not valid: + raise HTTPException(status_code=401, detail="Invalid current password") + + current_user.password_hash = hash_password(data.new_password) + await db.commit() + + return {"message": "Password changed successfully"} diff --git a/backend/app/routers/calendars.py b/backend/app/routers/calendars.py index ba0c292..70322ed 100644 --- a/backend/app/routers/calendars.py +++ b/backend/app/routers/calendars.py @@ -7,8 +7,8 @@ from app.database import get_db from app.models.calendar import Calendar from app.models.calendar_event import CalendarEvent from app.schemas.calendar import CalendarCreate, CalendarUpdate, CalendarResponse -from app.routers.auth import get_current_session -from app.models.settings import Settings +from app.routers.auth import get_current_user +from app.models.user import User router = APIRouter() @@ -16,7 +16,7 @@ router = APIRouter() @router.get("/", response_model=List[CalendarResponse]) async def get_calendars( db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: User = Depends(get_current_user) ): result = await db.execute(select(Calendar).order_by(Calendar.is_default.desc(), Calendar.name.asc())) return result.scalars().all() @@ -26,7 +26,7 @@ async def get_calendars( async def create_calendar( calendar: CalendarCreate, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: User = Depends(get_current_user) ): new_calendar = Calendar( name=calendar.name, @@ -46,7 +46,7 @@ async def update_calendar( calendar_id: int, calendar_update: CalendarUpdate, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: User = Depends(get_current_user) ): result = await db.execute(select(Calendar).where(Calendar.id == calendar_id)) calendar = result.scalar_one_or_none() @@ -72,7 +72,7 @@ async def update_calendar( async def delete_calendar( calendar_id: int, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: User = Depends(get_current_user) ): result = await db.execute(select(Calendar).where(Calendar.id == calendar_id)) calendar = result.scalar_one_or_none() diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index 7ef8220..c6b874c 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -10,7 +10,7 @@ from app.models.todo import Todo from app.models.calendar_event import CalendarEvent from app.models.reminder import Reminder from app.models.project import Project -from app.routers.auth import get_current_session +from app.routers.auth import get_current_settings router = APIRouter() @@ -26,7 +26,7 @@ _not_parent_template = or_( async def get_dashboard( client_date: Optional[date] = Query(None), db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: Settings = Depends(get_current_settings) ): """Get aggregated dashboard data.""" today = client_date or date.today() @@ -143,7 +143,7 @@ async def get_upcoming( days: int = Query(default=7, ge=1, le=90), client_date: Optional[date] = Query(None), db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: Settings = Depends(get_current_settings) ): """Get unified list of upcoming items (todos, events, reminders) sorted by date.""" today = client_date or date.today() diff --git a/backend/app/routers/event_templates.py b/backend/app/routers/event_templates.py index 246a753..95efe42 100644 --- a/backend/app/routers/event_templates.py +++ b/backend/app/routers/event_templates.py @@ -3,7 +3,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from app.database import get_db -from app.routers.auth import get_current_session +from app.routers.auth import get_current_user +from app.models.user import User from app.models.event_template import EventTemplate from app.schemas.event_template import ( EventTemplateCreate, @@ -17,7 +18,7 @@ router = APIRouter() @router.get("/", response_model=list[EventTemplateResponse]) async def list_templates( db: AsyncSession = Depends(get_db), - _: str = Depends(get_current_session), + current_user: User = Depends(get_current_user), ): result = await db.execute(select(EventTemplate).order_by(EventTemplate.name)) return result.scalars().all() @@ -27,7 +28,7 @@ async def list_templates( async def create_template( payload: EventTemplateCreate, db: AsyncSession = Depends(get_db), - _: str = Depends(get_current_session), + current_user: User = Depends(get_current_user), ): template = EventTemplate(**payload.model_dump()) db.add(template) @@ -41,7 +42,7 @@ async def update_template( template_id: int, payload: EventTemplateUpdate, db: AsyncSession = Depends(get_db), - _: str = Depends(get_current_session), + current_user: User = Depends(get_current_user), ): result = await db.execute( select(EventTemplate).where(EventTemplate.id == template_id) @@ -62,7 +63,7 @@ async def update_template( async def delete_template( template_id: int, db: AsyncSession = Depends(get_db), - _: str = Depends(get_current_session), + current_user: User = Depends(get_current_user), ): result = await db.execute( select(EventTemplate).where(EventTemplate.id == template_id) diff --git a/backend/app/routers/events.py b/backend/app/routers/events.py index 21c2e92..d91776f 100644 --- a/backend/app/routers/events.py +++ b/backend/app/routers/events.py @@ -16,8 +16,8 @@ from app.schemas.calendar_event import ( CalendarEventUpdate, CalendarEventResponse, ) -from app.routers.auth import get_current_session -from app.models.settings import Settings +from app.routers.auth import get_current_user +from app.models.user import User from app.services.recurrence import generate_occurrences router = APIRouter() @@ -119,7 +119,7 @@ async def get_events( start: Optional[date] = Query(None), end: Optional[date] = Query(None), db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session), + current_user: User = Depends(get_current_user), ) -> List[Any]: """ Get all calendar events with optional date range filtering. @@ -180,7 +180,7 @@ async def get_events( async def create_event( event: CalendarEventCreate, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session), + current_user: User = Depends(get_current_user), ): if event.end_datetime < event.start_datetime: raise HTTPException(status_code=400, detail="End datetime must be after start datetime") @@ -243,7 +243,7 @@ async def create_event( async def get_event( event_id: int, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session), + current_user: User = Depends(get_current_user), ): result = await db.execute( select(CalendarEvent) @@ -263,7 +263,7 @@ async def update_event( event_id: int, event_update: CalendarEventUpdate, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session), + current_user: User = Depends(get_current_user), ): result = await db.execute( select(CalendarEvent) @@ -379,7 +379,7 @@ async def delete_event( event_id: int, scope: Optional[Literal["this", "this_and_future"]] = Query(None), db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session), + current_user: User = Depends(get_current_user), ): result = await db.execute(select(CalendarEvent).where(CalendarEvent.id == event_id)) event = result.scalar_one_or_none() diff --git a/backend/app/routers/locations.py b/backend/app/routers/locations.py index 1329f18..ed64541 100644 --- a/backend/app/routers/locations.py +++ b/backend/app/routers/locations.py @@ -12,8 +12,8 @@ import logging from app.database import get_db from app.models.location import Location from app.schemas.location import LocationCreate, LocationUpdate, LocationResponse, LocationSearchResult -from app.routers.auth import get_current_session -from app.models.settings import Settings +from app.routers.auth import get_current_user +from app.models.user import User logger = logging.getLogger(__name__) @@ -24,7 +24,7 @@ router = APIRouter() async def search_locations( q: str = Query(..., min_length=1), db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session), + current_user: User = Depends(get_current_user), ): """Search locations from local DB and Nominatim OSM.""" results: List[LocationSearchResult] = [] @@ -86,7 +86,7 @@ async def search_locations( async def get_locations( category: Optional[str] = Query(None), db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: User = Depends(get_current_user) ): """Get all locations with optional category filter.""" query = select(Location) @@ -106,7 +106,7 @@ async def get_locations( async def create_location( location: LocationCreate, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: User = Depends(get_current_user) ): """Create a new location.""" new_location = Location(**location.model_dump()) @@ -121,7 +121,7 @@ async def create_location( async def get_location( location_id: int, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: User = Depends(get_current_user) ): """Get a specific location by ID.""" result = await db.execute(select(Location).where(Location.id == location_id)) @@ -138,7 +138,7 @@ async def update_location( location_id: int, location_update: LocationUpdate, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: User = Depends(get_current_user) ): """Update a location.""" result = await db.execute(select(Location).where(Location.id == location_id)) @@ -165,7 +165,7 @@ async def update_location( async def delete_location( location_id: int, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: User = Depends(get_current_user) ): """Delete a location.""" result = await db.execute(select(Location).where(Location.id == location_id)) diff --git a/backend/app/routers/people.py b/backend/app/routers/people.py index 451aca0..150f268 100644 --- a/backend/app/routers/people.py +++ b/backend/app/routers/people.py @@ -7,8 +7,8 @@ from typing import Optional, List from app.database import get_db from app.models.person import Person from app.schemas.person import PersonCreate, PersonUpdate, PersonResponse -from app.routers.auth import get_current_session -from app.models.settings import Settings +from app.routers.auth import get_current_user +from app.models.user import User router = APIRouter() @@ -34,7 +34,7 @@ async def get_people( search: Optional[str] = Query(None), category: Optional[str] = Query(None), db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: User = Depends(get_current_user) ): """Get all people with optional search and category filter.""" query = select(Person) @@ -66,7 +66,7 @@ async def get_people( async def create_person( person: PersonCreate, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: User = Depends(get_current_user) ): """Create a new person with denormalised display name.""" data = person.model_dump() @@ -93,7 +93,7 @@ async def create_person( async def get_person( person_id: int, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: User = Depends(get_current_user) ): """Get a specific person by ID.""" result = await db.execute(select(Person).where(Person.id == person_id)) @@ -110,7 +110,7 @@ async def update_person( person_id: int, person_update: PersonUpdate, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: User = Depends(get_current_user) ): """Update a person and refresh the denormalised display name.""" result = await db.execute(select(Person).where(Person.id == person_id)) @@ -144,7 +144,7 @@ async def update_person( async def delete_person( person_id: int, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: User = Depends(get_current_user) ): """Delete a person.""" result = await db.execute(select(Person).where(Person.id == person_id)) diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py index 0728e04..dd86618 100644 --- a/backend/app/routers/projects.py +++ b/backend/app/routers/projects.py @@ -13,8 +13,8 @@ from app.models.task_comment import TaskComment from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse, TrackedTaskResponse from app.schemas.project_task import ProjectTaskCreate, ProjectTaskUpdate, ProjectTaskResponse from app.schemas.task_comment import TaskCommentCreate, TaskCommentResponse -from app.routers.auth import get_current_session -from app.models.settings import Settings +from app.routers.auth import get_current_user +from app.models.user import User router = APIRouter() @@ -46,7 +46,7 @@ def _task_load_options(): async def get_projects( tracked: Optional[bool] = Query(None), db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: User = Depends(get_current_user) ): """Get all projects with their tasks. Optionally filter by tracked status.""" query = select(Project).options(*_project_load_options()).order_by(Project.created_at.desc()) @@ -63,7 +63,7 @@ async def get_projects( async def get_tracked_tasks( days: int = Query(7, ge=1, le=90), db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: User = Depends(get_current_user) ): """Get tasks and subtasks from tracked projects with due dates within the next N days.""" today = date.today() @@ -107,7 +107,7 @@ async def get_tracked_tasks( async def create_project( project: ProjectCreate, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: User = Depends(get_current_user) ): """Create a new project.""" new_project = Project(**project.model_dump()) @@ -124,7 +124,7 @@ async def create_project( async def get_project( project_id: int, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: User = Depends(get_current_user) ): """Get a specific project by ID with its tasks.""" query = select(Project).options(*_project_load_options()).where(Project.id == project_id) @@ -142,7 +142,7 @@ async def update_project( project_id: int, project_update: ProjectUpdate, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: User = Depends(get_current_user) ): """Update a project.""" result = await db.execute(select(Project).where(Project.id == project_id)) @@ -168,7 +168,7 @@ async def update_project( async def delete_project( project_id: int, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: User = Depends(get_current_user) ): """Delete a project and all its tasks.""" result = await db.execute(select(Project).where(Project.id == project_id)) @@ -187,7 +187,7 @@ async def delete_project( async def get_project_tasks( project_id: int, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: User = Depends(get_current_user) ): """Get top-level tasks for a specific project (subtasks are nested).""" result = await db.execute(select(Project).where(Project.id == project_id)) @@ -216,7 +216,7 @@ async def create_project_task( project_id: int, task: ProjectTaskCreate, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: User = Depends(get_current_user) ): """Create a new task or subtask for a project.""" result = await db.execute(select(Project).where(Project.id == project_id)) @@ -262,7 +262,7 @@ async def reorder_tasks( project_id: int, items: List[ReorderItem], db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: User = Depends(get_current_user) ): """Bulk update sort_order for tasks.""" result = await db.execute(select(Project).where(Project.id == project_id)) @@ -293,7 +293,7 @@ async def update_project_task( task_id: int, task_update: ProjectTaskUpdate, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: User = Depends(get_current_user) ): """Update a project task.""" result = await db.execute( @@ -329,7 +329,7 @@ async def delete_project_task( project_id: int, task_id: int, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: User = Depends(get_current_user) ): """Delete a project task (cascades to subtasks).""" result = await db.execute( @@ -355,7 +355,7 @@ async def create_task_comment( task_id: int, comment: TaskCommentCreate, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: User = Depends(get_current_user) ): """Add a comment to a task.""" result = await db.execute( @@ -383,7 +383,7 @@ async def delete_task_comment( task_id: int, comment_id: int, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: User = Depends(get_current_user) ): """Delete a task comment.""" result = await db.execute( diff --git a/backend/app/routers/reminders.py b/backend/app/routers/reminders.py index f64e28d..b33872a 100644 --- a/backend/app/routers/reminders.py +++ b/backend/app/routers/reminders.py @@ -8,8 +8,8 @@ from typing import Optional, List from app.database import get_db from app.models.reminder import Reminder from app.schemas.reminder import ReminderCreate, ReminderUpdate, ReminderResponse, ReminderSnooze -from app.routers.auth import get_current_session -from app.models.settings import Settings +from app.routers.auth import get_current_user +from app.models.user import User router = APIRouter() @@ -19,7 +19,7 @@ async def get_reminders( active: Optional[bool] = Query(None), dismissed: Optional[bool] = Query(None), db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: User = Depends(get_current_user) ): """Get all reminders with optional filters.""" query = select(Reminder) @@ -42,7 +42,7 @@ async def get_reminders( async def get_due_reminders( client_now: Optional[datetime] = Query(None), db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: User = Depends(get_current_user) ): """Get reminders that are currently due for alerting.""" now = client_now or datetime.now() @@ -71,7 +71,7 @@ async def snooze_reminder( reminder_id: int, body: ReminderSnooze, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: User = Depends(get_current_user) ): """Snooze a reminder for N minutes from now.""" result = await db.execute(select(Reminder).where(Reminder.id == reminder_id)) @@ -96,7 +96,7 @@ async def snooze_reminder( async def create_reminder( reminder: ReminderCreate, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: User = Depends(get_current_user) ): """Create a new reminder.""" new_reminder = Reminder(**reminder.model_dump()) @@ -111,7 +111,7 @@ async def create_reminder( async def get_reminder( reminder_id: int, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: User = Depends(get_current_user) ): """Get a specific reminder by ID.""" result = await db.execute(select(Reminder).where(Reminder.id == reminder_id)) @@ -128,7 +128,7 @@ async def update_reminder( reminder_id: int, reminder_update: ReminderUpdate, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: User = Depends(get_current_user) ): """Update a reminder.""" result = await db.execute(select(Reminder).where(Reminder.id == reminder_id)) @@ -161,7 +161,7 @@ async def update_reminder( async def delete_reminder( reminder_id: int, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: User = Depends(get_current_user) ): """Delete a reminder.""" result = await db.execute(select(Reminder).where(Reminder.id == reminder_id)) @@ -180,7 +180,7 @@ async def delete_reminder( async def dismiss_reminder( reminder_id: int, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: User = Depends(get_current_user) ): """Dismiss a reminder.""" result = await db.execute(select(Reminder).where(Reminder.id == reminder_id)) diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py index bf1c89e..fe36b7a 100644 --- a/backend/app/routers/settings.py +++ b/backend/app/routers/settings.py @@ -1,11 +1,11 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select from app.database import get_db from app.models.settings import Settings -from app.schemas.settings import SettingsUpdate, SettingsResponse, ChangePinRequest -from app.routers.auth import get_current_session, hash_pin, verify_pin +from app.models.user import User +from app.schemas.settings import SettingsUpdate, SettingsResponse +from app.routers.auth import get_current_user, get_current_settings router = APIRouter() @@ -43,51 +43,34 @@ def _to_settings_response(s: Settings) -> SettingsResponse: @router.get("/", response_model=SettingsResponse) async def get_settings( db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_settings: Settings = Depends(get_current_settings) ): - """Get current settings (excluding PIN hash and ntfy auth token).""" - return _to_settings_response(current_user) + """Get current settings (excluding ntfy auth token).""" + return _to_settings_response(current_settings) @router.put("/", response_model=SettingsResponse) async def update_settings( settings_update: SettingsUpdate, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_settings: Settings = Depends(get_current_settings) ): """Update settings.""" update_data = settings_update.model_dump(exclude_unset=True) for key, value in update_data.items(): - setattr(current_user, key, value) + setattr(current_settings, key, value) await db.commit() - await db.refresh(current_user) + await db.refresh(current_settings) - return _to_settings_response(current_user) - - -@router.put("/pin") -async def change_pin( - pin_change: ChangePinRequest, - db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) -): - """Change PIN. Requires old PIN verification.""" - if not verify_pin(pin_change.old_pin, current_user.pin_hash): - raise HTTPException(status_code=401, detail="Invalid old PIN") - - current_user.pin_hash = hash_pin(pin_change.new_pin) - - await db.commit() - - return {"message": "PIN changed successfully"} + return _to_settings_response(current_settings) @router.post("/ntfy/test") async def test_ntfy( db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_settings: Settings = Depends(get_current_settings) ): """ Send a test ntfy notification to verify the user's configuration. @@ -95,7 +78,7 @@ async def test_ntfy( Note: ntfy_enabled does not need to be True to run the test — the service call bypasses that check because we pass settings directly. """ - if not current_user.ntfy_server_url or not current_user.ntfy_topic: + if not current_settings.ntfy_server_url or not current_settings.ntfy_topic: raise HTTPException( status_code=400, detail="ntfy server URL and topic must be configured before sending a test" @@ -104,7 +87,7 @@ async def test_ntfy( # SSRF-validate the URL before attempting the outbound request from app.services.ntfy import validate_ntfy_host, send_ntfy_notification try: - validate_ntfy_host(current_user.ntfy_server_url) + validate_ntfy_host(current_settings.ntfy_server_url) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) @@ -112,9 +95,9 @@ async def test_ntfy( class _TestSettings: """Thin wrapper that forces ntfy_enabled=True for the test call.""" ntfy_enabled = True - ntfy_server_url = current_user.ntfy_server_url - ntfy_topic = current_user.ntfy_topic - ntfy_auth_token = current_user.ntfy_auth_token + ntfy_server_url = current_settings.ntfy_server_url + ntfy_topic = current_settings.ntfy_topic + ntfy_auth_token = current_settings.ntfy_auth_token success = await send_ntfy_notification( settings=_TestSettings(), # type: ignore[arg-type] diff --git a/backend/app/routers/todos.py b/backend/app/routers/todos.py index b1541ad..57841a7 100644 --- a/backend/app/routers/todos.py +++ b/backend/app/routers/todos.py @@ -8,7 +8,8 @@ import calendar from app.database import get_db from app.models.todo import Todo from app.schemas.todo import TodoCreate, TodoUpdate, TodoResponse -from app.routers.auth import get_current_session +from app.routers.auth import get_current_user, get_current_settings +from app.models.user import User from app.models.settings import Settings router = APIRouter() @@ -109,7 +110,7 @@ async def get_todos( category: Optional[str] = Query(None), search: Optional[str] = Query(None), db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: Settings = Depends(get_current_settings) ): """Get all todos with optional filters.""" # Reactivate any recurring todos whose reset time has passed @@ -143,7 +144,7 @@ async def get_todos( async def create_todo( todo: TodoCreate, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: Settings = Depends(get_current_settings) ): """Create a new todo.""" new_todo = Todo(**todo.model_dump()) @@ -158,7 +159,7 @@ async def create_todo( async def get_todo( todo_id: int, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: Settings = Depends(get_current_settings) ): """Get a specific todo by ID.""" result = await db.execute(select(Todo).where(Todo.id == todo_id)) @@ -175,7 +176,7 @@ async def update_todo( todo_id: int, todo_update: TodoUpdate, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: Settings = Depends(get_current_settings) ): """Update a todo.""" result = await db.execute(select(Todo).where(Todo.id == todo_id)) @@ -228,7 +229,7 @@ async def update_todo( async def delete_todo( todo_id: int, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: Settings = Depends(get_current_settings) ): """Delete a todo.""" result = await db.execute(select(Todo).where(Todo.id == todo_id)) @@ -247,7 +248,7 @@ async def delete_todo( async def toggle_todo( todo_id: int, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: Settings = Depends(get_current_settings) ): """Toggle todo completion status. For recurring todos, calculates reset schedule.""" result = await db.execute(select(Todo).where(Todo.id == todo_id)) diff --git a/backend/app/routers/weather.py b/backend/app/routers/weather.py index 695bfc8..25f8b94 100644 --- a/backend/app/routers/weather.py +++ b/backend/app/routers/weather.py @@ -12,7 +12,8 @@ import json from app.database import get_db from app.models.settings import Settings from app.config import settings as app_settings -from app.routers.auth import get_current_session +from app.routers.auth import get_current_user, get_current_settings +from app.models.user import User router = APIRouter() @@ -35,7 +36,7 @@ def _fetch_json(url: str) -> dict: @router.get("/search", response_model=list[GeoSearchResult]) async def search_locations( q: str = Query(..., min_length=1, max_length=100), - current_user: Settings = Depends(get_current_session) + current_user: User = Depends(get_current_user) ): api_key = app_settings.OPENWEATHERMAP_API_KEY if not api_key: @@ -65,14 +66,11 @@ async def search_locations( @router.get("/") async def get_weather( db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_session) + current_user: Settings = Depends(get_current_settings) ): - # Get settings - result = await db.execute(select(Settings)) - settings_row = result.scalar_one_or_none() - city = settings_row.weather_city if settings_row else None - lat = settings_row.weather_lat if settings_row else None - lon = settings_row.weather_lon if settings_row else None + city = current_user.weather_city + lat = current_user.weather_lat + lon = current_user.weather_lon if not city and (lat is None or lon is None): raise HTTPException(status_code=400, detail="No weather location configured") diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..eb09b0d --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,62 @@ +import re +from pydantic import BaseModel, field_validator + + +def _validate_password_strength(v: str) -> str: + """ + Shared password validation (OWASP ASVS v4 Level 1). + - Minimum 12 chars (OWASP minimum) + - Maximum 128 chars (prevents DoS via large input to argon2) + - Must contain at least one letter and one non-letter + - No complexity rules per NIST SP 800-63B + """ + if len(v) < 12: + raise ValueError("Password must be at least 12 characters") + if len(v) > 128: + raise ValueError("Password must be 128 characters or fewer") + if not re.search(r"[A-Za-z]", v): + raise ValueError("Password must contain at least one letter") + if not re.search(r"[^A-Za-z]", v): + raise ValueError("Password must contain at least one non-letter character") + return v + + +class SetupRequest(BaseModel): + username: str + password: str + + @field_validator("username") + @classmethod + def validate_username(cls, v: str) -> str: + v = v.strip().lower() + if not 3 <= len(v) <= 50: + raise ValueError("Username must be 3–50 characters") + if not re.fullmatch(r"[a-z0-9_\-]+", v): + raise ValueError("Username may only contain letters, numbers, _ and -") + return v + + @field_validator("password") + @classmethod + def validate_password(cls, v: str) -> str: + return _validate_password_strength(v) + + +class LoginRequest(BaseModel): + username: str + password: str + + @field_validator("username") + @classmethod + def normalize_username(cls, v: str) -> str: + """Normalise to lowercase so 'Admin' and 'admin' resolve to the same user.""" + return v.strip().lower() + + +class ChangePasswordRequest(BaseModel): + old_password: str + new_password: str + + @field_validator("new_password") + @classmethod + def validate_new_password(cls, v: str) -> str: + return _validate_password_strength(v) diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index 9491806..a22d398 100644 --- a/backend/app/schemas/settings.py +++ b/backend/app/schemas/settings.py @@ -8,23 +8,6 @@ AccentColor = Literal["cyan", "blue", "green", "purple", "red", "orange", "pink" _NTFY_TOPIC_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$') -def _validate_pin_length(v: str, label: str = "PIN") -> str: - if len(v) < 4: - raise ValueError(f'{label} must be at least 4 characters') - if len(v) > 72: - raise ValueError(f'{label} must be at most 72 characters') - return v - - -class SettingsCreate(BaseModel): - pin: str - - @field_validator('pin') - @classmethod - def pin_length(cls, v: str) -> str: - return _validate_pin_length(v) - - class SettingsUpdate(BaseModel): accent_color: Optional[AccentColor] = None upcoming_days: int | None = None @@ -154,13 +137,3 @@ class SettingsResponse(BaseModel): updated_at: datetime model_config = ConfigDict(from_attributes=True) - - -class ChangePinRequest(BaseModel): - old_pin: str - new_pin: str - - @field_validator('new_pin') - @classmethod - def new_pin_length(cls, v: str) -> str: - return _validate_pin_length(v, "New PIN") diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py new file mode 100644 index 0000000..186524b --- /dev/null +++ b/backend/app/services/auth.py @@ -0,0 +1,128 @@ +""" +Authentication service: password hashing, session tokens, MFA tokens. + +Password strategy: +- New passwords: Argon2id (OWASP/NIST preferred, PHC winner) +- Legacy bcrypt hashes (migrated from PIN auth): accepted on login, immediately + rehashed to Argon2id on first successful use. +""" +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError +from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired + +from app.config import settings as app_settings + +# OWASP-minimum Argon2id parameters (m=19 MB, t=2 iterations, p=1) +_ph = PasswordHasher( + time_cost=2, + memory_cost=19456, # 19 MB in KB + parallelism=1, + hash_len=32, + salt_len=16, +) + +# Session serializer — salt differentiates from MFA tokens +_serializer = URLSafeTimedSerializer( + secret_key=app_settings.SECRET_KEY, + salt="umbra-session-v2", +) + + +# --------------------------------------------------------------------------- +# Password helpers +# --------------------------------------------------------------------------- + +def hash_password(password: str) -> str: + """Hash a password with Argon2id.""" + return _ph.hash(password) + + +def verify_password(password: str, hashed: str) -> bool: + """Verify an Argon2id password hash. Returns False on any failure.""" + try: + return _ph.verify(hashed, password) + except (VerifyMismatchError, VerificationError, InvalidHashError): + return False + + +def needs_rehash(hashed: str) -> bool: + """True if the stored hash was created with outdated parameters.""" + return _ph.check_needs_rehash(hashed) + + +def verify_password_with_upgrade(password: str, hashed: str) -> tuple[bool, str | None]: + """ + Verify a password against a stored hash (Argon2id or legacy bcrypt). + + Returns (is_valid, new_hash_if_upgrade_needed). + new_hash is non-None only when the stored hash is bcrypt and the password is + correct — caller must persist the new hash to complete the migration. + Also returns a new hash when Argon2id parameters are outdated. + """ + if hashed.startswith("$2b$") or hashed.startswith("$2a$"): + # Legacy bcrypt — verify then immediately rehash to Argon2id + import bcrypt # noqa: PLC0415 — intentional lazy import; bcrypt is only needed during migration + try: + valid = bcrypt.checkpw(password.encode(), hashed.encode()) + except Exception: + return False, None + if valid: + return True, hash_password(password) + return False, None + + # Argon2id path + valid = verify_password(password, hashed) + new_hash = hash_password(password) if (valid and needs_rehash(hashed)) else None + return valid, new_hash + + +# --------------------------------------------------------------------------- +# Session tokens +# --------------------------------------------------------------------------- + +def create_session_token(user_id: int, session_id: str) -> str: + """Create a signed session cookie payload embedding user_id + session_id.""" + return _serializer.dumps({"uid": user_id, "sid": session_id}) + + +def verify_session_token(token: str, max_age: int | None = None) -> dict | None: + """ + Verify a session cookie and return its payload dict, or None if invalid/expired. + max_age defaults to SESSION_MAX_AGE_DAYS from config. + """ + if max_age is None: + max_age = app_settings.SESSION_MAX_AGE_DAYS * 86400 + try: + return _serializer.loads(token, max_age=max_age) + except (BadSignature, SignatureExpired): + return None + + +# --------------------------------------------------------------------------- +# MFA tokens (short-lived, used between password OK and TOTP verification) +# --------------------------------------------------------------------------- + +# MFA tokens use a distinct salt so they cannot be replayed as session tokens +_mfa_serializer = URLSafeTimedSerializer( + secret_key=app_settings.SECRET_KEY, + salt="mfa-challenge", +) + + +def create_mfa_token(user_id: int) -> str: + """Create a short-lived signed token for the MFA challenge step.""" + return _mfa_serializer.dumps({"uid": user_id}) + + +def verify_mfa_token(token: str) -> int | None: + """ + Verify an MFA challenge token. + Returns the user_id on success, None if invalid or expired (5-minute TTL). + """ + try: + data = _mfa_serializer.loads( + token, max_age=app_settings.MFA_TOKEN_MAX_AGE_SECONDS + ) + return data["uid"] + except Exception: + return None diff --git a/backend/requirements.txt b/backend/requirements.txt index 8e8c446..50ed902 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -6,6 +6,7 @@ alembic==1.14.1 pydantic==2.10.4 pydantic-settings==2.7.1 bcrypt==4.2.1 +argon2-cffi>=23.1.0 python-multipart==0.0.20 python-dateutil==2.9.0 itsdangerous==2.2.0