Implement authentication system with access control UI
This commit is contained in:
1208
backend/app/auth.py
Normal file
1208
backend/app/auth.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,7 @@ class Settings(BaseSettings):
|
||||
database_pool_recycle: int = (
|
||||
1800 # Recycle connections after this many seconds (30 min)
|
||||
)
|
||||
database_query_timeout: int = 30 # Query timeout in seconds (0 = no timeout)
|
||||
|
||||
# S3
|
||||
s3_endpoint: str = ""
|
||||
@@ -52,6 +53,17 @@ class Settings(BaseSettings):
|
||||
log_level: str = "INFO" # DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||
log_format: str = "auto" # "json", "standard", or "auto" (json in production)
|
||||
|
||||
# JWT Authentication settings (optional, for external identity providers)
|
||||
jwt_enabled: bool = False # Enable JWT token validation
|
||||
jwt_secret: str = "" # Secret key for HS256, or leave empty for RS256 with JWKS
|
||||
jwt_algorithm: str = "HS256" # HS256 or RS256
|
||||
jwt_issuer: str = "" # Expected issuer (iss claim), leave empty to skip validation
|
||||
jwt_audience: str = "" # Expected audience (aud claim), leave empty to skip validation
|
||||
jwt_jwks_url: str = "" # JWKS URL for RS256 (e.g., https://auth.example.com/.well-known/jwks.json)
|
||||
jwt_username_claim: str = (
|
||||
"sub" # JWT claim to use as username (sub, email, preferred_username, etc.)
|
||||
)
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
sslmode = f"?sslmode={self.database_sslmode}" if self.database_sslmode else ""
|
||||
|
||||
@@ -12,6 +12,12 @@ from .models import Base
|
||||
settings = get_settings()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Build connect_args with query timeout if configured
|
||||
connect_args = {}
|
||||
if settings.database_query_timeout > 0:
|
||||
# PostgreSQL statement_timeout is in milliseconds
|
||||
connect_args["options"] = f"-c statement_timeout={settings.database_query_timeout * 1000}"
|
||||
|
||||
# Create engine with connection pool configuration
|
||||
engine = create_engine(
|
||||
settings.database_url,
|
||||
@@ -21,6 +27,7 @@ engine = create_engine(
|
||||
max_overflow=settings.database_max_overflow,
|
||||
pool_timeout=settings.database_pool_timeout,
|
||||
pool_recycle=settings.database_pool_recycle,
|
||||
connect_args=connect_args,
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
from contextlib import asynccontextmanager
|
||||
import logging
|
||||
import os
|
||||
|
||||
from slowapi import _rate_limit_exceeded_handler
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
|
||||
from .config import get_settings
|
||||
from .database import init_db, SessionLocal
|
||||
from .routes import router
|
||||
from .seed import seed_database
|
||||
from .auth import create_default_admin
|
||||
from .rate_limit import limiter
|
||||
|
||||
settings = get_settings()
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
@@ -20,6 +25,18 @@ async def lifespan(app: FastAPI):
|
||||
# Startup: initialize database
|
||||
init_db()
|
||||
|
||||
# Create default admin user if no users exist
|
||||
db = SessionLocal()
|
||||
try:
|
||||
admin = create_default_admin(db)
|
||||
if admin:
|
||||
logger.warning(
|
||||
"Default admin user created with username 'admin' and password 'changeme123'. "
|
||||
"CHANGE THIS PASSWORD IMMEDIATELY!"
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Seed test data in development mode
|
||||
if settings.is_development:
|
||||
logger.info(f"Running in {settings.env} mode - checking for seed data")
|
||||
@@ -42,13 +59,21 @@ app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Set up rate limiting
|
||||
app.state.limiter = limiter
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
|
||||
# Include API routes
|
||||
app.include_router(router)
|
||||
|
||||
# Serve static files (React build) if the directory exists
|
||||
static_dir = os.path.join(os.path.dirname(__file__), "..", "..", "frontend", "dist")
|
||||
if os.path.exists(static_dir):
|
||||
app.mount("/assets", StaticFiles(directory=os.path.join(static_dir, "assets")), name="assets")
|
||||
app.mount(
|
||||
"/assets",
|
||||
StaticFiles(directory=os.path.join(static_dir, "assets")),
|
||||
name="assets",
|
||||
)
|
||||
|
||||
@app.get("/")
|
||||
async def serve_spa():
|
||||
@@ -60,6 +85,7 @@ if os.path.exists(static_dir):
|
||||
# Don't catch API routes or health endpoint
|
||||
if full_path.startswith("api/") or full_path.startswith("health"):
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
# Serve SPA for all other routes (including /project/*)
|
||||
@@ -68,4 +94,5 @@ if os.path.exists(static_dir):
|
||||
return FileResponse(index_path)
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy import (
|
||||
CheckConstraint,
|
||||
Index,
|
||||
JSON,
|
||||
ARRAY,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship, declarative_base
|
||||
@@ -302,20 +303,104 @@ class AccessPermission(Base):
|
||||
)
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""User account for authentication."""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
username = Column(String(255), unique=True, nullable=False)
|
||||
password_hash = Column(String(255)) # NULL if OIDC-only user
|
||||
email = Column(String(255))
|
||||
is_admin = Column(Boolean, default=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
must_change_password = Column(Boolean, default=False)
|
||||
oidc_subject = Column(String(255)) # OIDC subject claim
|
||||
oidc_issuer = Column(String(512)) # OIDC issuer URL
|
||||
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
updated_at = Column(
|
||||
DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow
|
||||
)
|
||||
last_login = Column(DateTime(timezone=True))
|
||||
|
||||
# Relationships
|
||||
api_keys = relationship(
|
||||
"APIKey", back_populates="owner", cascade="all, delete-orphan"
|
||||
)
|
||||
sessions = relationship(
|
||||
"Session", back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_users_username", "username"),
|
||||
Index("idx_users_email", "email"),
|
||||
Index("idx_users_oidc_subject", "oidc_subject"),
|
||||
)
|
||||
|
||||
|
||||
class Session(Base):
|
||||
"""User session for web login."""
|
||||
|
||||
__tablename__ = "sessions"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
token_hash = Column(String(64), unique=True, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||
last_accessed = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
user_agent = Column(String(512))
|
||||
ip_address = Column(String(45))
|
||||
|
||||
user = relationship("User", back_populates="sessions")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_sessions_user_id", "user_id"),
|
||||
Index("idx_sessions_token_hash", "token_hash"),
|
||||
Index("idx_sessions_expires_at", "expires_at"),
|
||||
)
|
||||
|
||||
|
||||
class AuthSettings(Base):
|
||||
"""Authentication settings for OIDC configuration."""
|
||||
|
||||
__tablename__ = "auth_settings"
|
||||
|
||||
key = Column(String(255), primary_key=True)
|
||||
value = Column(Text, nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
|
||||
|
||||
class APIKey(Base):
|
||||
__tablename__ = "api_keys"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
key_hash = Column(String(64), unique=True, nullable=False)
|
||||
name = Column(String(255), nullable=False)
|
||||
user_id = Column(String(255), nullable=False)
|
||||
user_id = Column(
|
||||
String(255), nullable=False
|
||||
) # Legacy field, kept for compatibility
|
||||
owner_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=True, # Nullable for migration compatibility
|
||||
)
|
||||
description = Column(Text)
|
||||
scopes = Column(ARRAY(String), default=["read", "write"])
|
||||
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
expires_at = Column(DateTime(timezone=True))
|
||||
last_used = Column(DateTime(timezone=True))
|
||||
|
||||
owner = relationship("User", back_populates="api_keys")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_api_keys_user_id", "user_id"),
|
||||
Index("idx_api_keys_key_hash", "key_hash"),
|
||||
Index("idx_api_keys_owner_id", "owner_id"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
16
backend/app/rate_limit.py
Normal file
16
backend/app/rate_limit.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""Rate limiting configuration for Orchard API.
|
||||
|
||||
Uses slowapi for rate limiting with IP-based keys.
|
||||
"""
|
||||
|
||||
import os
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
# Rate limiter - uses IP address as key
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
# Rate limit strings - configurable via environment for testing
|
||||
# Default: 5 login attempts per minute per IP
|
||||
# In tests: set ORCHARD_LOGIN_RATE_LIMIT to a high value like "1000/minute"
|
||||
LOGIN_RATE_LIMIT = os.environ.get("ORCHARD_LOGIN_RATE_LIMIT", "5/minute")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -47,6 +47,13 @@ class ProjectUpdate(BaseModel):
|
||||
is_public: Optional[bool] = None
|
||||
|
||||
|
||||
class ProjectWithAccessResponse(ProjectResponse):
|
||||
"""Project response with user's access level included"""
|
||||
|
||||
access_level: Optional[str] = None # 'read', 'write', 'admin', or None
|
||||
is_owner: bool = False
|
||||
|
||||
|
||||
# Package format and platform enums
|
||||
PACKAGE_FORMATS = [
|
||||
"generic",
|
||||
@@ -686,3 +693,173 @@ class StatsReportResponse(BaseModel):
|
||||
format: str # "json", "csv", "markdown"
|
||||
generated_at: datetime
|
||||
content: str # The report content
|
||||
|
||||
|
||||
# Authentication schemas
|
||||
class LoginRequest(BaseModel):
|
||||
"""Login request with username and password"""
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
"""Login response with user info"""
|
||||
id: UUID
|
||||
username: str
|
||||
email: Optional[str]
|
||||
is_admin: bool
|
||||
must_change_password: bool
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
"""Change password request"""
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""User information response"""
|
||||
id: UUID
|
||||
username: str
|
||||
email: Optional[str]
|
||||
is_admin: bool
|
||||
is_active: bool
|
||||
must_change_password: bool
|
||||
created_at: datetime
|
||||
last_login: Optional[datetime]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
"""Create user request (admin only)"""
|
||||
username: str
|
||||
password: str
|
||||
email: Optional[str] = None
|
||||
is_admin: bool = False
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
"""Update user request (admin only)"""
|
||||
email: Optional[str] = None
|
||||
is_admin: Optional[bool] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
"""Reset password request (admin only)"""
|
||||
new_password: str
|
||||
|
||||
|
||||
class APIKeyCreate(BaseModel):
|
||||
"""Create API key request"""
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
scopes: Optional[List[str]] = None
|
||||
|
||||
|
||||
class APIKeyResponse(BaseModel):
|
||||
"""API key response (without the secret key)"""
|
||||
id: UUID
|
||||
name: str
|
||||
description: Optional[str]
|
||||
scopes: Optional[List[str]]
|
||||
created_at: datetime
|
||||
expires_at: Optional[datetime]
|
||||
last_used: Optional[datetime]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class APIKeyCreateResponse(BaseModel):
|
||||
"""API key creation response (includes the secret key - only shown once)"""
|
||||
id: UUID
|
||||
name: str
|
||||
description: Optional[str]
|
||||
scopes: Optional[List[str]]
|
||||
key: str # The actual API key - only returned on creation
|
||||
created_at: datetime
|
||||
expires_at: Optional[datetime]
|
||||
|
||||
|
||||
# OIDC Configuration schemas
|
||||
class OIDCConfigResponse(BaseModel):
|
||||
"""OIDC configuration response (hides client secret)"""
|
||||
enabled: bool
|
||||
issuer_url: str
|
||||
client_id: str
|
||||
has_client_secret: bool # True if secret is configured, but don't expose it
|
||||
scopes: List[str]
|
||||
auto_create_users: bool
|
||||
admin_group: str
|
||||
|
||||
|
||||
class OIDCConfigUpdate(BaseModel):
|
||||
"""Update OIDC configuration"""
|
||||
enabled: Optional[bool] = None
|
||||
issuer_url: Optional[str] = None
|
||||
client_id: Optional[str] = None
|
||||
client_secret: Optional[str] = None # Only set if changing
|
||||
scopes: Optional[List[str]] = None
|
||||
auto_create_users: Optional[bool] = None
|
||||
admin_group: Optional[str] = None
|
||||
|
||||
|
||||
class OIDCStatusResponse(BaseModel):
|
||||
"""Public OIDC status response"""
|
||||
enabled: bool
|
||||
issuer_url: Optional[str] = None # Only included if enabled
|
||||
|
||||
|
||||
class OIDCLoginResponse(BaseModel):
|
||||
"""OIDC login initiation response"""
|
||||
authorization_url: str
|
||||
|
||||
|
||||
# Access Permission schemas
|
||||
class AccessPermissionCreate(BaseModel):
|
||||
"""Grant access to a user for a project"""
|
||||
username: str
|
||||
level: str # 'read', 'write', or 'admin'
|
||||
expires_at: Optional[datetime] = None
|
||||
|
||||
@field_validator('level')
|
||||
@classmethod
|
||||
def validate_level(cls, v):
|
||||
if v not in ('read', 'write', 'admin'):
|
||||
raise ValueError("level must be 'read', 'write', or 'admin'")
|
||||
return v
|
||||
|
||||
|
||||
class AccessPermissionUpdate(BaseModel):
|
||||
"""Update access permission"""
|
||||
level: Optional[str] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
|
||||
@field_validator('level')
|
||||
@classmethod
|
||||
def validate_level(cls, v):
|
||||
if v is not None and v not in ('read', 'write', 'admin'):
|
||||
raise ValueError("level must be 'read', 'write', or 'admin'")
|
||||
return v
|
||||
|
||||
|
||||
class AccessPermissionResponse(BaseModel):
|
||||
"""Access permission response"""
|
||||
id: UUID
|
||||
project_id: UUID
|
||||
user_id: str
|
||||
level: str
|
||||
created_at: datetime
|
||||
expires_at: Optional[datetime]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ProjectWithAccessResponse(ProjectResponse):
|
||||
"""Project response with user's access level"""
|
||||
user_access_level: Optional[str] = None
|
||||
|
||||
|
||||
Reference in New Issue
Block a user