Files
gw/backend/app/routers/auth.py
T
ponzischeme89 6d44e05de4 v1
2026-04-18 07:23:55 +12:00

130 lines
3.7 KiB
Python

from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.jwt import (
create_access_token,
create_refresh_token,
hash_refresh_token,
get_token_expiry,
)
from app.auth.password import verify_password
from app.database import get_db
from app.middleware.rate_limit import limiter
from app.models.user import User, RefreshToken
from app.schemas.auth import LoginRequest, TokenResponse, RefreshRequest
router = APIRouter(prefix="/auth", tags=["Auth"])
@router.post("/login", response_model=TokenResponse)
@limiter.limit("5/minute")
async def login(
request: Request,
response: Response,
data: LoginRequest,
db: AsyncSession = Depends(get_db),
):
"""
Authenticate with email and password.
Returns access token (15 min) and refresh token (7 days).
"""
result = await db.execute(select(User).where(User.email == data.email))
user = result.scalars().first()
if user is None or not verify_password(data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
headers={"WWW-Authenticate": "Bearer"},
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Account is inactive",
)
access_token = create_access_token(data={"sub": str(user.id)})
plaintext_refresh, refresh_hash = create_refresh_token()
refresh_token_row = RefreshToken(
user_id=user.id,
token_hash=refresh_hash,
expires_at=get_token_expiry(),
revoked=False,
created_at=datetime.now(timezone.utc),
)
db.add(refresh_token_row)
await db.flush()
return TokenResponse(
access_token=access_token,
refresh_token=plaintext_refresh,
token_type="bearer",
)
@router.post("/refresh", response_model=TokenResponse)
@limiter.limit("5/minute")
async def refresh_tokens(
request: Request,
response: Response,
data: RefreshRequest,
db: AsyncSession = Depends(get_db),
):
"""
Exchange a valid refresh token for a new token pair.
The old refresh token is revoked atomically.
"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired refresh token",
headers={"WWW-Authenticate": "Bearer"},
)
now = datetime.now(timezone.utc)
token_hash = hash_refresh_token(data.refresh_token)
result = await db.execute(
select(RefreshToken).where(
RefreshToken.token_hash == token_hash,
RefreshToken.revoked == False,
RefreshToken.expires_at > now,
)
)
matched_row = result.scalars().first()
if matched_row is None:
raise credentials_exception
# Revoke old token
matched_row.revoked = True
# Load user
result = await db.execute(select(User).where(User.id == matched_row.user_id))
user = result.scalars().first()
if user is None or not user.is_active:
raise credentials_exception
# Issue new tokens
access_token = create_access_token(data={"sub": str(user.id)})
plaintext_refresh, refresh_hash = create_refresh_token()
new_refresh_row = RefreshToken(
user_id=user.id,
token_hash=refresh_hash,
expires_at=get_token_expiry(),
revoked=False,
created_at=now,
)
db.add(new_refresh_row)
await db.flush()
return TokenResponse(
access_token=access_token,
refresh_token=plaintext_refresh,
token_type="bearer",
)