feat(07.3-03): backend remember_me — TTL split + cookie Max-Age + 3 promoted tests

- services/auth.py: create_refresh_token gains remember_me=False param; selects 16h or 30d TTL (D-09, D-10, D-11)
- api/auth.py: LoginRequest.remember_me bool field; _set_refresh_cookie remember_me param for conditional max_age; login handler threads remember_me through both calls (D-11, RM-03)
- test_auth_es256.py: promote RM-01, RM-02, RM-03 stubs — all 9 phase tests now PASSED
This commit is contained in:
curo1305
2026-06-06 17:21:14 +02:00
parent 8be792ab4c
commit 9cc11b5446
3 changed files with 98 additions and 13 deletions
+17 -5
View File
@@ -59,6 +59,7 @@ class LoginRequest(BaseModel):
password: str password: str
totp_code: Optional[str] = None totp_code: Optional[str] = None
backup_code: Optional[str] = None backup_code: Optional[str] = None
remember_me: bool = False
class ChangePasswordRequest(BaseModel): class ChangePasswordRequest(BaseModel):
@@ -68,8 +69,19 @@ class ChangePasswordRequest(BaseModel):
# ── Helper: set httpOnly refresh cookie ────────────────────────────────────── # ── Helper: set httpOnly refresh cookie ──────────────────────────────────────
def _set_refresh_cookie(response: Response, raw_token: str) -> None: def _set_refresh_cookie(
"""Set the httpOnly Secure SameSite=Strict refresh cookie (CLAUDE.md constraint).""" 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( response.set_cookie(
key="refresh_token", key="refresh_token",
value=raw_token, value=raw_token,
@@ -77,7 +89,7 @@ def _set_refresh_cookie(response: Response, raw_token: str) -> None:
secure=True, secure=True,
samesite="strict", samesite="strict",
path="/api/auth/refresh", 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 # Issue tokens
access_token = auth_service.create_access_token(str(user.id), user.role) access_token = auth_service.create_access_token(str(user.id), user.role)
raw_refresh = await auth_service.create_refresh_token(session, user.id) raw_refresh = await auth_service.create_refresh_token(session, user.id, remember_me=body.remember_me)
_set_refresh_cookie(response, raw_refresh) _set_refresh_cookie(response, raw_refresh, remember_me=body.remember_me)
# D-13: login success event # D-13: login success event
await write_audit_log( await write_audit_log(
+12 -2
View File
@@ -157,20 +157,30 @@ def decode_password_reset_token(token: str) -> str:
# ── Refresh token lifecycle ───────────────────────────────────────────────────── # ── 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. """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. The raw token is returned to the caller and set as an httpOnly cookie.
Only the SHA-256 hash is stored in the database. 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) raw = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(raw.encode()).hexdigest() token_hash = hashlib.sha256(raw.encode()).hexdigest()
now = datetime.now(timezone.utc) 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( row = RefreshToken(
id=uuid.uuid4(), id=uuid.uuid4(),
user_id=user_id, user_id=user_id,
token_hash=token_hash, token_hash=token_hash,
expires_at=now + timedelta(days=settings.refresh_token_expire_days), expires_at=now + ttl,
revoked=False, revoked=False,
) )
session.add(row) session.add(row)
+69 -6
View File
@@ -234,31 +234,94 @@ async def test_startup_rotation_idempotent(db_session):
assert len(markers) == 1 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 ─────────────────────────────────────────── # ── RM-01: default TTL is 16 hours ───────────────────────────────────────────
@pytest.mark.xfail(strict=True, reason="RM-01: not yet implemented")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_default_ttl_16_hours(async_client, db_session, auth_user): 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 ──────────────────────────────────────── # ── RM-02: remember_me TTL is 30 days ────────────────────────────────────────
@pytest.mark.xfail(strict=True, reason="RM-02: not yet implemented")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_remember_me_ttl_30_days(async_client, db_session, auth_user): 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 ───────────────────────────────────────────── # ── RM-03: cookie Max-Age values ─────────────────────────────────────────────
@pytest.mark.xfail(strict=True, reason="RM-03: not yet implemented")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_remember_me_cookie_max_age(async_client, auth_user): 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 ──────────────────────────── # ── CFG-01 satellite: settings has jwt key fields ────────────────────────────