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:
+17
-5
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 ────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user