feat: rebuild xiteng.site homelab platform
This commit is contained in:
@@ -0,0 +1,337 @@
|
||||
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};
|
||||
}
|
||||
Reference in New Issue
Block a user