From 12c648785589cbf9bc004a6f20dbc06c284a40de Mon Sep 17 00:00:00 2001 From: curo1305 Date: Fri, 22 May 2026 19:19:52 +0200 Subject: [PATCH] feat(02-01): add BackupCode ORM model, password_must_change field, Alembic migration, extend Settings - Add BackupCode model to db/models.py with user_id FK, code_hash (Argon2), used_at (nullable) - Add ix_backup_codes_user_id index on backup_codes.user_id - Add password_must_change BOOLEAN NOT NULL DEFAULT false to User model (ADMIN-01) - Extend config.py Settings with JWT, SMTP, admin bootstrap, and CORS fields (D-01, D-04, D-09) - Add env_list_separator=',' for cors_origins env var parsing - Append PyJWT, pwdlib[argon2], pyotp, aioredis, slowapi to requirements.txt - Add .env.example entries for SECRET_KEY, ADMIN_EMAIL, SMTP_*, CORS_ORIGINS - Create migration 0002 adding backup_codes table and password_must_change column - Add TDD tests for all Task 1 acceptance criteria (7 tests pass) --- .env.example | 21 +++++- backend/config.py | 19 +++++ backend/db/models.py | 27 +++++++ ...d_backup_codes_and_password_must_change.py | 62 ++++++++++++++++ backend/requirements.txt | 5 ++ backend/tests/test_task1_models_config.py | 71 +++++++++++++++++++ 6 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 backend/migrations/versions/0002_add_backup_codes_and_password_must_change.py create mode 100644 backend/tests/test_task1_models_config.py diff --git a/.env.example b/.env.example index 8b32033..222f541 100644 --- a/.env.example +++ b/.env.example @@ -28,5 +28,24 @@ REDIS_PASSWORD=changeme_redis REDIS_URL=redis://:changeme_redis@redis:6379/0 # ── Security (Phase 2) ─────────────────────────────────────────────────────── -# Not read by the app in Phase 1 — documented here for Phase 2 JWT + HKDF use +# JWT signing secret — generate with: python3 -c "import secrets; print(secrets.token_hex(64))" SECRET_KEY=CHANGEME-replace-with-64-char-random-hex + +# ── Admin Bootstrap (Phase 2 — D-04) ───────────────────────────────────────── +# First admin account created on startup if users table is empty. +# Both vars must be set; if missing, a WARNING is logged but app starts normally. +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=CHANGEME-replace-with-strong-password + +# ── SMTP / Email (Phase 2 — D-01) ──────────────────────────────────────────── +# When SMTP_HOST is unset, password reset links are logged to stdout (dev mode). +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM=noreply@docuvault.local + +# ── CORS (Phase 2 — D-09) ──────────────────────────────────────────────────── +# Comma-separated list of allowed origins. Default: http://localhost:5173 +# Example for production: https://app.docuvault.example.com +CORS_ORIGINS=http://localhost:5173 diff --git a/backend/config.py b/backend/config.py index 64e624a..97e66de 100644 --- a/backend/config.py +++ b/backend/config.py @@ -10,6 +10,7 @@ class Settings(BaseSettings): env_file=".env", env_file_encoding="utf-8", extra="ignore", + env_list_separator=",", ) # Data directory — used only for the flat-file settings.json path (Phase 1) @@ -31,6 +32,24 @@ class Settings(BaseSettings): # Security (Phase 2 — documented now, not read by Phase 1 code paths) secret_key: str = "CHANGEME" + # Auth / JWT (Phase 2) + access_token_expire_minutes: int = 15 + refresh_token_expire_days: int = 30 + + # SMTP (Phase 2 — D-01) + smtp_host: str = "" + smtp_port: int = 587 + smtp_user: str = "" + smtp_password: str = "" + smtp_from: str = "noreply@docuvault.local" + + # Admin bootstrap (Phase 2 — D-04) + admin_email: str = "" + admin_password: str = "" + + # CORS (Phase 2 — D-09) + cors_origins: list[str] = ["http://localhost:5173"] + settings = Settings() diff --git a/backend/db/models.py b/backend/db/models.py index 6abd199..e65841f 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -55,6 +55,7 @@ class User(Base): totp_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) role: Mapped[str] = mapped_column(String, nullable=False, default="user") is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + password_must_change: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false") ai_provider: Mapped[Optional[str]] = mapped_column(Text, nullable=True) ai_model: Mapped[Optional[str]] = mapped_column(Text, nullable=True) default_storage_backend: Mapped[str] = mapped_column( @@ -103,6 +104,32 @@ class RefreshToken(Base): __table_args__ = (Index("ix_refresh_tokens_user_revoked", "user_id", "revoked"),) +class BackupCode(Base): + """Single-use backup codes for TOTP recovery (AUTH-02). + + code_hash stores the Argon2 hash of the original code (never plaintext). + used_at is None when the code is unused; set to now() on first use. + Verification iterates ALL codes to prevent timing-based enumeration (SEC-06). + """ + + __tablename__ = "backup_codes" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ) + code_hash: Mapped[str] = mapped_column(Text, nullable=False) + used_at: Mapped[Optional[datetime]] = mapped_column( + TIMESTAMP(timezone=True), nullable=True + ) + + __table_args__ = (Index("ix_backup_codes_user_id", "user_id"),) + + class Folder(Base): __tablename__ = "folders" diff --git a/backend/migrations/versions/0002_add_backup_codes_and_password_must_change.py b/backend/migrations/versions/0002_add_backup_codes_and_password_must_change.py new file mode 100644 index 0000000..87fae19 --- /dev/null +++ b/backend/migrations/versions/0002_add_backup_codes_and_password_must_change.py @@ -0,0 +1,62 @@ +"""Add backup_codes table and password_must_change column to users. + +Revision ID: 0002 +Revises: 0001 +Create Date: 2026-05-22 + +Changes: + 1. Add `password_must_change` BOOLEAN NOT NULL DEFAULT false to users table (ADMIN-01) + 2. Create `backup_codes` table for TOTP recovery codes (AUTH-02) + +Note: documents.user_id stays nullable per D-03 — NOT NULL is deferred to Phase 3. +""" +from __future__ import annotations + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +from alembic import op + +# revision identifiers, used by Alembic. +revision = "0002" +down_revision = "0001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ── 1. Add password_must_change to users ────────────────────────────────── + op.add_column( + "users", + sa.Column( + "password_must_change", + sa.Boolean(), + nullable=False, + server_default="false", + ), + ) + + # ── 2. Create backup_codes table ────────────────────────────────────────── + op.create_table( + "backup_codes", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("code_hash", sa.Text(), nullable=False), + sa.Column("used_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_backup_codes_user_id", "backup_codes", ["user_id"]) + + # ── Privilege grants (Pitfall 4) ─────────────────────────────────────────── + op.execute( + "GRANT SELECT, INSERT, UPDATE, DELETE ON backup_codes TO docuvault_app;" + ) + + +def downgrade() -> None: + # ── Drop backup_codes ───────────────────────────────────────────────────── + op.drop_index("ix_backup_codes_user_id", table_name="backup_codes") + op.drop_table("backup_codes") + + # ── Remove password_must_change from users ──────────────────────────────── + op.drop_column("users", "password_must_change") diff --git a/backend/requirements.txt b/backend/requirements.txt index acfa8c6..4da0fcd 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -19,3 +19,8 @@ minio>=7.2.20 celery[redis]>=5.6.3 redis>=7.4.0 aiosqlite>=0.20.0 +PyJWT>=2.8.0 +pwdlib[argon2]>=0.2.1 +pyotp>=2.9.0 +aioredis>=2.0.0 +slowapi>=0.1.9 diff --git a/backend/tests/test_task1_models_config.py b/backend/tests/test_task1_models_config.py new file mode 100644 index 0000000..c833ff0 --- /dev/null +++ b/backend/tests/test_task1_models_config.py @@ -0,0 +1,71 @@ +""" +TDD tests for Task 1: BackupCode ORM model, password_must_change field, Settings extension. + +These tests should FAIL before implementation (RED phase). +""" +import pytest + + +def test_backup_code_model_exists(): + """BackupCode ORM model must exist and have correct tablename + columns.""" + from db.models import BackupCode + assert BackupCode.__tablename__ == "backup_codes" + assert hasattr(BackupCode, "id") + assert hasattr(BackupCode, "user_id") + assert hasattr(BackupCode, "code_hash") + assert hasattr(BackupCode, "used_at") + + +def test_user_has_password_must_change(): + """User model must have password_must_change column.""" + from db.models import User + assert hasattr(User, "password_must_change") + + +def test_settings_has_jwt_config(): + """Settings must have access_token_expire_minutes and refresh_token_expire_days.""" + from config import settings + assert hasattr(settings, "access_token_expire_minutes") + assert settings.access_token_expire_minutes == 15 + assert hasattr(settings, "refresh_token_expire_days") + assert settings.refresh_token_expire_days == 30 + + +def test_settings_has_smtp_config(): + """Settings must have SMTP fields.""" + from config import settings + assert hasattr(settings, "smtp_host") + assert hasattr(settings, "smtp_port") + assert hasattr(settings, "smtp_user") + assert hasattr(settings, "smtp_password") + assert hasattr(settings, "smtp_from") + + +def test_settings_has_admin_config(): + """Settings must have admin bootstrap fields.""" + from config import settings + assert hasattr(settings, "admin_email") + assert hasattr(settings, "admin_password") + + +def test_settings_has_cors_origins(): + """Settings must have cors_origins as a list with default localhost.""" + from config import settings + assert hasattr(settings, "cors_origins") + assert isinstance(settings.cors_origins, list) + assert "http://localhost:5173" in settings.cors_origins + + +def test_backup_code_table_in_schema(db_session): + """backup_codes table must be created in the SQLite test schema.""" + from db.models import BackupCode + import uuid + # Should not raise — table must exist + bc = BackupCode( + id=uuid.uuid4(), + user_id=uuid.uuid4(), + code_hash="testhash", + used_at=None, + ) + db_session.add(bc) + # We don't commit here — just verify the model is valid