Files
app-service/backend/app/api/v1/auth.py
matteoscrugli 8c4a555b88 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>
2025-12-17 22:27:32 +01:00

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)