BankBot-AI / backend /app /auth /router.py
mohsin-devs's picture
Deploy to HF
a282d4b
Raw
History Blame Contribute Delete
7.47 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)):
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"}