The frontend only worked from localhost:3000 — the browser made absolute
cross-origin calls to localhost:8000, which (a) points at the visitor's own
machine when accessed via the LAN IP or Cloudflare tunnel, and (b) was blocked
by CORS (backend only allowed localhost:3000). Now all API calls are
same-origin and proxied to the backend:
- api.ts: default API base to same-origin ("") instead of localhost:8000
- docker-compose: NEXT_PUBLIC_API_URL="" (client uses relative /api)
- next.config.ts: server-side rewrite uses API_INTERNAL_URL (http://backend:8000
inside Docker) so :3000 direct access proxies correctly
- main.py: broaden CORS via allow_origin_regex (localhost + private LAN) and an
optional CORS_ORIGINS env var, as defense-in-depth for direct :8000 access
Login was returning 500: passlib 1.7.4 is incompatible with the bcrypt 5.0.0
actually installed in the image (requirements pin 4.1.2, but the image drifted).
- security.py: call bcrypt directly, truncating to 72 bytes; existing $2b$
hashes verify unchanged, and it works under both bcrypt 4.x and 5.x
Security: SECRET_KEY was a known placeholder string while the app is exposed on
the LAN and via Cloudflare tunnel — anyone could forge admin JWTs. Regenerated
to a strong random value in backend/.env (gitignored; not committed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
71 lines
2.5 KiB
Python
71 lines
2.5 KiB
Python
from datetime import datetime, timedelta
|
|
from typing import Optional, Any
|
|
import bcrypt
|
|
from jose import JWTError, jwt
|
|
from fastapi import HTTPException, status, Depends
|
|
from fastapi.security import OAuth2PasswordBearer
|
|
from app.core.config import settings
|
|
|
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/auth/login")
|
|
|
|
# We call bcrypt directly rather than through passlib: passlib 1.7.x is
|
|
# incompatible with bcrypt >= 4.1 (its version shim raises on the modern
|
|
# library). bcrypt only uses the first 72 bytes of a password, so we truncate
|
|
# to that to avoid the ValueError bcrypt 5.x raises on longer inputs. Existing
|
|
# $2b$ hashes (created via passlib's bcrypt backend) verify unchanged.
|
|
_BCRYPT_MAX_BYTES = 72
|
|
|
|
|
|
def _to_bytes(password: str) -> bytes:
|
|
return password.encode("utf-8")[:_BCRYPT_MAX_BYTES]
|
|
|
|
|
|
def verify_password(plain: str, hashed: str) -> bool:
|
|
try:
|
|
return bcrypt.checkpw(_to_bytes(plain), hashed.encode("utf-8"))
|
|
except (ValueError, TypeError):
|
|
return False
|
|
|
|
|
|
def hash_password(password: str) -> str:
|
|
return bcrypt.hashpw(_to_bytes(password), bcrypt.gensalt()).decode("utf-8")
|
|
|
|
|
|
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
|
to_encode = data.copy()
|
|
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
|
|
to_encode.update({"exp": expire})
|
|
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
|
|
|
|
|
def decode_token(token: str) -> dict:
|
|
try:
|
|
return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
|
except JWTError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Could not validate credentials",
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
|
|
|
|
async def get_current_user_payload(token: str = Depends(oauth2_scheme)) -> dict:
|
|
return decode_token(token)
|
|
|
|
|
|
def require_roles(*roles: str):
|
|
"""Dependency factory — require one of the specified roles."""
|
|
async def role_checker(payload: dict = Depends(get_current_user_payload)):
|
|
if payload.get("role") not in roles:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Insufficient permissions"
|
|
)
|
|
return payload
|
|
return role_checker
|
|
|
|
|
|
require_executive_or_above = require_roles("executive", "it_admin", "trustos_admin")
|
|
require_it_or_above = require_roles("it_admin", "trustos_admin")
|
|
require_admin = require_roles("trustos_admin")
|