mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
fix login gate session loop
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## 4.9.11 - 2026-07-09
|
||||
|
||||
- 修复登录成功后仍停留在登录页、刷新后又要求登录的循环问题。
|
||||
- `PasswordGate` 登录成功后会直接解除客户端锁定,不再依赖整页刷新;服务端 session 存在但本地镜像缺失时也会直接补齐会话并进入应用。
|
||||
- 服务端 session cookie 的 `Secure` 属性改为按实际请求协议和 `x-forwarded-proto` 判断,避免 Docker / HTTP 自托管生产模式下浏览器拒收登录 cookie。
|
||||
- 新增登录 gate 状态决策和 session cookie 协议判断回归测试。
|
||||
|
||||
## 4.9.10 - 2026-07-09
|
||||
|
||||
- 从首页移除硬编码的快捷搜索区,不再显示“内容分类”“电影类型”“年份”三组按钮。
|
||||
|
||||
+11
-1
@@ -4,8 +4,18 @@
|
||||
"name": "KVideo",
|
||||
"branch": "main"
|
||||
},
|
||||
"currentVersion": "4.9.10",
|
||||
"currentVersion": "4.9.11",
|
||||
"releases": [
|
||||
{
|
||||
"version": "4.9.11",
|
||||
"publishedAt": "2026-07-09",
|
||||
"title": "修复登录成功后回到登录页",
|
||||
"notes": [
|
||||
"访问密码 gate 登录成功后会直接放行当前页面,不再依赖整页刷新进入主界面,避免会话镜像时序导致重复登录。",
|
||||
"服务端 session cookie 的 Secure 属性现在按实际请求协议和 x-forwarded-proto 判断,修复 Docker / HTTP 自托管生产模式下浏览器拒收 cookie 后刷新又回登录页的问题。",
|
||||
"新增登录 gate 状态决策和 session cookie 协议判断回归测试。"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "4.9.10",
|
||||
"publishedAt": "2026-07-09",
|
||||
|
||||
@@ -31,7 +31,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ valid: false });
|
||||
}
|
||||
|
||||
return createLoginResponse(session);
|
||||
return createLoginResponse(session, request);
|
||||
} catch {
|
||||
return NextResponse.json({ valid: false, message: 'Invalid request' }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -7,6 +7,6 @@ export async function GET(request: NextRequest) {
|
||||
return createSessionStatusResponse(request);
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
return logoutResponse();
|
||||
export async function DELETE(request: NextRequest) {
|
||||
return logoutResponse(request);
|
||||
}
|
||||
|
||||
+23
-17
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Lock, User } from 'lucide-react';
|
||||
import { clearSession, getSession, setSession, type AuthSession } from '@/lib/store/auth-store';
|
||||
import { resolvePasswordGateState } from '@/lib/auth/password-gate-state';
|
||||
import { useSubscriptionSync } from '@/lib/hooks/useSubscriptionSync';
|
||||
import { hasStoredAppSetting, settingsStore } from '@/lib/store/settings-store';
|
||||
import { useIPTVStore } from '@/lib/store/iptv-store';
|
||||
@@ -147,31 +148,34 @@ export function PasswordGate({
|
||||
setLoginMode(config.loginMode || 'none');
|
||||
applyRuntimeConfig(config);
|
||||
|
||||
if (sessionStatus.authenticated && sessionStatus.session) {
|
||||
const session = toAuthSession(sessionStatus.session);
|
||||
const hasMatchingMirror = mirroredSession &&
|
||||
mirroredSession.accountId === session.accountId &&
|
||||
mirroredSession.profileId === session.profileId;
|
||||
|
||||
setSession(session, config.persistSession);
|
||||
|
||||
if (!hasMatchingMirror) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
const serverSession = sessionStatus.authenticated && sessionStatus.session
|
||||
? toAuthSession(sessionStatus.session)
|
||||
: null;
|
||||
const gateState = resolvePasswordGateState({
|
||||
hasAuth: !!config.hasAuth,
|
||||
serverSession,
|
||||
mirroredSession,
|
||||
persistSession: config.persistSession,
|
||||
});
|
||||
|
||||
if (gateState.action === 'unlock-session') {
|
||||
setSession(gateState.session, gateState.persistSession);
|
||||
setIsLocked(false);
|
||||
setIsClient(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mirroredSession) {
|
||||
clearSession();
|
||||
window.location.reload();
|
||||
if (gateState.action === 'unlock-public') {
|
||||
setIsLocked(false);
|
||||
setIsClient(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLocked(!!config.hasAuth);
|
||||
if (gateState.clearMirroredSession) {
|
||||
clearSession();
|
||||
}
|
||||
|
||||
setIsLocked(true);
|
||||
setIsClient(true);
|
||||
} catch {
|
||||
if (!mounted) return;
|
||||
@@ -205,7 +209,9 @@ export function PasswordGate({
|
||||
|
||||
if (data.valid && data.session) {
|
||||
setSession(toAuthSession(data.session), data.persistSession ?? persistSession);
|
||||
window.location.reload();
|
||||
setIsLocked(false);
|
||||
setIsClient(true);
|
||||
setIsValidating(false);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { AuthSession } from '@/lib/store/auth-store';
|
||||
|
||||
export type PasswordGateStateResolution =
|
||||
| {
|
||||
action: 'unlock-session';
|
||||
session: AuthSession;
|
||||
persistSession: boolean;
|
||||
}
|
||||
| {
|
||||
action: 'unlock-public';
|
||||
}
|
||||
| {
|
||||
action: 'lock';
|
||||
clearMirroredSession: boolean;
|
||||
};
|
||||
|
||||
export function resolvePasswordGateState({
|
||||
hasAuth,
|
||||
serverSession,
|
||||
mirroredSession,
|
||||
persistSession,
|
||||
}: {
|
||||
hasAuth: boolean;
|
||||
serverSession: AuthSession | null;
|
||||
mirroredSession: AuthSession | null;
|
||||
persistSession: boolean;
|
||||
}): PasswordGateStateResolution {
|
||||
if (serverSession) {
|
||||
return {
|
||||
action: 'unlock-session',
|
||||
session: serverSession,
|
||||
persistSession,
|
||||
};
|
||||
}
|
||||
|
||||
if (mirroredSession) {
|
||||
return {
|
||||
action: 'lock',
|
||||
clearMirroredSession: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (!hasAuth) {
|
||||
return { action: 'unlock-public' };
|
||||
}
|
||||
|
||||
return {
|
||||
action: 'lock',
|
||||
clearMirroredSession: false,
|
||||
};
|
||||
}
|
||||
@@ -38,6 +38,15 @@ export interface SessionPayload {
|
||||
iat: number;
|
||||
}
|
||||
|
||||
export interface SessionCookieProtocolRequest {
|
||||
headers: {
|
||||
get(name: string): string | null;
|
||||
};
|
||||
nextUrl: {
|
||||
protocol: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveLoginMode({
|
||||
managedAccountCount,
|
||||
managedAuthEnabled,
|
||||
@@ -56,6 +65,28 @@ export function resolveLoginMode({
|
||||
return legacyAuthConfigured ? 'legacy_password' : 'none';
|
||||
}
|
||||
|
||||
export function shouldUseSecureSessionCookie(request?: SessionCookieProtocolRequest): boolean {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!request) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const forwardedProtocol = request.headers
|
||||
.get('x-forwarded-proto')
|
||||
?.split(',')[0]
|
||||
?.trim()
|
||||
.toLowerCase();
|
||||
|
||||
if (forwardedProtocol) {
|
||||
return forwardedProtocol === 'https';
|
||||
}
|
||||
|
||||
return request.nextUrl.protocol === 'https:';
|
||||
}
|
||||
|
||||
const PBKDF2_ITERATIONS = 120_000;
|
||||
const PBKDF2_KEY_BYTES = 32;
|
||||
const SESSION_TOKEN_VERSION = 'v1';
|
||||
|
||||
+23
-25
@@ -8,11 +8,13 @@ import {
|
||||
normalizeUsername,
|
||||
parseBootstrapAccounts,
|
||||
resolveLoginMode,
|
||||
shouldUseSecureSessionCookie,
|
||||
signSessionPayload,
|
||||
verifyPassword,
|
||||
verifySessionToken,
|
||||
type LoginMode,
|
||||
type SeedAccountInput,
|
||||
type SessionCookieProtocolRequest,
|
||||
type SessionPayload,
|
||||
type StoredAccountRecord,
|
||||
} from '@/lib/server/auth-helpers';
|
||||
@@ -319,27 +321,6 @@ export async function getServerSession(request: NextRequest): Promise<ServerAuth
|
||||
return sessionPayloadToServerSession(payload);
|
||||
}
|
||||
|
||||
function applySessionCookie(response: NextResponse, token: string, persist: boolean): void {
|
||||
response.cookies.set(SESSION_COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
...(persist ? { maxAge: SESSION_MAX_AGE_SECONDS } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function clearSessionCookie(response: NextResponse): NextResponse {
|
||||
response.cookies.set(SESSION_COOKIE_NAME, '', {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
maxAge: 0,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
export function hasServerPermission(session: ServerAuthSession, permission: Permission): boolean {
|
||||
return hasResolvedPermission(session.role, permission, session.customPermissions);
|
||||
}
|
||||
@@ -457,7 +438,10 @@ export async function validatePremiumAccess(
|
||||
return authenticateLegacyAdminCredential(body.password);
|
||||
}
|
||||
|
||||
export async function createLoginResponse(session: ServerAuthSession): Promise<NextResponse> {
|
||||
export async function createLoginResponse(
|
||||
session: ServerAuthSession,
|
||||
request?: SessionCookieProtocolRequest,
|
||||
): Promise<NextResponse> {
|
||||
const config = await getPublicAuthConfig();
|
||||
const token = await signSession(session, config.loginMode);
|
||||
if (!token) {
|
||||
@@ -470,7 +454,13 @@ export async function createLoginResponse(session: ServerAuthSession): Promise<N
|
||||
...config,
|
||||
});
|
||||
|
||||
applySessionCookie(response, token, PERSIST_SESSION);
|
||||
response.cookies.set(SESSION_COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: shouldUseSecureSessionCookie(request),
|
||||
path: '/',
|
||||
...(PERSIST_SESSION ? { maxAge: SESSION_MAX_AGE_SECONDS } : {}),
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -485,8 +475,16 @@ export async function createSessionStatusResponse(request: NextRequest): Promise
|
||||
});
|
||||
}
|
||||
|
||||
export function logoutResponse(): NextResponse {
|
||||
return clearSessionCookie(NextResponse.json({ success: true }));
|
||||
export function logoutResponse(request?: SessionCookieProtocolRequest): NextResponse {
|
||||
const response = NextResponse.json({ success: true });
|
||||
response.cookies.set(SESSION_COOKIE_NAME, '', {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: shouldUseSecureSessionCookie(request),
|
||||
path: '/',
|
||||
maxAge: 0,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function listAccountInfo(): Promise<AccountInfo[]> {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "kvideo",
|
||||
"version": "4.9.10",
|
||||
"version": "4.9.11",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "kvideo",
|
||||
"version": "4.9.10",
|
||||
"version": "4.9.11",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kvideo",
|
||||
"version": "4.9.10",
|
||||
"version": "4.9.11",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "node scripts/next-with-lan-access.mjs dev",
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
hashPassword,
|
||||
parseBootstrapAccounts,
|
||||
resolveLoginMode,
|
||||
shouldUseSecureSessionCookie,
|
||||
signSessionPayload,
|
||||
verifyPassword,
|
||||
verifySessionToken,
|
||||
@@ -15,6 +16,35 @@ import {
|
||||
resolvePermissions,
|
||||
} from '@/lib/auth/permissions';
|
||||
|
||||
function withNodeEnv<T>(value: string | undefined, callback: () => T): T {
|
||||
const previous = process.env.NODE_ENV;
|
||||
|
||||
if (value === undefined) {
|
||||
delete process.env.NODE_ENV;
|
||||
} else {
|
||||
process.env.NODE_ENV = value;
|
||||
}
|
||||
|
||||
try {
|
||||
return callback();
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.NODE_ENV;
|
||||
} else {
|
||||
process.env.NODE_ENV = previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mockCookieRequest(protocol: 'http:' | 'https:', forwardedProtocol?: string) {
|
||||
return {
|
||||
headers: new Headers(
|
||||
forwardedProtocol ? { 'x-forwarded-proto': forwardedProtocol } : undefined,
|
||||
),
|
||||
nextUrl: { protocol },
|
||||
};
|
||||
}
|
||||
|
||||
test('parseBootstrapAccounts supports legacy password:name entries', () => {
|
||||
const accounts = parseBootstrapAccounts('pass1:张三:admin,pass2:李四:viewer:iptv_access|danmaku_api');
|
||||
|
||||
@@ -103,3 +133,16 @@ test('resolvePermissions applies role defaults and IPTV management inheritance',
|
||||
assert.equal(hasResolvedPermission('viewer', 'account_management'), false);
|
||||
assert.equal(hasRoleAtLeast('super_admin', 'admin'), true);
|
||||
});
|
||||
|
||||
test('session cookies are secure only for HTTPS production requests', () => {
|
||||
withNodeEnv('production', () => {
|
||||
assert.equal(shouldUseSecureSessionCookie(mockCookieRequest('http:')), false);
|
||||
assert.equal(shouldUseSecureSessionCookie(mockCookieRequest('https:')), true);
|
||||
assert.equal(shouldUseSecureSessionCookie(mockCookieRequest('http:', 'https')), true);
|
||||
assert.equal(shouldUseSecureSessionCookie(mockCookieRequest('https:', 'http')), false);
|
||||
});
|
||||
|
||||
withNodeEnv('development', () => {
|
||||
assert.equal(shouldUseSecureSessionCookie(mockCookieRequest('https:')), false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { resolvePasswordGateState } from '@/lib/auth/password-gate-state';
|
||||
import type { AuthSession } from '@/lib/store/auth-store';
|
||||
|
||||
const session: AuthSession = {
|
||||
accountId: 'account-1',
|
||||
profileId: 'profile-1',
|
||||
username: 'alice',
|
||||
name: 'Alice',
|
||||
role: 'viewer',
|
||||
customPermissions: [],
|
||||
mode: 'managed',
|
||||
};
|
||||
|
||||
test('PasswordGate unlocks when a server session exists even without a local mirror', () => {
|
||||
assert.deepEqual(resolvePasswordGateState({
|
||||
hasAuth: true,
|
||||
serverSession: session,
|
||||
mirroredSession: null,
|
||||
persistSession: true,
|
||||
}), {
|
||||
action: 'unlock-session',
|
||||
session,
|
||||
persistSession: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('PasswordGate clears stale local mirrors without requiring a page reload', () => {
|
||||
assert.deepEqual(resolvePasswordGateState({
|
||||
hasAuth: true,
|
||||
serverSession: null,
|
||||
mirroredSession: session,
|
||||
persistSession: true,
|
||||
}), {
|
||||
action: 'lock',
|
||||
clearMirroredSession: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('PasswordGate unlocks public deployments without auth state', () => {
|
||||
assert.deepEqual(resolvePasswordGateState({
|
||||
hasAuth: false,
|
||||
serverSession: null,
|
||||
mirroredSession: null,
|
||||
persistSession: true,
|
||||
}), {
|
||||
action: 'unlock-public',
|
||||
});
|
||||
});
|
||||
|
||||
test('PasswordGate remains locked when auth is configured and no valid session exists', () => {
|
||||
assert.deepEqual(resolvePasswordGateState({
|
||||
hasAuth: true,
|
||||
serverSession: null,
|
||||
mirroredSession: null,
|
||||
persistSession: true,
|
||||
}), {
|
||||
action: 'lock',
|
||||
clearMirroredSession: false,
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user