diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e8351f..19ee3db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 - 从首页移除硬编码的快捷搜索区,不再显示“内容分类”“电影类型”“年份”三组按钮。 diff --git a/app-release.json b/app-release.json index 0c68a29..dc3a2c1 100644 --- a/app-release.json +++ b/app-release.json @@ -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", diff --git a/app/api/auth/route.ts b/app/api/auth/route.ts index 2c04aaa..4fdc364 100644 --- a/app/api/auth/route.ts +++ b/app/api/auth/route.ts @@ -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 }); } diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index 7089665..2463982 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -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); } diff --git a/components/PasswordGate.tsx b/components/PasswordGate.tsx index 0bfc4d9..d97d059 100644 --- a/components/PasswordGate.tsx +++ b/components/PasswordGate.tsx @@ -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 { diff --git a/lib/auth/password-gate-state.ts b/lib/auth/password-gate-state.ts new file mode 100644 index 0000000..6a56a13 --- /dev/null +++ b/lib/auth/password-gate-state.ts @@ -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, + }; +} diff --git a/lib/server/auth-helpers.ts b/lib/server/auth-helpers.ts index e9cc569..4bcd687 100644 --- a/lib/server/auth-helpers.ts +++ b/lib/server/auth-helpers.ts @@ -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'; diff --git a/lib/server/auth.ts b/lib/server/auth.ts index 70ed322..2a71490 100644 --- a/lib/server/auth.ts +++ b/lib/server/auth.ts @@ -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 { +export async function createLoginResponse( + session: ServerAuthSession, + request?: SessionCookieProtocolRequest, +): Promise { const config = await getPublicAuthConfig(); const token = await signSession(session, config.loginMode); if (!token) { @@ -470,7 +454,13 @@ export async function createLoginResponse(session: ServerAuthSession): Promise { diff --git a/package-lock.json b/package-lock.json index 64fcfa3..03a8901 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 4968451..85d09b5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tests/auth.test.ts b/tests/auth.test.ts index cf6ee22..983d15c 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -5,6 +5,7 @@ import { hashPassword, parseBootstrapAccounts, resolveLoginMode, + shouldUseSecureSessionCookie, signSessionPayload, verifyPassword, verifySessionToken, @@ -15,6 +16,35 @@ import { resolvePermissions, } from '@/lib/auth/permissions'; +function withNodeEnv(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); + }); +}); diff --git a/tests/password-gate-state.test.ts b/tests/password-gate-state.test.ts new file mode 100644 index 0000000..e7b43ca --- /dev/null +++ b/tests/password-gate-state.test.ts @@ -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, + }); +});