MyMidas/backend/app/api/v1/auth.py
megaproxy 1a2c8efd01 Add pensions module and integrate with tax report
Adds a full pensions feature: SIPP/workplace DC/LISA account metadata,
contribution recording with relief-at-source/net-pay/salary-sacrifice
gross calculations, state pension tracker, annual allowance monitor,
and LISA summary. Pension contributions feed into the tax report
(RAS gross totals, allowance used). Includes two Alembic migrations,
backend service/schema/API, and full frontend pensions page with
cards for allowance, state pension, LISA, and retirement projection.

Also fixes CSRF cookie secure flag (must be false for HTTP deployments)
and extends tax schemas/service to expose pension data in the report.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 09:59:01 +00:00

333 lines
10 KiB
Python

"""
Auth endpoints: register, login, TOTP, refresh, logout, sessions.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from redis.asyncio import Redis
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import write_audit
from app.core.rate_limiter import is_rate_limited
from app.core.security import create_refresh_token, decode_token, generate_csrf_token, hash_token
from app.schemas.auth import TOTPEnableRequest
from app.dependencies import get_current_user, get_db, get_redis
from app.schemas.auth import (
LoginRequest,
RegisterRequest,
SessionInfo,
TOTPChallengeResponse,
TOTPLoginRequest,
TOTPSetupResponse,
TokenResponse,
)
from app.services.auth_service import (
AuthError,
authenticate_user,
complete_totp_login,
create_totp_challenge_token,
disable_totp,
enable_totp,
get_sessions,
register_user,
revoke_all_sessions,
revoke_session,
setup_totp,
)
router = APIRouter()
def _ip(request: Request) -> str | None:
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else None
def _ua(request: Request) -> str | None:
return request.headers.get("User-Agent")
def _set_refresh_cookie(response: Response, token: str) -> None:
response.set_cookie(
"refresh_token",
token,
httponly=True,
secure=True,
samesite="strict",
max_age=7 * 24 * 3600,
path="/api/v1/auth",
)
def _set_csrf_cookie(response: Response, token: str) -> None:
response.set_cookie(
"csrf_token",
token,
httponly=False,
secure=False, # must be readable by JS; Secure breaks HTTP deployments
samesite="lax",
max_age=604800,
)
@router.post("/register", status_code=201)
async def register(
body: RegisterRequest,
request: Request,
response: Response,
db: AsyncSession = Depends(get_db),
):
try:
user = await register_user(db, body.email, body.password, body.display_name)
await write_audit(db, user_id=user.id, action="register", ip_address=_ip(request))
await db.commit()
except AuthError as e:
raise HTTPException(status_code=e.status_code, detail=e.detail)
return {"message": "Account created. Please log in."}
@router.post("/login")
async def login(
body: LoginRequest,
request: Request,
response: Response,
db: AsyncSession = Depends(get_db),
redis: Redis = Depends(get_redis),
):
ip = _ip(request) or "unknown"
limited, _ = await is_rate_limited(redis, f"rate:login:{ip}", limit=20, window_seconds=60)
if limited:
raise HTTPException(status_code=429, detail="Too many login attempts — try again shortly")
try:
user, access_token, refresh_token = await authenticate_user(
db, redis, body.email, body.password, _ip(request), _ua(request)
)
except AuthError as e:
raise HTTPException(status_code=e.status_code, detail=e.detail)
if access_token is None:
# TOTP required
challenge_token = create_totp_challenge_token(user.id)
await write_audit(db, user_id=user.id, action="login", ip_address=_ip(request), metadata={"totp_required": True})
await db.commit()
return TOTPChallengeResponse(challenge_token=challenge_token)
csrf = generate_csrf_token()
_set_refresh_cookie(response, refresh_token)
_set_csrf_cookie(response, csrf)
await write_audit(db, user_id=user.id, action="login", ip_address=_ip(request))
await db.commit()
settings_expire = 15 * 60
return TokenResponse(access_token=access_token, expires_in=settings_expire)
@router.post("/login/totp")
async def login_totp(
body: TOTPLoginRequest,
request: Request,
response: Response,
db: AsyncSession = Depends(get_db),
redis: Redis = Depends(get_redis),
):
ip = _ip(request) or "unknown"
limited, _ = await is_rate_limited(redis, f"rate:totp:{ip}", limit=10, window_seconds=60)
if limited:
raise HTTPException(status_code=429, detail="Too many TOTP attempts — try again shortly")
try:
access_token, refresh_token = await complete_totp_login(
db, body.challenge_token, body.totp_code, _ip(request), _ua(request)
)
except AuthError as e:
raise HTTPException(status_code=e.status_code, detail=e.detail)
csrf = generate_csrf_token()
_set_refresh_cookie(response, refresh_token)
_set_csrf_cookie(response, csrf)
await db.commit()
return TokenResponse(access_token=access_token, expires_in=15 * 60)
@router.post("/refresh")
async def refresh_token(
request: Request,
response: Response,
db: AsyncSession = Depends(get_db),
):
token = request.cookies.get("refresh_token")
if not token:
raise HTTPException(status_code=401, detail="No refresh token")
try:
payload = decode_token(token, token_type="refresh")
except Exception:
raise HTTPException(status_code=401, detail="Invalid refresh token")
import uuid
from app.core.security import create_access_token
from sqlalchemy import select
from datetime import datetime, timezone
from app.db.models.session import Session
user_id = uuid.UUID(payload["sub"])
now = datetime.now(timezone.utc)
refresh_hash = hash_token(token)
# Find the specific session this refresh token was issued for
result = await db.execute(
select(Session).where(
Session.user_id == user_id,
Session.refresh_token_hash == refresh_hash,
Session.revoked_at.is_(None),
Session.expires_at > now,
)
)
session = result.scalars().first()
if not session:
raise HTTPException(status_code=401, detail="Session not found or refresh token already used")
new_access = create_access_token(str(user_id))
new_refresh = create_refresh_token(str(user_id))
# Rotate both token hashes — old refresh token is now invalid
session.token_hash = hash_token(new_access)
session.refresh_token_hash = hash_token(new_refresh)
session.last_active_at = now
await db.commit()
csrf = generate_csrf_token()
_set_refresh_cookie(response, new_refresh)
_set_csrf_cookie(response, csrf)
return TokenResponse(access_token=new_access, expires_in=15 * 60)
@router.post("/logout")
async def logout(
request: Request,
response: Response,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
token = request.headers.get("Authorization", "")[7:]
th = hash_token(token)
await revoke_session_by_hash(db, th, user.id)
await write_audit(db, user_id=user.id, action="logout", ip_address=_ip(request))
await db.commit()
response.delete_cookie("refresh_token", path="/api/v1/auth")
response.delete_cookie("csrf_token")
return {"message": "Logged out"}
async def revoke_session_by_hash(db, token_hash: str, user_id):
from sqlalchemy import select, update
from datetime import datetime, timezone
from app.db.models.session import Session
await db.execute(
update(Session)
.where(Session.user_id == user_id, Session.token_hash == token_hash)
.values(revoked_at=datetime.now(timezone.utc))
)
@router.post("/logout-all")
async def logout_all(
request: Request,
response: Response,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
token = request.headers.get("Authorization", "")[7:]
await revoke_all_sessions(db, user.id)
await write_audit(db, user_id=user.id, action="logout_all", ip_address=_ip(request))
await db.commit()
response.delete_cookie("refresh_token", path="/api/v1/auth")
response.delete_cookie("csrf_token")
return {"message": "All sessions revoked"}
@router.get("/sessions", response_model=list[SessionInfo])
async def list_sessions(
request: Request,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
token = request.headers.get("Authorization", "")[7:]
current_hash = hash_token(token)
sessions = await get_sessions(db, user.id)
result = []
for s in sessions:
info = SessionInfo.model_validate(s)
info.is_current = (s.token_hash == current_hash)
result.append(info)
return result
@router.delete("/sessions/{session_id}", status_code=204)
async def delete_session(
session_id,
request: Request,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
import uuid
try:
sid = uuid.UUID(str(session_id))
except ValueError:
raise HTTPException(status_code=422, detail="Invalid session ID")
try:
await revoke_session(db, sid, user.id)
except AuthError as e:
raise HTTPException(status_code=e.status_code, detail=e.detail)
await write_audit(db, user_id=user.id, action="session_revoke", resource_type="session", resource_id=sid)
await db.commit()
@router.get("/totp/setup", response_model=TOTPSetupResponse)
async def totp_setup(
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
secret, qr_b64, backup_codes = await setup_totp(user, db)
return TOTPSetupResponse(secret=secret, qr_code_png_b64=qr_b64, backup_codes=backup_codes)
@router.post("/totp/enable", status_code=200)
async def totp_enable(
body: TOTPEnableRequest,
request: Request,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
try:
await enable_totp(user, db, body.secret, body.code)
except AuthError as e:
raise HTTPException(status_code=e.status_code, detail=e.detail)
await write_audit(db, user_id=user.id, action="totp_enable", ip_address=_ip(request))
await db.commit()
return {"message": "TOTP enabled"}
@router.delete("/totp", status_code=200)
async def totp_disable(
body: dict,
request: Request,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
password = body.get("password")
if not password:
raise HTTPException(status_code=422, detail="password required")
try:
await disable_totp(user, db, password)
except AuthError as e:
raise HTTPException(status_code=e.status_code, detail=e.detail)
await write_audit(db, user_id=user.id, action="totp_disable", ip_address=_ip(request))
await db.commit()
return {"message": "TOTP disabled"}