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