diff --git a/app/api/auth/route.ts b/app/api/auth/route.ts index 4fdc364..837921f 100644 --- a/app/api/auth/route.ts +++ b/app/api/auth/route.ts @@ -3,13 +3,24 @@ import { authenticateLogin, createLoginResponse, getPublicAuthConfig, + ManagedAuthStorageError, validatePremiumAccess, } from '@/lib/server/auth'; export const runtime = 'edge'; export async function GET() { - return NextResponse.json(await getPublicAuthConfig()); + try { + return NextResponse.json(await getPublicAuthConfig()); + } catch (error) { + if (error instanceof ManagedAuthStorageError) { + return NextResponse.json( + { error: 'Managed authentication storage is unavailable' }, + { status: 503 }, + ); + } + throw error; + } } export async function POST(request: NextRequest) { @@ -32,7 +43,13 @@ export async function POST(request: NextRequest) { } return createLoginResponse(session, request); - } catch { + } catch (error) { + if (error instanceof ManagedAuthStorageError) { + return NextResponse.json( + { valid: false, message: 'Managed authentication storage is unavailable' }, + { status: 503 }, + ); + } return NextResponse.json({ valid: false, message: 'Invalid request' }, { status: 400 }); } } diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index 2463982..0ab58af 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -1,10 +1,24 @@ import { NextRequest } from 'next/server'; -import { createSessionStatusResponse, logoutResponse } from '@/lib/server/auth'; +import { + createSessionStatusResponse, + logoutResponse, + ManagedAuthStorageError, +} from '@/lib/server/auth'; export const runtime = 'edge'; export async function GET(request: NextRequest) { - return createSessionStatusResponse(request); + try { + return await createSessionStatusResponse(request); + } catch (error) { + if (error instanceof ManagedAuthStorageError) { + return Response.json( + { authenticated: false, error: 'Managed authentication storage is unavailable' }, + { status: 503 }, + ); + } + throw error; + } } export async function DELETE(request: NextRequest) { diff --git a/app/api/user/config/route.ts b/app/api/user/config/route.ts index 3195d41..e980c6a 100644 --- a/app/api/user/config/route.ts +++ b/app/api/user/config/route.ts @@ -5,7 +5,7 @@ * so they persist across browsers, devices, and PWA installs. */ -import { Redis } from '@upstash/redis'; +import { Redis } from '@upstash/redis/cloudflare'; import { NextRequest, NextResponse } from 'next/server'; import { authenticationRequiredResponse } from '@/lib/server/api-responses'; import { getServerSession } from '@/lib/server/auth'; diff --git a/app/api/user/sync/route.ts b/app/api/user/sync/route.ts index cd190d6..e52aee3 100644 --- a/app/api/user/sync/route.ts +++ b/app/api/user/sync/route.ts @@ -1,4 +1,4 @@ -import { Redis } from '@upstash/redis'; +import { Redis } from '@upstash/redis/cloudflare'; import { NextRequest, NextResponse } from 'next/server'; import { authenticationRequiredResponse } from '@/lib/server/api-responses'; import { getServerSession } from '@/lib/server/auth'; diff --git a/components/PasswordGate.tsx b/components/PasswordGate.tsx index d97d059..45fd718 100644 --- a/components/PasswordGate.tsx +++ b/components/PasswordGate.tsx @@ -207,6 +207,12 @@ export function PasswordGate({ }); const data = await response.json(); + if (response.status === 503) { + setError('认证存储暂时不可用,请检查 Upstash Redis 配置'); + setIsValidating(false); + return; + } + if (data.valid && data.session) { setSession(toAuthSession(data.session), data.persistSession ?? persistSession); setIsLocked(false); diff --git a/lib/server/auth-helpers.ts b/lib/server/auth-helpers.ts index 4bcd687..e153d8f 100644 --- a/lib/server/auth-helpers.ts +++ b/lib/server/auth-helpers.ts @@ -87,7 +87,8 @@ export function shouldUseSecureSessionCookie(request?: SessionCookieProtocolRequ return request.nextUrl.protocol === 'https:'; } -const PBKDF2_ITERATIONS = 120_000; +// Cloudflare Workers rejects PBKDF2 iteration counts above 100,000. +const PBKDF2_ITERATIONS = 100_000; const PBKDF2_KEY_BYTES = 32; const SESSION_TOKEN_VERSION = 'v1'; @@ -177,6 +178,14 @@ export async function verifyPassword(password: string, salt: string, expectedHas return actual.hash === expectedHash; } +export function isBootstrapAdminCredential( + username: string, + password: string, + adminPassword: string +): boolean { + return normalizeUsername(username) === 'admin' && !!adminPassword && password === adminPassword; +} + export async function signSessionPayload(payload: SessionPayload, secret: string): Promise { const payloadBytes = encodeText(JSON.stringify(payload)); const encodedPayload = encodeBase64Url(payloadBytes); diff --git a/lib/server/auth.ts b/lib/server/auth.ts index 2a71490..375af6a 100644 --- a/lib/server/auth.ts +++ b/lib/server/auth.ts @@ -1,10 +1,12 @@ -import { Redis } from '@upstash/redis'; +import { Redis } from '@upstash/redis/cloudflare'; +import { getOptionalRequestContext } from '@cloudflare/next-on-pages'; import { NextRequest, NextResponse } from 'next/server'; import { getRuntimeFeatures } from '@/lib/server/runtime-features'; import { createStoredAccount, ensureUniqueUsername, hashPassword, + isBootstrapAdminCredential, normalizeUsername, parseBootstrapAccounts, resolveLoginMode, @@ -85,30 +87,57 @@ const DANMAKU_API_URL = process.env.DANMAKU_API_URL || process.env.NEXT_PUBLIC_D const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; const MANAGED_AUTH_FORCED = process.env.MANAGED_AUTH_ENABLED === 'true'; -const effectiveAdminPassword = ADMIN_PASSWORD || ACCESS_PASSWORD; +function getRuntimeEnvValue(name: string, fallback = ''): string { + try { + const runtimeEnv = getOptionalRequestContext()?.env as unknown as Record | undefined; + const value = runtimeEnv?.[name]; + if (typeof value === 'string') return value; + } catch { + // Outside Cloudflare's request runtime, fall back to process.env. + } + + return process.env[name] || fallback; +} + +function getEffectiveAdminPassword(): string { + return getRuntimeEnvValue('ADMIN_PASSWORD', ADMIN_PASSWORD) || + getRuntimeEnvValue('ACCESS_PASSWORD', ACCESS_PASSWORD); +} let cachedRedis: Redis | null | undefined; +export class ManagedAuthStorageError extends Error { + constructor(operation: 'read' | 'write', cause?: unknown) { + super(`Managed auth storage ${operation} failed`, { cause }); + this.name = 'ManagedAuthStorageError'; + } +} + function getRedisClient(): Redis | null { if (cachedRedis !== undefined) { return cachedRedis; } - if (!process.env.UPSTASH_REDIS_REST_URL || !process.env.UPSTASH_REDIS_REST_TOKEN) { + const url = getRuntimeEnvValue('UPSTASH_REDIS_REST_URL'); + const token = getRuntimeEnvValue('UPSTASH_REDIS_REST_TOKEN'); + if (!url || !token) { cachedRedis = null; return cachedRedis; } - cachedRedis = Redis.fromEnv(); + cachedRedis = new Redis({ + url, + token, + }); return cachedRedis; } function isManagedAuthEnabled(): boolean { - return !!AUTH_SECRET && !!getRedisClient(); + return !!getRuntimeEnvValue('AUTH_SECRET', AUTH_SECRET) && !!getRedisClient(); } function isLegacyAuthConfigured(): boolean { - return !!(effectiveAdminPassword || ACCOUNTS); + return !!(getEffectiveAdminPassword() || ACCOUNTS); } function isStoredAccountRecord(value: unknown): value is StoredAccountRecord { @@ -140,23 +169,33 @@ async function readManagedAccounts(): Promise { const stored = await redis.get(MANAGED_ACCOUNTS_KEY); if (!Array.isArray(stored)) return []; return stored.filter(isStoredAccountRecord).map(normalizeStoredAccount); - } catch { - return []; + } catch (error) { + console.error('Managed auth Redis read failed:', error); + throw new ManagedAuthStorageError('read', error); } } async function saveManagedAccounts(accounts: StoredAccountRecord[]): Promise { const redis = getRedisClient(); if (!redis) { - throw new Error('Managed auth storage unavailable'); + throw new ManagedAuthStorageError( + 'write', + new Error('Managed auth storage unavailable') + ); } - await redis.set(MANAGED_ACCOUNTS_KEY, accounts); + try { + await redis.set(MANAGED_ACCOUNTS_KEY, accounts); + } catch (error) { + console.error('Managed auth Redis write failed:', error); + throw new ManagedAuthStorageError('write', error); + } } function getBootstrapSeeds(): SeedAccountInput[] { const seeds: SeedAccountInput[] = []; const usernames = new Set(); + const effectiveAdminPassword = getEffectiveAdminPassword(); if (effectiveAdminPassword) { usernames.add('admin'); @@ -226,7 +265,7 @@ export async function getPublicAuthConfig(): Promise { const loginMode = resolveLoginMode({ managedAccountCount, managedAuthEnabled, - managedAuthForced: MANAGED_AUTH_FORCED, + managedAuthForced: getRuntimeEnvValue('MANAGED_AUTH_ENABLED') === 'true' || MANAGED_AUTH_FORCED, legacyAuthConfigured: isLegacyAuthConfigured(), }); @@ -252,12 +291,13 @@ async function generateLegacyProfileId(password: string): Promise { } function resolveSessionSecret(loginMode: LoginMode): string | null { - if (AUTH_SECRET) { - return AUTH_SECRET; + const authSecret = getRuntimeEnvValue('AUTH_SECRET', AUTH_SECRET); + if (authSecret) { + return authSecret; } if (loginMode === 'legacy_password' && isLegacyAuthConfigured()) { - return `legacy:${effectiveAdminPassword}:${ACCOUNTS}:${PREMIUM_PASSWORD}`; + return `legacy:${getEffectiveAdminPassword()}:${ACCOUNTS}:${PREMIUM_PASSWORD}`; } return null; @@ -333,12 +373,43 @@ async function authenticateManagedLogin(username: string, password: string): Pro const normalizedUsername = normalizeUsername(username); if (!normalizedUsername || !password) return null; - const accounts = await ensureManagedAccountsBootstrapped(); - const account = accounts.find((item) => item.username === normalizedUsername); - if (!account) return null; + const usesBootstrapAdminCredential = isBootstrapAdminCredential( + normalizedUsername, + password, + getEffectiveAdminPassword() + ); - const valid = await verifyPassword(password, account.passwordSalt, account.passwordHash); - if (!valid) return null; + const accounts = await ensureManagedAccountsBootstrapped(); + let account = accounts.find((item) => item.username === normalizedUsername); + + if (!account) { + if (!usesBootstrapAdminCredential) return null; + + account = await createStoredAccount({ + username: 'admin', + password, + name: '超级管理员', + role: 'super_admin', + customPermissions: [], + }); + await saveManagedAccounts([...accounts, account]); + } else { + const valid = await verifyPassword(password, account.passwordSalt, account.passwordHash); + if (!valid) { + if (!usesBootstrapAdminCredential) return null; + + const nextPassword = await hashPassword(password); + account = { + ...account, + passwordHash: nextPassword.hash, + passwordSalt: nextPassword.salt, + updatedAt: Date.now(), + }; + await saveManagedAccounts( + accounts.map((item) => item.id === account?.id ? account : item) + ); + } + } return { accountId: account.id, @@ -354,6 +425,7 @@ async function authenticateManagedLogin(username: string, password: string): Pro async function authenticateLegacyLogin(password: string): Promise { if (!password) return null; + const effectiveAdminPassword = getEffectiveAdminPassword(); if (effectiveAdminPassword && password === effectiveAdminPassword) { return { @@ -506,7 +578,7 @@ export async function listAccountInfo(): Promise { const legacyAccounts: AccountInfo[] = []; let index = 0; - if (effectiveAdminPassword) { + if (getEffectiveAdminPassword()) { legacyAccounts.push({ id: 'legacy-admin', username: 'admin', diff --git a/package-lock.json b/package-lock.json index f4dabde..5a077fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kvideo", - "version": "4.9.13", + "version": "4.9.14", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "kvideo", - "version": "4.9.13", + "version": "4.9.14", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", diff --git a/package.json b/package.json index e20f61e..5e6728f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kvideo", - "version": "4.9.13", + "version": "4.9.14", "private": true, "scripts": { "dev": "node scripts/next-with-lan-access.mjs dev", diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 983d15c..65be611 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { createStoredAccount, hashPassword, + isBootstrapAdminCredential, parseBootstrapAccounts, resolveLoginMode, shouldUseSecureSessionCookie, @@ -73,6 +74,13 @@ test('hashPassword and verifyPassword round-trip correctly', async () => { assert.equal(await verifyPassword('wrong-password', password.salt, password.hash), false); }); +test('bootstrap admin credential only accepts the configured admin password', () => { + assert.equal(isBootstrapAdminCredential('ADMIN', 'current-secret', 'current-secret'), true); + assert.equal(isBootstrapAdminCredential('admin', 'old-secret', 'current-secret'), false); + assert.equal(isBootstrapAdminCredential('viewer', 'current-secret', 'current-secret'), false); + assert.equal(isBootstrapAdminCredential('admin', '', ''), false); +}); + test('signSessionPayload and verifySessionToken reject tampering', async () => { const token = await signSessionPayload({ accountId: 'account-1',