ThreadHouse / app /routers /auth.py
paudelapil's picture
Project do-over
65fa0c5
Raw
History Blame Contribute Delete
8.21 kB
"""Auth router (signup / login / admin login). Uses asyncpg pool from app.db.asyncpg_pool."""
import os
from datetime import datetime, timedelta, timezone
import asyncpg
import bcrypt
import jwt
from fastapi import APIRouter, Depends, HTTPException, status
from app.db.asyncpg_pool import get_asyncpg_conn
from app.schemas.shop import AuthResponse, LoginRequest, SignUpRequest
router = APIRouter()
# Strict JWT secret validation - fails fast if not configured properly
JWT_SECRET = os.getenv("JWT_SECRET")
if not JWT_SECRET or len(JWT_SECRET) < 32 or JWT_SECRET == "change-me-in-production":
raise RuntimeError("JWT_SECRET must be set to a secure value (min 32 characters)")
JWT_ALGO = "HS256"
TOKEN_EXPIRY = timedelta(days=7)
# Dummy hash for timing attack prevention
DUMMY_HASH = bcrypt.hashpw(b"dummy_password", bcrypt.gensalt()).decode()
def validate_password_strength(password: str) -> None:
"""Validate password meets security requirements"""
if len(password) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Password must be at least 8 characters",
)
if not any(c.isupper() for c in password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Password must contain at least one uppercase letter",
)
if not any(c.islower() for c in password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Password must contain at least one lowercase letter",
)
if not any(c.isdigit() for c in password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Password must contain at least one number",
)
def _hash_password(plain: str) -> str:
return bcrypt.hashpw(plain.encode(), bcrypt.gensalt()).decode()
def _verify_password(plain: str, hashed: str) -> bool:
try:
return bcrypt.checkpw(plain.encode(), hashed.encode())
except Exception:
return False
def _create_token(user_id: int, email: str) -> str:
payload = {
"sub": str(user_id),
"email": email,
"exp": datetime.now(timezone.utc) + TOKEN_EXPIRY,
}
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGO)
@router.post("/signup", response_model=AuthResponse, status_code=status.HTTP_201_CREATED)
async def signup(
body: SignUpRequest,
conn: asyncpg.Connection = Depends(get_asyncpg_conn),
):
"""Register a new user with password validation"""
validate_password_strength(body.password)
existing = await conn.fetchval(
"SELECT id FROM users WHERE email = $1", body.email
)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Email already registered",
)
hashed_password = _hash_password(body.password)
user_id = await conn.fetchval(
"""
INSERT INTO users (name, email, password_hash)
VALUES ($1, $2, $3)
RETURNING id
""",
body.name,
body.email,
hashed_password,
)
token = _create_token(user_id, body.email)
return AuthResponse(
token=token,
user_id=user_id,
name=body.name,
email=body.email,
)
@router.post("/login", response_model=AuthResponse)
async def login(
body: LoginRequest,
conn: asyncpg.Connection = Depends(get_asyncpg_conn),
):
"""Authenticate user and return JWT token"""
user = await conn.fetchrow(
"SELECT id, name, email, password_hash FROM users WHERE email = $1",
body.email,
)
# Timing attack prevention: always verify a hash even if user doesn't exist
hash_to_check = user["password_hash"] if user else DUMMY_HASH
password_valid = _verify_password(body.password, hash_to_check)
if not user or not password_valid:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
)
token = _create_token(user["id"], user["email"])
return AuthResponse(
token=token,
user_id=user["id"],
name=user["name"],
email=user["email"],
)
@router.post("/admin/login", response_model=AuthResponse)
async def admin_login(
body: LoginRequest,
conn: asyncpg.Connection = Depends(get_asyncpg_conn),
):
"""Admin-only login endpoint"""
user = await conn.fetchrow(
"SELECT id, name, email, password_hash, role FROM users WHERE email = $1",
body.email,
)
hash_to_check = user["password_hash"] if user else DUMMY_HASH
password_valid = _verify_password(body.password, hash_to_check)
if not user or not password_valid:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
)
if user["role"] != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
token = _create_token(user["id"], user["email"])
return AuthResponse(
token=token,
user_id=user["id"],
name=user["name"],
email=user["email"],
)
# Legacy alias for the admin/Login.jsx frontend, which POSTs to /api/user/admin
# instead of /api/auth/admin/login. Same handler.
from fastapi import APIRouter as _APIRouter
admin_alias_router = _APIRouter()
@admin_alias_router.post("/admin", response_model=AuthResponse)
async def admin_login_alias(
body: LoginRequest,
conn: asyncpg.Connection = Depends(get_asyncpg_conn),
):
return await admin_login(body, conn)
# ---------------------------------------------------------------------------
# Authenticated profile endpoints
# ---------------------------------------------------------------------------
from app.auth_deps import get_current_user_id # noqa: E402
from app.schemas.shop import UpdateProfileRequest, UserProfileResponse # noqa: E402
@router.get("/me", response_model=UserProfileResponse)
async def get_me(
user_id: int = Depends(get_current_user_id),
conn: asyncpg.Connection = Depends(get_asyncpg_conn),
):
"""Return the currently logged-in user's profile (decoded from the JWT)."""
user = await conn.fetchrow(
"SELECT id, name, email FROM users WHERE id = $1", user_id
)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return UserProfileResponse(
user_id=user["id"], name=user["name"], email=user["email"]
)
@router.patch("/profile", response_model=UserProfileResponse)
async def update_profile(
body: UpdateProfileRequest,
user_id: int = Depends(get_current_user_id),
conn: asyncpg.Connection = Depends(get_asyncpg_conn),
):
"""Update name and/or password for the logged-in user."""
if body.name is None and body.new_password is None:
raise HTTPException(status_code=400, detail="Nothing to update")
if body.new_password is not None:
validate_password_strength(body.new_password)
new_hash = _hash_password(body.new_password)
if body.name is not None:
await conn.execute(
"UPDATE users SET name = $1, password_hash = $2 WHERE id = $3",
body.name, new_hash, user_id,
)
else:
await conn.execute(
"UPDATE users SET password_hash = $1 WHERE id = $2",
new_hash, user_id,
)
elif body.name is not None:
await conn.execute(
"UPDATE users SET name = $1 WHERE id = $2", body.name, user_id,
)
user = await conn.fetchrow(
"SELECT id, name, email FROM users WHERE id = $1", user_id
)
return UserProfileResponse(
user_id=user["id"], name=user["name"], email=user["email"]
)
@router.post("/logout")
async def logout():
"""
Symbolic logout. JWTs are stateless so the server has no session to
invalidate - the frontend should drop the token from storage. We expose
this endpoint so the frontend can call it for analytics/UX consistency.
"""
return {"ok": True, "message": "Logged out"}