338 lines
15 KiB
JavaScript
338 lines
15 KiB
JavaScript
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};
|
|
}
|