From 02db73aa4e8accb8fbd094342c35abf73618a077 Mon Sep 17 00:00:00 2001 From: xiaomai Date: Thu, 7 May 2026 20:31:52 +0800 Subject: [PATCH] feat(auth): add view as user and role functionality for owners Allow owners to impersonate users or roles for debugging permissions. Add view-as targets to user sessions and resolve effective permissions. Display a persistent banner in the app shell to exit view-as mode. --- DESIGN.md | 7 + backend/db/schema.sql | 13 +- backend/src/auth.ts | 188 ++++++++++++++++++++++- backend/src/server.ts | 44 ++++++ frontend/app.vue | 18 +++ frontend/src/components/AppShell.vue | 14 ++ frontend/src/components/ViewAsBanner.vue | 32 ++++ frontend/src/icons.ts | 1 + frontend/src/services/api.ts | 9 ++ frontend/src/styles/main.css | 49 ++++++ frontend/src/views/AdminView.vue | 27 ++++ system-wordings.ts | 12 ++ 12 files changed, 411 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/ViewAsBanner.vue diff --git a/DESIGN.md b/DESIGN.md index 3913e38..90a6505 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -200,6 +200,13 @@ - 调用者只能分配或移除 `roles.level` 严格低于自己最高启用角色等级的角色。 - `owner` 角色只能由当前拥有启用 `owner` 角色且拥有 `admin.users.assign-owner` 权限的调用者分配或移除。 - 非 Owner 即使拥有 `admin.users.update` 或自定义高等级角色,也不能分配或移除 `owner` 角色。 +- Owner 可使用 View As 调试权限和用户工作流: + - 只有当前 session 的真实用户拥有启用 `owner` 角色且邮箱已验证时,才能启动或退出 View As。 + - View As User 会让当前 session 以目标用户的对外身份、角色和权限进行后续操作;普通写入仍按当前生效用户记录编辑署名。 + - View As Role 会保留真实 Owner 的用户资料和邮箱验证状态,但后续权限判断只使用所选启用角色的权限;该模式用于验证角色能力边界,不伪造某个具体用户。 + - 同一 session 同一时间只能 View As 一个用户或一个角色;退出后恢复真实 Owner 身份。 + - 当前用户 API 可返回必要的 `viewAs` 展示状态,只包含模式和展示标签;不返回 session token、token hash、内部 session 字段或调试 payload。 + - 前端在顶部显示全站 View As Banner,文案为当前 View As 对象,并提供退出按钮;View As 状态不得只隐藏在管理页内。 - 管理 API 只返回权限管理所需字段,不返回密码、session token hash、verification/reset token hash、内部审计 payload 或调试字段。 ## Admin Data Tools diff --git a/backend/db/schema.sql b/backend/db/schema.sql index 10540c0..827564d 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -724,10 +724,21 @@ CREATE TABLE IF NOT EXISTS user_sessions ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash text NOT NULL UNIQUE, + view_as_user_id integer REFERENCES users(id) ON DELETE SET NULL, + view_as_role_id integer REFERENCES roles(id) ON DELETE SET NULL, expires_at timestamptz NOT NULL, - created_at timestamptz NOT NULL DEFAULT now() + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT user_sessions_view_as_single_target_check CHECK (view_as_user_id IS NULL OR view_as_role_id IS NULL) ); +ALTER TABLE user_sessions + ADD COLUMN IF NOT EXISTS view_as_user_id integer REFERENCES users(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS view_as_role_id integer REFERENCES roles(id) ON DELETE SET NULL; + +ALTER TABLE user_sessions + DROP CONSTRAINT IF EXISTS user_sessions_view_as_single_target_check, + ADD CONSTRAINT user_sessions_view_as_single_target_check CHECK (view_as_user_id IS NULL OR view_as_role_id IS NULL); + CREATE INDEX IF NOT EXISTS user_sessions_user_id_idx ON user_sessions(user_id); diff --git a/backend/src/auth.ts b/backend/src/auth.ts index 2805aed..0fa1777 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -85,6 +85,12 @@ export type AuthUser = { emailVerified: boolean; roles: RoleSummary[]; permissions: string[]; + viewAs?: ViewAsSummary; +}; + +export type ViewAsSummary = { + mode: 'user' | 'role'; + label: string; }; export type ReferralSummary = { @@ -148,6 +154,12 @@ type RolePermissionRow = QueryResultRow & { permission_id: number; }; +type SessionRow = QueryResultRow & { + user_id: number; + view_as_user_id: number | null; + view_as_role_id: number | null; +}; + const roleKeyPattern = /^[a-z][a-z0-9-]{1,63}$/; const permissionKeyPattern = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/; const ownerRoleKey = 'owner'; @@ -555,6 +567,38 @@ async function userPermissions(userId: number, client: DbClient | null = null): return rows.map((row) => row.key); } +async function rolePermissions(roleId: number, client: DbClient | null = null): Promise { + const rows = await runQuery( + client, + ` + SELECT DISTINCT p.key + FROM role_permissions rp + JOIN permissions p ON p.id = rp.permission_id + WHERE rp.role_id = $1 + AND p.enabled = true + ORDER BY p.key + `, + [roleId] + ); + + return rows.map((row) => row.key); +} + +async function roleById(roleId: number, client: DbClient | null = null): Promise { + const role = await runQueryOne( + client, + ` + SELECT id, key, name, description, level, enabled, system_role + FROM roles + WHERE id = $1 + AND enabled = true + `, + [roleId] + ); + + return role ? toRoleSummary(role) : null; +} + async function publicUserById(userId: number, client: DbClient | null = null): Promise { const user = await runQueryOne( client, @@ -1275,9 +1319,66 @@ export async function getUserBySessionToken(token: string): Promise( + const session = await queryOne( ` - SELECT s.user_id + SELECT s.user_id, s.view_as_user_id, s.view_as_role_id + FROM user_sessions s + WHERE s.token_hash = $1 + AND s.expires_at > now() + `, + [hashToken(token)] + ); + + if (!session) { + return null; + } + + const realUser = await publicUserById(session.user_id); + if (!realUser) { + return null; + } + + const realUserCanViewAs = realUser.emailVerified && realUser.roles.some((role) => role.key === ownerRoleKey); + + if (realUserCanViewAs && session.view_as_user_id) { + const viewAsUser = await publicUserById(session.view_as_user_id); + if (viewAsUser) { + return { + ...viewAsUser, + viewAs: { + mode: 'user', + label: viewAsUser.displayName || viewAsUser.email + } + }; + } + } + + if (realUserCanViewAs && session.view_as_role_id) { + const role = await roleById(session.view_as_role_id); + if (role) { + return { + ...realUser, + roles: [role], + permissions: await rolePermissions(role.id), + viewAs: { + mode: 'role', + label: role.name + } + }; + } + } + + return realUser; +} + +async function realUserBySessionToken(token: string): Promise { + if (token.length < 32) { + return null; + } + + const session = await queryOne( + ` + SELECT s.user_id, s.view_as_user_id, s.view_as_role_id FROM user_sessions s WHERE s.token_hash = $1 AND s.expires_at > now() @@ -1288,6 +1389,89 @@ export async function getUserBySessionToken(token: string): Promise role.key === ownerRoleKey)) { + throw statusError('server.permissions.permissionDenied', 403); + } + + return user; +} + +function cleanViewAsId(value: unknown): number { + const id = Number(value); + if (!Number.isInteger(id) || id <= 0) { + throw statusError('server.permissions.invalidSelection', 400); + } + return id; +} + +export async function startViewAsUser(sessionToken: string, payload: Record): Promise { + assertOwnerViewAsUser(await realUserBySessionToken(sessionToken)); + const targetUserId = cleanViewAsId(payload.userId); + const targetUser = await publicUserById(targetUserId); + if (!targetUser) { + throw statusError('server.permissions.userNotFound', 404); + } + + await pool.query( + ` + UPDATE user_sessions + SET view_as_user_id = $1, + view_as_role_id = NULL + WHERE token_hash = $2 + AND expires_at > now() + `, + [targetUserId, hashToken(sessionToken)] + ); + + const user = await getUserBySessionToken(sessionToken); + if (!user) { + throw statusError('server.errors.loginRequired', 401); + } + return user; +} + +export async function startViewAsRole(sessionToken: string, payload: Record): Promise { + assertOwnerViewAsUser(await realUserBySessionToken(sessionToken)); + const targetRoleId = cleanViewAsId(payload.roleId); + const role = await roleById(targetRoleId); + if (!role) { + throw statusError('server.permissions.roleNotFound', 404); + } + + await pool.query( + ` + UPDATE user_sessions + SET view_as_user_id = NULL, + view_as_role_id = $1 + WHERE token_hash = $2 + AND expires_at > now() + `, + [targetRoleId, hashToken(sessionToken)] + ); + + const user = await getUserBySessionToken(sessionToken); + if (!user) { + throw statusError('server.errors.loginRequired', 401); + } + return user; +} + +export async function stopViewAs(sessionToken: string): Promise { + const realUser = assertOwnerViewAsUser(await realUserBySessionToken(sessionToken)); + await pool.query( + ` + UPDATE user_sessions + SET view_as_user_id = NULL, + view_as_role_id = NULL + WHERE token_hash = $1 + AND expires_at > now() + `, + [hashToken(sessionToken)] + ); + return realUser; +} + export async function updateCurrentUser( userId: number, payload: Record, diff --git a/backend/src/server.ts b/backend/src/server.ts index 97b6d71..b483219 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -22,6 +22,9 @@ import { registerUser, requestPasswordReset, resetPassword, + startViewAsRole, + startViewAsUser, + stopViewAs, updateAdminUserRoles, updateCurrentUser, updatePermission, @@ -1089,6 +1092,47 @@ app.get('/api/auth/me', async (request, reply) => { return { user }; }); +app.post('/api/auth/view-as/user', async (request, reply) => { + if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) { + return; + } + + const token = getSessionToken(request); + if (!token) { + return reply.code(401).send({ message: await serverMessage(requestLocale(request), 'loginRequired') }); + } + + const payload = request.body && typeof request.body === 'object' ? (request.body as Record) : {}; + return { user: await startViewAsUser(token, payload) }; +}); + +app.post('/api/auth/view-as/role', async (request, reply) => { + if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) { + return; + } + + const token = getSessionToken(request); + if (!token) { + return reply.code(401).send({ message: await serverMessage(requestLocale(request), 'loginRequired') }); + } + + const payload = request.body && typeof request.body === 'object' ? (request.body as Record) : {}; + return { user: await startViewAsRole(token, payload) }; +}); + +app.post('/api/auth/view-as/stop', async (request, reply) => { + if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) { + return; + } + + const token = getSessionToken(request); + if (!token) { + return reply.code(401).send({ message: await serverMessage(requestLocale(request), 'loginRequired') }); + } + + return { user: await stopViewAs(token) }; +}); + app.patch('/api/auth/me', async (request, reply) => { if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) { return; diff --git a/frontend/app.vue b/frontend/app.vue index 9a6a114..606f6fc 100644 --- a/frontend/app.vue +++ b/frontend/app.vue @@ -27,6 +27,7 @@ import { api, notifyAuthChange, onAuthChange, type AuthUser, type Language } fro const { t, locale } = useI18n(); const router = useRouter(); const currentUser = ref(null); +const viewAsBusy = ref(false); const languages = ref([ { code: 'en', name: 'English', enabled: true, isDefault: true, sortOrder: 10 }, { code: 'zh-CN', name: '简体中文', enabled: true, isDefault: false, sortOrder: 20 } @@ -134,6 +135,21 @@ async function logout() { await router.push('/'); } +async function stopViewAs() { + if (viewAsBusy.value) { + return; + } + + viewAsBusy.value = true; + try { + const response = await api.stopViewAs(); + currentUser.value = response.user; + notifyAuthChange(); + } finally { + viewAsBusy.value = false; + } +} + async function loadLanguages() { try { const loadedLanguages = await api.languages(); @@ -179,7 +195,9 @@ onUnmounted(() => { :languages="languages" :locale="locale" :nav-items="navItems" + :view-as-busy="viewAsBusy" @logout="logout" + @stop-view-as="stopViewAs" @update:locale="updateLocale" > diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue index 5587a48..766def4 100644 --- a/frontend/src/components/AppShell.vue +++ b/frontend/src/components/AppShell.vue @@ -20,6 +20,7 @@ import GlobalSearch from './GlobalSearch.vue'; import NotificationBell from './NotificationBell.vue'; import PokeBallMark from './PokeBallMark.vue'; import StatusBadge from './StatusBadge.vue'; +import ViewAsBanner from './ViewAsBanner.vue'; type NavBadge = { label: string; @@ -53,10 +54,12 @@ defineProps<{ languages: Language[]; locale: string; navItems: NavItem[]; + viewAsBusy?: boolean; }>(); const emit = defineEmits<{ logout: []; + stopViewAs: []; 'update:locale': [value: string]; }>(); @@ -147,6 +150,10 @@ function requestLogout() { emit('logout'); } +function requestStopViewAs() { + emit('stopViewAs'); +} + function isDesktopSidebar() { return typeof window !== 'undefined' && window.matchMedia('(min-width: 901px)').matches; } @@ -345,6 +352,13 @@ onBeforeUnmount(() => { + +