- storage_instance 表 + secret 用途 storage_credential;令牌 / 密码 / 路径密码只写不读 - /storage-instances CRUD 与测试连接(版本、用户、根路径、容量);被库根引用不能删 - 远端路径 /@alist/<id>/<路径>,vfs 统一本地 / Alist 的 stat、列目录、建目录、读写、移动、删除 - 整理矩阵中涉及 Alist 的传输(storage/transfer.ts)与远端 ffprobe(直链只读头) - 媒体库目录可登记在 Alist 上(不写哨兵,目录可访问即在线);健康检查列出存储实例 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
241 lines
8.8 KiB
TypeScript
241 lines
8.8 KiB
TypeScript
import { z } from "zod";
|
||
import { ApiScope, Timestamp } from "./common.ts";
|
||
|
||
// 管理员登录门禁(任务书 §3.1-14,做法见 docs/design/auth-reference.md):
|
||
// 单管理员账号密码 + 服务端会话(cookie umt_session)+ HMAC 派生 CSRF 双提交 + 可选 TOTP;
|
||
// 外部程序走 API Key(Bearer)。两种调用者在服务端统一为 Actor。
|
||
|
||
/** 会话 cookie 名(HttpOnly,SameSite=Lax) */
|
||
export const SESSION_COOKIE = "umt_session";
|
||
/** CSRF cookie 名(非 HttpOnly,前端读出后放进 CSRF_HEADER) */
|
||
export const CSRF_COOKIE = "umt_csrf";
|
||
/** 写方法(POST/PUT/PATCH/DELETE)用 cookie 会话调用时必须带的请求头 */
|
||
export const CSRF_HEADER = "x-csrf-token";
|
||
|
||
/** 统一调用者:控制台会话(admin)或外部 API Key */
|
||
export const ActorKind = z.enum(["admin", "api_key"]);
|
||
export type ActorKind = z.infer<typeof ActorKind>;
|
||
export const Actor = z.object({
|
||
type: ActorKind,
|
||
/** admin:admin_user.id(开发模式 DEV_ADMIN_KEY 为 "dev-admin");api_key:api_key.id */
|
||
id: z.string(),
|
||
name: z.string(),
|
||
scopes: z.array(ApiScope),
|
||
});
|
||
export type Actor = z.infer<typeof Actor>;
|
||
|
||
const Username = z
|
||
.string()
|
||
.min(1)
|
||
.max(64)
|
||
.regex(/^[A-Za-z0-9_.@-]+$/, "用户名只能包含字母、数字与 _ . @ -");
|
||
/** 密码:长度 10–256(argon2id 存储,不限制字符集) */
|
||
export const Password = z.string().min(10, "密码至少 10 个字符").max(256);
|
||
|
||
/** POST /auth/setup:首次初始化管理员(setup token 在首次启动时打印到服务端日志一次) */
|
||
export const AdminSetupRequest = z.object({
|
||
setupToken: z.string().min(16).max(256),
|
||
username: Username,
|
||
password: Password,
|
||
});
|
||
export type AdminSetupRequest = z.infer<typeof AdminSetupRequest>;
|
||
|
||
/** 6 位 TOTP 码 */
|
||
export const TotpCode = z.string().regex(/^\d{6}$/, "验证码为 6 位数字");
|
||
|
||
/** 备用恢复码:10 位字母数字(不含易混的 I/O/0/1),展示为 XXXXX-XXXXX;输入时忽略大小写、空格与连字符 */
|
||
export const RecoveryCode = z
|
||
.string()
|
||
.trim()
|
||
.min(10)
|
||
.max(32)
|
||
.regex(/^[A-Za-z0-9\s-]+$/, "恢复码格式不正确");
|
||
/** 启用 TOTP 时一次性生成的恢复码个数 */
|
||
export const RECOVERY_CODE_COUNT = 8;
|
||
|
||
/** POST /auth/login */
|
||
export const LoginRequest = z.object({
|
||
username: z.string().min(1).max(64),
|
||
password: z.string().min(1).max(256),
|
||
/** 开启 TOTP 后第二步提交 */
|
||
totpCode: TotpCode.optional(),
|
||
/** 开启 TOTP 后第二步也可以用一次性恢复码代替验证码(用过即作废) */
|
||
recoveryCode: RecoveryCode.optional(),
|
||
});
|
||
export type LoginRequest = z.infer<typeof LoginRequest>;
|
||
|
||
/** GET /auth/session(免鉴权,SPA 探测登录态与是否需要初始化) */
|
||
export const SessionInfo = z.object({
|
||
initialized: z.boolean(),
|
||
authenticated: z.boolean(),
|
||
actor: Actor.nullable(),
|
||
/** 仅 cookie 会话有值 */
|
||
expiresAt: Timestamp.nullable(),
|
||
totpEnabled: z.boolean(),
|
||
/** 已登记至少一把 Passkey(登录页据此显示"用 Passkey 登录") */
|
||
passkeyEnabled: z.boolean(),
|
||
});
|
||
export type SessionInfo = z.infer<typeof SessionInfo>;
|
||
|
||
/**
|
||
* POST /auth/login 结果:ok=登录成功(cookie 已下发);totp_required=密码正确但需第二步验证码
|
||
* (不计入失败次数,前端保留用户名/密码并追加验证码重新提交)
|
||
*/
|
||
export const LoginResult = z.discriminatedUnion("status", [
|
||
z.object({ status: z.literal("ok"), session: SessionInfo }),
|
||
z.object({ status: z.literal("totp_required") }),
|
||
]);
|
||
export type LoginResult = z.infer<typeof LoginResult>;
|
||
|
||
/** POST /auth/password:校验旧密码 → 改密 → 吊销其它会话 */
|
||
export const PasswordChangeRequest = z.object({
|
||
currentPassword: z.string().min(1).max(256),
|
||
newPassword: Password,
|
||
});
|
||
export type PasswordChangeRequest = z.infer<typeof PasswordChangeRequest>;
|
||
export const PasswordChangeResult = z.object({ revokedSessions: z.number().int().nonnegative() });
|
||
export type PasswordChangeResult = z.infer<typeof PasswordChangeResult>;
|
||
|
||
/** GET /auth/totp */
|
||
export const TotpStatus = z.object({
|
||
enabled: z.boolean(),
|
||
pending: z.boolean(),
|
||
/** 未用过的恢复码个数(未开启时为 0) */
|
||
recoveryCodesRemaining: z.number().int().nonnegative(),
|
||
});
|
||
export type TotpStatus = z.infer<typeof TotpStatus>;
|
||
|
||
/**
|
||
* GET /auth/totp/setup:待确认的 TOTP 密钥(没有就生成一个;已有未确认的原样返回,刷新页面不作废)。
|
||
* 前端把 otpauthUri 渲染成二维码;已开启时 409。
|
||
*/
|
||
export const TotpSetup = z.object({
|
||
otpauthUri: z.string(),
|
||
/** base32 密钥(无法扫码时手动输入) */
|
||
secret: z.string(),
|
||
issuer: z.string(),
|
||
account: z.string(),
|
||
});
|
||
export type TotpSetup = z.infer<typeof TotpSetup>;
|
||
|
||
/**
|
||
* POST /auth/totp:begin 生成待确认密钥(返回 otpauth URI,只此一次);enable 用验证码确认(同时生成恢复码);
|
||
* disable 需要当前密码 + 验证码(或恢复码);recovery_codes 用当前密码重新生成恢复码(旧的全部作废)
|
||
*/
|
||
export const TotpAction = z.discriminatedUnion("action", [
|
||
z.object({ action: z.literal("begin") }),
|
||
z.object({ action: z.literal("enable"), code: TotpCode }),
|
||
z.object({
|
||
action: z.literal("disable"),
|
||
password: z.string().min(1).max(256),
|
||
code: z.union([TotpCode, RecoveryCode]),
|
||
}),
|
||
z.object({ action: z.literal("recovery_codes"), password: z.string().min(1).max(256) }),
|
||
]);
|
||
export type TotpAction = z.infer<typeof TotpAction>;
|
||
export const TotpActionResult = z.object({
|
||
status: TotpStatus,
|
||
/** 仅 begin 返回:otpauth://totp/... 与 base32 密钥 */
|
||
otpauthUri: z.string().optional(),
|
||
secret: z.string().optional(),
|
||
/** 仅 enable / recovery_codes 返回:新生成的恢复码明文(只显示这一次,库里只存哈希) */
|
||
recoveryCodes: z.array(z.string()).optional(),
|
||
});
|
||
export type TotpActionResult = z.infer<typeof TotpActionResult>;
|
||
|
||
// ── Passkey(WebAuthn,替代密码的第一因子;登录成功直接建会话,不再要求 TOTP) ──
|
||
|
||
export const PasskeyName = z.string().trim().min(1, "输入名称").max(60);
|
||
export const PasskeyInfo = z.object({
|
||
id: z.string(),
|
||
name: z.string(),
|
||
/** singleDevice / multiDevice(可同步) */
|
||
deviceType: z.string().nullable(),
|
||
backedUp: z.boolean(),
|
||
createdAt: Timestamp,
|
||
lastUsedAt: Timestamp.nullable(),
|
||
});
|
||
export type PasskeyInfo = z.infer<typeof PasskeyInfo>;
|
||
export const PasskeyList = z.object({ items: z.array(PasskeyInfo) });
|
||
export type PasskeyList = z.infer<typeof PasskeyList>;
|
||
|
||
/**
|
||
* 注册 / 登录挑战:options 原样交给 @simplewebauthn/browser(startRegistration / startAuthentication),
|
||
* challengeId 随校验请求带回(挑战存库、一次性、5 分钟有效)。
|
||
*/
|
||
export const PasskeyChallenge = z.object({
|
||
challengeId: z.string(),
|
||
/** WebAuthn 选项 JSON 由 @simplewebauthn 定义,合同里不重复建模 */
|
||
options: z.record(z.string(), z.unknown()),
|
||
});
|
||
export type PasskeyChallenge = z.infer<typeof PasskeyChallenge>;
|
||
|
||
const WebAuthnResponse = z.record(z.string(), z.unknown());
|
||
export const PasskeyRegisterRequest = z.object({
|
||
challengeId: z.string().uuid(),
|
||
name: PasskeyName,
|
||
/** startRegistration 的返回值(RegistrationResponseJSON) */
|
||
response: WebAuthnResponse,
|
||
});
|
||
export type PasskeyRegisterRequest = z.infer<typeof PasskeyRegisterRequest>;
|
||
export const PasskeyLoginRequest = z.object({
|
||
challengeId: z.string().uuid(),
|
||
/** startAuthentication 的返回值(AuthenticationResponseJSON) */
|
||
response: WebAuthnResponse,
|
||
});
|
||
export type PasskeyLoginRequest = z.infer<typeof PasskeyLoginRequest>;
|
||
export const PasskeyRenameRequest = z.object({ name: PasskeyName });
|
||
export type PasskeyRenameRequest = z.infer<typeof PasskeyRenameRequest>;
|
||
|
||
/**
|
||
* 审计动作枚举(审计写失败不影响主流程;被限速拦下的登录尝试不写审计)。
|
||
* 新增动作只在这里加。
|
||
*/
|
||
export const AuditAction = z.enum([
|
||
"admin.setup",
|
||
"admin.login_success",
|
||
"admin.login_failed",
|
||
"admin.logout",
|
||
"admin.password_change",
|
||
"admin.totp_enable",
|
||
"admin.totp_disable",
|
||
"admin.recovery_codes_regenerate",
|
||
"admin.recovery_code_login",
|
||
"admin.passkey_register",
|
||
"admin.passkey_rename",
|
||
"admin.passkey_remove",
|
||
"admin.passkey_login",
|
||
"api_key.create",
|
||
"api_key.update",
|
||
"api_key.revoke",
|
||
"settings.update",
|
||
"secret.create",
|
||
"secret.rotate",
|
||
"secret.delete",
|
||
"secret.master_key_rotate",
|
||
"library_root.create",
|
||
"library_root.update",
|
||
"library_root.delete",
|
||
"library_root.test",
|
||
"downloader_instance.create",
|
||
"downloader_instance.update",
|
||
"downloader_instance.delete",
|
||
"downloader_instance.test",
|
||
"media_server_instance.create",
|
||
"media_server_instance.update",
|
||
"media_server_instance.delete",
|
||
"media_server_instance.test",
|
||
"storage_instance.create",
|
||
"storage_instance.update",
|
||
"storage_instance.delete",
|
||
"storage_instance.test",
|
||
"path_mapping.create",
|
||
"path_mapping.update",
|
||
"path_mapping.delete",
|
||
"path_mapping.verify",
|
||
"site_account.credentials_set",
|
||
"engine.run_cancel",
|
||
"engine.run_retry",
|
||
]);
|
||
export type AuditAction = z.infer<typeof AuditAction>;
|