Backend: - Add 2FA authentication with TOTP support - Add API keys management system - Add audit logging for security events - Add file upload/management system - Add notifications system with preferences - Add session management - Add webhooks integration - Add analytics endpoints - Add export functionality - Add password policy enforcement - Add new database migrations for core tables Frontend: - Add module position system (top/bottom sidebar sections) - Add search and notifications module configuration tabs - Add mobile logo replacing hamburger menu - Center page title absolutely when no tabs present - Align sidebar footer toggles with navigation items - Add lighter icon color in dark theme for mobile - Add API keys management page - Add notifications page with context - Add admin analytics and audit logs pages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
531 lines
16 KiB
Python
531 lines
16 KiB
Python
"""Authentication endpoints."""
|
|
|
|
from datetime import datetime, timedelta
|
|
from typing import Any
|
|
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
|
from fastapi.security import HTTPAuthorizationCredentials
|
|
from sqlalchemy.orm import Session
|
|
from slowapi import Limiter
|
|
from slowapi.util import get_remote_address
|
|
|
|
from app import crud, schemas
|
|
from app.dependencies import get_db, get_current_user, security
|
|
from app.core.security import create_access_token
|
|
from app.config import settings
|
|
from app.models.user import User
|
|
|
|
|
|
router = APIRouter()
|
|
|
|
# Rate limiter for auth endpoints
|
|
limiter = Limiter(key_func=get_remote_address)
|
|
|
|
|
|
def get_client_ip(request: Request) -> str:
|
|
"""Extract client IP from request, considering proxies."""
|
|
forwarded = request.headers.get("X-Forwarded-For")
|
|
if forwarded:
|
|
return forwarded.split(",")[0].strip()
|
|
return request.client.host if request.client else "unknown"
|
|
|
|
|
|
@router.post("/register", response_model=schemas.User, status_code=status.HTTP_201_CREATED)
|
|
@limiter.limit("5/minute") # Limit registration attempts
|
|
def register(
|
|
request: Request,
|
|
*,
|
|
db: Session = Depends(get_db),
|
|
user_in: schemas.RegisterRequest
|
|
) -> Any:
|
|
"""
|
|
Register a new user.
|
|
|
|
Creates a new user account with the provided credentials.
|
|
Registration can be disabled by administrators via settings.
|
|
"""
|
|
ip_address = get_client_ip(request)
|
|
user_agent = request.headers.get("User-Agent", "")[:500]
|
|
|
|
# Check if this is the first user (always allow for initial setup)
|
|
user_count = db.query(User).count()
|
|
is_first_user = user_count == 0
|
|
|
|
# If not the first user, check if registration is enabled
|
|
if not is_first_user:
|
|
registration_enabled_raw = crud.settings.get_setting_value(
|
|
db,
|
|
key="registration_enabled",
|
|
default=True # Default to enabled if setting doesn't exist
|
|
)
|
|
|
|
if isinstance(registration_enabled_raw, str):
|
|
registration_enabled = registration_enabled_raw.strip().lower() in ("true", "1")
|
|
else:
|
|
registration_enabled = bool(registration_enabled_raw)
|
|
|
|
if not registration_enabled:
|
|
# Log failed registration attempt
|
|
crud.audit_log.log_action(
|
|
db,
|
|
action="register",
|
|
resource_type="user",
|
|
details={"username": user_in.username, "reason": "registration_disabled"},
|
|
ip_address=ip_address,
|
|
user_agent=user_agent,
|
|
status="failure"
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="User registration is currently disabled"
|
|
)
|
|
|
|
# Check if username already exists
|
|
user = crud.user.get_by_username(db, username=user_in.username)
|
|
if user:
|
|
crud.audit_log.log_action(
|
|
db,
|
|
action="register",
|
|
resource_type="user",
|
|
details={"username": user_in.username, "reason": "username_exists"},
|
|
ip_address=ip_address,
|
|
user_agent=user_agent,
|
|
status="failure"
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Username already registered"
|
|
)
|
|
|
|
# Check if email already exists
|
|
user = crud.user.get_by_email(db, email=user_in.email)
|
|
if user:
|
|
crud.audit_log.log_action(
|
|
db,
|
|
action="register",
|
|
resource_type="user",
|
|
details={"username": user_in.username, "reason": "email_exists"},
|
|
ip_address=ip_address,
|
|
user_agent=user_agent,
|
|
status="failure"
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Email already registered"
|
|
)
|
|
|
|
# Validate password against policy
|
|
from app.core.password_policy import get_password_policy, validate_password
|
|
policy = get_password_policy(db)
|
|
password_errors = validate_password(
|
|
user_in.password,
|
|
policy=policy,
|
|
username=user_in.username,
|
|
email=user_in.email
|
|
)
|
|
if password_errors:
|
|
crud.audit_log.log_action(
|
|
db,
|
|
action="register",
|
|
resource_type="user",
|
|
details={"username": user_in.username, "reason": "weak_password"},
|
|
ip_address=ip_address,
|
|
user_agent=user_agent,
|
|
status="failure"
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail={"message": "Password does not meet requirements", "errors": password_errors}
|
|
)
|
|
|
|
# Create new user (first user becomes superuser)
|
|
user_create = schemas.UserCreate(
|
|
username=user_in.username,
|
|
email=user_in.email,
|
|
password=user_in.password,
|
|
is_active=True,
|
|
is_superuser=is_first_user # First user is superuser
|
|
)
|
|
|
|
user = crud.user.create(db, obj_in=user_create)
|
|
|
|
# Log successful registration
|
|
crud.audit_log.log_action(
|
|
db,
|
|
user_id=user.id,
|
|
username=user.username,
|
|
action="register",
|
|
resource_type="user",
|
|
resource_id=user.id,
|
|
details={"is_first_user": is_first_user},
|
|
ip_address=ip_address,
|
|
user_agent=user_agent,
|
|
status="success"
|
|
)
|
|
|
|
return user
|
|
|
|
|
|
@router.post("/login")
|
|
@limiter.limit("10/minute") # Stricter limit for login attempts
|
|
def login(
|
|
request: Request,
|
|
*,
|
|
db: Session = Depends(get_db),
|
|
credentials: schemas.LoginRequest
|
|
) -> Any:
|
|
"""
|
|
Login and get access token.
|
|
|
|
Authenticates user and returns a JWT access token.
|
|
If 2FA is enabled, returns requires_2fa=True with a temp_token.
|
|
"""
|
|
ip_address = get_client_ip(request)
|
|
user_agent = request.headers.get("User-Agent", "")[:500]
|
|
|
|
# Authenticate user
|
|
user = crud.user.authenticate(
|
|
db,
|
|
username=credentials.username,
|
|
password=credentials.password
|
|
)
|
|
|
|
if not user:
|
|
# Log failed login attempt
|
|
crud.audit_log.log_action(
|
|
db,
|
|
action="login",
|
|
resource_type="auth",
|
|
details={"username": credentials.username, "reason": "invalid_credentials"},
|
|
ip_address=ip_address,
|
|
user_agent=user_agent,
|
|
status="failure"
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Incorrect username or password",
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
|
|
if not crud.user.is_active(user):
|
|
# Log inactive user login attempt
|
|
crud.audit_log.log_action(
|
|
db,
|
|
user_id=user.id,
|
|
username=user.username,
|
|
action="login",
|
|
resource_type="auth",
|
|
details={"reason": "inactive_user"},
|
|
ip_address=ip_address,
|
|
user_agent=user_agent,
|
|
status="failure"
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Inactive user"
|
|
)
|
|
|
|
# Check if 2FA is enabled
|
|
if user.totp_enabled:
|
|
# If TOTP code provided, verify it
|
|
if credentials.totp_code:
|
|
from app.api.v1.two_factor import verify_totp_or_backup
|
|
if not verify_totp_or_backup(user, credentials.totp_code, db):
|
|
crud.audit_log.log_action(
|
|
db,
|
|
user_id=user.id,
|
|
username=user.username,
|
|
action="login",
|
|
resource_type="auth",
|
|
details={"reason": "invalid_2fa_code"},
|
|
ip_address=ip_address,
|
|
user_agent=user_agent,
|
|
status="failure"
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid 2FA code"
|
|
)
|
|
else:
|
|
# No code provided, return temp token for 2FA verification
|
|
temp_token_expires = timedelta(minutes=5) # 5 minute expiry
|
|
temp_token = create_access_token(
|
|
data={"sub": user.id, "temp": True, "purpose": "2fa"},
|
|
expires_delta=temp_token_expires
|
|
)
|
|
|
|
crud.audit_log.log_action(
|
|
db,
|
|
user_id=user.id,
|
|
username=user.username,
|
|
action="login_2fa_required",
|
|
resource_type="auth",
|
|
ip_address=ip_address,
|
|
user_agent=user_agent,
|
|
status="pending"
|
|
)
|
|
|
|
return {
|
|
"access_token": None,
|
|
"token_type": "bearer",
|
|
"requires_2fa": True,
|
|
"temp_token": temp_token
|
|
}
|
|
|
|
# Update last_login timestamp
|
|
user.last_login = datetime.utcnow()
|
|
db.add(user)
|
|
db.commit()
|
|
|
|
# Create access token
|
|
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
|
access_token = create_access_token(
|
|
data={"sub": user.id},
|
|
expires_delta=access_token_expires
|
|
)
|
|
|
|
# Track session for revocation / active sessions
|
|
session = crud.session.create(
|
|
db,
|
|
user_id=user.id,
|
|
token=access_token,
|
|
user_agent=user_agent,
|
|
ip_address=ip_address,
|
|
expires_at=datetime.utcnow() + access_token_expires,
|
|
)
|
|
crud.session.mark_as_current(db, session_id=session.id, user_id=user.id)
|
|
|
|
# Log successful login
|
|
crud.audit_log.log_action(
|
|
db,
|
|
user_id=user.id,
|
|
username=user.username,
|
|
action="login",
|
|
resource_type="auth",
|
|
details={"session_id": session.id},
|
|
ip_address=ip_address,
|
|
user_agent=user_agent,
|
|
status="success"
|
|
)
|
|
|
|
return {
|
|
"access_token": access_token,
|
|
"token_type": "bearer",
|
|
"requires_2fa": False,
|
|
"temp_token": None
|
|
}
|
|
|
|
|
|
@router.post("/verify-2fa", response_model=schemas.Token)
|
|
def verify_2fa_login(
|
|
request: Request,
|
|
*,
|
|
db: Session = Depends(get_db),
|
|
verify_request: schemas.Verify2FARequest
|
|
) -> Any:
|
|
"""
|
|
Complete login by verifying 2FA code.
|
|
|
|
Use the temp_token from the login response along with the TOTP code.
|
|
"""
|
|
from jose import JWTError
|
|
from app.core.security import decode_access_token
|
|
from app.api.v1.two_factor import verify_totp_or_backup
|
|
|
|
ip_address = get_client_ip(request)
|
|
user_agent = request.headers.get("User-Agent", "")[:500]
|
|
|
|
try:
|
|
payload = decode_access_token(verify_request.temp_token)
|
|
user_id = payload.get("sub")
|
|
is_temp = payload.get("temp")
|
|
purpose = payload.get("purpose")
|
|
|
|
if not user_id or not is_temp or purpose != "2fa":
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid temporary token"
|
|
)
|
|
except JWTError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid or expired temporary token"
|
|
)
|
|
|
|
user = crud.user.get(db, id=user_id)
|
|
if not user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="User not found"
|
|
)
|
|
|
|
# Verify TOTP code
|
|
if not verify_totp_or_backup(user, verify_request.code, db):
|
|
crud.audit_log.log_action(
|
|
db,
|
|
user_id=user.id,
|
|
username=user.username,
|
|
action="login_2fa_verify",
|
|
resource_type="auth",
|
|
details={"reason": "invalid_code"},
|
|
ip_address=ip_address,
|
|
user_agent=user_agent,
|
|
status="failure"
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid 2FA code"
|
|
)
|
|
|
|
# Update last_login timestamp
|
|
user.last_login = datetime.utcnow()
|
|
db.add(user)
|
|
db.commit()
|
|
|
|
# Create full access token
|
|
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
|
access_token = create_access_token(
|
|
data={"sub": user.id},
|
|
expires_delta=access_token_expires
|
|
)
|
|
|
|
# Track session for revocation / active sessions
|
|
session = crud.session.create(
|
|
db,
|
|
user_id=user.id,
|
|
token=access_token,
|
|
user_agent=user_agent,
|
|
ip_address=ip_address,
|
|
expires_at=datetime.utcnow() + access_token_expires,
|
|
)
|
|
crud.session.mark_as_current(db, session_id=session.id, user_id=user.id)
|
|
|
|
# Log successful login
|
|
crud.audit_log.log_action(
|
|
db,
|
|
user_id=user.id,
|
|
username=user.username,
|
|
action="login",
|
|
resource_type="auth",
|
|
details={"method": "2fa", "session_id": session.id},
|
|
ip_address=ip_address,
|
|
user_agent=user_agent,
|
|
status="success"
|
|
)
|
|
|
|
return {
|
|
"access_token": access_token,
|
|
"token_type": "bearer"
|
|
}
|
|
|
|
|
|
@router.get("/me", response_model=schemas.User)
|
|
def read_users_me(
|
|
current_user: User = Depends(get_current_user)
|
|
) -> Any:
|
|
"""
|
|
Get current user.
|
|
|
|
Returns the currently authenticated user's information.
|
|
Requires a valid JWT token in the Authorization header.
|
|
"""
|
|
return current_user
|
|
|
|
|
|
@router.post("/logout")
|
|
def logout(
|
|
request: Request,
|
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
) -> Any:
|
|
"""
|
|
Logout by revoking the current session.
|
|
|
|
This invalidates the current access token server-side (session revocation).
|
|
"""
|
|
ip_address = get_client_ip(request)
|
|
user_agent = request.headers.get("User-Agent", "")[:500]
|
|
token = credentials.credentials
|
|
|
|
session = crud.session.get_by_token(db, token)
|
|
if session:
|
|
crud.session.revoke(db, id=session.id, user_id=current_user.id)
|
|
|
|
crud.audit_log.log_action(
|
|
db,
|
|
user_id=current_user.id,
|
|
username=current_user.username,
|
|
action="logout",
|
|
resource_type="auth",
|
|
details={"session_id": session.id if session else None},
|
|
ip_address=ip_address,
|
|
user_agent=user_agent,
|
|
status="success",
|
|
)
|
|
|
|
return {"message": "Logged out"}
|
|
|
|
|
|
@router.get("/registration-status")
|
|
def get_registration_status(
|
|
db: Session = Depends(get_db)
|
|
) -> Any:
|
|
"""
|
|
Check if user registration is enabled.
|
|
|
|
This is a public endpoint that doesn't require authentication.
|
|
Returns the registration_enabled setting value.
|
|
"""
|
|
registration_enabled_raw = crud.settings.get_setting_value(
|
|
db,
|
|
key="registration_enabled",
|
|
default=True
|
|
)
|
|
|
|
if isinstance(registration_enabled_raw, str):
|
|
registration_enabled = registration_enabled_raw.strip().lower() in ("true", "1")
|
|
else:
|
|
registration_enabled = bool(registration_enabled_raw)
|
|
|
|
return {"registration_enabled": registration_enabled}
|
|
|
|
|
|
@router.get("/password-requirements")
|
|
def get_password_requirements(
|
|
db: Session = Depends(get_db)
|
|
) -> Any:
|
|
"""
|
|
Get password requirements/policy.
|
|
|
|
This is a public endpoint that returns the password policy
|
|
for display during registration.
|
|
"""
|
|
from app.core.password_policy import get_password_policy, get_password_requirements as get_reqs
|
|
|
|
policy = get_password_policy(db)
|
|
return get_reqs(policy)
|
|
|
|
|
|
@router.post("/check-password-strength")
|
|
def check_password_strength(
|
|
password_data: dict,
|
|
db: Session = Depends(get_db)
|
|
) -> Any:
|
|
"""
|
|
Check password strength.
|
|
|
|
This is a public endpoint that returns password strength analysis
|
|
for real-time feedback during registration.
|
|
"""
|
|
from app.core.password_policy import check_password_strength as check_strength
|
|
|
|
password = password_data.get("password", "")
|
|
if not password:
|
|
return {
|
|
"score": 0,
|
|
"level": "weak",
|
|
"feedback": ["Password is required"]
|
|
}
|
|
|
|
return check_strength(password)
|