Add user authentication system with API key management (#50)
- Add User, Session, AuthSettings models with bcrypt password hashing - Add auth endpoints: login, logout, change-password, me - Add API key CRUD: create (orch_xxx format), list, revoke - Add admin user management: list, create, update, reset-password - Create default admin user on startup (admin/admin) - Add frontend: Login page, API Keys page, Admin Users page - Add AuthContext for session state management - Add user menu to Layout header with login/logout/settings - Add 15 integration tests for auth system - Add migration 006_auth_tables.sql
This commit is contained in:
412
backend/app/auth.py
Normal file
412
backend/app/auth.py
Normal file
@@ -0,0 +1,412 @@
|
||||
"""Authentication service for Orchard.
|
||||
|
||||
Handles password hashing, session management, and API key operations.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import User, Session as UserSession, APIKey
|
||||
|
||||
|
||||
# Password hashing context (bcrypt with cost factor 12)
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
# API key prefix
|
||||
API_KEY_PREFIX = "orch_"
|
||||
|
||||
# Session duration (24 hours default)
|
||||
SESSION_DURATION_HOURS = 24
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password using bcrypt."""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a password against its hash."""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
"""Hash a token (session or API key) using SHA256."""
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def generate_session_token() -> str:
|
||||
"""Generate a cryptographically secure session token."""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def generate_api_key() -> str:
|
||||
"""Generate a new API key with prefix.
|
||||
|
||||
Format: orch_<32 random bytes as hex>
|
||||
"""
|
||||
random_part = secrets.token_hex(32)
|
||||
return f"{API_KEY_PREFIX}{random_part}"
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""Authentication service for user management and session handling."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
# --- User Operations ---
|
||||
|
||||
def create_user(
|
||||
self,
|
||||
username: str,
|
||||
password: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
is_admin: bool = False,
|
||||
must_change_password: bool = False,
|
||||
) -> User:
|
||||
"""Create a new user account."""
|
||||
user = User(
|
||||
username=username,
|
||||
password_hash=hash_password(password) if password else None,
|
||||
email=email,
|
||||
is_admin=is_admin,
|
||||
must_change_password=must_change_password,
|
||||
)
|
||||
self.db.add(user)
|
||||
self.db.commit()
|
||||
self.db.refresh(user)
|
||||
return user
|
||||
|
||||
def get_user_by_username(self, username: str) -> Optional[User]:
|
||||
"""Get a user by username."""
|
||||
return self.db.query(User).filter(User.username == username).first()
|
||||
|
||||
def get_user_by_id(self, user_id: str) -> Optional[User]:
|
||||
"""Get a user by ID."""
|
||||
return self.db.query(User).filter(User.id == user_id).first()
|
||||
|
||||
def authenticate_user(self, username: str, password: str) -> Optional[User]:
|
||||
"""Authenticate a user with username and password.
|
||||
|
||||
Returns the user if authentication succeeds, None otherwise.
|
||||
"""
|
||||
user = self.get_user_by_username(username)
|
||||
if not user:
|
||||
return None
|
||||
if not user.password_hash:
|
||||
return None # OIDC-only user
|
||||
if not user.is_active:
|
||||
return None
|
||||
if not verify_password(password, user.password_hash):
|
||||
return None
|
||||
return user
|
||||
|
||||
def change_password(self, user: User, new_password: str) -> None:
|
||||
"""Change a user's password."""
|
||||
user.password_hash = hash_password(new_password)
|
||||
user.must_change_password = False
|
||||
self.db.commit()
|
||||
|
||||
def update_last_login(self, user: User) -> None:
|
||||
"""Update the user's last login timestamp."""
|
||||
user.last_login = datetime.utcnow()
|
||||
self.db.commit()
|
||||
|
||||
def list_users(self, include_inactive: bool = False) -> list[User]:
|
||||
"""List all users."""
|
||||
query = self.db.query(User)
|
||||
if not include_inactive:
|
||||
query = query.filter(User.is_active == True)
|
||||
return query.order_by(User.username).all()
|
||||
|
||||
def set_user_active(self, user: User, is_active: bool) -> None:
|
||||
"""Enable or disable a user account."""
|
||||
user.is_active = is_active
|
||||
self.db.commit()
|
||||
|
||||
def set_user_admin(self, user: User, is_admin: bool) -> None:
|
||||
"""Grant or revoke admin privileges."""
|
||||
user.is_admin = is_admin
|
||||
self.db.commit()
|
||||
|
||||
def reset_user_password(self, user: User, new_password: str) -> None:
|
||||
"""Reset a user's password (admin action)."""
|
||||
user.password_hash = hash_password(new_password)
|
||||
user.must_change_password = True
|
||||
self.db.commit()
|
||||
|
||||
# --- Session Operations ---
|
||||
|
||||
def create_session(
|
||||
self,
|
||||
user: User,
|
||||
user_agent: Optional[str] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
) -> tuple[UserSession, str]:
|
||||
"""Create a new session for a user.
|
||||
|
||||
Returns a tuple of (session, token) where token is the plaintext
|
||||
token that should be sent to the client. The token is only returned
|
||||
once and should be stored securely.
|
||||
"""
|
||||
token = generate_session_token()
|
||||
token_hash = hash_token(token)
|
||||
|
||||
session = UserSession(
|
||||
user_id=user.id,
|
||||
token_hash=token_hash,
|
||||
expires_at=datetime.utcnow() + timedelta(hours=SESSION_DURATION_HOURS),
|
||||
user_agent=user_agent,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
self.db.add(session)
|
||||
self.db.commit()
|
||||
self.db.refresh(session)
|
||||
|
||||
return session, token
|
||||
|
||||
def get_session_by_token(self, token: str) -> Optional[UserSession]:
|
||||
"""Get a session by its token.
|
||||
|
||||
Returns None if the session doesn't exist or has expired.
|
||||
"""
|
||||
token_hash = hash_token(token)
|
||||
session = (
|
||||
self.db.query(UserSession)
|
||||
.filter(UserSession.token_hash == token_hash)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not session:
|
||||
return None
|
||||
|
||||
if session.expires_at < datetime.utcnow():
|
||||
# Session has expired, delete it
|
||||
self.db.delete(session)
|
||||
self.db.commit()
|
||||
return None
|
||||
|
||||
# Update last accessed time
|
||||
session.last_accessed = datetime.utcnow()
|
||||
self.db.commit()
|
||||
|
||||
return session
|
||||
|
||||
def delete_session(self, session: UserSession) -> None:
|
||||
"""Delete a session (logout)."""
|
||||
self.db.delete(session)
|
||||
self.db.commit()
|
||||
|
||||
def delete_user_sessions(self, user: User) -> int:
|
||||
"""Delete all sessions for a user. Returns count of deleted sessions."""
|
||||
count = (
|
||||
self.db.query(UserSession).filter(UserSession.user_id == user.id).delete()
|
||||
)
|
||||
self.db.commit()
|
||||
return count
|
||||
|
||||
def cleanup_expired_sessions(self) -> int:
|
||||
"""Delete all expired sessions. Returns count of deleted sessions."""
|
||||
count = (
|
||||
self.db.query(UserSession)
|
||||
.filter(UserSession.expires_at < datetime.utcnow())
|
||||
.delete()
|
||||
)
|
||||
self.db.commit()
|
||||
return count
|
||||
|
||||
# --- API Key Operations ---
|
||||
|
||||
def create_api_key(
|
||||
self,
|
||||
user: User,
|
||||
name: str,
|
||||
description: Optional[str] = None,
|
||||
scopes: Optional[list[str]] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
) -> tuple[APIKey, str]:
|
||||
"""Create a new API key for a user.
|
||||
|
||||
Returns a tuple of (api_key, key) where key is the plaintext
|
||||
API key that should be sent to the client. The key is only returned
|
||||
once and should be stored securely by the user.
|
||||
"""
|
||||
key = generate_api_key()
|
||||
key_hash = hash_token(key)
|
||||
|
||||
api_key = APIKey(
|
||||
key_hash=key_hash,
|
||||
name=name,
|
||||
user_id=user.username, # Legacy field
|
||||
owner_id=user.id,
|
||||
description=description,
|
||||
scopes=scopes or ["read", "write"],
|
||||
expires_at=expires_at,
|
||||
)
|
||||
self.db.add(api_key)
|
||||
self.db.commit()
|
||||
self.db.refresh(api_key)
|
||||
|
||||
return api_key, key
|
||||
|
||||
def get_api_key_by_key(self, key: str) -> Optional[APIKey]:
|
||||
"""Get an API key by its plaintext key.
|
||||
|
||||
Returns None if the key doesn't exist or has expired.
|
||||
"""
|
||||
if not key.startswith(API_KEY_PREFIX):
|
||||
return None
|
||||
|
||||
key_hash = hash_token(key)
|
||||
api_key = self.db.query(APIKey).filter(APIKey.key_hash == key_hash).first()
|
||||
|
||||
if not api_key:
|
||||
return None
|
||||
|
||||
# Check expiration
|
||||
if api_key.expires_at and api_key.expires_at < datetime.utcnow():
|
||||
return None
|
||||
|
||||
# Update last used time
|
||||
api_key.last_used = datetime.utcnow()
|
||||
self.db.commit()
|
||||
|
||||
return api_key
|
||||
|
||||
def get_api_key_by_id(self, key_id: str) -> Optional[APIKey]:
|
||||
"""Get an API key by its ID."""
|
||||
return self.db.query(APIKey).filter(APIKey.id == key_id).first()
|
||||
|
||||
def list_user_api_keys(self, user: User) -> list[APIKey]:
|
||||
"""List all API keys for a user."""
|
||||
return (
|
||||
self.db.query(APIKey)
|
||||
.filter(APIKey.owner_id == user.id)
|
||||
.order_by(APIKey.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
def delete_api_key(self, api_key: APIKey) -> None:
|
||||
"""Delete an API key."""
|
||||
self.db.delete(api_key)
|
||||
self.db.commit()
|
||||
|
||||
def get_user_from_api_key(self, key: str) -> Optional[User]:
|
||||
"""Get the user associated with an API key.
|
||||
|
||||
Returns None if the key is invalid or the user is inactive.
|
||||
"""
|
||||
api_key = self.get_api_key_by_key(key)
|
||||
if not api_key:
|
||||
return None
|
||||
|
||||
if not api_key.owner_id:
|
||||
return None
|
||||
|
||||
user = self.db.query(User).filter(User.id == api_key.owner_id).first()
|
||||
if not user or not user.is_active:
|
||||
return None
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def create_default_admin(db: Session) -> Optional[User]:
|
||||
"""Create the default admin user if no users exist.
|
||||
|
||||
Returns the created user, or None if users already exist.
|
||||
"""
|
||||
# Check if any users exist
|
||||
user_count = db.query(User).count()
|
||||
if user_count > 0:
|
||||
return None
|
||||
|
||||
# Create default admin
|
||||
auth_service = AuthService(db)
|
||||
admin = auth_service.create_user(
|
||||
username="admin",
|
||||
password="admin",
|
||||
is_admin=True,
|
||||
must_change_password=True,
|
||||
)
|
||||
|
||||
return admin
|
||||
|
||||
|
||||
# --- FastAPI Dependencies ---
|
||||
|
||||
from fastapi import Depends, HTTPException, status, Cookie, Header
|
||||
from .database import get_db
|
||||
|
||||
# Cookie name for session token
|
||||
SESSION_COOKIE_NAME = "orchard_session"
|
||||
|
||||
|
||||
def get_current_user_optional(
|
||||
db: Session = Depends(get_db),
|
||||
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.
|
||||
|
||||
Returns None if no valid authentication is provided.
|
||||
Does not raise an exception for unauthenticated requests.
|
||||
"""
|
||||
auth_service = AuthService(db)
|
||||
|
||||
# First try session cookie (web UI)
|
||||
if session_token:
|
||||
session = auth_service.get_session_by_token(session_token)
|
||||
if session:
|
||||
user = auth_service.get_user_by_id(str(session.user_id))
|
||||
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)
|
||||
if user:
|
||||
return user
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_current_user(
|
||||
user: Optional[User] = Depends(get_current_user_optional),
|
||||
) -> User:
|
||||
"""Get the current authenticated user.
|
||||
|
||||
Raises HTTPException 401 if not authenticated.
|
||||
"""
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def require_admin(
|
||||
user: User = Depends(get_current_user),
|
||||
) -> User:
|
||||
"""Require the current user to be an admin.
|
||||
|
||||
Raises HTTPException 403 if user is not an admin.
|
||||
"""
|
||||
if not user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin privileges required",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def get_auth_service(db: Session = Depends(get_db)) -> AuthService:
|
||||
"""Get an AuthService instance."""
|
||||
return AuthService(db)
|
||||
@@ -9,6 +9,7 @@ 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
|
||||
|
||||
settings = get_settings()
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
@@ -20,6 +21,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 'admin'. "
|
||||
"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")
|
||||
@@ -48,7 +61,11 @@ 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 +77,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 +86,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"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ from fastapi import (
|
||||
Query,
|
||||
Header,
|
||||
Response,
|
||||
Cookie,
|
||||
status,
|
||||
)
|
||||
from fastapi.responses import StreamingResponse, RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -42,6 +44,7 @@ from .models import (
|
||||
UploadLock,
|
||||
Consumer,
|
||||
AuditLog,
|
||||
User,
|
||||
)
|
||||
from .schemas import (
|
||||
ProjectCreate,
|
||||
@@ -94,6 +97,16 @@ from .schemas import (
|
||||
StatsReportResponse,
|
||||
GlobalArtifactResponse,
|
||||
GlobalTagResponse,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
ChangePasswordRequest,
|
||||
UserResponse,
|
||||
UserCreate,
|
||||
UserUpdate,
|
||||
ResetPasswordRequest,
|
||||
APIKeyCreate,
|
||||
APIKeyResponse,
|
||||
APIKeyCreateResponse,
|
||||
)
|
||||
from .metadata import extract_metadata
|
||||
from .config import get_settings
|
||||
@@ -118,14 +131,39 @@ def sanitize_filename(filename: str) -> str:
|
||||
return re.sub(r'[\r\n"]', "", filename)
|
||||
|
||||
|
||||
def get_user_id_from_request(
|
||||
request: Request,
|
||||
db: Session,
|
||||
current_user: Optional[User] = None,
|
||||
) -> str:
|
||||
"""Extract user ID from request using auth system.
|
||||
|
||||
If a current_user is provided (from auth dependency), use their username.
|
||||
Otherwise, try to authenticate from headers and fall back to 'anonymous'.
|
||||
"""
|
||||
if current_user:
|
||||
return current_user.username
|
||||
|
||||
# Try to authenticate from API key header
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if auth_header and auth_header.startswith("Bearer "):
|
||||
api_key = auth_header[7:]
|
||||
auth_service = AuthService(db)
|
||||
user = auth_service.get_user_from_api_key(api_key)
|
||||
if user:
|
||||
return user.username
|
||||
|
||||
return "anonymous"
|
||||
|
||||
|
||||
def get_user_id(request: Request) -> str:
|
||||
"""Extract user ID from request (simplified for now)"""
|
||||
api_key = request.headers.get("X-Orchard-API-Key")
|
||||
if api_key:
|
||||
return "api-user"
|
||||
"""Legacy function for backward compatibility.
|
||||
|
||||
DEPRECATED: Use get_user_id_from_request with db session for proper auth.
|
||||
"""
|
||||
auth = request.headers.get("Authorization")
|
||||
if auth:
|
||||
return "bearer-user"
|
||||
if auth and auth.startswith("Bearer "):
|
||||
return "authenticated-user"
|
||||
return "anonymous"
|
||||
|
||||
|
||||
@@ -320,6 +358,460 @@ def health_check(
|
||||
)
|
||||
|
||||
|
||||
# --- Authentication Routes ---
|
||||
|
||||
from .auth import (
|
||||
AuthService,
|
||||
get_current_user,
|
||||
get_current_user_optional,
|
||||
require_admin,
|
||||
get_auth_service,
|
||||
SESSION_COOKIE_NAME,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v1/auth/login", response_model=LoginResponse)
|
||||
def login(
|
||||
login_request: LoginRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
auth_service: AuthService = Depends(get_auth_service),
|
||||
):
|
||||
"""
|
||||
Login with username and password.
|
||||
Returns user info and sets a session cookie.
|
||||
"""
|
||||
user = auth_service.authenticate_user(
|
||||
login_request.username, login_request.password
|
||||
)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid username or password",
|
||||
)
|
||||
|
||||
# Create session
|
||||
session, token = auth_service.create_session(
|
||||
user,
|
||||
user_agent=request.headers.get("User-Agent"),
|
||||
ip_address=request.client.host if request.client else None,
|
||||
)
|
||||
|
||||
# Update last login
|
||||
auth_service.update_last_login(user)
|
||||
|
||||
# Set session cookie
|
||||
response.set_cookie(
|
||||
key=SESSION_COOKIE_NAME,
|
||||
value=token,
|
||||
httponly=True,
|
||||
secure=request.url.scheme == "https",
|
||||
samesite="lax",
|
||||
max_age=24 * 60 * 60, # 24 hours
|
||||
)
|
||||
|
||||
# Log audit
|
||||
_log_audit(
|
||||
auth_service.db,
|
||||
"auth.login",
|
||||
f"user:{user.username}",
|
||||
user.username,
|
||||
request,
|
||||
{"user_id": str(user.id)},
|
||||
)
|
||||
|
||||
return LoginResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
is_admin=user.is_admin,
|
||||
must_change_password=user.must_change_password,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v1/auth/logout")
|
||||
def logout(
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
session_token: Optional[str] = Cookie(None, alias=SESSION_COOKIE_NAME),
|
||||
):
|
||||
"""
|
||||
Logout and invalidate the session.
|
||||
"""
|
||||
if session_token:
|
||||
auth_service = AuthService(db)
|
||||
session = auth_service.get_session_by_token(session_token)
|
||||
if session:
|
||||
auth_service.delete_session(session)
|
||||
|
||||
# Clear the session cookie
|
||||
response.delete_cookie(key=SESSION_COOKIE_NAME)
|
||||
|
||||
return {"message": "Logged out successfully"}
|
||||
|
||||
|
||||
@router.get("/api/v1/auth/me", response_model=UserResponse)
|
||||
def get_current_user_info(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get information about the currently authenticated user.
|
||||
"""
|
||||
return UserResponse(
|
||||
id=current_user.id,
|
||||
username=current_user.username,
|
||||
email=current_user.email,
|
||||
is_admin=current_user.is_admin,
|
||||
is_active=current_user.is_active,
|
||||
must_change_password=current_user.must_change_password,
|
||||
created_at=current_user.created_at,
|
||||
last_login=current_user.last_login,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v1/auth/change-password")
|
||||
def change_password(
|
||||
password_request: ChangePasswordRequest,
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
auth_service: AuthService = Depends(get_auth_service),
|
||||
):
|
||||
"""
|
||||
Change the current user's password.
|
||||
Requires the current password for verification.
|
||||
"""
|
||||
# Verify current password
|
||||
if not verify_password(
|
||||
password_request.current_password, current_user.password_hash
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Current password is incorrect",
|
||||
)
|
||||
|
||||
# Change password
|
||||
auth_service.change_password(current_user, password_request.new_password)
|
||||
|
||||
# Log audit
|
||||
_log_audit(
|
||||
auth_service.db,
|
||||
"auth.password_change",
|
||||
f"user:{current_user.username}",
|
||||
current_user.username,
|
||||
request,
|
||||
)
|
||||
|
||||
return {"message": "Password changed successfully"}
|
||||
|
||||
|
||||
# --- API Key Routes ---
|
||||
|
||||
|
||||
@router.post("/api/v1/auth/keys", response_model=APIKeyCreateResponse)
|
||||
def create_api_key(
|
||||
key_request: APIKeyCreate,
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
auth_service: AuthService = Depends(get_auth_service),
|
||||
):
|
||||
"""
|
||||
Create a new API key for the current user.
|
||||
The key is only returned once - store it securely!
|
||||
"""
|
||||
api_key, key = auth_service.create_api_key(
|
||||
user=current_user,
|
||||
name=key_request.name,
|
||||
description=key_request.description,
|
||||
scopes=key_request.scopes,
|
||||
)
|
||||
|
||||
# Log audit
|
||||
_log_audit(
|
||||
auth_service.db,
|
||||
"auth.api_key_create",
|
||||
f"api_key:{api_key.id}",
|
||||
current_user.username,
|
||||
request,
|
||||
{"key_name": key_request.name},
|
||||
)
|
||||
|
||||
return APIKeyCreateResponse(
|
||||
id=api_key.id,
|
||||
name=api_key.name,
|
||||
description=api_key.description,
|
||||
scopes=api_key.scopes,
|
||||
key=key,
|
||||
created_at=api_key.created_at,
|
||||
expires_at=api_key.expires_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/v1/auth/keys", response_model=List[APIKeyResponse])
|
||||
def list_api_keys(
|
||||
current_user: User = Depends(get_current_user),
|
||||
auth_service: AuthService = Depends(get_auth_service),
|
||||
):
|
||||
"""
|
||||
List all API keys for the current user.
|
||||
Does not include the secret key.
|
||||
"""
|
||||
keys = auth_service.list_user_api_keys(current_user)
|
||||
return [
|
||||
APIKeyResponse(
|
||||
id=k.id,
|
||||
name=k.name,
|
||||
description=k.description,
|
||||
scopes=k.scopes,
|
||||
created_at=k.created_at,
|
||||
expires_at=k.expires_at,
|
||||
last_used=k.last_used,
|
||||
)
|
||||
for k in keys
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/api/v1/auth/keys/{key_id}")
|
||||
def delete_api_key(
|
||||
key_id: str,
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
auth_service: AuthService = Depends(get_auth_service),
|
||||
):
|
||||
"""
|
||||
Revoke an API key.
|
||||
Users can only delete their own keys, unless they are an admin.
|
||||
"""
|
||||
api_key = auth_service.get_api_key_by_id(key_id)
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="API key not found",
|
||||
)
|
||||
|
||||
# Check ownership (admins can delete any key)
|
||||
if api_key.owner_id != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Cannot delete another user's API key",
|
||||
)
|
||||
|
||||
key_name = api_key.name
|
||||
auth_service.delete_api_key(api_key)
|
||||
|
||||
# Log audit
|
||||
_log_audit(
|
||||
auth_service.db,
|
||||
"auth.api_key_delete",
|
||||
f"api_key:{key_id}",
|
||||
current_user.username,
|
||||
request,
|
||||
{"key_name": key_name},
|
||||
)
|
||||
|
||||
return {"message": "API key deleted successfully"}
|
||||
|
||||
|
||||
# --- Admin User Management Routes ---
|
||||
|
||||
|
||||
@router.get("/api/v1/admin/users", response_model=List[UserResponse])
|
||||
def list_users(
|
||||
include_inactive: bool = Query(default=False),
|
||||
current_user: User = Depends(require_admin),
|
||||
auth_service: AuthService = Depends(get_auth_service),
|
||||
):
|
||||
"""
|
||||
List all users (admin only).
|
||||
"""
|
||||
users = auth_service.list_users(include_inactive=include_inactive)
|
||||
return [
|
||||
UserResponse(
|
||||
id=u.id,
|
||||
username=u.username,
|
||||
email=u.email,
|
||||
is_admin=u.is_admin,
|
||||
is_active=u.is_active,
|
||||
must_change_password=u.must_change_password,
|
||||
created_at=u.created_at,
|
||||
last_login=u.last_login,
|
||||
)
|
||||
for u in users
|
||||
]
|
||||
|
||||
|
||||
@router.post("/api/v1/admin/users", response_model=UserResponse)
|
||||
def create_user(
|
||||
user_create: UserCreate,
|
||||
request: Request,
|
||||
current_user: User = Depends(require_admin),
|
||||
auth_service: AuthService = Depends(get_auth_service),
|
||||
):
|
||||
"""
|
||||
Create a new user (admin only).
|
||||
"""
|
||||
# Check if username already exists
|
||||
existing = auth_service.get_user_by_username(user_create.username)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Username already exists",
|
||||
)
|
||||
|
||||
user = auth_service.create_user(
|
||||
username=user_create.username,
|
||||
password=user_create.password,
|
||||
email=user_create.email,
|
||||
is_admin=user_create.is_admin,
|
||||
)
|
||||
|
||||
# Log audit
|
||||
_log_audit(
|
||||
auth_service.db,
|
||||
"admin.user_create",
|
||||
f"user:{user.username}",
|
||||
current_user.username,
|
||||
request,
|
||||
{"new_user": user_create.username, "is_admin": user_create.is_admin},
|
||||
)
|
||||
|
||||
return UserResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
is_admin=user.is_admin,
|
||||
is_active=user.is_active,
|
||||
must_change_password=user.must_change_password,
|
||||
created_at=user.created_at,
|
||||
last_login=user.last_login,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/v1/admin/users/{username}", response_model=UserResponse)
|
||||
def get_user(
|
||||
username: str,
|
||||
current_user: User = Depends(require_admin),
|
||||
auth_service: AuthService = Depends(get_auth_service),
|
||||
):
|
||||
"""
|
||||
Get a specific user by username (admin only).
|
||||
"""
|
||||
user = auth_service.get_user_by_username(username)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
return UserResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
is_admin=user.is_admin,
|
||||
is_active=user.is_active,
|
||||
must_change_password=user.must_change_password,
|
||||
created_at=user.created_at,
|
||||
last_login=user.last_login,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/api/v1/admin/users/{username}", response_model=UserResponse)
|
||||
def update_user(
|
||||
username: str,
|
||||
user_update: UserUpdate,
|
||||
request: Request,
|
||||
current_user: User = Depends(require_admin),
|
||||
auth_service: AuthService = Depends(get_auth_service),
|
||||
):
|
||||
"""
|
||||
Update a user (admin only).
|
||||
"""
|
||||
user = auth_service.get_user_by_username(username)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
# Prevent removing the last admin
|
||||
if user_update.is_admin is False and user.is_admin:
|
||||
admin_count = (
|
||||
auth_service.db.query(User)
|
||||
.filter(User.is_admin == True, User.is_active == True)
|
||||
.count()
|
||||
)
|
||||
if admin_count <= 1:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot remove the last admin",
|
||||
)
|
||||
|
||||
# Update fields
|
||||
if user_update.email is not None:
|
||||
user.email = user_update.email
|
||||
if user_update.is_admin is not None:
|
||||
user.is_admin = user_update.is_admin
|
||||
if user_update.is_active is not None:
|
||||
user.is_active = user_update.is_active
|
||||
|
||||
auth_service.db.commit()
|
||||
|
||||
# Log audit
|
||||
_log_audit(
|
||||
auth_service.db,
|
||||
"admin.user_update",
|
||||
f"user:{username}",
|
||||
current_user.username,
|
||||
request,
|
||||
{"updates": user_update.model_dump(exclude_none=True)},
|
||||
)
|
||||
|
||||
return UserResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
is_admin=user.is_admin,
|
||||
is_active=user.is_active,
|
||||
must_change_password=user.must_change_password,
|
||||
created_at=user.created_at,
|
||||
last_login=user.last_login,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v1/admin/users/{username}/reset-password")
|
||||
def reset_user_password(
|
||||
username: str,
|
||||
reset_request: ResetPasswordRequest,
|
||||
request: Request,
|
||||
current_user: User = Depends(require_admin),
|
||||
auth_service: AuthService = Depends(get_auth_service),
|
||||
):
|
||||
"""
|
||||
Reset a user's password (admin only).
|
||||
Sets must_change_password to True.
|
||||
"""
|
||||
user = auth_service.get_user_by_username(username)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
auth_service.reset_user_password(user, reset_request.new_password)
|
||||
|
||||
# Log audit
|
||||
_log_audit(
|
||||
auth_service.db,
|
||||
"admin.password_reset",
|
||||
f"user:{username}",
|
||||
current_user.username,
|
||||
request,
|
||||
)
|
||||
|
||||
return {"message": f"Password reset for user {username}"}
|
||||
|
||||
|
||||
# Global search
|
||||
@router.get("/api/v1/search", response_model=GlobalSearchResponse)
|
||||
def global_search(
|
||||
@@ -513,9 +1005,12 @@ def list_projects(
|
||||
|
||||
@router.post("/api/v1/projects", response_model=ProjectResponse)
|
||||
def create_project(
|
||||
project: ProjectCreate, request: Request, db: Session = Depends(get_db)
|
||||
project: ProjectCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Optional[User] = Depends(get_current_user_optional),
|
||||
):
|
||||
user_id = get_user_id(request)
|
||||
user_id = get_user_id_from_request(request, db, current_user)
|
||||
|
||||
existing = db.query(Project).filter(Project.name == project.name).first()
|
||||
if existing:
|
||||
@@ -1150,6 +1645,7 @@ def upload_artifact(
|
||||
content_length: Optional[int] = Header(None, alias="Content-Length"),
|
||||
user_agent: Optional[str] = Header(None, alias="User-Agent"),
|
||||
client_checksum: Optional[str] = Header(None, alias="X-Checksum-SHA256"),
|
||||
current_user: Optional[User] = Depends(get_current_user_optional),
|
||||
):
|
||||
"""
|
||||
Upload an artifact to a package.
|
||||
@@ -1157,9 +1653,10 @@ def upload_artifact(
|
||||
Headers:
|
||||
- X-Checksum-SHA256: Optional client-provided SHA256 for verification
|
||||
- User-Agent: Captured for audit purposes
|
||||
- Authorization: Bearer <api-key> for authentication
|
||||
"""
|
||||
start_time = time.time()
|
||||
user_id = get_user_id(request)
|
||||
user_id = get_user_id_from_request(request, db, current_user)
|
||||
settings = get_settings()
|
||||
storage_result = None
|
||||
|
||||
|
||||
@@ -686,3 +686,93 @@ 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]
|
||||
|
||||
|
||||
383
backend/tests/integration/test_auth_api.py
Normal file
383
backend/tests/integration/test_auth_api.py
Normal file
@@ -0,0 +1,383 @@
|
||||
"""Integration tests for authentication API endpoints."""
|
||||
|
||||
import pytest
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
class TestAuthLogin:
|
||||
"""Tests for login endpoint."""
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_login_success(self, integration_client):
|
||||
"""Test successful login with default admin credentials."""
|
||||
response = integration_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "admin"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["username"] == "admin"
|
||||
assert data["is_admin"] is True
|
||||
assert "orchard_session" in response.cookies
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_login_invalid_password(self, integration_client):
|
||||
"""Test login with wrong password."""
|
||||
response = integration_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "wrongpassword"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert "Invalid username or password" in response.json()["detail"]
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_login_nonexistent_user(self, integration_client):
|
||||
"""Test login with non-existent user."""
|
||||
response = integration_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "nonexistent", "password": "password"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestAuthLogout:
|
||||
"""Tests for logout endpoint."""
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_logout_success(self, integration_client):
|
||||
"""Test successful logout."""
|
||||
# First login
|
||||
login_response = integration_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "admin"},
|
||||
)
|
||||
assert login_response.status_code == 200
|
||||
|
||||
# Then logout
|
||||
logout_response = integration_client.post("/api/v1/auth/logout")
|
||||
assert logout_response.status_code == 200
|
||||
assert "Logged out successfully" in logout_response.json()["message"]
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_logout_without_session(self, integration_client):
|
||||
"""Test logout without being logged in."""
|
||||
response = integration_client.post("/api/v1/auth/logout")
|
||||
# Should succeed even without session
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestAuthMe:
|
||||
"""Tests for get current user endpoint."""
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_get_me_authenticated(self, integration_client):
|
||||
"""Test getting current user when authenticated."""
|
||||
# Login first
|
||||
integration_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "admin"},
|
||||
)
|
||||
|
||||
response = integration_client.get("/api/v1/auth/me")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["username"] == "admin"
|
||||
assert data["is_admin"] is True
|
||||
assert "id" in data
|
||||
assert "created_at" in data
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_get_me_unauthenticated(self, integration_client):
|
||||
"""Test getting current user without authentication."""
|
||||
# Clear any existing cookies
|
||||
integration_client.cookies.clear()
|
||||
|
||||
response = integration_client.get("/api/v1/auth/me")
|
||||
assert response.status_code == 401
|
||||
assert "Not authenticated" in response.json()["detail"]
|
||||
|
||||
|
||||
class TestAuthChangePassword:
|
||||
"""Tests for change password endpoint."""
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_change_password_success(self, integration_client):
|
||||
"""Test successful password change."""
|
||||
# Login first
|
||||
integration_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "admin"},
|
||||
)
|
||||
|
||||
# Change password
|
||||
response = integration_client.post(
|
||||
"/api/v1/auth/change-password",
|
||||
json={"current_password": "admin", "new_password": "newpassword123"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify old password no longer works
|
||||
integration_client.cookies.clear()
|
||||
response = integration_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "admin"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
# Verify new password works
|
||||
response = integration_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "newpassword123"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Reset password back to original for other tests
|
||||
integration_client.post(
|
||||
"/api/v1/auth/change-password",
|
||||
json={"current_password": "newpassword123", "new_password": "admin"},
|
||||
)
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_change_password_wrong_current(self, integration_client):
|
||||
"""Test password change with wrong current password."""
|
||||
# Login first
|
||||
integration_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "admin"},
|
||||
)
|
||||
|
||||
response = integration_client.post(
|
||||
"/api/v1/auth/change-password",
|
||||
json={"current_password": "wrongpassword", "new_password": "newpassword"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "Current password is incorrect" in response.json()["detail"]
|
||||
|
||||
|
||||
class TestAPIKeys:
|
||||
"""Tests for API key management endpoints."""
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_create_and_list_api_key(self, integration_client):
|
||||
"""Test creating and listing API keys."""
|
||||
# Login first
|
||||
integration_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "admin"},
|
||||
)
|
||||
|
||||
# Create API key
|
||||
create_response = integration_client.post(
|
||||
"/api/v1/auth/keys",
|
||||
json={"name": "test-key", "description": "Test API key"},
|
||||
)
|
||||
assert create_response.status_code == 200
|
||||
data = create_response.json()
|
||||
assert data["name"] == "test-key"
|
||||
assert data["description"] == "Test API key"
|
||||
assert "key" in data
|
||||
assert data["key"].startswith("orch_")
|
||||
key_id = data["id"]
|
||||
api_key = data["key"]
|
||||
|
||||
# List API keys
|
||||
list_response = integration_client.get("/api/v1/auth/keys")
|
||||
assert list_response.status_code == 200
|
||||
keys = list_response.json()
|
||||
assert any(k["id"] == key_id for k in keys)
|
||||
|
||||
# Clean up - delete the key
|
||||
integration_client.delete(f"/api/v1/auth/keys/{key_id}")
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_use_api_key_for_auth(self, integration_client):
|
||||
"""Test using API key for authentication."""
|
||||
# Login and create API key
|
||||
integration_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "admin"},
|
||||
)
|
||||
create_response = integration_client.post(
|
||||
"/api/v1/auth/keys",
|
||||
json={"name": "auth-test-key"},
|
||||
)
|
||||
api_key = create_response.json()["key"]
|
||||
key_id = create_response.json()["id"]
|
||||
|
||||
# Clear cookies and use API key
|
||||
integration_client.cookies.clear()
|
||||
response = integration_client.get(
|
||||
"/api/v1/auth/me",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["username"] == "admin"
|
||||
|
||||
# Clean up
|
||||
integration_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "admin"},
|
||||
)
|
||||
integration_client.delete(f"/api/v1/auth/keys/{key_id}")
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_delete_api_key(self, integration_client):
|
||||
"""Test revoking an API key."""
|
||||
# Login and create API key
|
||||
integration_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "admin"},
|
||||
)
|
||||
create_response = integration_client.post(
|
||||
"/api/v1/auth/keys",
|
||||
json={"name": "delete-test-key"},
|
||||
)
|
||||
key_id = create_response.json()["id"]
|
||||
api_key = create_response.json()["key"]
|
||||
|
||||
# Delete the key
|
||||
delete_response = integration_client.delete(f"/api/v1/auth/keys/{key_id}")
|
||||
assert delete_response.status_code == 200
|
||||
|
||||
# Verify key no longer works
|
||||
integration_client.cookies.clear()
|
||||
response = integration_client.get(
|
||||
"/api/v1/auth/me",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestAdminUserManagement:
|
||||
"""Tests for admin user management endpoints."""
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_list_users(self, integration_client):
|
||||
"""Test listing users as admin."""
|
||||
# Login as admin
|
||||
integration_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "admin"},
|
||||
)
|
||||
|
||||
response = integration_client.get("/api/v1/admin/users")
|
||||
assert response.status_code == 200
|
||||
users = response.json()
|
||||
assert len(users) >= 1
|
||||
assert any(u["username"] == "admin" for u in users)
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_create_user(self, integration_client):
|
||||
"""Test creating a new user as admin."""
|
||||
# Login as admin
|
||||
integration_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "admin"},
|
||||
)
|
||||
|
||||
# Create new user
|
||||
test_username = f"testuser_{uuid4().hex[:8]}"
|
||||
response = integration_client.post(
|
||||
"/api/v1/admin/users",
|
||||
json={
|
||||
"username": test_username,
|
||||
"password": "testpassword",
|
||||
"email": "test@example.com",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["username"] == test_username
|
||||
assert data["email"] == "test@example.com"
|
||||
assert data["is_admin"] is False
|
||||
|
||||
# Verify new user can login
|
||||
integration_client.cookies.clear()
|
||||
login_response = integration_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": test_username, "password": "testpassword"},
|
||||
)
|
||||
assert login_response.status_code == 200
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_update_user(self, integration_client):
|
||||
"""Test updating a user as admin."""
|
||||
# Login as admin
|
||||
integration_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "admin"},
|
||||
)
|
||||
|
||||
# Create a test user
|
||||
test_username = f"updateuser_{uuid4().hex[:8]}"
|
||||
integration_client.post(
|
||||
"/api/v1/admin/users",
|
||||
json={"username": test_username, "password": "password"},
|
||||
)
|
||||
|
||||
# Update the user
|
||||
response = integration_client.put(
|
||||
f"/api/v1/admin/users/{test_username}",
|
||||
json={"email": "updated@example.com", "is_admin": True},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["email"] == "updated@example.com"
|
||||
assert data["is_admin"] is True
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_reset_user_password(self, integration_client):
|
||||
"""Test resetting a user's password as admin."""
|
||||
# Login as admin
|
||||
integration_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "admin"},
|
||||
)
|
||||
|
||||
# Create a test user
|
||||
test_username = f"resetuser_{uuid4().hex[:8]}"
|
||||
integration_client.post(
|
||||
"/api/v1/admin/users",
|
||||
json={"username": test_username, "password": "oldpassword"},
|
||||
)
|
||||
|
||||
# Reset password
|
||||
response = integration_client.post(
|
||||
f"/api/v1/admin/users/{test_username}/reset-password",
|
||||
json={"new_password": "newpassword"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify new password works
|
||||
integration_client.cookies.clear()
|
||||
login_response = integration_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": test_username, "password": "newpassword"},
|
||||
)
|
||||
assert login_response.status_code == 200
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_non_admin_cannot_access_admin_endpoints(self, integration_client):
|
||||
"""Test that non-admin users cannot access admin endpoints."""
|
||||
# Login as admin and create non-admin user
|
||||
integration_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "admin"},
|
||||
)
|
||||
test_username = f"nonadmin_{uuid4().hex[:8]}"
|
||||
integration_client.post(
|
||||
"/api/v1/admin/users",
|
||||
json={"username": test_username, "password": "password", "is_admin": False},
|
||||
)
|
||||
|
||||
# Login as non-admin
|
||||
integration_client.cookies.clear()
|
||||
integration_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": test_username, "password": "password"},
|
||||
)
|
||||
|
||||
# Try to access admin endpoints
|
||||
response = integration_client.get("/api/v1/admin/users")
|
||||
assert response.status_code == 403
|
||||
assert "Admin privileges required" in response.json()["detail"]
|
||||
Reference in New Issue
Block a user