Merge pull request #216 from ustdbus/codex/upstream-cloudflare-auth

fix: support managed Upstash auth on Cloudflare Pages
This commit is contained in:
Kuek Hao Yang
2026-07-21 20:53:36 +08:00
committed by GitHub
10 changed files with 156 additions and 30 deletions
+19 -2
View File
@@ -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 });
}
}
+16 -2
View File
@@ -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) {
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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';
+6
View File
@@ -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);
+10 -1
View File
@@ -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<string> {
const payloadBytes = encodeText(JSON.stringify(payload));
const encodedPayload = encodeBase64Url(payloadBytes);
+92 -20
View File
@@ -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<string, unknown> | 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<StoredAccountRecord[]> {
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<void> {
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<string>();
const effectiveAdminPassword = getEffectiveAdminPassword();
if (effectiveAdminPassword) {
usernames.add('admin');
@@ -226,7 +265,7 @@ export async function getPublicAuthConfig(): Promise<PublicAuthConfig> {
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<string> {
}
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<ServerAuthSession | null> {
if (!password) return null;
const effectiveAdminPassword = getEffectiveAdminPassword();
if (effectiveAdminPassword && password === effectiveAdminPassword) {
return {
@@ -506,7 +578,7 @@ export async function listAccountInfo(): Promise<AccountInfo[]> {
const legacyAccounts: AccountInfo[] = [];
let index = 0;
if (effectiveAdminPassword) {
if (getEffectiveAdminPassword()) {
legacyAccounts.push({
id: 'legacy-admin',
username: 'admin',
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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",
+8
View File
@@ -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',