import {appendFileSync, readFileSync} from "node:fs"; const managedApplications = new Map([ ["xiteng-portal-admin", "Portal"], ["xiteng-portal", "Portal Home"], ["xiteng-chat", "Xiteng Chat"], ["code-server", "Code Server"], ["comfyui", "ComfyUI"], ["invokeai", "InvokeAI"], ["gitea", "Gitea"], ["hedgedoc", "HedgeDoc"], ["hedgedoc2", "HedgeDoc 2"], ["minio", "SeaweedFS Console"], ["seaweedfs", "SeaweedFS"], ["remark42", "Remark42"] ]); function text(value, maximum = 160) { return typeof value === "string" ? value.trim().slice(0, maximum) : ""; } function readAudit(path) { try { return readFileSync(path, "utf8").trim().split("\n").filter(Boolean).slice(-100).reverse().map(JSON.parse); } catch { return []; } } function reject(message, statusCode = 400) { const error = new Error(message); error.statusCode = statusCode; throw error; } function writeAudit(path, actor, action, target, detail = "") { appendFileSync(path, `${JSON.stringify({createdAt: new Date().toISOString(), actor, action, target, detail})}\n`, {mode: 0o600}); } export function createAuthentikAdmin({baseUrl, token, auditPath, adminUsername = "liooil", protectedGroup = "liuhome"}) { async function request(pathname, {method = "GET", body} = {}) { if (!token) { const error = new Error("Authentik API token is not configured"); error.statusCode = 503; throw error; } const response = await fetch(new URL(`/api/v3${pathname}`, baseUrl), { method, headers: { "Accept": "application/json", "Authorization": `Bearer ${token}`, ...(body === undefined ? {} : {"Content-Type": "application/json"}) }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(10000) }); const payload = response.status === 204 ? null : await response.json().catch(() => null); if (!response.ok) { const detail = payload && typeof payload === "object" ? Object.entries(payload).map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(", ") : value}`).join("; ") : `HTTP ${response.status}`; const error = new Error(detail || `Authentik HTTP ${response.status}`); error.statusCode = response.status; throw error; } return payload; } async function page(pathname) { const payload = await request(pathname); return Array.isArray(payload) ? payload : payload?.results || []; } async function groupByName(name) { const groups = await page(`/core/groups/?name=${encodeURIComponent(name)}&include_users=true&page_size=100`); return groups.find((group) => group.name === name) || null; } async function userByUsername(username) { const users = await page(`/core/users/?username=${encodeURIComponent(username)}&include_groups=true&page_size=10`); const user = users.find((item) => item.username === username && item.type === "internal"); if (!user) reject("Authentik 用户不存在", 404); return user; } async function securityForUser(user) { const [totpDevices, passkeys, sessions] = await Promise.all([ page("/authenticators/admin/totp/?page_size=200"), page("/authenticators/admin/webauthn/?page_size=200"), page(`/core/authenticated_sessions/?user__username=${encodeURIComponent(user.username)}&page_size=100`) ]); return { totp: totpDevices.filter((device) => device.user?.pk === user.pk).map((device) => ({id: device.pk, name: device.name})), passkeys: passkeys.filter((device) => device.user?.pk === user.pk).map((device) => ({ id: device.pk, name: device.name, createdAt: device.created_on, deviceType: device.device_type, aaguid: device.aaguid })), sessions: sessions.filter((session) => session.user === user.pk).map((session) => ({ id: session.uuid, current: session.current, ip: session.last_ip, userAgent: session.last_user_agent, lastUsed: session.last_used, expires: session.expires })) }; } async function accountSummary(username) { const user = await userByUsername(username); const security = await securityForUser(user); const next = encodeURIComponent("https://xiteng.site/account"); return { profile: { id: user.pk, uuid: user.uuid, username: user.username, name: user.name, email: user.email, groups: (user.groups_obj || []).map((group) => group.name), }, security: { ...security, totpSetupUrl: `${baseUrl.replace(/\/$/, "")}/if/flow/default-authenticator-totp-setup/?next=${next}`, passkeySetupUrl: `${baseUrl.replace(/\/$/, "")}/if/flow/default-authenticator-webauthn-setup/?next=${next}` } }; } async function accountMutate(pathname, body, username) { const user = await userByUsername(username); let result; let action; if (pathname === "/api/account/profile") { result = await request(`/core/users/${user.pk}/`, {method: "PATCH", body: {name: text(body.name), email: text(body.email, 254)}}); action = "profile.update"; } else if (pathname === "/api/account/security/password") { result = await request(`/core/users/${user.pk}/recovery/`, {method: "POST", body: {token_duration: "minutes=15"}}); action = "password.recovery"; } else { const deviceAction = pathname.match(/^\/api\/account\/security\/(totp|passkeys)\/(\d+)\/(rename|delete)$/); const sessionAction = pathname.match(/^\/api\/account\/security\/sessions\/([0-9a-f-]+)\/delete$/); if (deviceAction) { const [, kind, id, operation] = deviceAction; const endpoint = kind === "totp" ? "totp" : "webauthn"; const device = await request(`/authenticators/admin/${endpoint}/${id}/`); if (device.user?.pk !== user.pk) reject("认证设备不属于当前用户", 404); result = operation === "rename" ? await request(`/authenticators/admin/${endpoint}/${id}/`, {method: "PATCH", body: {name: text(body.name, 200)}}) : await request(`/authenticators/admin/${endpoint}/${id}/`, {method: "DELETE"}); action = `${kind}.${operation}`; } else if (sessionAction) { const session = await request(`/core/authenticated_sessions/${sessionAction[1]}/`); if (session.user !== user.pk) reject("会话不属于当前用户", 404); result = await request(`/core/authenticated_sessions/${sessionAction[1]}/`, {method: "DELETE"}); action = "session.delete"; } else if (pathname === "/api/account/security/sessions/all") { result = await request(`/core/authenticated_sessions/bulk_delete/?user_pks=${user.pk}`, {method: "DELETE"}); action = "session.delete_all"; } else { reject("Account action not found", 404); } } writeAudit(auditPath, username, action, username, "{}"); return result; } async function summary(search = "") { const query = new URLSearchParams({type: "internal", include_groups: "true", page_size: "100"}); if (search) query.set("search", search); const [users, groups, applications, bindings, totpDevices, passkeys, health] = await Promise.all([ page(`/core/users/?${query}`), page("/core/groups/?include_users=true&page_size=100"), page("/core/applications/?superuser_full_list=true&page_size=100"), page("/policies/bindings/?page_size=200"), page("/authenticators/admin/totp/?page_size=200"), page("/authenticators/admin/webauthn/?page_size=200"), fetch(new URL("/-/health/ready/", baseUrl), {signal: AbortSignal.timeout(3000)}).then((response) => response.ok).catch(() => false) ]); const humanUsers = users.filter((user) => user.type === "internal" && user.path === "users" && user.username !== "AnonymousUser" && !user.username.startsWith("ak-")); const ordinaryGroups = groups.filter((group) => !group.is_superuser && !group.name.startsWith("authentik ")); const groupNames = new Map(groups.map((group) => [group.pk, group.name])); const applicationBySlug = new Map(applications.map((application) => [application.slug, application])); return { configured: Boolean(token), healthy: health, protectedGroup, users: humanUsers.map((user) => ({ id: user.pk, uuid: user.uuid, username: user.username, name: user.name, email: user.email, active: user.is_active, administrator: user.is_superuser, lastLogin: user.last_login, createdAt: user.date_joined, groups: (user.groups_obj || []).map((group) => ({id: group.pk, name: group.name})), totpCount: totpDevices.filter((device) => device.user?.pk === user.pk).length, passkeyCount: passkeys.filter((device) => device.user?.pk === user.pk).length })), groups: ordinaryGroups.map((group) => ({ id: group.pk, name: group.name, userIds: group.users || [], users: (group.users_obj || []).map((user) => ({id: user.pk, username: user.username, name: user.name})) })), applications: [...managedApplications].map(([slug, fallbackName]) => { const application = applicationBySlug.get(slug); const allowedGroupIds = application ? bindings.filter((binding) => binding.target === application.pk && binding.enabled !== false && binding.group).map((binding) => binding.group) : []; return { slug, name: application?.name || fallbackName, available: Boolean(application), allowedGroupIds, allowedGroups: allowedGroupIds.map((id) => groupNames.get(id) || id) }; }), audit: readAudit(auditPath) }; } async function mutate(pathname, body, actor) { let result; let action; let target; if (pathname === "/api/admin/identity/users") { const username = text(body.username, 150); if (!/^[A-Za-z0-9@._-]+$/.test(username)) reject("用户名只能包含字母、数字和 @._-"); const defaultGroup = await groupByName(protectedGroup); const groups = Array.isArray(body.groupIds) && body.groupIds.length ? body.groupIds : defaultGroup ? [defaultGroup.pk] : []; result = await request("/core/users/", {method: "POST", body: { username, name: text(body.name) || username, email: text(body.email, 254), is_active: true, path: "users", type: "internal", groups }}); action = "user.create"; target = username; } else { const userAction = pathname.match(/^\/api\/admin\/identity\/users\/(\d+)\/(update|enable|disable|password|recovery|sessions|reset-totp|reset-passkeys)$/); const groupAction = pathname.match(/^\/api\/admin\/identity\/groups\/([0-9a-f-]+)\/(update|delete|members)$/); const appAction = pathname.match(/^\/api\/admin\/identity\/applications\/([a-z0-9-]+)\/groups$/); if (userAction) { const [, id, operation] = userAction; const user = await request(`/core/users/${id}/`); if (user.username === adminUsername && operation === "disable") reject(`不能停用管理员 ${adminUsername}`, 409); if (operation === "update") { result = await request(`/core/users/${id}/`, {method: "PATCH", body: {name: text(body.name), email: text(body.email, 254)}}); } else if (["enable", "disable"].includes(operation)) { result = await request(`/core/users/${id}/`, {method: "PATCH", body: {is_active: operation === "enable"}}); } else if (operation === "password") { const password = text(body.password, 1024); if (password.length < 12) reject("临时密码至少需要 12 个字符"); await request(`/core/users/${id}/set_password/`, {method: "POST", body: {password}}); result = {changed: true}; } else if (operation === "recovery") { result = await request(`/core/users/${id}/recovery/`, {method: "POST", body: {token_duration: "hours=1"}}); } else if (["reset-totp", "reset-passkeys"].includes(operation)) { const endpoint = operation === "reset-totp" ? "totp" : "webauthn"; const devices = (await page(`/authenticators/admin/${endpoint}/?page_size=200`)).filter((device) => device.user?.pk === user.pk); await Promise.all(devices.map((device) => request(`/authenticators/admin/${endpoint}/${device.pk}/`, {method: "DELETE"}))); result = {deleted: devices.length}; } else { result = await request(`/core/authenticated_sessions/bulk_delete/?user_pks=${id}`, {method: "DELETE"}); } action = `user.${operation}`; target = user.username; } else if (pathname === "/api/admin/identity/groups") { const name = text(body.name); if (!name) reject("用户组名称不能为空"); result = await request("/core/groups/", {method: "POST", body: {name, is_superuser: false}}); action = "group.create"; target = name; } else if (groupAction) { const [, id, operation] = groupAction; const group = await request(`/core/groups/${id}/?include_users=true`); if (group.name === protectedGroup && ["update", "delete"].includes(operation)) reject(`不能修改或删除基础用户组 ${protectedGroup}`, 409); if (operation === "update") { result = await request(`/core/groups/${id}/`, {method: "PATCH", body: {name: text(body.name)}}); } else if (operation === "delete") { result = await request(`/core/groups/${id}/`, {method: "DELETE"}); } else { const userId = Number.parseInt(body.userId, 10); const user = await request(`/core/users/${userId}/`); if (group.name === protectedGroup && [adminUsername, "ziyue"].includes(user.username) && body.member === false) { reject(`${user.username} 必须保留在 ${protectedGroup}`, 409); } await request(`/core/groups/${id}/${body.member === false ? "remove_user" : "add_user"}/`, {method: "POST", body: {pk: userId}}); result = {changed: true}; } action = `group.${operation}`; target = group.name; } else if (appAction) { const slug = appAction[1]; if (!managedApplications.has(slug)) reject("应用不在 Portal 管理范围内", 404); const application = await request(`/core/applications/${slug}/`); const current = await page(`/policies/bindings/?target=${application.pk}&page_size=100`); await Promise.all(current.map((binding) => request(`/policies/bindings/${binding.pk}/`, {method: "DELETE"}))); const groupIds = [...new Set(Array.isArray(body.groupIds) ? body.groupIds : [])]; await Promise.all(groupIds.map((group) => request("/policies/bindings/", {method: "POST", body: { target: application.pk, group, order: 0, enabled: true, negate: false, failure_result: false }}))); result = {allowedGroupIds: groupIds}; action = "application.groups"; target = slug; } else { const error = new Error("Identity action not found"); error.statusCode = 404; throw error; } } const auditDetail = action === "user.password" ? "{}" : JSON.stringify(body); writeAudit(auditPath, actor, action, target, auditDetail); return result; } return {summary, mutate, accountSummary, accountMutate}; }