diff --git a/README.md b/README.md index bc3239a..7d19012 100644 --- a/README.md +++ b/README.md @@ -269,7 +269,7 @@ KVideo 现在支持两套认证模式: - 配置 `AUTH_SECRET` - 配置 `UPSTASH_REDIS_REST_URL` - 配置 `UPSTASH_REDIS_REST_TOKEN` -- (可选)配置 `MANAGED_AUTH_ENABLED=true` 强制启用托管账户模式 (默认方式无法开启时可添加此环境变量) +- (可选)配置 `MANAGED_AUTH_ENABLED=true` 强制优先使用托管账户模式 启用后: @@ -278,7 +278,7 @@ KVideo 现在支持两套认证模式: - 超级管理员可在设置页直接管理账户和权限 - 配置同步、历史、收藏等跨设备数据会按登录账户自动隔离 -> **强制启用托管模式**:设置 `MANAGED_AUTH_ENABLED=true` 可跳过 `AUTH_SECRET` 和 Redis 的检查,强制进入托管账户模式。 +> **强制优先托管模式**:设置 `MANAGED_AUTH_ENABLED=true` 只会改变认证模式选择,不会跳过 `AUTH_SECRET` 和 Redis 检查。缺少 `AUTH_SECRET`、`UPSTASH_REDIS_REST_URL` 或 `UPSTASH_REDIS_REST_TOKEN` 时,托管账户模式仍不会启用。 首次启用时,如果 Redis 里还没有账户,会自动使用 `ADMIN_PASSWORD` 和 `ACCOUNTS` 作为引导种子创建首批托管账户。 ### 方式二:单管理员密码(环境变量模式) @@ -682,6 +682,7 @@ docker run -e PORT=8080 -p 8080:8080 --name kvideo kuekhaoyang/kvideo:latest | 变量名 | 说明 | 默认值 | |--------|------|--------| | `AUTH_SECRET` | 托管账户模式的会话签名密钥;启用 Redis 托管账户时必填 | - | +| `MANAGED_AUTH_ENABLED` | 设为 `true` 时强制优先使用托管账户模式;仍要求 `AUTH_SECRET` 和 Upstash Redis 配置完整 | - | | `ADMIN_PASSWORD` | 管理员密码;环境变量模式直接生效,也可作为托管模式首次引导的超级管理员种子 | - | | `ACCESS_PASSWORD` | 访问密码(向后兼容,等同于 `ADMIN_PASSWORD`) | - | | `ACCOUNTS` | 多账户配置;支持 `密码:名称[:角色[:权限1\|权限2]]` 和 `用户名:密码:名称[:角色[:权限1\|权限2]]` 两种格式 | - | diff --git a/lib/server/auth-helpers.ts b/lib/server/auth-helpers.ts index cc5c028..e9cc569 100644 --- a/lib/server/auth-helpers.ts +++ b/lib/server/auth-helpers.ts @@ -5,6 +5,8 @@ import { type Role, } from '@/lib/auth/permissions'; +export type LoginMode = 'none' | 'legacy_password' | 'managed'; + export interface SeedAccountInput { username: string; password: string; @@ -36,6 +38,24 @@ export interface SessionPayload { iat: number; } +export function resolveLoginMode({ + managedAccountCount, + managedAuthEnabled, + managedAuthForced, + legacyAuthConfigured, +}: { + managedAccountCount: number; + managedAuthEnabled: boolean; + managedAuthForced: boolean; + legacyAuthConfigured: boolean; +}): LoginMode { + if (managedAccountCount > 0 || (managedAuthForced && managedAuthEnabled)) { + return 'managed'; + } + + return legacyAuthConfigured ? 'legacy_password' : 'none'; +} + const PBKDF2_ITERATIONS = 120_000; const PBKDF2_KEY_BYTES = 32; const SESSION_TOKEN_VERSION = 'v1'; diff --git a/lib/server/auth.ts b/lib/server/auth.ts index ea11b58..70ed322 100644 --- a/lib/server/auth.ts +++ b/lib/server/auth.ts @@ -7,9 +7,11 @@ import { hashPassword, normalizeUsername, parseBootstrapAccounts, + resolveLoginMode, signSessionPayload, verifyPassword, verifySessionToken, + type LoginMode, type SeedAccountInput, type SessionPayload, type StoredAccountRecord, @@ -22,7 +24,7 @@ import { type Role, } from '@/lib/auth/permissions'; -export type LoginMode = 'none' | 'legacy_password' | 'managed'; +export type { LoginMode }; export interface ServerAuthSession { accountId: string; @@ -79,7 +81,7 @@ const IPTV_SOURCES = process.env.IPTV_SOURCES || process.env.NEXT_PUBLIC_IPTV_SO const MERGE_SOURCES = process.env.MERGE_SOURCES || process.env.NEXT_PUBLIC_MERGE_SOURCES || ''; const DANMAKU_API_URL = process.env.DANMAKU_API_URL || process.env.NEXT_PUBLIC_DANMAKU_API_URL || ''; const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; -const MANAGED_AUTH_ENABLED = process.env.MANAGED_AUTH_ENABLED === 'true'; +const MANAGED_AUTH_FORCED = process.env.MANAGED_AUTH_ENABLED === 'true'; const effectiveAdminPassword = ADMIN_PASSWORD || ACCESS_PASSWORD; @@ -100,9 +102,6 @@ function getRedisClient(): Redis | null { } function isManagedAuthEnabled(): boolean { - if (MANAGED_AUTH_ENABLED) { - return true; - } return !!AUTH_SECRET && !!getRedisClient(); } @@ -220,12 +219,14 @@ function getPublicRuntimeConfig(): Omit { + const managedAuthEnabled = isManagedAuthEnabled(); const managedAccountCount = await getManagedAccountCount(); - const loginMode: LoginMode = managedAccountCount > 0 - ? 'managed' - : isLegacyAuthConfigured() - ? 'legacy_password' - : 'none'; + const loginMode = resolveLoginMode({ + managedAccountCount, + managedAuthEnabled, + managedAuthForced: MANAGED_AUTH_FORCED, + legacyAuthConfigured: isLegacyAuthConfigured(), + }); return { hasAuth: loginMode !== 'none', diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 2d61f2f..cf6ee22 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -4,6 +4,7 @@ import { createStoredAccount, hashPassword, parseBootstrapAccounts, + resolveLoginMode, signSessionPayload, verifyPassword, verifySessionToken, @@ -78,6 +79,22 @@ test('createStoredAccount stores hashed password and normalized permissions', as assert.equal(await verifyPassword('secret', account.passwordSalt, account.passwordHash), true); }); +test('MANAGED_AUTH_ENABLED does not bypass managed auth hard dependencies', () => { + assert.equal(resolveLoginMode({ + managedAccountCount: 0, + managedAuthEnabled: false, + managedAuthForced: true, + legacyAuthConfigured: true, + }), 'legacy_password'); + + assert.equal(resolveLoginMode({ + managedAccountCount: 0, + managedAuthEnabled: true, + managedAuthForced: true, + legacyAuthConfigured: true, + }), 'managed'); +}); + test('resolvePermissions applies role defaults and IPTV management inheritance', () => { const viewerPermissions = resolvePermissions('viewer', ['iptv_access']); assert.ok(viewerPermissions.includes('iptv_access'));