Files
trustos/backend/app/core/security.py
drjones c44fa6fcda
Some checks failed
Deploy / deploy (push) Has been cancelled
Deploy / docker-build (push) Has been cancelled
Test / backend-test (push) Has been cancelled
Test / frontend-test (push) Has been cancelled
Test / security-scan (push) Has been cancelled
Fix cross-origin API access, bcrypt login break, and weak secret key
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>
2026-07-07 15:13:23 +00:00

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")