diff --git a/app/api/v1/routes/auth.py b/app/api/v1/routes/auth.py index 9077f120..11c1474d 100644 --- a/app/api/v1/routes/auth.py +++ b/app/api/v1/routes/auth.py @@ -287,6 +287,18 @@ def _extract_bearer(authorization: Optional[str]) -> Optional[str]: return token.strip() +def _revoke_local_password_access_token(bearer: str) -> None: + try: + claims = decode_access_token(bearer) + jti = claims.get("jti") + exp = claims.get("exp") + if jti and exp: + ttl = max(int(exp) - int(datetime.now(timezone.utc).timestamp()), 1) + revoke_access_jti(jti, ttl) + except JWTError: + pass + + def _issue_session_tokens( db: Session, *, @@ -611,15 +623,7 @@ def logout( """Revoke the current session's refresh token and blacklist the access token.""" bearer = _extract_bearer(authorization) if bearer and principal.auth_method == AuthMethod.LOCAL_PASSWORD: - try: - claims = decode_access_token(bearer) - jti = claims.get("jti") - exp = claims.get("exp") - if jti and exp: - ttl = max(int(exp) - int(datetime.now(timezone.utc).timestamp()), 1) - revoke_access_jti(jti, ttl) - except JWTError: - pass + _revoke_local_password_access_token(bearer) if payload and payload.refresh_token: revoke_refresh_token(db, payload.refresh_token) @@ -697,12 +701,14 @@ def refresh_session(payload: RefreshRequest, db: Session = Depends(get_db)) -> T class SwitchOrgRequest(BaseModel): organization_id: str + refresh_token: Optional[str] = None @router.post("/switch-org", response_model=TokenResponse) def switch_organization( payload: SwitchOrgRequest, principal: Principal = Depends(get_principal), + authorization: Optional[str] = Header(None, alias="Authorization"), db: Session = Depends(get_db), ) -> TokenResponse: """ @@ -766,6 +772,13 @@ def switch_organization( detail="User is no longer active.", ) + if principal.auth_method == AuthMethod.LOCAL_PASSWORD: + bearer = _extract_bearer(authorization) + if bearer: + _revoke_local_password_access_token(bearer) + if payload.refresh_token: + revoke_refresh_token(db, payload.refresh_token) + user.last_login_at = datetime.now(timezone.utc) db.commit() db.refresh(user) diff --git a/app/api/v1/routes/platform_admin.py b/app/api/v1/routes/platform_admin.py index f29630ff..433b1e3c 100644 --- a/app/api/v1/routes/platform_admin.py +++ b/app/api/v1/routes/platform_admin.py @@ -6,7 +6,8 @@ from typing import List, Optional from uuid import UUID -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, Header, HTTPException, Query, status +from jose import JWTError from pydantic import BaseModel, EmailStr, Field from sqlalchemy import func from sqlalchemy.orm import Session @@ -16,6 +17,7 @@ create_platform_access_token, get_platform_admin, platform_admin_feature_enabled, + revoke_platform_access_token, ) from app.core.auth.refresh_tokens import revoke_all_user_refresh_tokens from app.core.password import hash_password, validate_password_strength, verify_password @@ -184,6 +186,26 @@ def platform_me( return PlatformAdminSummary(id=str(principal.platform_admin_id), email=principal.email) +def _extract_bearer(authorization: Optional[str]) -> Optional[str]: + if not authorization: + return None + scheme, _, token = authorization.partition(" ") + if scheme.lower() != "bearer" or not token.strip(): + return None + return token.strip() + + +@router.post("/auth/logout") +def platform_logout( + authorization: Optional[str] = Header(None, alias="Authorization"), + principal: PlatformAdminPrincipal = Depends(get_platform_admin), +) -> dict: + bearer = _extract_bearer(authorization) + if bearer: + revoke_platform_access_token(bearer) + return {"success": True, "admin_id": str(principal.platform_admin_id)} + + @router.get("/organizations", response_model=OrganizationListResponse) def list_organizations( offset: int = Query(0, ge=0), diff --git a/app/config.py b/app/config.py index 8242409c..7e5f4f1f 100644 --- a/app/config.py +++ b/app/config.py @@ -125,9 +125,9 @@ class Settings(BaseSettings): FRONTEND_DIR: str = "./frontend/dist" FRONTEND_BASE_URL: str = "" - # Content Security Policy (Report-Only by default; set CSP_REPORT_ONLY=false to enforce) + # Content Security Policy (enforcing by default; set CSP_REPORT_ONLY=true for local report-only mode) CSP_ENABLED: bool = True - CSP_REPORT_ONLY: bool = True + CSP_REPORT_ONLY: bool = False CSP_POLICY: str = ( "default-src 'self'; " "script-src 'self'; " @@ -859,6 +859,15 @@ def _apply_llm_gateway_settings(gateway_cfg: dict, *, gateway_type: str) -> None if "trusted_ips" in operational_config: settings.OPERATIONAL_TRUSTED_IPS = operational_config["trusted_ips"] + if "security" in config_data: + security_config = config_data["security"] + if "csp_enabled" in security_config: + settings.CSP_ENABLED = bool(security_config["csp_enabled"]) + if "csp_report_only" in security_config: + settings.CSP_REPORT_ONLY = bool(security_config["csp_report_only"]) + if security_config.get("csp_policy"): + settings.CSP_POLICY = security_config["csp_policy"] + if "flexprice" in config_data: flexprice_config = config_data["flexprice"] if "enabled" in flexprice_config: diff --git a/app/core/auth/platform_admin.py b/app/core/auth/platform_admin.py index 920c0164..6e468385 100644 --- a/app/core/auth/platform_admin.py +++ b/app/core/auth/platform_admin.py @@ -12,6 +12,7 @@ from sqlalchemy.orm import Session from app.config import settings +from app.core.auth.token_revocation import is_access_jti_revoked, revoke_access_jti from app.database import get_db from app.models.database import PlatformAdmin @@ -58,6 +59,18 @@ def decode_platform_access_token(token: str) -> Dict[str, Any]: ) +def revoke_platform_access_token(token: str) -> None: + try: + claims = decode_platform_access_token(token) + jti = claims.get("jti") + exp = claims.get("exp") + if jti and exp: + ttl = max(int(exp) - int(datetime.now(timezone.utc).timestamp()), 1) + revoke_access_jti(jti, ttl) + except JWTError: + pass + + def _extract_bearer(authorization: Optional[str]) -> Optional[str]: if not authorization: return None @@ -107,6 +120,13 @@ def get_platform_admin( detail="Invalid platform admin token scope.", ) + jti = claims.get("jti") + if jti and is_access_jti_revoked(jti): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token has been revoked.", + ) + try: admin_id = UUID(claims["sub"]) except (KeyError, ValueError) as exc: diff --git a/app/core/operational_access_middleware.py b/app/core/operational_access_middleware.py index 7bf4d8d3..7a0db428 100644 --- a/app/core/operational_access_middleware.py +++ b/app/core/operational_access_middleware.py @@ -1,4 +1,8 @@ -"""Restrict /metrics from the public internet (/health stays open for load balancers).""" +"""Restrict /metrics from the public internet (/health stays open for load balancers). + +Not Spring Boot Actuator: this FastAPI app exposes /health (LB probes) and /metrics +(Prometheus scrape). /metrics is gated by trusted IPs or OPERATIONAL_PUBLIC only. +""" from __future__ import annotations @@ -6,14 +10,11 @@ import logging from typing import Iterable -from fastapi import HTTPException from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request from starlette.responses import JSONResponse, Response from app.config import settings -from app.core.auth.dependency import _resolve -from app.database import SessionLocal logger = logging.getLogger(__name__) @@ -75,22 +76,6 @@ def _resolved_trusted_ip(request: Request) -> str | None: return hops[-1] -def _has_authenticated_caller(request: Request) -> bool: - db = SessionLocal() - try: - principal = _resolve( - request.headers.get("authorization"), - request.headers.get("x-api-key"), - request.headers.get("x-efficientai-api-key"), - db, - ) - return principal is not None - except HTTPException: - return False - finally: - db.close() - - def is_operational_access_allowed(request: Request) -> bool: """Return True when the caller may access a protected operational endpoint.""" if settings.OPERATIONAL_PUBLIC: @@ -100,9 +85,6 @@ def is_operational_access_allowed(request: Request) -> bool: if resolved_ip and _ip_in_trusted(resolved_ip, settings.OPERATIONAL_TRUSTED_IPS): return True - if _has_authenticated_caller(request): - return True - return False diff --git a/config.docker.yml b/config.docker.yml index 3d169129..66a1bcdd 100644 --- a/config.docker.yml +++ b/config.docker.yml @@ -5,7 +5,7 @@ app: name: "EfficientAI Voice AI Evaluation Platform" version: "0.1.0" - debug: true + debug: false secret_key: "your-secret-key-here-change-in-production" frontend_base_url: "http://localhost:8000" @@ -15,12 +15,17 @@ server: port: 8000 # Operational endpoints (/metrics). /health is always open for load balancers. +# Not Spring Boot Actuator — /metrics is IP-gated via trusted_ips below. # Add VPC CIDRs here if Prometheus scrapes /metrics from inside the VPC. operational: public: false trusted_ips: - "10.0.0.0/8" +security: + csp_enabled: true + csp_report_only: false + # Database Configuration (Docker service name) database: url: "postgresql://efficientai:password@db:5432/efficientai" @@ -99,7 +104,7 @@ auth: local_password: # Lifetime of the Bearer tokens minted at POST /auth/login (in minutes). # Keep this short; clients re-authenticate silently. - token_ttl_minutes: 720 # 12 hours + token_ttl_minutes: 15 # Turn this off in Cloud SaaS to block self-serve signup. allow_signup: true diff --git a/config.yml.example b/config.yml.example index b361321b..1f9d014a 100644 --- a/config.yml.example +++ b/config.yml.example @@ -17,12 +17,20 @@ server: port: 8000 # Operational endpoints (/metrics). /health is always open for ALB/kube probes. +# This is FastAPI, not Spring Boot Actuator — scanners may flag /health or /metrics +# under an "Actuator" template. /metrics is IP-gated; /health returns minimal status only. # Include VPC CIDRs in trusted_ips if Prometheus scrapes /metrics from inside the VPC. operational: public: false trusted_ips: - "10.0.0.0/8" +# HTTP security headers (CSP, X-Frame-Options, etc.) +security: + csp_enabled: true + csp_report_only: false # false = enforcing Content-Security-Policy header (required for compliance scans) + # csp_policy: "default-src 'self'; ..." # optional override + # Database Configuration database: # Legacy single-DB mode: use one database (e.g. efficientai). Sharding off (default). diff --git a/env.example b/env.example index 40b2f1a5..53a08814 100644 --- a/env.example +++ b/env.example @@ -41,6 +41,10 @@ CORS_ORIGINS=["http://localhost:3000", "http://localhost:8000"] API_KEY_HEADER=X-API-Key RATE_LIMIT_PER_MINUTE=60 +# HTTP security headers (see config.yml.example security section) +CSP_ENABLED=true +CSP_REPORT_ONLY=false + # ----------------------------------------------------------------------------- # Authentication (pluggable auth providers) # ----------------------------------------------------------------------------- diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index c432705b..d3ffa521 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,9 +1,12 @@ import axios, { AxiosInstance } from 'axios' import { + clearAuthSession, getApiErrorDetail, + hasRevocableUserCredentials, isOrganizationAccessDenied, organizationAccessDeniedMessage, redirectToLoginWithMessage, + type UserSessionCredentials, } from './authSession' import type { GenerateScenariosFromPromptParams, @@ -585,6 +588,7 @@ class ApiClient { requestUrl.includes('/auth/login') || requestUrl.includes('/auth/signup') || requestUrl.includes('/auth/refresh') || + requestUrl.includes('/auth/logout') || requestUrl.includes('/auth/config') const detail = getApiErrorDetail(error) @@ -611,10 +615,7 @@ class ApiClient { } } - localStorage.removeItem('apiKey') - localStorage.removeItem('accessToken') - localStorage.removeItem('refreshToken') - localStorage.removeItem('authUser') + clearAuthSession() window.location.href = '/login' } return Promise.reject(error) @@ -832,6 +833,43 @@ class ApiClient { return response.data } + async platformLogout(accessToken?: string | null): Promise<{ success: boolean; admin_id: string }> { + const token = accessToken ?? localStorage.getItem('platformAccessToken') + const headers: Record = { 'Content-Type': 'application/json' } + if (token) { + headers.Authorization = `Bearer ${token}` + } + const response = await axios.post( + `${API_BASE_URL}/api/v1/platform/auth/logout`, + {}, + { headers }, + ) + return response.data + } + + /** + * Voluntary logout: revoke JWT blacklist + refresh token on the server. + * Uses raw axios so credentials survive after local session is cleared. + */ + revokeUserSessionBestEffort(credentials: UserSessionCredentials): void { + if (!hasRevocableUserCredentials(credentials)) { + return + } + const auth = { accessToken: credentials.accessToken, apiKey: credentials.apiKey } + void this.revokeUserSession(credentials.refreshToken, auth) + .catch(() => this.revokeUserSession(credentials.refreshToken, auth)) + .catch(() => {}) + } + + revokePlatformSessionBestEffort(accessToken?: string | null): void { + if (!accessToken) { + return + } + void this.platformLogout(accessToken) + .catch(() => this.platformLogout(accessToken)) + .catch(() => {}) + } + async getPlatformOrganizationStats(): Promise { const response = await axios.get(`${API_BASE_URL}/api/v1/platform/organizations/stats`, { headers: this.platformHeaders(), @@ -942,10 +980,32 @@ class ApiClient { return response.data } - async logout(refreshToken?: string | null): Promise<{ success: boolean; auth_method: string }> { - const response = await this.client.post('/api/v1/auth/logout', { - refresh_token: refreshToken || localStorage.getItem('refreshToken') || undefined, - }) + async logout( + refreshToken?: string | null, + credentials?: { accessToken?: string | null; apiKey?: string | null }, + ): Promise<{ success: boolean; auth_method: string }> { + return this.revokeUserSession(refreshToken, credentials) + } + + private async revokeUserSession( + refreshToken?: string | null, + credentials?: { accessToken?: string | null; apiKey?: string | null }, + ): Promise<{ success: boolean; auth_method: string }> { + const headers: Record = { 'Content-Type': 'application/json' } + const accessToken = credentials?.accessToken ?? localStorage.getItem('accessToken') + const apiKey = credentials?.apiKey ?? localStorage.getItem('apiKey') + if (accessToken) { + headers.Authorization = `Bearer ${accessToken}` + } else if (apiKey) { + headers['X-API-Key'] = apiKey + } + const response = await axios.post( + `${API_BASE_URL}/api/v1/auth/logout`, + { + refresh_token: refreshToken ?? localStorage.getItem('refreshToken') ?? undefined, + }, + { headers }, + ) return response.data } @@ -966,6 +1026,7 @@ class ApiClient { async switchOrganization(organizationId: string): Promise { const response = await this.client.post('/api/v1/auth/switch-org', { organization_id: organizationId, + refresh_token: localStorage.getItem('refreshToken') || undefined, }) return response.data } diff --git a/frontend/src/lib/authSession.ts b/frontend/src/lib/authSession.ts index 4a1e673c..ecfbf6e5 100644 --- a/frontend/src/lib/authSession.ts +++ b/frontend/src/lib/authSession.ts @@ -1,5 +1,11 @@ export const AUTH_REDIRECT_MESSAGE_KEY = 'authRedirectMessage' +export type UserSessionCredentials = { + accessToken?: string | null + refreshToken?: string | null + apiKey?: string | null +} + export function getApiErrorDetail(error: unknown): string | undefined { const detail = (error as { response?: { data?: { detail?: unknown } } })?.response?.data ?.detail @@ -23,6 +29,16 @@ export function clearAuthSession(): void { localStorage.removeItem('activeWorkspaceId') } +/** True when a voluntary logout can still revoke something server-side. */ +export function hasRevocableUserCredentials(credentials: UserSessionCredentials): boolean { + return Boolean(credentials.accessToken || credentials.apiKey || credentials.refreshToken) +} + +export function clearPlatformAdminSession(): void { + localStorage.removeItem('platformAccessToken') + localStorage.removeItem('platformAdminUser') +} + export function redirectToLoginWithMessage(message: string): void { sessionStorage.setItem(AUTH_REDIRECT_MESSAGE_KEY, message) clearAuthSession() diff --git a/frontend/src/pages/auth/Login.tsx b/frontend/src/pages/auth/Login.tsx index 35837549..eea8a92b 100644 --- a/frontend/src/pages/auth/Login.tsx +++ b/frontend/src/pages/auth/Login.tsx @@ -6,6 +6,7 @@ import type { AuthConfigResponse, AuthProviderConfig, LoginOrgOption } from '../ import { buildAuthorizeUrl } from '../../lib/oidc' import { PASSWORD_POLICY_HINT, validatePasswordPolicy } from '../../lib/passwordPolicy' import { consumeAuthRedirectMessage } from '../../lib/authSession' +import { getApiErrorMessage } from '../../lib/apiErrors' import { consumePendingInviteToken, getPendingInviteToken, @@ -219,8 +220,8 @@ export default function Login() { consumePendingInviteToken() setSession(res.access_token, res.user, res.refresh_token) navigate('/') - } catch (err: any) { - setError(err?.response?.data?.detail || 'Sign up failed') + } catch (err: unknown) { + setError(getApiErrorMessage(err, 'Sign up failed')) } finally { setIsLoading(false) } diff --git a/frontend/src/pages/auth/SelectOrganization.tsx b/frontend/src/pages/auth/SelectOrganization.tsx index ac7a3eef..62e4eca6 100644 --- a/frontend/src/pages/auth/SelectOrganization.tsx +++ b/frontend/src/pages/auth/SelectOrganization.tsx @@ -5,6 +5,7 @@ import { Building2, Loader2 } from 'lucide-react' import Logo from '../../components/Logo' import { Card, CardBody } from '@heroui/react' import { apiClient } from '../../lib/api' +import { getApiErrorMessage } from '../../lib/apiErrors' import { useAuthStore } from '../../store/authStore' export default function SelectOrganization() { @@ -32,8 +33,8 @@ export default function SelectOrganization() { try { await switchOrg(orgId) navigate('/', { replace: true }) - } catch (err: any) { - setError(err?.response?.data?.detail || 'Could not enter the selected organization') + } catch (err: unknown) { + setError(getApiErrorMessage(err, 'Could not enter the selected organization')) } finally { setSwitchingTo(null) } diff --git a/frontend/src/pages/platform/PlatformLogin.tsx b/frontend/src/pages/platform/PlatformLogin.tsx index d9c97c1c..389e99a2 100644 --- a/frontend/src/pages/platform/PlatformLogin.tsx +++ b/frontend/src/pages/platform/PlatformLogin.tsx @@ -4,6 +4,7 @@ import { AlertCircle, Eye, EyeOff } from 'lucide-react' import { Button, Chip } from '@heroui/react' import Logo from '../../components/Logo' import { apiClient } from '../../lib/api' +import { getApiErrorMessage } from '../../lib/apiErrors' import { usePlatformAdminStore } from '../../store/platformAdminStore' export default function PlatformLogin() { @@ -42,7 +43,7 @@ export default function PlatformLogin() { 'Is the backend running?', ) } else { - setError(detail || 'Sign in failed') + setError(getApiErrorMessage(err, 'Sign in failed')) } } finally { setIsLoading(false) diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts index 4db27106..2b37a2b0 100644 --- a/frontend/src/store/authStore.ts +++ b/frontend/src/store/authStore.ts @@ -1,5 +1,6 @@ import { create } from 'zustand' import { apiClient } from '../lib/api' +import { clearAuthSession } from '../lib/authSession' import { useWorkspaceStore } from './workspaceStore' /** @@ -113,17 +114,15 @@ export const useAuthStore = create((set, get) => { }, logout: () => { - const refreshToken = get().refreshToken - apiClient.logout(refreshToken).catch(() => {}) - apiClient.clearApiKey() - apiClient.clearAccessToken() - apiClient.clearRefreshToken() - localStorage.removeItem(STORAGE_API_KEY) - localStorage.removeItem(STORAGE_ACCESS_TOKEN) - localStorage.removeItem(STORAGE_REFRESH_TOKEN) - localStorage.removeItem(STORAGE_USER) + const credentials = { + accessToken: get().accessToken, + refreshToken: get().refreshToken, + apiKey: get().apiKey, + } + clearAuthSession() useWorkspaceStore.getState().clearActiveWorkspaceId() set({ apiKey: null, accessToken: null, refreshToken: null, user: null }) + apiClient.revokeUserSessionBestEffort(credentials) }, validate: async () => { diff --git a/frontend/src/store/platformAdminStore.ts b/frontend/src/store/platformAdminStore.ts index 1b79b15c..92e86a2d 100644 --- a/frontend/src/store/platformAdminStore.ts +++ b/frontend/src/store/platformAdminStore.ts @@ -1,4 +1,6 @@ import { create } from 'zustand' +import { apiClient } from '../lib/api' +import { clearPlatformAdminSession } from '../lib/authSession' type PlatformAdminUser = { id: string @@ -24,7 +26,7 @@ function readStoredAdmin(): PlatformAdminUser | null { } } -export const usePlatformAdminStore = create((set) => { +export const usePlatformAdminStore = create((set, get) => { const storedToken = localStorage.getItem(STORAGE_TOKEN) const storedAdmin = readStoredAdmin() @@ -37,9 +39,10 @@ export const usePlatformAdminStore = create((set) => { set({ accessToken: token, admin }) }, logout: () => { - localStorage.removeItem(STORAGE_TOKEN) - localStorage.removeItem(STORAGE_ADMIN) + const accessToken = get().accessToken + clearPlatformAdminSession() set({ accessToken: null, admin: null }) + apiClient.revokePlatformSessionBestEffort(accessToken) }, } }) diff --git a/tests/test_api/test_platform_admin.py b/tests/test_api/test_platform_admin.py index 4c650c0f..206fee50 100644 --- a/tests/test_api/test_platform_admin.py +++ b/tests/test_api/test_platform_admin.py @@ -191,6 +191,43 @@ def test_platform_reset_password(platform_admin_client, client, db_session, enab assert login.status_code == 200 +def test_platform_reset_password_rejects_weak_password( + platform_admin_client, db_session, enable_local_password +): + org = Organization(name="Weak Reset Org") + user = User( + email="weak@reset.org", + password_hash=hash_password(TEST_PASSWORD), + is_active=True, + auth_provider="local", + ) + db_session.add_all([org, user]) + db_session.flush() + db_session.add( + OrganizationMember( + organization_id=org.id, + user_id=user.id, + role=RoleEnum.ADMIN.value, + ) + ) + db_session.commit() + + response = platform_admin_client.post( + f"/api/v1/platform/organizations/{org.id}/users/{user.id}/reset-password", + json={"new_password": "alllowercase"}, + ) + assert response.status_code == 400 + assert "uppercase" in response.json()["detail"].lower() + + +def test_platform_logout_revokes_access_token(platform_admin_client): + logout = platform_admin_client.post("/api/v1/platform/auth/logout") + assert logout.status_code == 200 + + me = platform_admin_client.get("/api/v1/platform/auth/me") + assert me.status_code == 401 + + def test_create_and_use_signup_reference_code( platform_admin_client, client, db_session, enable_local_password, monkeypatch ):