feat(auth): имперсонация — вход админа под пользователем и возврат обратно
Some checks failed
Deploy to Production / Trigger production update (push) Has been cancelled
Backend Tests / pytest (Python 3.12) (push) Has been cancelled

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>
This commit is contained in:
markmorado
2026-07-22 05:32:48 +05:00
parent 982522b8b5
commit 43336c02c7
7 changed files with 254 additions and 5 deletions

View File

@@ -17,14 +17,17 @@ from ..schemas import (
)
from ..services import two_factor_service as tfa
from ..auth import (
get_password_hash,
verify_password,
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
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
@@ -294,8 +297,138 @@ def logout(
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)):
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:
@@ -308,6 +441,14 @@ def get_current_user_info(current_user: User = Depends(get_current_user)):
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,
@@ -315,6 +456,7 @@ def get_current_user_info(current_user: User = Depends(get_current_user)):
"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"),

View File

@@ -1,8 +1,9 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import {
Search, Plus, Shield, ShieldOff, Trash2, RotateCcw, Unlock, Pencil, Server, LogOut, Ban, KeyRound, Gauge,
Search, Plus, Shield, ShieldOff, Trash2, RotateCcw, Unlock, Pencil, Server, LogOut, Ban, KeyRound, Gauge, UserCog,
} from 'lucide-react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -31,6 +32,7 @@ import {
type Role, type SecuritySettings,
} from '@/hooks/use-users';
import { SSHKeysManager } from '@/features/settings/SSHKeysManager';
import { useAuthStore } from '@/stores/auth-store';
// ==================== Helpers ====================
@@ -83,6 +85,9 @@ export default function UsersPage() {
function UsersTab() {
const { t } = useTranslation();
const navigate = useNavigate();
const currentUser = useAuthStore(s => s.user);
const impersonate = useAuthStore(s => s.impersonate);
const [search, setSearch] = useState('');
const [createOpen, setCreateOpen] = useState(false);
const [editUser, setEditUser] = useState<User | null>(null);
@@ -97,6 +102,18 @@ function UsersTab() {
const disable2FA = useDisableUser2FA();
const terminateSessions = useTerminateUserSessions();
const canImpersonate = !!currentUser?.is_admin && !currentUser?.impersonator;
const handleImpersonate = async (u: User) => {
try {
await impersonate(u.id);
toast.success(t('users.impersonate_started', { name: u.username }));
navigate('/dashboard');
} catch (e) {
toast.error(getErrMsg(e));
}
};
const filtered = users.filter(u => {
if (!search) return true;
const s = search.toLowerCase();
@@ -193,6 +210,15 @@ function UsersTab() {
>
<KeyRound className="h-3.5 w-3.5" />
</Button>
{canImpersonate && !u.is_admin && u.id !== currentUser?.id && u.is_active && (
<Button
variant="ghost" size="icon" className="h-7 w-7"
title={t('users.impersonate')}
onClick={() => handleImpersonate(u)}
>
<UserCog className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost" size="icon" className="h-7 w-7"
title={t('users.reset_password')}

View File

@@ -11,7 +11,9 @@ import {
Check,
Search,
FolderKanban,
UserCog,
} from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import {
@@ -183,6 +185,41 @@ function WorkspaceSwitcher() {
);
}
function ImpersonationBanner() {
const { t } = useTranslation();
const navigate = useNavigate();
const user = useAuthStore((s) => s.user);
const stopImpersonate = useAuthStore((s) => s.stopImpersonate);
if (!user?.impersonator) return null;
const handleReturn = async () => {
try {
await stopImpersonate();
toast.success(t('users.impersonate_stopped'));
navigate('/dashboard');
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
}
};
return (
<div className="flex items-center justify-between gap-3 border-b border-amber-500/40 bg-amber-500/15 px-4 py-2 text-sm text-amber-700 dark:text-amber-300 lg:px-6">
<div className="flex min-w-0 items-center gap-2">
<UserCog className="h-4 w-4 shrink-0" />
<span className="truncate">
{t('users.impersonating_as', { name: user.username })}
<span className="text-muted-foreground"> · {t('users.impersonated_by', { name: user.impersonator })}</span>
</span>
</div>
<Button size="sm" variant="outline" onClick={handleReturn} className="shrink-0">
<LogOut className="mr-1.5 h-3.5 w-3.5" />
{t('users.return_to_admin')}
</Button>
</div>
);
}
function TopBar() {
const { theme, toggleTheme } = useThemeStore();
const { i18n, t } = useTranslation();
@@ -263,6 +300,7 @@ export default function AppLayout() {
{/* Main area */}
<div className="flex flex-1 flex-col overflow-hidden">
<ImpersonationBanner />
<TopBar />
<main className="flex-1 overflow-y-auto p-4 lg:p-6">
<Outlet />

View File

@@ -1383,6 +1383,12 @@
"reset_password": "Reset Password",
"new_password": "New password",
"unlock": "Unlock",
"impersonate": "Log in as this user",
"impersonate_started": "You are now logged in as {{name}}",
"impersonate_stopped": "Returned to your admin account",
"impersonating_as": "Viewing as {{name}}",
"impersonated_by": "impersonated by {{name}}",
"return_to_admin": "Return to admin",
"manage_servers": "Assigned Servers",
"manage_ssh_keys": "Manage SSH Keys",
"assigned_servers": "Assigned Servers",

View File

@@ -1382,6 +1382,12 @@
"require_password_change": "Требовать смену пароля",
"reset_password": "Сбросить пароль",
"new_password": "Новый пароль",
"impersonate": "Войти под этим пользователем",
"impersonate_started": "Вы вошли как {{name}}",
"impersonate_stopped": "Возврат в учётную запись администратора",
"impersonating_as": "Просмотр от имени {{name}}",
"impersonated_by": "администратор {{name}}",
"return_to_admin": "Вернуться к администратору",
"unlock": "Разблокировать",
"manage_servers": "Назначенные серверы",
"manage_ssh_keys": "Управление SSH ключами",

View File

@@ -13,9 +13,25 @@ interface AuthState {
login: (credentials: LoginRequest) => Promise<{ twoFactorRequired: boolean }>;
logout: () => Promise<void>;
loadUser: () => Promise<void>;
impersonate: (userId: number) => Promise<void>;
stopImpersonate: () => Promise<void>;
clearError: () => void;
}
// Swap the active token for a freshly-issued one (impersonation start/stop),
// resetting the React Query cache so the previous identity's data cannot leak,
// then reload the current-user info under the new token.
async function switchToken(
set: (partial: Partial<AuthState>) => void,
token: string,
): Promise<void> {
apiClient.setToken(token);
queryClient.clear();
set({ token, isAuthenticated: true });
const user = await apiClient.get<User>('/api/auth/me');
set({ user });
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
token: localStorage.getItem('access_token'),
@@ -81,5 +97,17 @@ export const useAuthStore = create<AuthState>((set) => ({
}
},
impersonate: async (userId: number) => {
const data = await apiClient.post<AuthResponse>(`/api/auth/impersonate/${userId}`);
if (!data.access_token) throw new Error('Impersonation failed');
await switchToken(set, data.access_token);
},
stopImpersonate: async () => {
const data = await apiClient.post<AuthResponse>('/api/auth/stop-impersonate');
if (!data.access_token) throw new Error('Failed to return to admin');
await switchToken(set, data.access_token);
},
clearError: () => set({ error: null }),
}));

View File

@@ -16,6 +16,9 @@ export interface User {
locked_until?: string | null;
require_password_change?: boolean;
two_factor_enabled?: boolean;
// Set to the original admin's username while that admin is impersonating this
// user; null/undefined during a normal session.
impersonator?: string | null;
// Map of "resource:action" -> granted. Admins receive every permission.
permissions?: Record<string, boolean>;
}