""" 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)): settings = current_user.ai_personalization_settings or {} return { "user_id": current_user.id, "email": current_user.email, "name": current_user.profile_data.get("name", "User"), "financial_personality": current_user.financial_personality, "ai_personalization_settings": settings, "notifications_enabled": settings.get("notifications_enabled", True), "ai_coaching_enabled": settings.get("ai_coaching_enabled", True), "fraud_alerts_enabled": settings.get("fraud_alerts_enabled", True), } class SettingsUpdateRequest(BaseModel): name: Optional[str] = None financial_personality: Optional[str] = None notifications_enabled: Optional[bool] = None ai_coaching_enabled: Optional[bool] = None fraud_alerts_enabled: Optional[bool] = None @router.patch("/settings") def update_settings( body: SettingsUpdateRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): if body.name is not None: profile = dict(current_user.profile_data or {}) profile["name"] = body.name.strip() current_user.profile_data = profile if body.financial_personality is not None: current_user.financial_personality = body.financial_personality.strip() settings = dict(current_user.ai_personalization_settings or {}) if body.notifications_enabled is not None: settings["notifications_enabled"] = body.notifications_enabled if body.ai_coaching_enabled is not None: settings["ai_coaching_enabled"] = body.ai_coaching_enabled if body.fraud_alerts_enabled is not None: settings["fraud_alerts_enabled"] = body.fraud_alerts_enabled current_user.ai_personalization_settings = settings db.commit() db.refresh(current_user) return get_me(current_user) @router.post("/logout") def logout(): # Stateless JWT — client drops the token. # Production: add token to a Redis blacklist here. return {"message": "Logged out successfully"}