130 lines
3.7 KiB
Python
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",
|
|
)
|