M1: authentication — register/login/refresh/logout, rotation + reuse detection

- 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
This commit is contained in:
avi 2026-09-08 13:23:58 -05:00
commit b50f9d8517
4 changed files with 368 additions and 0 deletions

View file

@ -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}

View file

@ -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).

View file

110
backend/tests/test_auth.py Normal file
View file

@ -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