feat(auth): имперсонация — вход админа под пользователем и возврат обратно
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:
@@ -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"),
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ключами",
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user