Add comprehensive backend features and mobile UI improvements

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>
This commit is contained in:
2025-12-17 22:27:32 +01:00
parent f698aa4d51
commit 8c4a555b88
76 changed files with 9751 additions and 323 deletions

View File

@@ -2,11 +2,14 @@
from datetime import datetime, timedelta
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
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
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
@@ -14,9 +17,22 @@ 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
@@ -27,32 +43,54 @@ def register(
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",
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"
@@ -61,11 +99,44 @@ def register(
# 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,
@@ -76,11 +147,28 @@ def register(
)
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", response_model=schemas.Token)
@router.post("/login")
@limiter.limit("10/minute") # Stricter limit for login attempts
def login(
request: Request,
*,
db: Session = Depends(get_db),
credentials: schemas.LoginRequest
@@ -89,7 +177,11 @@ def login(
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,
@@ -98,6 +190,16 @@ def login(
)
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",
@@ -105,11 +207,70 @@ def login(
)
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)
@@ -122,6 +283,135 @@ def login(
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"
@@ -141,6 +431,41 @@ def read_users_me(
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)
@@ -163,3 +488,43 @@ def get_registration_status(
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)