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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user