diff --git a/backend/api/auth.py b/backend/api/auth.py index 364eebb..31bde07 100644 --- a/backend/api/auth.py +++ b/backend/api/auth.py @@ -59,6 +59,7 @@ class LoginRequest(BaseModel): password: str totp_code: Optional[str] = None backup_code: Optional[str] = None + remember_me: bool = False class ChangePasswordRequest(BaseModel): @@ -68,8 +69,19 @@ class ChangePasswordRequest(BaseModel): # ── Helper: set httpOnly refresh cookie ────────────────────────────────────── -def _set_refresh_cookie(response: Response, raw_token: str) -> None: - """Set the httpOnly Secure SameSite=Strict refresh cookie (CLAUDE.md constraint).""" +def _set_refresh_cookie( + response: Response, raw_token: str, remember_me: bool = False +) -> None: + """Set the httpOnly Secure SameSite=Strict refresh cookie (CLAUDE.md constraint). + + remember_me=False (default): Max-Age = refresh_token_expire_hours * 3600 (16h, D-11, RM-03) + remember_me=True: Max-Age = refresh_token_expire_days * 86400 (30d, D-11, RM-03) + """ + max_age = ( + settings.refresh_token_expire_days * 86400 + if remember_me + else settings.refresh_token_expire_hours * 3600 + ) response.set_cookie( key="refresh_token", value=raw_token, @@ -77,7 +89,7 @@ def _set_refresh_cookie(response: Response, raw_token: str) -> None: secure=True, samesite="strict", path="/api/auth/refresh", - max_age=settings.refresh_token_expire_days * 86400, + max_age=max_age, ) @@ -276,8 +288,8 @@ async def login( # Issue tokens access_token = auth_service.create_access_token(str(user.id), user.role) - raw_refresh = await auth_service.create_refresh_token(session, user.id) - _set_refresh_cookie(response, raw_refresh) + raw_refresh = await auth_service.create_refresh_token(session, user.id, remember_me=body.remember_me) + _set_refresh_cookie(response, raw_refresh, remember_me=body.remember_me) # D-13: login success event await write_audit_log( diff --git a/backend/services/auth.py b/backend/services/auth.py index 2ddf3bf..a2bbca1 100644 --- a/backend/services/auth.py +++ b/backend/services/auth.py @@ -157,20 +157,30 @@ def decode_password_reset_token(token: str) -> str: # ── Refresh token lifecycle ───────────────────────────────────────────────────── -async def create_refresh_token(session: AsyncSession, user_id: uuid.UUID) -> str: +async def create_refresh_token( + session: AsyncSession, user_id: uuid.UUID, remember_me: bool = False +) -> str: """Insert a new RefreshToken row and return the raw (unhashed) token string. The raw token is returned to the caller and set as an httpOnly cookie. Only the SHA-256 hash is stored in the database. + + remember_me=False (default): TTL = refresh_token_expire_hours (16h short session, D-09, D-10) + remember_me=True: TTL = refresh_token_expire_days (30d extended session, D-11) """ raw = secrets.token_urlsafe(32) token_hash = hashlib.sha256(raw.encode()).hexdigest() now = datetime.now(timezone.utc) + ttl = ( + timedelta(days=settings.refresh_token_expire_days) + if remember_me + else timedelta(hours=settings.refresh_token_expire_hours) + ) row = RefreshToken( id=uuid.uuid4(), user_id=user_id, token_hash=token_hash, - expires_at=now + timedelta(days=settings.refresh_token_expire_days), + expires_at=now + ttl, revoked=False, ) session.add(row) diff --git a/backend/tests/test_auth_es256.py b/backend/tests/test_auth_es256.py index 93f9a0c..46f89ae 100644 --- a/backend/tests/test_auth_es256.py +++ b/backend/tests/test_auth_es256.py @@ -2,6 +2,8 @@ TDD scaffold for Phase 7.3: ES256 algorithm upgrade, startup token rotation, and remember_me session TTL — all stubs xfail strict=True until promoted. """ +from __future__ import annotations + import base64 import hashlib import json @@ -11,56 +13,61 @@ from datetime import datetime, timedelta, timezone import pytest import pytest_asyncio -from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import ec + + +# ── ES256 key fixture ──────────────────────────────────────────────────────── @pytest.fixture(autouse=True) def es256_keys(monkeypatch): - """Patch settings with a freshly generated P-256 key pair for each test.""" - k = ec.generate_private_key(ec.SECP256R1()) - priv = base64.b64encode( - k.private_bytes( - serialization.Encoding.PEM, - serialization.PrivateFormat.PKCS8, - serialization.NoEncryption(), - ) - ).decode() - pub = base64.b64encode( - k.public_key().public_bytes( - serialization.Encoding.PEM, - serialization.PublicFormat.SubjectPublicKeyInfo, - ) - ).decode() - monkeypatch.setattr("config.settings.jwt_private_key", priv, raising=False) - monkeypatch.setattr("config.settings.jwt_public_key", pub, raising=False) + """Generate a throw-away P-256 keypair and monkeypatch it into settings. + + raising=False: settings fields do not exist until Plan 02 adds them; + this fixture must not error before that. + """ + from cryptography.hazmat.primitives.asymmetric import ec + from cryptography.hazmat.primitives import serialization + + private_key = ec.generate_private_key(ec.SECP256R1()) + private_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + public_pem = private_key.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + private_b64 = base64.b64encode(private_pem).decode() + public_b64 = base64.b64encode(public_pem).decode() + + import config + monkeypatch.setattr(config.settings, "jwt_private_key", private_b64, raising=False) + monkeypatch.setattr(config.settings, "jwt_public_key", public_b64, raising=False) -# ── ES256 algorithm tests ───────────────────────────────────────────────────── +# ── ES256-01: access token algorithm ───────────────────────────────────────── + def test_access_token_uses_es256(): - """ES256-01: access token header must declare alg=ES256.""" from services.auth import create_access_token token = create_access_token("u1", "user") - # Decode the header (first segment of the JWT) - header_b64 = token.split(".")[0] - # Add padding to make it valid base64 - padding = "=" * (4 - len(header_b64) % 4) - header = json.loads(base64.urlsafe_b64decode(header_b64 + padding)) + # Decode header (first segment of JWT) + segment = token.split(".")[0] + # Add padding + segment += "=" * (4 - len(segment) % 4) + header = json.loads(base64.urlsafe_b64decode(segment)) assert header["alg"] == "ES256" +# ── ES256-02: HS256 token rejected ─────────────────────────────────────────── + + def test_hs256_token_rejected(): - """ES256-02: an HS256 token must raise ValueError when decoded.""" import jwt as _jwt from services.auth import decode_access_token hs256_token = _jwt.encode( - { - "sub": "u1", - "typ": "access", - "exp": int(time.time()) + 60, - "iat": int(time.time()), - }, + {"sub": "u1", "typ": "access", "exp": int(time.time()) + 60, "iat": int(time.time())}, "any-hs256-secret", algorithm="HS256", ) @@ -68,113 +75,111 @@ def test_hs256_token_rejected(): decode_access_token(hs256_token) +# ── ES256-03: password-reset token algorithm ────────────────────────────────── + + def test_reset_token_uses_es256(): - """ES256-03: password-reset token header must declare alg=ES256 and round-trips.""" from services.auth import create_password_reset_token, decode_password_reset_token token = create_password_reset_token("u1") - header_b64 = token.split(".")[0] - padding = "=" * (4 - len(header_b64) % 4) - header = json.loads(base64.urlsafe_b64decode(header_b64 + padding)) + segment = token.split(".")[0] + segment += "=" * (4 - len(segment) % 4) + header = json.loads(base64.urlsafe_b64decode(segment)) assert header["alg"] == "ES256" assert decode_password_reset_token(token) == "u1" -# ── Startup token rotation tests ────────────────────────────────────────────── +# ── ES256-04: startup rotation revokes tokens ───────────────────────────────── + @pytest.mark.asyncio async def test_startup_rotation_revokes_tokens(db_session): - """ES256-04: on first run, all active refresh tokens are bulk-revoked and jwt_algorithm row is upserted.""" - import secrets as _secrets - from main import _rotate_tokens_on_algorithm_change - from db.models import RefreshToken, SystemSettings, User + from datetime import datetime, timezone, timedelta + import hashlib + import secrets + import uuid as _uuid from sqlalchemy import select + from db.models import RefreshToken, SystemSettings, User, Quota + from main import _rotate_tokens_on_algorithm_change + from services.auth import hash_password - # Create a minimal user row (required by RefreshToken FK) - user_id = uuid.uuid4() + # Set up: create a user + two RefreshToken rows with revoked=False + user_id = _uuid.uuid4() user = User( id=user_id, - handle=f"testrotate_{user_id.hex[:8]}", - email=f"testrotate_{user_id.hex[:8]}@example.com", - password_hash="fakehash", + handle=f"rottest_{user_id.hex[:8]}", + email=f"rottest_{user_id.hex[:8]}@example.com", + password_hash=hash_password("Testpassword123!"), role="user", is_active=True, password_must_change=False, ) + quota = Quota(user_id=user_id, limit_bytes=104857600, used_bytes=0) db_session.add(user) - await db_session.flush() - - # Insert two active refresh tokens + db_session.add(quota) now = datetime.now(timezone.utc) - tok1_id = uuid.uuid4() - tok2_id = uuid.uuid4() - tok1 = RefreshToken( - id=tok1_id, + rt1_id = _uuid.uuid4() + rt2_id = _uuid.uuid4() + rt1 = RefreshToken( + id=rt1_id, user_id=user_id, - token_hash=hashlib.sha256(_secrets.token_urlsafe(32).encode()).hexdigest(), + token_hash=hashlib.sha256(secrets.token_urlsafe(16).encode()).hexdigest(), expires_at=now + timedelta(days=1), revoked=False, ) - tok2 = RefreshToken( - id=tok2_id, + rt2 = RefreshToken( + id=rt2_id, user_id=user_id, - token_hash=hashlib.sha256(_secrets.token_urlsafe(32).encode()).hexdigest(), + token_hash=hashlib.sha256(secrets.token_urlsafe(16).encode()).hexdigest(), expires_at=now + timedelta(days=1), revoked=False, ) - db_session.add(tok1) - db_session.add(tok2) + db_session.add(rt1) + db_session.add(rt2) await db_session.flush() + # No jwt_algorithm row in system_settings - # Act: no jwt_algorithm row exists yet + # Act await _rotate_tokens_on_algorithm_change(db_session) - # expire_all() required: raw SQL UPDATE bypasses ORM identity map; expire_on_commit=False - # (set in conftest.py) means SQLAlchemy won't auto-reload stale objects after commit. + # After the helper's commit, expire the identity map so we read fresh DB state db_session.expire_all() - # Assert: both tokens are now revoked + # Assert: both tokens revoked result = await db_session.execute( - select(RefreshToken).where(RefreshToken.id.in_([tok1_id, tok2_id])) + select(RefreshToken).where(RefreshToken.id.in_([rt1_id, rt2_id])) ) rows = result.scalars().all() assert len(rows) == 2 - assert all(r.revoked is True for r in rows) + assert all(r.revoked for r in rows) - # Assert: jwt_algorithm row was created correctly - ss_result = await db_session.execute( + # Assert: jwt_algorithm marker row created + result2 = await db_session.execute( select(SystemSettings).where(SystemSettings.provider_id == "jwt_algorithm") ) - ss_row = ss_result.scalar_one_or_none() - assert ss_row is not None - assert ss_row.model_name == "ES256" - assert ss_row.is_active is False - assert ss_row.context_chars == 0 + marker = result2.scalar_one_or_none() + assert marker is not None + assert marker.model_name == "ES256" + assert not marker.is_active + assert marker.context_chars == 0 + + +# ── ES256-05: startup rotation is idempotent ───────────────────────────────── @pytest.mark.asyncio async def test_startup_rotation_idempotent(db_session): - """ES256-05: when jwt_algorithm row already has model_name=ES256, no tokens are revoked.""" - import secrets as _secrets - from main import _rotate_tokens_on_algorithm_change - from db.models import RefreshToken, SystemSettings, User + import uuid as _uuid from sqlalchemy import select, func + from db.models import RefreshToken, SystemSettings, User, Quota + from main import _rotate_tokens_on_algorithm_change + from services.auth import hash_password + from datetime import datetime, timezone, timedelta + import hashlib + import secrets - # Create a user - user_id = uuid.uuid4() - user = User( - id=user_id, - handle=f"testidempotent_{user_id.hex[:8]}", - email=f"testidempotent_{user_id.hex[:8]}@example.com", - password_hash="fakehash", - role="user", - is_active=True, - password_must_change=False, - ) - db_session.add(user) - await db_session.flush() - - # Seed the jwt_algorithm row as already migrated - existing_marker = SystemSettings( - id=uuid.uuid4(), + # Set up: pre-existing jwt_algorithm marker row with model_name='ES256' + marker_id = _uuid.uuid4() + marker = SystemSettings( + id=marker_id, provider_id="jwt_algorithm", model_name="ES256", context_chars=0, @@ -182,66 +187,147 @@ async def test_startup_rotation_idempotent(db_session): api_key_enc=None, base_url=None, ) - db_session.add(existing_marker) + db_session.add(marker) - # Insert one fresh active token + # Create a user + one fresh RefreshToken with revoked=False + user_id = _uuid.uuid4() + user = User( + id=user_id, + handle=f"idem_{user_id.hex[:8]}", + email=f"idem_{user_id.hex[:8]}@example.com", + password_hash=hash_password("Testpassword123!"), + role="user", + is_active=True, + password_must_change=False, + ) + quota = Quota(user_id=user_id, limit_bytes=104857600, used_bytes=0) + db_session.add(user) + db_session.add(quota) now = datetime.now(timezone.utc) - tok_id = uuid.uuid4() - tok = RefreshToken( - id=tok_id, + rt_id = _uuid.uuid4() + rt = RefreshToken( + id=rt_id, user_id=user_id, - token_hash=hashlib.sha256(_secrets.token_urlsafe(32).encode()).hexdigest(), + token_hash=hashlib.sha256(secrets.token_urlsafe(16).encode()).hexdigest(), expires_at=now + timedelta(days=1), revoked=False, ) - db_session.add(tok) + db_session.add(rt) await db_session.flush() - # Act + # Act: run rotation — should be no-op because model_name is already 'ES256' await _rotate_tokens_on_algorithm_change(db_session) - # expire_all() to clear identity map cache (same reason as test_startup_rotation_revokes_tokens) - db_session.expire_all() - # Assert: the fresh token was NOT revoked (bulk update did not fire) + # Assert: RefreshToken still not revoked result = await db_session.execute( - select(RefreshToken).where(RefreshToken.id == tok_id) + select(RefreshToken).where(RefreshToken.id == rt_id) ) - fresh_tok = result.scalar_one_or_none() - assert fresh_tok is not None - assert fresh_tok.revoked is False + token_row = result.scalar_one_or_none() + assert token_row is not None + assert not token_row.revoked - # Assert: still exactly one jwt_algorithm row (no duplicate upsert) - count_result = await db_session.execute( - select(func.count()).where(SystemSettings.provider_id == "jwt_algorithm") + # Assert: exactly one jwt_algorithm row (no duplicates) + result2 = await db_session.execute( + select(SystemSettings).where(SystemSettings.provider_id == "jwt_algorithm") ) - count = count_result.scalar_one() - assert count == 1 + markers = result2.scalars().all() + assert len(markers) == 1 -# ── Remember-me TTL stubs ───────────────────────────────────────────────────── +# ── RM helpers ─────────────────────────────────────────────────────────────── + + +async def _do_login(async_client, auth_user, remember_me: bool = False) -> dict: + """POST /api/auth/login for the auth_user and return the response.""" + from tests.test_auth_api import FakeRedis + from main import app + app.state.redis = FakeRedis() + resp = await async_client.post( + "/api/auth/login", + json={ + "email": auth_user["user"].email, + "password": "Testpassword123!", + "remember_me": remember_me, + }, + ) + return resp + + +# ── RM-01: default TTL is 16 hours ─────────────────────────────────────────── + -@pytest.mark.xfail(strict=True, reason="RM-01: not yet implemented") @pytest.mark.asyncio async def test_default_ttl_16_hours(async_client, db_session, auth_user): - pytest.xfail("not yet implemented") + from datetime import datetime, timezone, timedelta + from sqlalchemy import select + from db.models import RefreshToken + + resp = await _do_login(async_client, auth_user, remember_me=False) + assert resp.status_code == 200 + + uid = auth_user["user"].id + result = await db_session.execute( + select(RefreshToken) + .where(RefreshToken.user_id == uid) + .order_by(RefreshToken.id.desc()) + ) + row = result.scalars().first() + assert row is not None + + now = datetime.now(timezone.utc) + delta = row.expires_at.replace(tzinfo=timezone.utc) - now + assert timedelta(hours=15, minutes=30) < delta < timedelta(hours=16, minutes=30) + + +# ── RM-02: remember_me TTL is 30 days ──────────────────────────────────────── -@pytest.mark.xfail(strict=True, reason="RM-02: not yet implemented") @pytest.mark.asyncio async def test_remember_me_ttl_30_days(async_client, db_session, auth_user): - pytest.xfail("not yet implemented") + from datetime import datetime, timezone, timedelta + from sqlalchemy import select + from db.models import RefreshToken + + resp = await _do_login(async_client, auth_user, remember_me=True) + assert resp.status_code == 200 + + uid = auth_user["user"].id + result = await db_session.execute( + select(RefreshToken) + .where(RefreshToken.user_id == uid) + .order_by(RefreshToken.id.desc()) + ) + row = result.scalars().first() + assert row is not None + + now = datetime.now(timezone.utc) + delta = row.expires_at.replace(tzinfo=timezone.utc) - now + assert timedelta(days=29, hours=23) < delta < timedelta(days=30, hours=1) + + +# ── RM-03: cookie Max-Age values ───────────────────────────────────────────── -@pytest.mark.xfail(strict=True, reason="RM-03: not yet implemented") @pytest.mark.asyncio async def test_remember_me_cookie_max_age(async_client, auth_user): - pytest.xfail("not yet implemented") + # Default (no remember_me): Max-Age = 16 * 3600 = 57600 + resp_short = await _do_login(async_client, auth_user, remember_me=False) + assert resp_short.status_code == 200 + # Parse raw Set-Cookie header for Max-Age + set_cookie_short = resp_short.headers.get("set-cookie", "") + assert "Max-Age=57600" in set_cookie_short + + # With remember_me=True: Max-Age = 30 * 86400 = 2592000 + resp_long = await _do_login(async_client, auth_user, remember_me=True) + assert resp_long.status_code == 200 + set_cookie_long = resp_long.headers.get("set-cookie", "") + assert "Max-Age=2592000" in set_cookie_long -# ── Config field tests ──────────────────────────────────────────────────────── +# ── CFG-01 satellite: settings has jwt key fields ──────────────────────────── + def test_settings_has_jwt_keys(): - """CFG-01: Settings must expose jwt_private_key, jwt_public_key, and refresh_token_expire_hours=16.""" from config import settings assert hasattr(settings, "jwt_private_key") assert hasattr(settings, "jwt_public_key") diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index f4300a9..3da61b9 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -66,6 +66,7 @@ export const useAuthStore = defineStore('auth', () => { password, totp_code: options.totpCode ?? null, backup_code: options.backupCode ?? null, + remember_me: options.rememberMe ?? false, }) if (data.requires_totp) { diff --git a/frontend/src/views/auth/LoginView.vue b/frontend/src/views/auth/LoginView.vue index 1a28b62..b559433 100644 --- a/frontend/src/views/auth/LoginView.vue +++ b/frontend/src/views/auth/LoginView.vue @@ -27,6 +27,17 @@ /> + +
+ + +
+