Add frontend access control enhancements and JWT support
- Hide New Project button for unauthenticated users, show login link - Add lock icon for private projects on home page - Show access level badges on project cards (Owner, Admin, Write, Read) - Add permission expiration date field to AccessManagement component - Add query timeout configuration for database (ORCHARD_DATABASE_QUERY_TIMEOUT) - Add JWT token validation support for external identity providers - Configurable via ORCHARD_JWT_* environment variables - Supports HS256 with secret or RS256 with JWKS - Auto-provisions users from JWT claims
This commit is contained in:
@@ -1,16 +1,20 @@
|
||||
"""Authentication service for Orchard.
|
||||
|
||||
Handles password hashing, session management, and API key operations.
|
||||
Handles password hashing, session management, API key operations, and JWT validation.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import User, Session as UserSession, APIKey
|
||||
from .config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Password hashing context (bcrypt with cost factor 12)
|
||||
@@ -374,6 +378,147 @@ def create_default_admin(db: Session) -> Optional[User]:
|
||||
return admin
|
||||
|
||||
|
||||
# --- JWT Validation ---
|
||||
|
||||
|
||||
def validate_jwt_token(token: str) -> Optional[dict]:
|
||||
"""Validate a JWT token and return the decoded payload.
|
||||
|
||||
Returns None if validation fails or JWT is not configured.
|
||||
Uses python-jose for JWT operations.
|
||||
"""
|
||||
settings = get_settings()
|
||||
|
||||
if not settings.jwt_enabled:
|
||||
return None
|
||||
|
||||
try:
|
||||
from jose import jwt, JWTError, ExpiredSignatureError
|
||||
from jose.exceptions import JWTClaimsError
|
||||
except ImportError:
|
||||
logger.warning("python-jose not installed, JWT authentication disabled")
|
||||
return None
|
||||
|
||||
try:
|
||||
# Build decode options
|
||||
decode_options = {}
|
||||
|
||||
# Set up key for validation
|
||||
if settings.jwt_algorithm.startswith("RS"):
|
||||
# RS256/RS384/RS512 - use JWKS
|
||||
if not settings.jwt_jwks_url:
|
||||
logger.error("JWT JWKS URL not configured for RSA algorithm")
|
||||
return None
|
||||
|
||||
try:
|
||||
import httpx
|
||||
|
||||
# Fetch JWKS from the URL
|
||||
response = httpx.get(settings.jwt_jwks_url, timeout=10.0)
|
||||
response.raise_for_status()
|
||||
jwks = response.json()
|
||||
|
||||
# Get the key ID from the token header
|
||||
unverified_header = jwt.get_unverified_header(token)
|
||||
kid = unverified_header.get("kid")
|
||||
|
||||
# Find the matching key
|
||||
rsa_key = None
|
||||
for key in jwks.get("keys", []):
|
||||
if key.get("kid") == kid:
|
||||
rsa_key = key
|
||||
break
|
||||
|
||||
if not rsa_key:
|
||||
logger.error(f"No matching key found in JWKS for kid: {kid}")
|
||||
return None
|
||||
|
||||
key = rsa_key
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get signing key from JWKS: {e}")
|
||||
return None
|
||||
else:
|
||||
# HS256/HS384/HS512 - use secret
|
||||
if not settings.jwt_secret:
|
||||
logger.error("JWT secret not configured for HMAC algorithm")
|
||||
return None
|
||||
key = settings.jwt_secret
|
||||
|
||||
# Build decode kwargs
|
||||
decode_kwargs = {
|
||||
"algorithms": [settings.jwt_algorithm],
|
||||
"options": decode_options,
|
||||
}
|
||||
|
||||
# Add issuer validation if configured
|
||||
if settings.jwt_issuer:
|
||||
decode_kwargs["issuer"] = settings.jwt_issuer
|
||||
|
||||
# Add audience validation if configured
|
||||
if settings.jwt_audience:
|
||||
decode_kwargs["audience"] = settings.jwt_audience
|
||||
|
||||
# Decode and validate the token
|
||||
payload = jwt.decode(token, key, **decode_kwargs)
|
||||
return payload
|
||||
|
||||
except ExpiredSignatureError:
|
||||
logger.debug("JWT token expired")
|
||||
return None
|
||||
except JWTClaimsError as e:
|
||||
logger.debug(f"JWT claims error: {e}")
|
||||
return None
|
||||
except JWTError as e:
|
||||
logger.debug(f"Invalid JWT token: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"JWT validation error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_or_create_user_from_jwt(db: Session, payload: dict) -> Optional[User]:
|
||||
"""Get or create a user from JWT payload.
|
||||
|
||||
Uses the configured username claim to extract the username.
|
||||
Creates a new user if one doesn't exist (for SSO auto-provisioning).
|
||||
"""
|
||||
settings = get_settings()
|
||||
username = payload.get(settings.jwt_username_claim)
|
||||
|
||||
if not username:
|
||||
logger.warning(f"JWT missing username claim: {settings.jwt_username_claim}")
|
||||
return None
|
||||
|
||||
# Sanitize username (remove domain from email if needed)
|
||||
if "@" in username and settings.jwt_username_claim == "email":
|
||||
# Keep full email as username for email-based auth
|
||||
pass
|
||||
|
||||
auth_service = AuthService(db)
|
||||
user = auth_service.get_user_by_username(username)
|
||||
|
||||
if user:
|
||||
if not user.is_active:
|
||||
logger.debug(f"JWT user {username} is inactive")
|
||||
return None
|
||||
return user
|
||||
|
||||
# Auto-provision user from JWT
|
||||
logger.info(f"Auto-provisioning user from JWT: {username}")
|
||||
try:
|
||||
user = auth_service.create_user(
|
||||
username=username,
|
||||
password=None, # No password for SSO users
|
||||
email=payload.get("email"),
|
||||
is_admin=False,
|
||||
must_change_password=False,
|
||||
)
|
||||
return user
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to auto-provision JWT user: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# --- FastAPI Dependencies ---
|
||||
|
||||
from fastapi import Depends, HTTPException, status, Cookie, Header
|
||||
@@ -388,10 +533,15 @@ def get_current_user_optional(
|
||||
session_token: Optional[str] = Cookie(None, alias=SESSION_COOKIE_NAME),
|
||||
authorization: Optional[str] = Header(None),
|
||||
) -> Optional[User]:
|
||||
"""Get the current user from session cookie or API key.
|
||||
"""Get the current user from session cookie, API key, or JWT token.
|
||||
|
||||
Returns None if no valid authentication is provided.
|
||||
Does not raise an exception for unauthenticated requests.
|
||||
|
||||
Authentication methods are tried in order:
|
||||
1. Session cookie (web UI)
|
||||
2. API key (Bearer token starting with 'orch_')
|
||||
3. JWT token (Bearer token that's a valid JWT)
|
||||
"""
|
||||
auth_service = AuthService(db)
|
||||
|
||||
@@ -403,13 +553,24 @@ def get_current_user_optional(
|
||||
if user and user.is_active:
|
||||
return user
|
||||
|
||||
# Then try API key (CLI/programmatic access)
|
||||
if authorization:
|
||||
if authorization.startswith("Bearer "):
|
||||
api_key = authorization[7:] # Remove "Bearer " prefix
|
||||
user = auth_service.get_user_from_api_key(api_key)
|
||||
# Then try Bearer token (API key or JWT)
|
||||
if authorization and authorization.startswith("Bearer "):
|
||||
token = authorization[7:] # Remove "Bearer " prefix
|
||||
|
||||
# Check if it's an API key (starts with orch_)
|
||||
if token.startswith(API_KEY_PREFIX):
|
||||
user = auth_service.get_user_from_api_key(token)
|
||||
if user:
|
||||
return user
|
||||
else:
|
||||
# Try JWT validation
|
||||
settings = get_settings()
|
||||
if settings.jwt_enabled:
|
||||
payload = validate_jwt_token(token)
|
||||
if payload:
|
||||
user = get_or_create_user_from_jwt(db, payload)
|
||||
if user:
|
||||
return user
|
||||
|
||||
return None
|
||||
|
||||
|
||||
Reference in New Issue
Block a user