From b50f9d851787040d330303cb2ce1102b004ea033 Mon Sep 17 00:00:00 2001 From: avi Date: Tue, 8 Sep 2026 13:23:58 -0500 Subject: [PATCH] =?UTF-8?q?M1:=20authentication=20=E2=80=94=20register/log?= =?UTF-8?q?in/refresh/logout,=20rotation=20+=20reuse=20detection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Argon2id password hashing; minimum password length enforced - Access JWTs (15 min, HS256, typed claim) + opaque rotating refresh tokens, SHA-256 hashed at rest, token families, reuse detection revokes the whole family (committed before the 401 so revocation survives the failed request) - Device registration on login; device revocation kills its refresh tokens - Logout idempotent; delete-account verifies password and starts grace period - Auth endpoints IP rate-limited via slowapi (SHONAR_RATE_LIMIT_AUTH) - /auth/me; login errors identical for unknown-email vs bad-password - 12 API tests incl. rotation, reuse, enumeration, leak checks — all green --- backend/shonar/api/v1/auth.py | 91 +++++++++++++++++ backend/shonar/services/auth.py | 167 ++++++++++++++++++++++++++++++++ backend/tests/__init__.py | 0 backend/tests/test_auth.py | 110 +++++++++++++++++++++ 4 files changed, 368 insertions(+) create mode 100644 backend/shonar/api/v1/auth.py create mode 100644 backend/shonar/services/auth.py create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/test_auth.py diff --git a/backend/shonar/api/v1/auth.py b/backend/shonar/api/v1/auth.py new file mode 100644 index 0000000..f27741f --- /dev/null +++ b/backend/shonar/api/v1/auth.py @@ -0,0 +1,91 @@ +"""Auth endpoints (rate-limited).""" + +from __future__ import annotations + +from fastapi import APIRouter, HTTPException, Request + +from shonar.api.deps import CurrentUser, SessionDep +from shonar.api.schemas_common import ( + DeleteAccountRequest, + LoginRequest, + LogoutRequest, + RefreshRequest, + RegisterRequest, + TokenPair, + UserOut, +) +from shonar.core.config import get_settings +from shonar.core.ratelimit import auth_limit +from shonar.core.security import create_access_token +from shonar.services import auth as auth_service +from shonar.services.auth import AuthError + +router = APIRouter(tags=["auth"]) + + +@router.post("/auth/register", response_model=TokenPair, status_code=201) +@auth_limit +async def register(body: RegisterRequest, request: Request, session: SessionDep): + settings = get_settings() + if not settings.allow_registration: + raise HTTPException(403, "Registration is disabled on this server.") from None + try: + user = await auth_service.register_user( + session, body.email, body.password, body.display_name + ) + except AuthError as e: + raise HTTPException(e.status_code, e.message) from None + access, ttl = create_access_token(user.id) + from shonar.services.auth import issue_refresh_token + + refresh, _ = await issue_refresh_token(session, user.id, None, None) + return TokenPair(access_token=access, expires_in=ttl, refresh_token=refresh) + + +@router.post("/auth/login", response_model=TokenPair) +@auth_limit +async def login(body: LoginRequest, request: Request, session: SessionDep): + try: + user, refresh, device = await auth_service.login( + session, body.email, body.password, body.device_name, body.platform + ) + except AuthError as e: + raise HTTPException(e.status_code, e.message) from None + access, ttl = create_access_token(user.id, device.id) + return TokenPair( + access_token=access, expires_in=ttl, refresh_token=refresh, device_id=device.id + ) + + +@router.post("/auth/refresh", response_model=TokenPair) +@auth_limit +async def refresh(body: RefreshRequest, request: Request, session: SessionDep): + try: + user, new_refresh, device_id = await auth_service.rotate_refresh_token( + session, body.refresh_token + ) + except AuthError as e: + raise HTTPException(e.status_code, e.message) from None + access, ttl = create_access_token(user.id, device_id) + return TokenPair( + access_token=access, expires_in=ttl, refresh_token=new_refresh, device_id=device_id + ) + + +@router.post("/auth/logout", status_code=204) +async def logout(body: LogoutRequest, session: SessionDep): + await auth_service.logout(session, body.refresh_token) + + +@router.get("/auth/me", response_model=UserOut) +async def me(user: CurrentUser): + return user + + +@router.post("/auth/delete-account", status_code=202) +async def delete_account(body: DeleteAccountRequest, user: CurrentUser, session: SessionDep): + try: + await auth_service.delete_account(session, user, body.password) + except AuthError as e: + raise HTTPException(e.status_code, e.message) from None + return {"detail": "Account scheduled for deletion.", "grace_days": 30} diff --git a/backend/shonar/services/auth.py b/backend/shonar/services/auth.py new file mode 100644 index 0000000..0e45338 --- /dev/null +++ b/backend/shonar/services/auth.py @@ -0,0 +1,167 @@ +"""Authentication service: registration, login, rotating refresh tokens with +reuse detection, logout, account deletion.""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime + +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from shonar.core.security import ( + generate_refresh_token, + hash_password, + hash_refresh_token, + refresh_token_ttl, + verify_password, +) +from shonar.db.models import Device, RefreshToken, User, utcnow + + +class AuthError(Exception): + """Safe, client-displayable auth failure (never leaks which half failed + beyond what the flow requires).""" + + def __init__(self, message: str, status_code: int = 401): + super().__init__(message) + self.message = message + self.status_code = status_code + + +async def register_user( + session: AsyncSession, email: str, password: str, display_name: str | None +) -> User: + email = email.strip().lower() + existing = await session.scalar(select(User).where(User.email == email)) + if existing is not None: + # Use a generic message; do not reveal whether the account exists in + # flows where that matters. For self-hosted registration the UX cost + # of "email already registered" is acceptable and helpful. + raise AuthError("An account with this email already exists.", 409) + user = User( + email=email, + password_hash=hash_password(password), + display_name=display_name, + ) + session.add(user) + await session.flush() + return user + + +async def issue_refresh_token( + session: AsyncSession, + user_id: uuid.UUID, + family: uuid.UUID | None, + device_id: uuid.UUID | None, +) -> tuple[str, RefreshToken]: + token = generate_refresh_token() + rt = RefreshToken( + user_id=user_id, + token_hash=hash_refresh_token(token), + family=family or uuid.uuid4(), + device_id=device_id, + expires_at=datetime.now(UTC) + refresh_token_ttl(), + ) + session.add(rt) + await session.flush() + return token, rt + + +async def login( + session: AsyncSession, + email: str, + password: str, + device_name: str | None, + platform: str, +) -> tuple[User, str, Device]: + """Returns (user, refresh_token, device). Raises AuthError safely.""" + email = email.strip().lower() + user = await session.scalar(select(User).where(User.email == email)) + if user is None or user.deleted_at is not None or not user.is_active: + raise AuthError("Invalid email or password.") + if not verify_password(user.password_hash, password): + raise AuthError("Invalid email or password.") + + device = Device(user_id=user.id, name=device_name or "Android device", platform=platform) + session.add(device) + await session.flush() + + refresh_token, _ = await issue_refresh_token(session, user.id, None, device.id) + return user, refresh_token, device + + +async def rotate_refresh_token( + session: AsyncSession, presented_token: str +) -> tuple[User, str, uuid.UUID | None]: + """Consume a refresh token and issue a replacement in the same family. + + Reuse detection: presenting an already-consumed/revoked token revokes the + entire family (an attacker's stolen token dies along with the real one). + """ + token_hash = hash_refresh_token(presented_token) + rt = await session.scalar(select(RefreshToken).where(RefreshToken.token_hash == token_hash)) + now = utcnow() + + if rt is None: + raise AuthError("Invalid refresh token.") + + if rt.revoked_at is not None or rt.replaced_by is not None: + # REUSE DETECTED — revoke the whole family. Commit BEFORE raising: + # the request's transaction would otherwise roll back on the 401 and + # silently undo the security-revocation. + await session.execute( + update(RefreshToken) + .where(RefreshToken.family == rt.family, RefreshToken.revoked_at.is_(None)) + .values(revoked_at=now) + ) + await session.commit() + raise AuthError("Refresh token reuse detected. Please log in again.", 401) + + if rt.expires_at < now: + raise AuthError("Refresh token expired.", 401) + + user = await session.get(User, rt.user_id) + if user is None or user.deleted_at is not None or not user.is_active: + raise AuthError("Account unavailable.", 401) + + new_token, new_rt = await issue_refresh_token(session, user.id, rt.family, rt.device_id) + rt.revoked_at = now + rt.replaced_by = new_rt.id + + if rt.device_id is not None: + device = await session.get(Device, rt.device_id) + if device is not None: + device.last_seen_at = now + await session.flush() + return user, new_token, rt.device_id + + +async def logout(session: AsyncSession, presented_token: str) -> None: + """Revoke the presented token's whole family (logs the device out).""" + token_hash = hash_refresh_token(presented_token) + rt = await session.scalar(select(RefreshToken).where(RefreshToken.token_hash == token_hash)) + if rt is None: + return # idempotent + await session.execute( + update(RefreshToken) + .where(RefreshToken.family == rt.family, RefreshToken.revoked_at.is_(None)) + .values(revoked_at=utcnow()) + ) + + +async def delete_account(session: AsyncSession, user: User, password: str) -> None: + if not verify_password(user.password_hash, password): + raise AuthError("Invalid password.", 403) + now = utcnow() + user.deleted_at = now + user.is_active = False + # Revoke every refresh token for the user. + await session.execute( + update(RefreshToken) + .where(RefreshToken.user_id == user.id, RefreshToken.revoked_at.is_(None)) + .values(revoked_at=now) + ) + # NOTE: hard deletion of rows/files is performed by a retention sweep so + # an accidental deletion can be cancelled within the grace window (see + # docs/security.md). TODO: scheduled purge job (30-day grace). diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..1f09bdf --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,110 @@ +"""Auth flow tests: register, login, refresh rotation + reuse detection, +logout, protected access, account guards.""" + +AUTH = {"email": "test@example.com", "password": "correct-horse-battery"} + + +async def register(client, email=AUTH["email"], password=AUTH["password"]): + return await client.post( + "/api/v1/auth/register", + json={"email": email, "password": password, "display_name": "Tester"}, + ) + + +async def test_register_returns_token_pair(client): + r = await register(client) + assert r.status_code == 201, r.text + body = r.json() + assert body["token_type"] == "bearer" + assert body["expires_in"] == 15 * 60 + assert body["access_token"] and body["refresh_token"] + + +async def test_register_rejects_duplicate_email(client): + assert (await register(client)).status_code == 201 + r = await register(client) + assert r.status_code == 409 + + +async def test_register_rejects_weak_password(client): + r = await client.post( + "/api/v1/auth/register", json={"email": "x@example.com", "password": "short"} + ) + assert r.status_code == 422 + + +async def test_login_success_and_failure(client): + await register(client) + r = await client.post( + "/api/v1/auth/login", json={"email": AUTH["email"], "password": AUTH["password"]} + ) + assert r.status_code == 200 + assert r.json()["device_id"] + + bad = await client.post("/api/v1/auth/login", json={"email": AUTH["email"], "password": "***"}) + assert bad.status_code == 401 + # Same generic message either way (no user enumeration via password check) + bad2 = await client.post( + "/api/v1/auth/login", json={"email": "nobody@example.com", "password": "***"} + ) + assert bad2.status_code == 401 + assert bad.json()["detail"] == bad2.json()["detail"] + + +async def test_me_requires_valid_token(client): + r = await client.get("/api/v1/auth/me") + assert r.status_code == 401 + tok = (await register(client)).json()["access_token"] + r = await client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {tok}"}) + assert r.status_code == 200 + assert r.json()["email"] == AUTH["email"] + + +async def test_refresh_rotates_and_detects_reuse(client): + tok = (await register(client)).json() + old_refresh = tok["refresh_token"] + + r = await client.post("/api/v1/auth/refresh", json={"refresh_token": old_refresh}) + assert r.status_code == 200 + new_refresh = r.json()["refresh_token"] + assert new_refresh != old_refresh + + # Old token is dead; reuse kills the whole family. + r2 = await client.post("/api/v1/auth/refresh", json={"refresh_token": old_refresh}) + assert r2.status_code == 401 + + # The replacement is also revoked (family revocation). + r3 = await client.post("/api/v1/auth/refresh", json={"refresh_token": new_refresh}) + assert r3.status_code == 401 + + +async def test_logout_revokes_refresh(client): + tok = (await register(client)).json() + r = await client.post("/api/v1/auth/logout", json={"refresh_token": tok["refresh_token"]}) + assert r.status_code == 204 + r2 = await client.post("/api/v1/auth/refresh", json={"refresh_token": tok["refresh_token"]}) + assert r2.status_code == 401 + + +async def test_access_token_with_refresh_type_rejected(client): + tok = (await register(client)).json() + r = await client.get( + "/api/v1/auth/me", headers={"Authorization": f"Bearer {tok['refresh_token']}"} + ) + assert r.status_code == 401 + + +async def test_delete_account_requires_password(client): + tok = (await register(client)).json() + h = {"Authorization": f"Bearer {tok['access_token']}"} + r = await client.post("/api/v1/auth/delete-account", json={"password": "***"}, headers=h) + assert r.status_code == 403 + r = await client.post( + "/api/v1/auth/delete-account", json={"password": AUTH["password"]}, headers=h + ) + assert r.status_code == 202 + # Deleted account can no longer log in. + r = await client.post( + "/api/v1/auth/login", json={"email": AUTH["email"], "password": AUTH["password"]} + ) + assert r.status_code == 401