Add /auth/refresh endpoint to issue new access tokens using refresh tokens with token type validation and user activity checks, implement automatic token refresh on 401 responses with single retry logic in frontend API client, add authorizedFetch helper for non-JSON endpoints with refresh support, store both access and refresh tokens in localStorage with clearTokens cleanup helper, add nexafabric.authExpired event
66 lines
2.8 KiB
Python
66 lines
2.8 KiB
Python
from datetime import datetime
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.api.deps import CurrentUser
|
|
from app.core.security import create_access_token, create_refresh_token, decode_token, verify_password
|
|
from app.db.session import get_db
|
|
from app.models.domain import User
|
|
from app.schemas.domain import LoginRequest, TokenPair, UserRead
|
|
from app.services.audit import write_audit
|
|
|
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
|
|
|
|
@router.post("/login", response_model=TokenPair)
|
|
def login(payload: LoginRequest, db: Session = Depends(get_db)) -> TokenPair:
|
|
email = payload.email.strip().lower()
|
|
user = db.scalar(select(User).where(User.email == email))
|
|
if not user or not verify_password(payload.password, user.password_hash):
|
|
if user:
|
|
user.failed_login_attempts += 1
|
|
db.commit()
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
|
permissions = sorted({permission for role in user.roles for permission in role.permissions})
|
|
user.failed_login_attempts = 0
|
|
db.commit()
|
|
write_audit(db, action="login", object_type="user", object_id=user.id, user_id=user.id)
|
|
return TokenPair(
|
|
access_token=create_access_token(user.id, permissions),
|
|
refresh_token=create_refresh_token(user.id),
|
|
)
|
|
|
|
|
|
@router.post("/refresh", response_model=TokenPair)
|
|
def refresh(payload: dict[str, str], db: Session = Depends(get_db)) -> TokenPair:
|
|
token = payload.get("refresh_token")
|
|
if not token:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing refresh token")
|
|
try:
|
|
claims = decode_token(token)
|
|
except Exception as exc:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token") from exc
|
|
if claims.get("typ") != "refresh":
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
|
|
user = db.scalar(select(User).where(User.id == claims.get("sub"), User.is_active.is_(True)))
|
|
if not user:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or missing user")
|
|
permissions = sorted({permission for role in user.roles for permission in role.permissions})
|
|
return TokenPair(
|
|
access_token=create_access_token(user.id, permissions),
|
|
refresh_token=create_refresh_token(user.id),
|
|
)
|
|
|
|
|
|
@router.get("/me", response_model=UserRead)
|
|
def me(user: CurrentUser) -> User:
|
|
return user
|
|
|
|
|
|
@router.post("/logout")
|
|
def logout(user: CurrentUser, db: Session = Depends(get_db)) -> dict[str, str]:
|
|
write_audit(db, action="logout", object_type="user", object_id=user.id, user_id=user.id)
|
|
return {"status": "ok", "at": datetime.utcnow().isoformat()}
|