feat(server): W2-A login gate, secrets, settings, instances, audit, engine panel API and PG-backed SSE bus
- Admin login gate (docs/design/auth-reference.md): admin_user/admin_session/login_attempt
(migration 0015), one-time setup token logged once + conditional UPDATE, argon2id via
hash-wasm (WASM; compiled binary has no --allow-ffi), sha256-stored sessions in HttpOnly
SameSite=Lax cookie umt_session (fixed 12h TTL, soft revoke), HMAC-derived double-submit
CSRF, IP (5) + account (15) failure limits per 15 min, password change revokes other
sessions, optional TOTP with replay counter, AuditAction enum.
- Unified auth middleware: cookie session or Bearer API key -> c.var.actor
{type: admin|api_key, id, name, scopes}; requireScope works for both; requireAdminSession.
- ctx.secrets: AES-256-GCM, per-secret HKDF-SHA256 key from SECRET_MASTER_KEY, AAD bound
to (id, purpose); ctx.settings: 30s cache invalidated by settings.changed events.
- Endpoints: /auth/*, /settings, /api-keys, /secrets, /audit-events, library-roots,
downloader/media-server instances (+test), path-mappings (+verify),
PUT /sites/{id}/credentials, /engine/runs|stats|cancel (read-only openworkflow schema).
- SSE: PgEventBus (sse_event table, migration 0016, LISTEN/NOTIFY umt_events, catch-up by
seq, pruning, resync); EventBusPort is now async.
- Tests: login/rate limit/revocation/CSRF/TOTP, secrets round trip and no log leakage,
admin APIs against mock qB/Emby, SSE across two Deno subprocesses.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
07db0dac5b
commit
ff230e5da7
34 files changed
+4363
-78
No files matched your search
@@ -0,0 +1,71 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { dropTables, entityTable, execDdl } from "../src/migration-helpers.ts";
|
||||
|
||||
// 管理员登录门禁(任务书 §3.1-14;docs/design/auth-reference.md):
|
||||
// admin_user 单行(singleton 唯一)、admin_session(只存 sha256(token),软删除吊销)、
|
||||
// login_attempt(IP + 账号双维度限速落表,多进程/重启一致);另补 audit_event 的 actor/action 查询索引。
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(
|
||||
db,
|
||||
`
|
||||
${entityTable(
|
||||
"admin_user",
|
||||
`
|
||||
singleton boolean not null default true check (singleton),
|
||||
username text,
|
||||
password_hash text,
|
||||
setup_token_hash text,
|
||||
password_changed_at timestamptz,
|
||||
totp_secret_ref uuid references secret (id) on delete set null,
|
||||
totp_pending_secret_ref uuid references secret (id) on delete set null,
|
||||
totp_enabled_at timestamptz,
|
||||
totp_last_counter bigint,
|
||||
constraint admin_user_singleton_key unique (singleton),
|
||||
constraint admin_user_initialized_check check ((username is null) = (password_hash is null))`,
|
||||
)}
|
||||
|
||||
${entityTable(
|
||||
"admin_session",
|
||||
`
|
||||
admin_user_id uuid not null references admin_user (id) on delete cascade,
|
||||
session_hash text not null,
|
||||
ip text,
|
||||
user_agent text,
|
||||
last_seen_at timestamptz not null default now(),
|
||||
expires_at timestamptz not null,
|
||||
revoked_at timestamptz,
|
||||
revoke_reason text,
|
||||
constraint admin_session_hash_key unique (session_hash)`,
|
||||
)}
|
||||
create index admin_session_active_idx on admin_session (admin_user_id) where revoked_at is null;
|
||||
|
||||
create table login_attempt (
|
||||
id bigserial primary key,
|
||||
at timestamptz not null default now(),
|
||||
ip text not null,
|
||||
username text not null,
|
||||
success boolean not null,
|
||||
reason text
|
||||
);
|
||||
create index login_attempt_ip_idx on login_attempt (ip, at) where not success;
|
||||
create index login_attempt_username_idx on login_attempt (username, at) where not success;
|
||||
|
||||
create index audit_event_actor_idx on audit_event (actor_type, actor_id, at);
|
||||
create index audit_event_action_idx on audit_event (action, at);
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(
|
||||
db,
|
||||
`
|
||||
drop index if exists audit_event_action_idx;
|
||||
drop index if exists audit_event_actor_idx;
|
||||
${dropTables(["login_attempt", "admin_session", "admin_user"])}
|
||||
`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { dropTables, execDdl } from "../src/migration-helpers.ts";
|
||||
|
||||
// SSE 多进程总线(W2-A):事件先落 sse_event(seq 全局单调,发布时持咨询锁保证提交顺序 = seq 顺序),
|
||||
// 再 pg_notify('umt_events', seq);各进程 LISTEN 后按 seq 补读并推给本进程的 SSE 连接。
|
||||
// 只保留最近 N 条供 ?after= 补读,由发布方定期裁剪。
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(
|
||||
db,
|
||||
`
|
||||
create table sse_event (
|
||||
seq bigserial primary key,
|
||||
type text not null,
|
||||
at timestamptz not null default now(),
|
||||
payload jsonb not null default 'null'::jsonb
|
||||
);
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(db, dropTables(["sse_event"]));
|
||||
}
|
||||
Reference in new issue
Block a user