From a84fdb1e2201722316c9d13a2fb9399d989a6fc8 Mon Sep 17 00:00:00 2001 From: katelya Date: Tue, 9 Jun 2026 17:44:07 +0800 Subject: [PATCH] fix(auth): support login cookies over LAN HTTP --- README.md | 18 +- __tests__/auth-cookie.test.js | 143 ++++++++++++++ __tests__/auth-routes.test.js | 344 ++++++++++++++++++++++++++++++++++ src/app/api/login/route.ts | 82 ++++---- src/app/api/logout/route.ts | 19 +- src/app/login/page.tsx | 3 +- src/lib/auth-cookie.ts | 46 +++++ src/lib/request-protocol.ts | 67 +++++++ src/lib/safe-redirect.ts | 22 +++ 9 files changed, 691 insertions(+), 53 deletions(-) create mode 100644 __tests__/auth-cookie.test.js create mode 100644 __tests__/auth-routes.test.js create mode 100644 src/lib/auth-cookie.ts create mode 100644 src/lib/request-protocol.ts create mode 100644 src/lib/safe-redirect.ts diff --git a/README.md b/README.md index d5d9f877e..2862fd7ae 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,21 @@ docker pull ghcr.io/decohererk/decotv:v0.9.0 > **注意**:使用 `latest` 标签时,重启容器不会自动拉取新镜像,需要手动执行 `docker pull` 才能获取更新。使用版本号标签可以明确控制何时更新。 +### 访问协议与反向代理 + +DecoTV 支持直接通过 Docker 端口映射在局域网 HTTP 地址访问,例如 `http://192.168.1.10:3000`。这种场景下登录 Cookie 不会设置 `Secure` 属性,浏览器可以正常保存认证状态。 + +公网部署强烈建议使用 HTTPS。若前面有 Nginx/OpenResty 等反向代理,请确保把外部访问协议传给 DecoTV: + +```nginx +proxy_set_header Host $host; +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Forwarded-Proto $scheme; +``` + +DecoTV 会根据请求 URL、`X-Forwarded-Proto` 或标准 `Forwarded` 头判断实际协议。HTTPS 访问会设置 `Secure` Cookie;HTTP 局域网直连和 HTTP 反代不会设置 `Secure` Cookie。不要仅依赖容器内的 `NODE_ENV=production` 判断访问协议。 + ### Kvrocks 存储(推荐) ```yml @@ -331,7 +346,8 @@ volumes: 1. **浏览器 Cookie 问题**:尝试清除浏览器 Cookie 后重新登录 2. **残留数据库配置**:确保没有设置 `REDIS_URL`、`KV_REST_API_URL` 等数据库变量 -3. **使用了 HTTPS 代理**:如果你通过 Nginx 等反向代理使用 HTTPS,确保正确配置了 `X-Forwarded-Proto` 头 +3. **反向代理协议头缺失**:如果你通过 Nginx/OpenResty 等反向代理使用 HTTPS,确保正确配置 `X-Forwarded-Proto $scheme`,否则应用可能无法按外部访问协议设置 Cookie +4. **镜像未更新**:`latest` 镜像重启不会自动拉取新版本,升级前需要先执行 `docker pull ghcr.io/decohererk/decotv:latest` **Q: 如何从本地模式迁移到数据库模式?** diff --git a/__tests__/auth-cookie.test.js b/__tests__/auth-cookie.test.js new file mode 100644 index 000000000..bb35c7183 --- /dev/null +++ b/__tests__/auth-cookie.test.js @@ -0,0 +1,143 @@ +/* global describe, expect, it */ + +const { + getAuthCookieClearOptions, + getAuthCookieOptions, +} = require('../src/lib/auth-cookie'); +const { + getEffectiveRequestProtocol, + isSecureRequest, +} = require('../src/lib/request-protocol'); +const { getSafeRedirectPath } = require('../src/lib/safe-redirect'); + +function requestLike(url, headers = {}) { + return { + headers: new Headers(headers), + nextUrl: new URL(url), + }; +} + +describe('request protocol detection', () => { + it('does not treat production mode as HTTPS', () => { + const originalNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + const request = requestLike('http://192.168.1.20:3334/api/login'); + + expect(getEffectiveRequestProtocol(request)).toBe('http'); + expect(isSecureRequest(request)).toBe(false); + + process.env.NODE_ENV = originalNodeEnv; + }); + + it('detects direct HTTPS requests', () => { + const request = requestLike('https://example.com/api/login'); + + expect(getEffectiveRequestProtocol(request)).toBe('https'); + expect(isSecureRequest(request)).toBe(true); + }); + + it('uses the first X-Forwarded-Proto value from a proxy chain', () => { + const request = requestLike('http://127.0.0.1:3000/api/login', { + 'x-forwarded-proto': ' HTTPS, http ', + }); + + expect(getEffectiveRequestProtocol(request)).toBe('https'); + expect(isSecureRequest(request)).toBe(true); + }); + + it('falls back to the standard Forwarded proto value', () => { + const request = requestLike('http://127.0.0.1:3000/api/login', { + forwarded: 'for=192.0.2.60;proto="https";host=example.com', + }); + + expect(getEffectiveRequestProtocol(request)).toBe('https'); + expect(isSecureRequest(request)).toBe(true); + }); + + it('honors an HTTP reverse proxy header', () => { + const request = requestLike('http://127.0.0.1:3000/api/login', { + 'x-forwarded-proto': 'http', + }); + + expect(getEffectiveRequestProtocol(request)).toBe('http'); + expect(isSecureRequest(request)).toBe(false); + }); +}); + +describe('auth cookie options', () => { + it('sets HTTP IP cookies without Secure and never uses SameSite=None', () => { + const request = requestLike('http://192.168.1.20:3334/api/login'); + const options = getAuthCookieOptions(request, new Date('2026-01-01')); + + expect(options.secure).toBe(false); + expect(options.sameSite).toBe('lax'); + expect(options.httpOnly).toBe(false); + expect(options.path).toBe('/'); + }); + + it('sets localhost HTTP cookies without relying on Secure cookie exceptions', () => { + const request = requestLike('http://localhost:3000/api/login'); + const options = getAuthCookieOptions(request, new Date('2026-01-01')); + + expect(options.secure).toBe(false); + expect(options.sameSite).toBe('lax'); + }); + + it('sets Secure only for effective HTTPS requests', () => { + expect( + getAuthCookieOptions( + requestLike('https://example.com/api/login'), + new Date('2026-01-01'), + ).secure, + ).toBe(true); + + expect( + getAuthCookieOptions( + requestLike('http://127.0.0.1:3000/api/login', { + 'x-forwarded-proto': 'https', + }), + new Date('2026-01-01'), + ).secure, + ).toBe(true); + }); + + it('uses matching path and security attributes when clearing cookies', () => { + const httpOptions = getAuthCookieClearOptions( + requestLike('http://192.168.1.20:3334/api/logout'), + ); + const httpsOptions = getAuthCookieClearOptions( + requestLike('https://example.com/api/logout'), + ); + + expect(httpOptions.path).toBe('/'); + expect(httpOptions.maxAge).toBe(0); + expect(httpOptions.secure).toBe(false); + expect(httpOptions.sameSite).toBe('lax'); + + expect(httpsOptions.path).toBe('/'); + expect(httpsOptions.maxAge).toBe(0); + expect(httpsOptions.secure).toBe(true); + expect(httpsOptions.sameSite).toBe('lax'); + }); +}); + +describe('safe login redirects', () => { + it.each(['/', '/search?q=test', '/play?source=x&id=1'])( + 'allows internal redirect %s', + (redirect) => { + expect(getSafeRedirectPath(redirect)).toBe(redirect); + }, + ); + + it.each([ + 'https://evil.example', + 'http://evil.example', + '//evil.example', + 'javascript:alert(1)', + '', + null, + ])('rejects unsafe redirect %s', (redirect) => { + expect(getSafeRedirectPath(redirect)).toBe('/'); + }); +}); diff --git a/__tests__/auth-routes.test.js b/__tests__/auth-routes.test.js new file mode 100644 index 000000000..fef4d4eab --- /dev/null +++ b/__tests__/auth-routes.test.js @@ -0,0 +1,344 @@ +/** + * @jest-environment node + */ +/* global afterEach, beforeEach, describe, expect, it, jest */ + +function restoreEnv(name, value) { + if (value === undefined) { + delete process.env[name]; + return; + } + process.env[name] = value; +} + +function createRequest(url, options = {}) { + const { NextRequest } = require('next/server'); + return new NextRequest(url, options); +} + +function getSetCookie(response) { + return response.headers.get('set-cookie') || ''; +} + +function getCookieHeader(response) { + return getSetCookie(response).split(';')[0]; +} + +function decodeSetCookieValue(response) { + return decodeURIComponent(decodeURIComponent(getCookieHeader(response))); +} + +function expectCookieSecurity(setCookie, { secure }) { + expect(setCookie).toContain('auth='); + expect(setCookie).toContain('Path=/'); + expect(setCookie).toContain('SameSite=lax'); + expect(setCookie).not.toContain('SameSite=none'); + if (secure) { + expect(setCookie).toContain('Secure'); + } else { + expect(setCookie).not.toContain('Secure'); + } +} + +const originalEnv = { + NODE_ENV: process.env.NODE_ENV, + NEXT_PUBLIC_STORAGE_TYPE: process.env.NEXT_PUBLIC_STORAGE_TYPE, + NEXT_PUBLIC_AUTH_MODE: process.env.NEXT_PUBLIC_AUTH_MODE, + PUBLIC_ALLOW_ADMIN: process.env.PUBLIC_ALLOW_ADMIN, + USERNAME: process.env.USERNAME, + PASSWORD: process.env.PASSWORD, +}; + +let mockDb; +let mockGetConfig; + +function mockModules() { + mockDb = { + verifyUser: jest.fn( + async (username, password) => + username === 'member' && password === 'member_password', + ), + }; + mockGetConfig = jest.fn(async () => ({ + UserConfig: { + Users: [ + { username: 'member', role: 'admin', banned: false }, + { username: 'banned', role: 'user', banned: true }, + ], + }, + })); + + jest.doMock('@/lib/db', () => ({ db: mockDb })); + jest.doMock('@/lib/config', () => ({ getConfig: mockGetConfig })); +} + +function loadLoginRoute(storageType) { + jest.resetModules(); + process.env.NEXT_PUBLIC_STORAGE_TYPE = storageType; + mockModules(); + return require('../src/app/api/login/route'); +} + +function loadLogoutRoute() { + jest.resetModules(); + return require('../src/app/api/logout/route'); +} + +function loadProxy() { + jest.resetModules(); + return require('../src/proxy'); +} + +async function login({ storageType, url, body, headers = {}, origin }) { + const route = loadLoginRoute(storageType); + const requestHeaders = { + 'content-type': 'application/json', + host: new URL(url).host, + ...headers, + }; + if (origin) { + requestHeaders.origin = origin; + } + + const response = await route.POST( + createRequest(url, { + method: 'POST', + headers: requestHeaders, + body: JSON.stringify(body), + }), + ); + + return response; +} + +describe('auth login and logout routes', () => { + beforeEach(() => { + jest.resetModules(); + process.env.NODE_ENV = 'production'; + process.env.NEXT_PUBLIC_AUTH_MODE = 'password'; + delete process.env.PUBLIC_ALLOW_ADMIN; + process.env.USERNAME = 'admin'; + process.env.PASSWORD = 'test_password'; + }); + + afterEach(() => { + jest.dontMock('@/lib/db'); + jest.dontMock('@/lib/config'); + Object.entries(originalEnv).forEach(([name, value]) => + restoreEnv(name, value), + ); + }); + + it('supports Docker production login over HTTP LAN IP without Secure cookies', async () => { + const response = await login({ + storageType: 'kvrocks', + url: 'http://192.168.1.20:3334/api/login', + body: { username: 'admin', password: 'test_password' }, + }); + + expect(response.status).toBe(200); + expectCookieSecurity(getSetCookie(response), { secure: false }); + + const { proxy } = loadProxy(); + const protectedResponse = await proxy( + createRequest('http://192.168.1.20:3334/', { + headers: { cookie: getCookieHeader(response) }, + }), + ); + + expect(protectedResponse.status).not.toBe(307); + expect(protectedResponse.headers.get('location')).toBeNull(); + }); + + it('supports HTTP localhost without Secure cookies', async () => { + const response = await login({ + storageType: 'localstorage', + url: 'http://localhost:3000/api/login', + body: { password: 'test_password' }, + }); + + expect(response.status).toBe(200); + expectCookieSecurity(getSetCookie(response), { secure: false }); + + const { proxy } = loadProxy(); + const protectedResponse = await proxy( + createRequest('http://localhost:3000/search?q=test', { + headers: { cookie: getCookieHeader(response) }, + }), + ); + + expect(protectedResponse.status).not.toBe(307); + expect(protectedResponse.headers.get('location')).toBeNull(); + }); + + it('marks direct HTTPS login cookies as Secure', async () => { + const response = await login({ + storageType: 'kvrocks', + url: 'https://example.com/api/login', + body: { username: 'admin', password: 'test_password' }, + }); + + expect(response.status).toBe(200); + expectCookieSecurity(getSetCookie(response), { secure: true }); + }); + + it('marks HTTPS reverse proxy login cookies as Secure', async () => { + const response = await login({ + storageType: 'kvrocks', + url: 'http://127.0.0.1:3000/api/login', + headers: { 'x-forwarded-proto': 'https' }, + body: { username: 'admin', password: 'test_password' }, + }); + + expect(response.status).toBe(200); + expectCookieSecurity(getSetCookie(response), { secure: true }); + }); + + it('uses the first proxy protocol value from multi-hop headers', async () => { + const response = await login({ + storageType: 'kvrocks', + url: 'http://127.0.0.1:3000/api/login', + headers: { 'x-forwarded-proto': 'https, http' }, + body: { username: 'admin', password: 'test_password' }, + }); + + expect(response.status).toBe(200); + expectCookieSecurity(getSetCookie(response), { secure: true }); + }); + + it('keeps HTTP reverse proxy login cookies non-Secure', async () => { + const response = await login({ + storageType: 'kvrocks', + url: 'http://127.0.0.1:3000/api/login', + headers: { 'x-forwarded-proto': 'http' }, + body: { username: 'admin', password: 'test_password' }, + }); + + expect(response.status).toBe(200); + expectCookieSecurity(getSetCookie(response), { secure: false }); + }); + + it.each(['redis', 'upstash'])( + 'uses the shared database-mode owner login path for %s', + async (storageType) => { + const response = await login({ + storageType, + url: 'http://127.0.0.1:3000/api/login', + body: { username: 'admin', password: 'test_password' }, + }); + + expect(response.status).toBe(200); + expectCookieSecurity(getSetCookie(response), { secure: false }); + expect(mockDb.verifyUser).not.toHaveBeenCalled(); + }, + ); + + it('does not require the database for the kvrocks environment owner branch', async () => { + const response = await login({ + storageType: 'kvrocks', + url: 'http://127.0.0.1:3000/api/login', + body: { username: 'admin', password: 'test_password' }, + }); + + expect(response.status).toBe(200); + expect(mockDb.verifyUser).not.toHaveBeenCalled(); + }); + + it('keeps the database user login path working', async () => { + const response = await login({ + storageType: 'kvrocks', + url: 'http://127.0.0.1:3000/api/login', + body: { username: 'member', password: 'member_password' }, + }); + + expect(mockGetConfig).toHaveBeenCalled(); + expect(mockDb.verifyUser).toHaveBeenCalledWith('member', 'member_password'); + expect(response.status).toBe(200); + expect(decodeSetCookieValue(response)).toContain('"username":"member"'); + expect(decodeSetCookieValue(response)).toContain('"role":"admin"'); + }); + + it('expires auth cookies on HTTP and HTTPS logout using matching paths', async () => { + const route = loadLogoutRoute(); + + const httpResponse = await route.POST( + createRequest('http://192.168.1.20:3334/api/logout', { + method: 'POST', + }), + ); + const httpsResponse = await route.POST( + createRequest('https://example.com/api/logout', { method: 'POST' }), + ); + + expectCookieSecurity(getSetCookie(httpResponse), { secure: false }); + expect(getSetCookie(httpResponse)).toContain('Max-Age=0'); + + expectCookieSecurity(getSetCookie(httpsResponse), { secure: true }); + expect(getSetCookie(httpsResponse)).toContain('Max-Age=0'); + + const { proxy } = loadProxy(); + const protectedResponse = await proxy( + createRequest('http://192.168.1.20:3334/', { + headers: { cookie: getCookieHeader(httpResponse) }, + }), + ); + + expect(protectedResponse.status).toBe(307); + expect(protectedResponse.headers.get('location')).toContain( + '/login?redirect=%2F', + ); + }); + + it('does not emit wildcard CORS with credentials for login', async () => { + const noOriginResponse = await login({ + storageType: 'localstorage', + url: 'http://127.0.0.1:3000/api/login', + body: { password: 'test_password' }, + }); + + expect( + noOriginResponse.headers.get('access-control-allow-origin'), + ).toBeNull(); + expect( + noOriginResponse.headers.get('access-control-allow-credentials'), + ).toBeNull(); + + const sameOriginResponse = await login({ + storageType: 'localstorage', + url: 'http://127.0.0.1:3000/api/login', + origin: 'http://127.0.0.1:3000', + body: { password: 'test_password' }, + }); + + expect(sameOriginResponse.headers.get('access-control-allow-origin')).toBe( + 'http://127.0.0.1:3000', + ); + expect( + sameOriginResponse.headers.get('access-control-allow-credentials'), + ).toBe('true'); + }); + + it('keeps public mode frontend access open and admin rules gated', async () => { + process.env.NEXT_PUBLIC_AUTH_MODE = 'public'; + process.env.PASSWORD = 'test_password'; + + const { proxy } = loadProxy(); + + const frontendResponse = await proxy( + createRequest('http://localhost:3000/'), + ); + expect(frontendResponse.status).not.toBe(307); + + const adminResponse = await proxy( + createRequest('http://localhost:3000/admin'), + ); + expect(adminResponse.status).toBe(307); + expect(adminResponse.headers.get('location')).toContain('/login'); + + process.env.PUBLIC_ALLOW_ADMIN = 'true'; + const allowedAdminResponse = await proxy( + createRequest('http://localhost:3000/admin'), + ); + expect(allowedAdminResponse.status).not.toBe(307); + }); +}); diff --git a/src/app/api/login/route.ts b/src/app/api/login/route.ts index 6b0ead35d..1dee069af 100644 --- a/src/app/api/login/route.ts +++ b/src/app/api/login/route.ts @@ -1,9 +1,16 @@ -/* eslint-disable no-console,@typescript-eslint/no-explicit-any */ +/* eslint-disable no-console */ import { NextRequest, NextResponse } from 'next/server'; +import { + AUTH_COOKIE_NAME, + getAuthCookieClearOptions, + getAuthCookieExpires, + getAuthCookieOptions, +} from '@/lib/auth-cookie'; import { isPublicMode } from '@/lib/auth-mode'; import { getConfig } from '@/lib/config'; import { db } from '@/lib/db'; +import { getEffectiveRequestOrigin } from '@/lib/request-protocol'; export const runtime = 'nodejs'; @@ -16,41 +23,20 @@ const STORAGE_TYPE = | 'kvrocks' | undefined) || 'localstorage'; -function isSecureRequest(req: NextRequest): boolean { - const forwardedProto = req.headers.get('x-forwarded-proto'); - return ( - forwardedProto === 'https' || - req.nextUrl.protocol === 'https:' || - process.env.NODE_ENV === 'production' - ); -} - -function getAuthCookieOptions(req: NextRequest, expires: Date) { - const secure = isSecureRequest(req); - return { - path: '/', - expires, - sameSite: secure ? ('none' as const) : ('lax' as const), - httpOnly: false, - secure, - }; -} - function withCors(response: NextResponse, req: NextRequest): NextResponse { const origin = req.headers.get('origin'); - response.headers.set('Access-Control-Allow-Origin', origin || '*'); + if (!origin || origin !== getEffectiveRequestOrigin(req)) { + return response; + } + + response.headers.set('Access-Control-Allow-Origin', origin); response.headers.set('Access-Control-Allow-Methods', 'POST, OPTIONS'); response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Cookie'); response.headers.set('Access-Control-Allow-Credentials', 'true'); + response.headers.append('Vary', 'Origin'); return response; } -function getAuthCookieExpires(): Date { - const expires = new Date(); - expires.setDate(expires.getDate() + 30); - return expires; -} - // 生成签名 async function generateSignature( data: string, @@ -85,7 +71,13 @@ async function generateAuthCookie( role?: 'owner' | 'admin' | 'user', includePassword = false, ): Promise { - const authData: any = { role: role || 'user' }; + const authData: { + role: 'owner' | 'admin' | 'user'; + password?: string; + username?: string; + signature?: string; + timestamp?: number; + } = { role: role || 'user' }; // 只在需要时包含 password if (includePassword && password) { @@ -118,9 +110,11 @@ export async function POST(req: NextRequest) { const response = NextResponse.json({ ok: true }); // 清除可能存在的认证cookie - response.cookies.set('auth', '', { - ...getAuthCookieOptions(req, new Date(0)), - }); + response.cookies.set( + AUTH_COOKIE_NAME, + '', + getAuthCookieClearOptions(req), + ); return withCors(response, req); } @@ -147,9 +141,11 @@ export async function POST(req: NextRequest) { ); // localstorage 模式包含 password const expires = getAuthCookieExpires(); - response.cookies.set('auth', cookieValue, { - ...getAuthCookieOptions(req, expires), - }); + response.cookies.set( + AUTH_COOKIE_NAME, + cookieValue, + getAuthCookieOptions(req, expires), + ); return withCors(response, req); } @@ -179,9 +175,11 @@ export async function POST(req: NextRequest) { ); // 数据库模式不包含 password const expires = getAuthCookieExpires(); - response.cookies.set('auth', cookieValue, { - ...getAuthCookieOptions(req, expires), - }); + response.cookies.set( + AUTH_COOKIE_NAME, + cookieValue, + getAuthCookieOptions(req, expires), + ); return withCors(response, req); } else if (username === process.env.USERNAME) { @@ -214,9 +212,11 @@ export async function POST(req: NextRequest) { ); // 数据库模式不包含 password const expires = getAuthCookieExpires(); - response.cookies.set('auth', cookieValue, { - ...getAuthCookieOptions(req, expires), - }); + response.cookies.set( + AUTH_COOKIE_NAME, + cookieValue, + getAuthCookieOptions(req, expires), + ); return withCors(response, req); } catch (err) { diff --git a/src/app/api/logout/route.ts b/src/app/api/logout/route.ts index 8bfa015f0..b4a68efa3 100644 --- a/src/app/api/logout/route.ts +++ b/src/app/api/logout/route.ts @@ -1,18 +1,17 @@ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; + +import { AUTH_COOKIE_NAME, getAuthCookieClearOptions } from '@/lib/auth-cookie'; export const runtime = 'nodejs'; -export async function POST() { +export async function POST(request: NextRequest) { const response = NextResponse.json({ ok: true }); - // 清除认证cookie - response.cookies.set('auth', '', { - path: '/', - expires: new Date(0), - sameSite: 'lax', // 改为 lax 以支持 PWA - httpOnly: false, // PWA 需要客户端可访问 - secure: false, // 根据协议自动设置 - }); + response.cookies.set( + AUTH_COOKIE_NAME, + '', + getAuthCookieClearOptions(request), + ); return response; } diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index c43235b82..0e0b2dbb0 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -4,6 +4,7 @@ import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; import { Suspense, useEffect, useState } from 'react'; +import { getSafeRedirectPath } from '@/lib/safe-redirect'; import { CURRENT_VERSION } from '@/lib/version'; import { checkForUpdates, @@ -224,7 +225,7 @@ function LoginPageClient() { }); if (res.ok) { - const redirect = searchParams.get('redirect') || '/'; + const redirect = getSafeRedirectPath(searchParams.get('redirect')); router.replace(redirect); } else if (res.status === 401) { setError('密码错误'); diff --git a/src/lib/auth-cookie.ts b/src/lib/auth-cookie.ts new file mode 100644 index 000000000..3bd302ef9 --- /dev/null +++ b/src/lib/auth-cookie.ts @@ -0,0 +1,46 @@ +import type { NextRequest } from 'next/server'; + +import { isSecureRequest } from './request-protocol'; + +export const AUTH_COOKIE_NAME = 'auth'; +export const AUTH_COOKIE_DAYS = 30; + +export interface AuthCookieOptions { + path: '/'; + expires: Date; + sameSite: 'lax'; + httpOnly: false; + secure: boolean; + maxAge?: number; +} + +export function getAuthCookieExpires(): Date { + const expires = new Date(); + expires.setDate(expires.getDate() + AUTH_COOKIE_DAYS); + return expires; +} + +export function getAuthCookieOptions( + request: Pick, + expires = getAuthCookieExpires(), +): AuthCookieOptions { + return { + path: '/', + expires, + // DecoTV login is same-origin; Lax works for HTTP LAN, localhost and HTTPS + // without creating the invalid SameSite=None + Secure=false pair. + sameSite: 'lax', + // Existing client UI reads the auth role from document.cookie. + httpOnly: false, + secure: isSecureRequest(request), + }; +} + +export function getAuthCookieClearOptions( + request: Pick, +): AuthCookieOptions { + return { + ...getAuthCookieOptions(request, new Date(0)), + maxAge: 0, + }; +} diff --git a/src/lib/request-protocol.ts b/src/lib/request-protocol.ts new file mode 100644 index 000000000..c587e96e2 --- /dev/null +++ b/src/lib/request-protocol.ts @@ -0,0 +1,67 @@ +import type { NextRequest } from 'next/server'; + +export type EffectiveRequestProtocol = 'http' | 'https'; + +type RequestWithUrl = Pick; + +function firstHeaderValue(value: string | null): string | null { + const first = value?.split(',')[0]?.trim(); + return first || null; +} + +function normalizeProtocol( + value: string | null, +): EffectiveRequestProtocol | null { + const normalized = value?.replace(/^"|"$/g, '').trim().toLowerCase(); + return normalized === 'http' || normalized === 'https' ? normalized : null; +} + +function getForwardedProto( + header: string | null, +): EffectiveRequestProtocol | null { + const firstForwarded = firstHeaderValue(header); + if (!firstForwarded) return null; + + for (const part of firstForwarded.split(';')) { + const [rawName, ...rawValueParts] = part.split('='); + if (rawName?.trim().toLowerCase() !== 'proto') continue; + + return normalizeProtocol(rawValueParts.join('=')); + } + + return null; +} + +export function getEffectiveRequestProtocol( + request: RequestWithUrl, +): EffectiveRequestProtocol { + const forwardedProto = normalizeProtocol( + firstHeaderValue(request.headers.get('x-forwarded-proto')), + ); + if (forwardedProto) return forwardedProto; + + const standardForwardedProto = getForwardedProto( + request.headers.get('forwarded'), + ); + if (standardForwardedProto) return standardForwardedProto; + + return request.nextUrl.protocol.toLowerCase() === 'https:' ? 'https' : 'http'; +} + +export function isSecureRequest(request: RequestWithUrl): boolean { + return getEffectiveRequestProtocol(request) === 'https'; +} + +export function getEffectiveRequestHost(request: RequestWithUrl): string { + return ( + firstHeaderValue(request.headers.get('x-forwarded-host')) || + request.headers.get('host')?.trim() || + request.nextUrl.host + ); +} + +export function getEffectiveRequestOrigin(request: RequestWithUrl): string { + return `${getEffectiveRequestProtocol(request)}://${getEffectiveRequestHost( + request, + )}`; +} diff --git a/src/lib/safe-redirect.ts b/src/lib/safe-redirect.ts new file mode 100644 index 000000000..ff19edc46 --- /dev/null +++ b/src/lib/safe-redirect.ts @@ -0,0 +1,22 @@ +const FALLBACK_REDIRECT = '/'; +const INTERNAL_ORIGIN = 'http://decotv.local'; + +export function getSafeRedirectPath( + redirect: string | null | undefined, +): string { + const value = redirect?.trim(); + if (!value || !value.startsWith('/') || value.startsWith('//')) { + return FALLBACK_REDIRECT; + } + + try { + const parsed = new URL(value, INTERNAL_ORIGIN); + if (parsed.origin !== INTERNAL_ORIGIN) { + return FALLBACK_REDIRECT; + } + + return `${parsed.pathname}${parsed.search}${parsed.hash}`; + } catch { + return FALLBACK_REDIRECT; + } +}