Backend:
- POST /api/auth/impersonate/{user_id} — только для админа, выдаёт JWT
с клеймом impersonator; запрещает вход под собой, отключённым,
другим админом и вложенную имперсонацию
- POST /api/auth/stop-impersonate — по клейму impersonator завершает
сессию имперсонации и перевыпускает токен исходного админа
- /api/auth/me отдаёт поле impersonator для баннера в SPA
- аудит-лог: impersonate_start / impersonate_stop
Frontend:
- auth-store: действия impersonate/stopImpersonate со сбросом кэша
React Query при смене токена
- UsersPage: кнопка "войти под пользователем" (только админу,
скрыта для себя/админов/неактивных и во время имперсонации)
- AppLayout: баннер имперсонации с кнопкой "вернуться к администратору"
- локали ru/en
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1008 lines
36 KiB
Python
1008 lines
36 KiB
Python
import hashlib
|
||
import secrets
|
||
from datetime import datetime, timedelta
|
||
from fastapi import APIRouter, Depends, HTTPException, status, Request, Form, BackgroundTasks
|
||
from fastapi.responses import RedirectResponse
|
||
from sqlalchemy.orm import Session
|
||
from loguru import logger
|
||
|
||
from ..db import get_db
|
||
from ..models import User, PasswordResetToken
|
||
from ..schemas import (
|
||
UserCreate, UserResponse, LoginRequest, Token,
|
||
ForgotPasswordRequest, ResetPasswordRequest,
|
||
TwoFactorStatusResponse, TwoFactorSetupResponse,
|
||
TwoFactorEnableRequest, TwoFactorEnableResponse,
|
||
TwoFactorDisableRequest, MessageResponse,
|
||
)
|
||
from ..services import two_factor_service as tfa
|
||
from ..auth import (
|
||
get_password_hash,
|
||
verify_password,
|
||
create_access_token,
|
||
decode_access_token,
|
||
get_current_user,
|
||
get_current_active_admin,
|
||
get_client_ip,
|
||
get_current_session_token,
|
||
security,
|
||
)
|
||
from fastapi.security import HTTPAuthorizationCredentials
|
||
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"
|
||
)
|
||
|
||
# Two-factor authentication gate (after password + active checks).
|
||
# Password is correct at this point; require the second factor before
|
||
# issuing a token or creating a session.
|
||
if user.two_factor_enabled:
|
||
if not login_data.code:
|
||
return {"two_factor_required": True, "token_type": "bearer"}
|
||
|
||
from ..services import two_factor_service as tfa
|
||
code_ok = tfa.verify_totp(user.two_factor_secret, login_data.code)
|
||
if not code_ok:
|
||
backup = list(user.two_factor_backup_codes or [])
|
||
if tfa.consume_backup_code(login_data.code, backup):
|
||
user.two_factor_backup_codes = backup
|
||
db.commit()
|
||
code_ok = True
|
||
|
||
if not code_ok:
|
||
SecurityService.record_login_attempt(
|
||
db, client_ip, login_data.username, False, "Invalid 2FA code", user_agent
|
||
)
|
||
LoggingService.log_auth(
|
||
db=db,
|
||
action="login_failed",
|
||
username=login_data.username,
|
||
ip_address=client_ip,
|
||
success=False,
|
||
error_message="Invalid two-factor code",
|
||
user_agent=user_agent,
|
||
user_id=user.id,
|
||
)
|
||
# Use 400 (not 401) so the SPA's global 401 handler does not force a
|
||
# redirect/reload and wipe the login form + error message.
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="Invalid two-factor code",
|
||
)
|
||
|
||
# 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"}
|
||
|
||
|
||
# ==================== Impersonation ====================
|
||
|
||
|
||
def _user_is_admin(user: User) -> bool:
|
||
"""Whether the user has administrator privileges (flag or admin role)."""
|
||
return bool(user.is_admin or (user.role and user.role.name == "admin"))
|
||
|
||
|
||
@router.post("/api/auth/impersonate/{user_id}", response_model=Token)
|
||
def impersonate_user(
|
||
user_id: int,
|
||
request: Request,
|
||
admin: User = Depends(get_current_active_admin),
|
||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""Issue a token acting as another user, keeping a link back to the admin.
|
||
|
||
Only administrators may impersonate. The returned JWT carries an
|
||
``impersonator`` claim (the admin's username) so ``/stop-impersonate`` can
|
||
restore the original admin session. Nested impersonation is rejected.
|
||
"""
|
||
# Reject nested impersonation: the acting admin must not already be
|
||
# impersonating someone else.
|
||
payload = decode_access_token(credentials.credentials)
|
||
if payload.get("impersonator"):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="Already impersonating. Return to your admin account first.",
|
||
)
|
||
|
||
target = db.query(User).filter(User.id == user_id).first()
|
||
if not target:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||
if target.id == admin.id:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot impersonate yourself"
|
||
)
|
||
if not target.is_active:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot impersonate a disabled user"
|
||
)
|
||
if _user_is_admin(target):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_403_FORBIDDEN, detail="Cannot impersonate another administrator"
|
||
)
|
||
|
||
client_ip = get_client_ip(request)
|
||
user_agent = request.headers.get("user-agent", "")[:500]
|
||
|
||
session, _ = SecurityService.create_session(db, target, client_ip, user_agent)
|
||
|
||
session_timeout = SecurityService.get_setting_int(db, "session_timeout_minutes", 60)
|
||
access_token = create_access_token(
|
||
data={"sub": target.username, "impersonator": admin.username},
|
||
session_token=session.session_token,
|
||
expires_delta=timedelta(minutes=session_timeout),
|
||
)
|
||
|
||
LoggingService.log_auth(
|
||
db=db,
|
||
action="impersonate_start",
|
||
username=admin.username,
|
||
ip_address=client_ip,
|
||
success=True,
|
||
user_agent=user_agent,
|
||
user_id=admin.id,
|
||
details={"target_user_id": target.id, "target_username": target.username},
|
||
)
|
||
logger.warning(f"Admin {admin.username} started impersonating {target.username}")
|
||
|
||
return {"access_token": access_token, "token_type": "bearer"}
|
||
|
||
|
||
@router.post("/api/auth/stop-impersonate", response_model=Token)
|
||
def stop_impersonating(
|
||
request: Request,
|
||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""Return to the original admin account after impersonating a user."""
|
||
payload = decode_access_token(credentials.credentials)
|
||
impersonator_username = payload.get("impersonator")
|
||
if not impersonator_username:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST, detail="Not currently impersonating"
|
||
)
|
||
|
||
admin = db.query(User).filter(User.username == impersonator_username).first()
|
||
if not admin or not admin.is_active or not _user_is_admin(admin):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_403_FORBIDDEN,
|
||
detail="Original administrator account is no longer available",
|
||
)
|
||
|
||
# Terminate the impersonation session so it does not linger.
|
||
impersonation_session = payload.get("session")
|
||
if impersonation_session:
|
||
SecurityService.terminate_session(db, impersonation_session)
|
||
|
||
client_ip = get_client_ip(request)
|
||
user_agent = request.headers.get("user-agent", "")[:500]
|
||
|
||
session, _ = SecurityService.create_session(db, admin, client_ip, user_agent)
|
||
|
||
session_timeout = SecurityService.get_setting_int(db, "session_timeout_minutes", 60)
|
||
access_token = create_access_token(
|
||
data={"sub": admin.username},
|
||
session_token=session.session_token,
|
||
expires_delta=timedelta(minutes=session_timeout),
|
||
)
|
||
|
||
LoggingService.log_auth(
|
||
db=db,
|
||
action="impersonate_stop",
|
||
username=admin.username,
|
||
ip_address=client_ip,
|
||
success=True,
|
||
user_agent=user_agent,
|
||
user_id=admin.id,
|
||
details={"impersonated_username": payload.get("sub")},
|
||
)
|
||
logger.warning(f"Admin {admin.username} stopped impersonating {payload.get('sub')}")
|
||
|
||
return {"access_token": access_token, "token_type": "bearer"}
|
||
|
||
|
||
@router.get("/api/auth/me")
|
||
def get_current_user_info(
|
||
current_user: User = Depends(get_current_user),
|
||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||
):
|
||
"""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
|
||
|
||
# Expose the impersonator (original admin username) so the SPA can render a
|
||
# "return to admin" banner while acting as another user.
|
||
impersonator = None
|
||
try:
|
||
impersonator = decode_access_token(credentials.credentials).get("impersonator")
|
||
except HTTPException:
|
||
impersonator = None
|
||
|
||
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,
|
||
"impersonator": impersonator,
|
||
"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,
|
||
"two_factor_enabled": current_user.two_factor_enabled,
|
||
"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]
|
||
|
||
|
||
# ==================== Two-Factor Authentication ====================
|
||
|
||
@router.get("/api/auth/2fa/status", response_model=TwoFactorStatusResponse)
|
||
def two_factor_status(current_user: User = Depends(get_current_user)):
|
||
"""Return whether 2FA is enabled and how many backup codes remain."""
|
||
remaining = len(current_user.two_factor_backup_codes or []) if current_user.two_factor_enabled else 0
|
||
return TwoFactorStatusResponse(
|
||
enabled=bool(current_user.two_factor_enabled),
|
||
backup_codes_remaining=remaining,
|
||
)
|
||
|
||
|
||
@router.post("/api/auth/2fa/setup", response_model=TwoFactorSetupResponse)
|
||
def two_factor_setup(
|
||
current_user: User = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""Begin 2FA enrollment: generate a fresh secret and QR code.
|
||
|
||
The secret is stored but 2FA is NOT active until confirmed via /enable.
|
||
"""
|
||
if current_user.two_factor_enabled:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA is already enabled")
|
||
|
||
secret = tfa.generate_secret()
|
||
current_user.two_factor_secret = secret
|
||
db.commit()
|
||
|
||
uri = tfa.build_otpauth_uri(secret, current_user.username)
|
||
return TwoFactorSetupResponse(secret=secret, otpauth_uri=uri, qr_svg=tfa.qr_svg(uri))
|
||
|
||
|
||
@router.post("/api/auth/2fa/enable", response_model=TwoFactorEnableResponse)
|
||
def two_factor_enable(
|
||
data: TwoFactorEnableRequest,
|
||
request: Request,
|
||
current_user: User = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""Confirm enrollment by verifying a TOTP code; return one-time backup codes."""
|
||
if current_user.two_factor_enabled:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA is already enabled")
|
||
if not current_user.two_factor_secret:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Run 2FA setup first")
|
||
if not tfa.verify_totp(current_user.two_factor_secret, data.code):
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid verification code")
|
||
|
||
backup_codes = tfa.generate_backup_codes()
|
||
current_user.two_factor_backup_codes = tfa.hash_backup_codes(backup_codes)
|
||
current_user.two_factor_enabled = True
|
||
db.commit()
|
||
|
||
LoggingService.log_auth(
|
||
db=db, action="2fa_enabled", username=current_user.username,
|
||
ip_address=get_client_ip(request), success=True, user_id=current_user.id,
|
||
)
|
||
logger.info(f"User {current_user.username} enabled 2FA")
|
||
return TwoFactorEnableResponse(enabled=True, backup_codes=backup_codes)
|
||
|
||
|
||
@router.post("/api/auth/2fa/disable", response_model=MessageResponse)
|
||
def two_factor_disable(
|
||
data: TwoFactorDisableRequest,
|
||
request: Request,
|
||
current_user: User = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""Disable 2FA. Requires the account password (and current code if enabled)."""
|
||
if not verify_password(data.password, current_user.hashed_password):
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Incorrect password")
|
||
|
||
if current_user.two_factor_enabled:
|
||
code_ok = tfa.verify_totp(current_user.two_factor_secret, data.code or "")
|
||
if not code_ok:
|
||
backup = list(current_user.two_factor_backup_codes or [])
|
||
code_ok = tfa.consume_backup_code(data.code or "", backup)
|
||
if not code_ok:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid two-factor code")
|
||
|
||
current_user.two_factor_enabled = False
|
||
current_user.two_factor_secret = None
|
||
current_user.two_factor_backup_codes = None
|
||
db.commit()
|
||
|
||
LoggingService.log_auth(
|
||
db=db, action="2fa_disabled", username=current_user.username,
|
||
ip_address=get_client_ip(request), success=True, user_id=current_user.id,
|
||
)
|
||
logger.info(f"User {current_user.username} disabled 2FA")
|
||
return MessageResponse(message="Two-factor authentication disabled")
|
||
|
||
|
||
@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"}
|
||
|
||
|
||
# ==================== Forgot / Reset Password (self-service) ====================
|
||
|
||
# Generic response returned by /forgot-password regardless of whether the email
|
||
# exists — prevents account enumeration.
|
||
_FORGOT_PASSWORD_GENERIC = {
|
||
"message": "If an account with that email exists, a password reset link has been sent."
|
||
}
|
||
|
||
|
||
def _hash_reset_token(raw_token: str) -> str:
|
||
"""Hash a raw reset token for storage/lookup (only the hash is persisted)."""
|
||
return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
|
||
|
||
|
||
async def _send_reset_email(to_email: str, display_name: str, lang: str, link: str, expire_minutes: int) -> None:
|
||
"""Send the password-reset email via the configured SMTP channel.
|
||
|
||
Runs as a FastAPI background task so the HTTP response time does not leak
|
||
whether the address exists.
|
||
"""
|
||
try:
|
||
from ..services.notification_channels import get_email_channel, get_panel_name
|
||
panel = get_panel_name()
|
||
name = display_name or to_email
|
||
|
||
if (lang or "").startswith("ru"):
|
||
subject = f"{panel}: восстановление пароля"
|
||
body = (
|
||
f"Здравствуйте, {name}!\n\n"
|
||
f"Поступил запрос на сброс пароля для вашего аккаунта в {panel}.\n"
|
||
f"Чтобы задать новый пароль, перейдите по ссылке (действует {expire_minutes} мин.):\n{link}\n\n"
|
||
f"Если вы не запрашивали сброс пароля, просто проигнорируйте это письмо — "
|
||
f"ваш текущий пароль останется без изменений."
|
||
)
|
||
btn = "Сбросить пароль"
|
||
note = f"Ссылка действительна {expire_minutes} минут. Если вы не запрашивали сброс — проигнорируйте письмо."
|
||
else:
|
||
subject = f"{panel}: password reset"
|
||
body = (
|
||
f"Hello, {name}!\n\n"
|
||
f"We received a request to reset the password for your {panel} account.\n"
|
||
f"To set a new password, open this link (valid for {expire_minutes} min):\n{link}\n\n"
|
||
f"If you did not request a password reset, you can safely ignore this email — "
|
||
f"your current password will remain unchanged."
|
||
)
|
||
btn = "Reset password"
|
||
note = f"This link is valid for {expire_minutes} minutes. If you didn't request a reset, ignore this email."
|
||
|
||
html_body = f"""
|
||
<div style="font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;max-width:480px;margin:0 auto;color:#1a1a1a">
|
||
<h2 style="font-size:20px;margin:0 0 16px">{panel}</h2>
|
||
<p style="font-size:15px;line-height:1.5;margin:0 0 20px">{body.splitlines()[0]}</p>
|
||
<p style="margin:0 0 24px">
|
||
<a href="{link}" style="display:inline-block;background:#4f46e5;color:#fff;text-decoration:none;
|
||
padding:10px 20px;border-radius:8px;font-size:15px;font-weight:600">{btn}</a>
|
||
</p>
|
||
<p style="font-size:13px;color:#666;line-height:1.5;margin:0 0 8px">{note}</p>
|
||
<p style="font-size:12px;color:#999;word-break:break-all;margin:0">{link}</p>
|
||
</div>
|
||
"""
|
||
|
||
channel = get_email_channel()
|
||
ok = await channel.send(to_email, subject, body, html_body)
|
||
if ok:
|
||
logger.info(f"Password reset email sent to {to_email}")
|
||
else:
|
||
logger.warning(f"Password reset email NOT sent to {to_email} (SMTP not configured or send failed)")
|
||
except Exception as e:
|
||
logger.error(f"Failed to send password reset email to {to_email}: {e}")
|
||
|
||
|
||
@router.post("/api/auth/forgot-password")
|
||
def forgot_password(
|
||
payload: ForgotPasswordRequest,
|
||
request: Request,
|
||
background: BackgroundTasks,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""
|
||
Start the self-service password reset flow.
|
||
|
||
Always returns a generic message (no account enumeration). When the email
|
||
belongs to an active user, a one-time reset link is emailed via SMTP.
|
||
"""
|
||
client_ip = get_client_ip(request)
|
||
email = payload.email.strip()
|
||
|
||
user = db.query(User).filter(User.email.ilike(email)).first()
|
||
|
||
if not user or not user.is_active:
|
||
LoggingService.log_auth(
|
||
db=db,
|
||
action="password_reset_requested",
|
||
username=email,
|
||
ip_address=client_ip,
|
||
success=False,
|
||
error_message="Unknown or inactive email",
|
||
)
|
||
return _FORGOT_PASSWORD_GENERIC
|
||
|
||
# Throttle: ignore repeat requests within 60s to avoid email spam
|
||
recent = (
|
||
db.query(PasswordResetToken)
|
||
.filter(
|
||
PasswordResetToken.user_id == user.id,
|
||
PasswordResetToken.created_at >= utcnow() - timedelta(seconds=60),
|
||
)
|
||
.first()
|
||
)
|
||
if recent:
|
||
return _FORGOT_PASSWORD_GENERIC
|
||
|
||
# Invalidate any previously issued (unused) tokens for this user
|
||
db.query(PasswordResetToken).filter(
|
||
PasswordResetToken.user_id == user.id,
|
||
PasswordResetToken.used_at.is_(None),
|
||
).update({PasswordResetToken.used_at: utcnow()}, synchronize_session=False)
|
||
|
||
raw_token = secrets.token_urlsafe(32)
|
||
expire_minutes = settings.PASSWORD_RESET_TOKEN_EXPIRE_MINUTES
|
||
reset = PasswordResetToken(
|
||
user_id=user.id,
|
||
token_hash=_hash_reset_token(raw_token),
|
||
expires_at=utcnow() + timedelta(minutes=expire_minutes),
|
||
ip_address=client_ip,
|
||
)
|
||
db.add(reset)
|
||
db.commit()
|
||
|
||
# Build the reset link from the request origin (SPA and API share an origin)
|
||
origin = request.headers.get("origin") or str(request.base_url).rstrip("/")
|
||
link = f"{origin}/reset-password?token={raw_token}"
|
||
|
||
background.add_task(
|
||
_send_reset_email,
|
||
user.email,
|
||
user.full_name or user.username,
|
||
user.language or "ru",
|
||
link,
|
||
expire_minutes,
|
||
)
|
||
|
||
LoggingService.log_auth(
|
||
db=db,
|
||
action="password_reset_requested",
|
||
username=user.username,
|
||
ip_address=client_ip,
|
||
success=True,
|
||
user_id=user.id,
|
||
)
|
||
logger.info(f"Password reset requested for {user.username} from {client_ip}")
|
||
|
||
return _FORGOT_PASSWORD_GENERIC
|
||
|
||
|
||
@router.get("/api/auth/reset-password/validate")
|
||
def validate_reset_token(token: str, db: Session = Depends(get_db)):
|
||
"""Check whether a reset token is still valid (for the reset form to gate UI)."""
|
||
reset = (
|
||
db.query(PasswordResetToken)
|
||
.filter(PasswordResetToken.token_hash == _hash_reset_token(token))
|
||
.first()
|
||
)
|
||
return {"valid": bool(reset and reset.is_valid())}
|
||
|
||
|
||
@router.post("/api/auth/reset-password")
|
||
def reset_password(
|
||
payload: ResetPasswordRequest,
|
||
request: Request,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""Complete the reset flow: validate the one-time token and set a new password."""
|
||
client_ip = get_client_ip(request)
|
||
|
||
reset = (
|
||
db.query(PasswordResetToken)
|
||
.filter(PasswordResetToken.token_hash == _hash_reset_token(payload.token))
|
||
.first()
|
||
)
|
||
|
||
if not reset or not reset.is_valid():
|
||
LoggingService.log_auth(
|
||
db=db,
|
||
action="password_reset_failed",
|
||
username="unknown",
|
||
ip_address=client_ip,
|
||
success=False,
|
||
error_message="Invalid or expired reset token",
|
||
)
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="Reset link is invalid or has expired",
|
||
)
|
||
|
||
user = db.query(User).filter(User.id == reset.user_id).first()
|
||
if not user or not user.is_active:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="Reset link is invalid or has expired",
|
||
)
|
||
|
||
# Validate the new password against the panel's password policy
|
||
lang = user.language or "en"
|
||
is_valid, errors = SecurityService.validate_password(db, payload.new_password, lang)
|
||
if not is_valid:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="; ".join(errors),
|
||
)
|
||
|
||
# Apply the new password and clear any lock state
|
||
user.hashed_password = get_password_hash(payload.new_password)
|
||
user.last_password_change = utcnow()
|
||
user.require_password_change = False
|
||
user.failed_login_attempts = 0
|
||
user.locked_until = None
|
||
|
||
# Consume this token and invalidate any other outstanding ones
|
||
reset.used_at = utcnow()
|
||
db.query(PasswordResetToken).filter(
|
||
PasswordResetToken.user_id == user.id,
|
||
PasswordResetToken.used_at.is_(None),
|
||
).update({PasswordResetToken.used_at: utcnow()}, synchronize_session=False)
|
||
db.commit()
|
||
|
||
# Force re-login everywhere
|
||
SecurityService.terminate_all_user_sessions(db, user.id)
|
||
|
||
LoggingService.log_auth(
|
||
db=db,
|
||
action="password_reset",
|
||
username=user.username,
|
||
ip_address=client_ip,
|
||
success=True,
|
||
user_id=user.id,
|
||
)
|
||
logger.info(f"Password reset completed for {user.username} from {client_ip}")
|
||
|
||
return {"message": "Password has been reset successfully"}
|