fix login gate session loop

This commit is contained in:
kuekhaoyang
2026-07-09 17:39:46 +08:00
parent db72a74817
commit d6f2a2e3c7
12 changed files with 258 additions and 49 deletions
+7
View File
@@ -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
View File
@@ -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",
+1 -1
View File
@@ -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 });
}
+2 -2
View File
@@ -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
View File
@@ -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 {
+51
View File
@@ -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,
};
}
+31
View File
@@ -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
View File
@@ -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[]> {
+2 -2
View File
@@ -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
View File
@@ -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",
+43
View File
@@ -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);
});
});
+63
View File
@@ -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,
});
});