Bankbot / backend /app /auth /router.py
mohsin-devs's picture
feat: implement Goals, Loans, and Settings pages with real APIs
6a71f57
Raw
History Blame Contribute Delete
9.09 kB
"""
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"}