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:
parent
5fab96e824
commit
b50f9d8517
4 changed files with 368 additions and 0 deletions
91
backend/shonar/api/v1/auth.py
Normal file
91
backend/shonar/api/v1/auth.py
Normal 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}
|
||||||
167
backend/shonar/services/auth.py
Normal file
167
backend/shonar/services/auth.py
Normal 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).
|
||||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
110
backend/tests/test_auth.py
Normal file
110
backend/tests/test_auth.py
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue