mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-13 07:43:43 +08:00
Merge pull request #216 from ustdbus/codex/upstream-cloudflare-auth
fix: support managed Upstash auth on Cloudflare Pages
This commit is contained in:
+19
-2
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user