Files
pvemanager/backend/app/api/auth.py
Dilshod Rasulov de967c35e0 refactor(rbac): migrate API permission checks to resource:action format
Replace all legacy dotted permission codes (vms.view, proxmox.manage, ...)
in PermissionChecker/has_permission across app/api with the new
resource:action format (vm:view, server:manage, ...), per LEGACY_PERMISSION_MAP.
All 33 codes used by the API are now registered in the permission registry.

Also switch the /api/auth/me admin branch to get_all_permissions_v2 so the
endpoint emits permissions in the new format only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 02:07:48 +05:00

485 lines
16 KiB
Python

from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, status, Request, Form
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from loguru import logger
from ..db import get_db
from ..models import User
from ..schemas import UserCreate, UserResponse, LoginRequest, Token
from ..auth import (
get_password_hash,
verify_password,
create_access_token,
get_current_user,
get_current_active_admin,
get_client_ip,
get_current_session_token
)
from ..config import settings, utcnow
from ..logging_service import LoggingService
from ..services.security_service import SecurityService
router = APIRouter()
@router.post("/api/auth/login", response_model=Token)
def login(login_data: LoginRequest, request: Request, db: Session = Depends(get_db)):
"""
Authenticate user and return JWT token with session management
"""
# Get client info
client_ip = get_client_ip(request)
user_agent = request.headers.get("user-agent", "")[:500]
# Check if IP is blocked
is_blocked, block_reason = SecurityService.is_ip_blocked(db, client_ip)
if is_blocked:
logger.warning(f"Blocked IP {client_ip} attempted login: {block_reason}")
SecurityService.record_login_attempt(
db, client_ip, login_data.username, False, "IP blocked", user_agent
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Access denied: {block_reason}"
)
# Check for brute force
should_block, reason = SecurityService.check_brute_force(db, client_ip, login_data.username)
if should_block:
block_duration = SecurityService.get_setting_int(db, "ip_block_duration_minutes", 60)
SecurityService.block_ip(db, client_ip, reason, "system", block_duration)
SecurityService.record_login_attempt(
db, client_ip, login_data.username, False, "Brute force detected", user_agent
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Too many failed attempts. IP blocked temporarily."
)
# Find user
user = db.query(User).filter(User.username == login_data.username).first()
if not user:
SecurityService.record_login_attempt(
db, client_ip, login_data.username, False, "User not found", user_agent
)
LoggingService.log_auth(
db=db,
action="login_failed",
username=login_data.username,
ip_address=client_ip,
success=False,
error_message="User not found",
user_agent=user_agent
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
# Check if account is locked
is_locked, locked_until = SecurityService.is_account_locked(user)
if is_locked:
SecurityService.record_login_attempt(
db, client_ip, login_data.username, False, "Account locked", user_agent
)
LoggingService.log_auth(
db=db,
action="login_blocked",
username=login_data.username,
ip_address=client_ip,
success=False,
error_message="Account locked",
user_agent=user_agent,
user_id=user.id
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Account is locked until {locked_until.strftime('%H:%M:%S')}"
)
# Verify password
if not verify_password(login_data.password, user.hashed_password):
SecurityService.record_login_attempt(
db, client_ip, login_data.username, False, "Wrong password", user_agent
)
# Increment failed attempts
was_locked = SecurityService.increment_failed_attempts(db, user)
LoggingService.log_auth(
db=db,
action="login_failed",
username=login_data.username,
ip_address=client_ip,
success=False,
error_message="Incorrect password" + (" - account locked" if was_locked else ""),
user_agent=user_agent,
user_id=user.id
)
if was_locked:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account locked due to too many failed attempts"
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
# Check if user is active
if not user.is_active:
SecurityService.record_login_attempt(
db, client_ip, login_data.username, False, "Account disabled", user_agent
)
LoggingService.log_auth(
db=db,
action="login_blocked",
username=login_data.username,
ip_address=client_ip,
success=False,
error_message="Account disabled",
user_agent=user_agent,
user_id=user.id
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is disabled"
)
# SUCCESS - Reset failed attempts
SecurityService.reset_failed_attempts(db, user)
# Create session
session, was_other_terminated = SecurityService.create_session(
db, user, client_ip, user_agent
)
# Update last login
user.last_login = utcnow()
db.commit()
# Create access token with session binding
session_timeout = SecurityService.get_setting_int(db, "session_timeout_minutes", 60)
access_token_expires = timedelta(minutes=session_timeout)
access_token = create_access_token(
data={"sub": user.username},
session_token=session.session_token,
expires_delta=access_token_expires
)
# Record successful login
SecurityService.record_login_attempt(
db, client_ip, login_data.username, True, None, user_agent
)
# Log successful login
LoggingService.log_auth(
db=db,
action="login",
username=user.username,
ip_address=client_ip,
success=True,
user_agent=user_agent,
user_id=user.id,
details={"session_id": session.id, "other_session_terminated": was_other_terminated}
)
logger.info(f"User {user.username} logged in from {client_ip}" +
(" (other session terminated)" if was_other_terminated else ""))
return {
"access_token": access_token,
"token_type": "bearer",
"session_id": session.id,
"other_session_terminated": was_other_terminated
}
@router.post("/api/auth/refresh", response_model=Token)
def refresh_token(
current_user: User = Depends(get_current_user),
session_token: str = Depends(get_current_session_token),
db: Session = Depends(get_db)
):
"""Refresh JWT access token without re-login (extends session)"""
session_timeout = SecurityService.get_setting_int(db, "session_timeout_minutes", 60)
access_token_expires = timedelta(minutes=session_timeout)
new_token = create_access_token(
data={"sub": current_user.username},
session_token=session_token,
expires_delta=access_token_expires
)
return {
"access_token": new_token,
"token_type": "bearer"
}
@router.post("/api/auth/logout")
def logout(
request: Request,
current_user: User = Depends(get_current_user),
session_token: str = Depends(get_current_session_token),
db: Session = Depends(get_db)
):
"""Logout user and terminate current session only"""
client_ip = get_client_ip(request)
# Terminate only current session (allow multiple sessions)
if session_token:
SecurityService.terminate_session(db, session_token)
LoggingService.log_auth(
db=db,
action="logout",
username=current_user.username,
ip_address=client_ip,
success=True,
user_id=current_user.id
)
logger.info(f"User {current_user.username} logged out")
return {"message": "Successfully logged out"}
@router.get("/api/auth/me")
def get_current_user_info(current_user: User = Depends(get_current_user)):
"""Get current user information with permissions"""
permissions = {}
if current_user.role:
permissions = current_user.role.permissions or {}
elif current_user.is_admin:
# Admin has all permissions (new resource:action format)
from ..services.security_service import SecurityService
all_perms = SecurityService.get_all_permissions_v2()
for category, perms in all_perms.items():
for perm in perms:
permissions[perm] = True
return {
"id": current_user.id,
"username": current_user.username,
"email": current_user.email,
"full_name": current_user.full_name,
"is_admin": current_user.is_admin,
"is_active": current_user.is_active,
"role": {
"id": current_user.role.id if current_user.role else None,
"name": current_user.role.name if current_user.role else ("admin" if current_user.is_admin else "user"),
"display_name": current_user.role.display_name if current_user.role else ("Administrator" if current_user.is_admin else "User")
},
"permissions": permissions,
"require_password_change": current_user.require_password_change,
"last_login": current_user.last_login.isoformat() if current_user.last_login else None,
"created_at": current_user.created_at.isoformat() if current_user.created_at else None
}
@router.get("/api/auth/sessions")
def get_my_sessions(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
"""Get current user's active sessions"""
sessions = SecurityService.get_user_sessions(db, current_user.id)
return [{
"id": s.id,
"ip_address": s.ip_address,
"device_info": s.device_info,
"created_at": s.created_at.isoformat() if s.created_at else None,
"last_activity": s.last_activity.isoformat() if s.last_activity else None,
"expires_at": s.expires_at.isoformat() if s.expires_at else None
} for s in sessions]
@router.post("/api/auth/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def register_user(
user_data: UserCreate,
request: Request,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_active_admin)
):
"""
Register a new user (admin only)
"""
client_ip = get_client_ip(request)
# Check if username already exists
if db.query(User).filter(User.username == user_data.username).first():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered"
)
# Check if email already exists
if db.query(User).filter(User.email == user_data.email).first():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
# Validate password
lang = request.cookies.get("language", "en")
is_valid, errors = SecurityService.validate_password(db, user_data.password, lang)
if not is_valid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="; ".join(errors)
)
# Create new user
hashed_password = get_password_hash(user_data.password)
new_user = User(
username=user_data.username,
email=user_data.email,
full_name=user_data.full_name,
hashed_password=hashed_password,
is_active=True,
is_admin=False,
last_password_change=utcnow()
)
db.add(new_user)
db.commit()
db.refresh(new_user)
LoggingService.log_auth(
db=db,
action="user_created",
username=current_admin.username,
ip_address=client_ip,
success=True,
user_id=current_admin.id,
details={"new_user": new_user.username}
)
logger.info(f"New user registered: {new_user.username} by admin {current_admin.username}")
return new_user
@router.get("/api/users", response_model=list[UserResponse])
def list_users(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_active_admin)
):
"""
List all users (admin only)
"""
users = db.query(User).all()
return users
@router.delete("/api/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(
user_id: int,
request: Request,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_active_admin)
):
"""
Delete a user (admin only)
"""
client_ip = get_client_ip(request)
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Prevent deleting yourself
if user.id == current_admin.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete your own account"
)
username = user.username
# Terminate sessions before deletion
SecurityService.terminate_all_user_sessions(db, user_id)
db.delete(user)
db.commit()
LoggingService.log_auth(
db=db,
action="user_deleted",
username=current_admin.username,
ip_address=client_ip,
success=True,
user_id=current_admin.id,
details={"deleted_user": username}
)
logger.info(f"User {username} deleted by admin {current_admin.username}")
return None
@router.post("/api/auth/change-password")
# Note: no explicit CSRF token needed here.
# This endpoint is protected by JWT Bearer token (Authorization header).
# Browsers cannot automatically include the Authorization header in cross-site requests,
# so CSRF attacks are not possible — the attacker cannot obtain or inject the token.
def change_password(
old_password: str = Form(...),
new_password: str = Form(...),
request: Request = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
Change user password
"""
client_ip = get_client_ip(request) if request else "unknown"
# Verify old password
if not verify_password(old_password, current_user.hashed_password):
LoggingService.log_auth(
db=db,
action="password_change_failed",
username=current_user.username,
ip_address=client_ip,
success=False,
error_message="Incorrect current password",
user_id=current_user.id
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Incorrect password"
)
# Validate new password
lang = request.cookies.get("language", "en")
is_valid, errors = SecurityService.validate_password(db, new_password, lang)
if not is_valid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="; ".join(errors)
)
# Update password
current_user.hashed_password = get_password_hash(new_password)
current_user.last_password_change = utcnow()
current_user.require_password_change = False
db.commit()
LoggingService.log_auth(
db=db,
action="password_changed",
username=current_user.username,
ip_address=client_ip,
success=True,
user_id=current_user.id
)
logger.info(f"User {current_user.username} changed password")
return {"message": "Password changed successfully"}