""" Authentication router — JWT login, register, refresh, logout. Uses bcrypt directly (avoids passlib 1.7.4 + bcrypt>=4 incompatibility). Uses python-jose for JWT. """ from datetime import datetime, timedelta from typing import Optional import bcrypt as _bcrypt from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from jose import JWTError, jwt from pydantic import BaseModel, EmailStr from sqlalchemy.orm import Session from app.database.database import get_db from app.database.models import User, generate_uuid import os router = APIRouter(prefix="/api/auth", tags=["Authentication"]) # ─── Config ─────────────────────────────────────────────────────────────────── SECRET_KEY = os.environ.get("JWT_SECRET_KEY", "bankbot-dev-secret-change-in-production") ALGORITHM = os.environ.get("JWT_ALGORITHM", "HS256") ACCESS_TOKEN_EXPIRE_MINUTES = int(os.environ.get("ACCESS_TOKEN_EXPIRE_MINUTES", "60")) REFRESH_TOKEN_EXPIRE_DAYS = 7 oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False) # ─── Schemas ────────────────────────────────────────────────────────────────── class RegisterRequest(BaseModel): email: EmailStr password: str name: str class LoginResponse(BaseModel): access_token: str refresh_token: str token_type: str = "bearer" user_id: str name: str email: str class RefreshRequest(BaseModel): refresh_token: str class TokenData(BaseModel): user_id: Optional[str] = None token_type: Optional[str] = None # ─── Password helpers (bcrypt direct — no passlib) ──────────────────────────── def hash_password(password: str) -> str: return _bcrypt.hashpw(password.encode("utf-8"), _bcrypt.gensalt(rounds=12)).decode("utf-8") def verify_password(plain: str, hashed: str) -> bool: try: return _bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8")) except Exception: return False # ─── JWT helpers ────────────────────────────────────────────────────────────── def create_token(data: dict, expires_delta: timedelta) -> str: payload = data.copy() payload["exp"] = datetime.utcnow() + expires_delta return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM) def create_access_token(user_id: str) -> str: return create_token( {"sub": user_id, "type": "access"}, timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES), ) def create_refresh_token(user_id: str) -> str: return create_token( {"sub": user_id, "type": "refresh"}, timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS), ) def decode_token(token: str) -> TokenData: try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) user_id: str = payload.get("sub") token_type: str = payload.get("type") if not user_id: raise HTTPException(status_code=401, detail="Invalid token payload") return TokenData(user_id=user_id, token_type=token_type) except JWTError: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired or invalid", headers={"WWW-Authenticate": "Bearer"}, ) # ─── Auth dependencies ──────────────────────────────────────────────────────── def get_current_user( token: Optional[str] = Depends(oauth2_scheme), db: Session = Depends(get_db), ) -> User: if not token: raise HTTPException(status_code=401, detail="Not authenticated") token_data = decode_token(token) if token_data.token_type != "access": raise HTTPException(status_code=401, detail="Invalid token type") user = db.query(User).filter(User.id == token_data.user_id).first() if not user: raise HTTPException(status_code=401, detail="User not found") return user def get_current_user_optional( token: Optional[str] = Depends(oauth2_scheme), db: Session = Depends(get_db), ) -> Optional[User]: if not token: return None try: return get_current_user(token, db) except HTTPException: return None # ─── Routes ─────────────────────────────────────────────────────────────────── @router.post("/register", response_model=LoginResponse, status_code=201) def register(req: RegisterRequest, db: Session = Depends(get_db)): existing = db.query(User).filter(User.email == req.email).first() if existing: raise HTTPException(status_code=409, detail="Email already registered") user = User( id=generate_uuid(), email=req.email, password_hash=hash_password(req.password), profile_data={"name": req.name}, financial_personality="Balanced", ) db.add(user) db.commit() db.refresh(user) return LoginResponse( access_token=create_access_token(user.id), refresh_token=create_refresh_token(user.id), user_id=user.id, name=req.name, email=user.email, ) @router.post("/login", response_model=LoginResponse) def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)): user = db.query(User).filter(User.email == form.username).first() if not user or not verify_password(form.password, user.password_hash): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect email or password", headers={"WWW-Authenticate": "Bearer"}, ) return LoginResponse( access_token=create_access_token(user.id), refresh_token=create_refresh_token(user.id), user_id=user.id, name=user.profile_data.get("name", "User"), email=user.email, ) @router.post("/refresh") def refresh_token(req: RefreshRequest, db: Session = Depends(get_db)): token_data = decode_token(req.refresh_token) if token_data.token_type != "refresh": raise HTTPException(status_code=401, detail="Invalid refresh token") user = db.query(User).filter(User.id == token_data.user_id).first() if not user: raise HTTPException(status_code=401, detail="User not found") return { "access_token": create_access_token(user.id), "token_type": "bearer", } @router.get("/me") def get_me(current_user: User = Depends(get_current_user)): return { "user_id": current_user.id, "email": current_user.email, "name": current_user.profile_data.get("name", "User"), "financial_personality": current_user.financial_personality, } @router.post("/logout") def logout(): # Stateless JWT — client drops the token. # Production: add token to a Redis blacklist here. return {"message": "Logged out successfully"}