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 fbcb621..46f89ae 100644 --- a/backend/tests/test_auth_es256.py +++ b/backend/tests/test_auth_es256.py @@ -234,31 +234,94 @@ async def test_startup_rotation_idempotent(db_session): assert len(markers) == 1 +# ── 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 # ── CFG-01 satellite: settings has jwt key fields ────────────────────────────