Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## [100.1.4] - 2026-08-18

### Added

- 支持标准 OIDC 登录,并映射到现有用户系统(收藏、播放记录、角色权限保持不变)

## [100.1.3] - 2026-05-28

### Fixed
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,28 @@ dockge/komodo 等 docker compose UI 也有自动更新功能
| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY | 自定义豆瓣图片代理 URL | url prefix | (空) |
| NEXT_PUBLIC_DISABLE_YELLOW_FILTER | 关闭色情内容过滤 | true/false | false |
| NEXT_PUBLIC_FLUID_SEARCH | 是否开启搜索接口流式输出 | true/ false | true |
| OIDC_ISSUER | OIDC 发行方地址(配置后自动启用 OIDC) | `https://auth.example.com` 或完整 discovery URL | 空(不启用) |
| OIDC_CLIENT_ID | OIDC 客户端 ID | 字符串 | 空 |
| OIDC_CLIENT_SECRET | OIDC 客户端密钥(机密客户端需要) | 字符串 | 空 |
| OIDC_REDIRECT_URI | OIDC 回调地址 | 完整 URL | `{SITE_BASE}/api/oidc/callback` |
| OIDC_SCOPES | OIDC 授权范围 | 空格分隔 | `openid profile email` |
| OIDC_USERNAME_CLAIM | 映射到 LunaTV 用户名的 claim | `preferred_username` / `email` / `sub` 等 | `preferred_username` |
| OIDC_AUTO_REGISTER | 首次 OIDC 登录是否自动创建本地用户 | true/false | true |
| OIDC_DISABLE_PASSWORD | 启用 OIDC 后是否隐藏账号密码登录 | true/false | false |
| OIDC_BUTTON_TEXT | 登录页 OIDC 按钮文案 | 任意字符串 | OIDC 登录 |
| OIDC_ENABLED | 强制开关(一般无需设置) | true/false | 配置了 ISSUER + CLIENT_ID 即为 true |
| OIDC_CLIENT_AUTH | 令牌端点客户端认证方式 | basic / post / none | 按 IdP 发现文档自动选择,缺省 post |

OIDC 说明:

- **仅支持** `redis` / `kvrocks` / `upstash` 多用户存储,不支持 `localstorage` 单密码模式
- 请在身份提供方登记回调地址:`https://你的站点/api/oidc/callback`,并设置相同的 `SITE_BASE` 或 `OIDC_REDIRECT_URI`。反代 / HTTPS / 非 localhost 访问时必须显式配置,否则换票会因回调地址不一致失败
- 机密客户端必须配置 `OIDC_CLIENT_SECRET`。若 IdP 只接受 Basic 认证,可设 `OIDC_CLIENT_AUTH=basic`
- OIDC 用户会进入现有用户系统:可在管理面板封禁、提权、分配用户组;收藏和播放记录按用户名同步
- 用户名优先取 `OIDC_USERNAME_CLAIM`,缺省回退 `preferred_username` → `email` → `name` → `sub`
- 若解析出的用户名等于 `USERNAME`,将以站长身份登录
- 已存在的同名本地用户会自动绑定 OIDC `sub`,之后即使 IdP 用户名变更也按 `sub` 识别
- 建议把用户范围限制在可信 IdP 内;若只允许预创建账号登录,设置 `OIDC_AUTO_REGISTER=false`

NEXT_PUBLIC_DOUBAN_PROXY_TYPE 选项解释:

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"framer-motion": "^12.18.1",
"he": "^1.2.0",
"hls.js": "^1.6.10",
"jose": "5.10.0",
"lucide-react": "^0.438.0",
"media-icons": "^1.1.5",
"next": "^14.2.23",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 8 additions & 1 deletion src/app/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1139,7 +1139,14 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
)}
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100'>
{user.username}
<div className='flex items-center gap-2'>
<span>{user.username}</span>
{user.from === 'oidc' && (
<span className='px-1.5 py-0.5 text-[10px] rounded-full bg-sky-100 dark:bg-sky-900/30 text-sky-700 dark:text-sky-300'>
OIDC
</span>
)}
</div>
</td>
<td className='px-6 py-4 whitespace-nowrap'>
<span
Expand Down
103 changes: 11 additions & 92 deletions src/app/api/login/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
import { NextRequest, NextResponse } from 'next/server';

import { applyAuthCookie, clearAuthCookie, generateAuthCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { db } from '@/lib/db';

Expand All @@ -15,58 +16,6 @@ const STORAGE_TYPE =
| 'kvrocks'
| undefined) || 'localstorage';

// 生成签名
async function generateSignature(
data: string,
secret: string
): Promise<string> {
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const messageData = encoder.encode(data);

// 导入密钥
const key = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);

// 生成签名
const signature = await crypto.subtle.sign('HMAC', key, messageData);

// 转换为十六进制字符串
return Array.from(new Uint8Array(signature))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}

// 生成认证Cookie(带签名)
async function generateAuthCookie(
username?: string,
password?: string,
role?: 'owner' | 'admin' | 'user',
includePassword = false
): Promise<string> {
const authData: any = { role: role || 'user' };

// 只在需要时包含 password
if (includePassword && password) {
authData.password = password;
}

if (username && process.env.PASSWORD) {
authData.username = username;
// 使用密码作为密钥对用户名进行签名
const signature = await generateSignature(username, process.env.PASSWORD);
authData.signature = signature;
authData.timestamp = Date.now(); // 添加时间戳防重放攻击
}

return encodeURIComponent(JSON.stringify(authData));
}

export async function POST(req: NextRequest) {
try {
// 本地 / localStorage 模式——仅校验固定密码
Expand All @@ -78,13 +27,7 @@ export async function POST(req: 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, // 根据协议自动设置
});
clearAuthCookie(response);

return response;
}
Expand All @@ -107,18 +50,10 @@ export async function POST(req: NextRequest) {
undefined,
password,
'user',
true
true,
'password'
); // localstorage 模式包含 password
const expires = new Date();
expires.setDate(expires.getDate() + 7); // 7天过期

response.cookies.set('auth', cookieValue, {
path: '/',
expires,
sameSite: 'lax', // 改为 lax 以支持 PWA
httpOnly: false, // PWA 需要客户端可访问
secure: false, // 根据协议自动设置
});
applyAuthCookie(response, cookieValue);

return response;
}
Expand All @@ -144,18 +79,10 @@ export async function POST(req: NextRequest) {
username,
password,
'owner',
false
false,
'password'
); // 数据库模式不包含 password
const expires = new Date();
expires.setDate(expires.getDate() + 7); // 7天过期

response.cookies.set('auth', cookieValue, {
path: '/',
expires,
sameSite: 'lax', // 改为 lax 以支持 PWA
httpOnly: false, // PWA 需要客户端可访问
secure: false, // 根据协议自动设置
});
applyAuthCookie(response, cookieValue);

return response;
} else if (username === process.env.USERNAME) {
Expand Down Expand Up @@ -184,18 +111,10 @@ export async function POST(req: NextRequest) {
username,
password,
user?.role || 'user',
false
false,
'password'
); // 数据库模式不包含 password
const expires = new Date();
expires.setDate(expires.getDate() + 7); // 7天过期

response.cookies.set('auth', cookieValue, {
path: '/',
expires,
sameSite: 'lax', // 改为 lax 以支持 PWA
httpOnly: false, // PWA 需要客户端可访问
secure: false, // 根据协议自动设置
});
applyAuthCookie(response, cookieValue);

return response;
} catch (err) {
Expand Down
27 changes: 21 additions & 6 deletions src/app/api/logout/route.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,33 @@
import { NextResponse } from 'next/server';

import { clearAuthCookie } from '@/lib/auth';
import {
OIDC_CALLBACK_URI_COOKIE,
OIDC_FLOW_COOKIE_BASE,
OIDC_NONCE_COOKIE,
OIDC_REDIRECT_COOKIE,
OIDC_STATE_COOKIE,
OIDC_VERIFIER_COOKIE,
} from '@/lib/oidc';

export const runtime = 'nodejs';

export async function POST() {
const response = NextResponse.json({ ok: true });

// 清除认证cookie
response.cookies.set('auth', '', {
path: '/',
clearAuthCookie(response);

const expired = {
...OIDC_FLOW_COOKIE_BASE,
maxAge: 0,
expires: new Date(0),
sameSite: 'lax', // 改为 lax 以支持 PWA
httpOnly: false, // PWA 需要客户端可访问
secure: false, // 根据协议自动设置
});
};
response.cookies.set(OIDC_STATE_COOKIE, '', expired);
response.cookies.set(OIDC_NONCE_COOKIE, '', expired);
response.cookies.set(OIDC_VERIFIER_COOKIE, '', expired);
response.cookies.set(OIDC_REDIRECT_COOKIE, '', expired);
response.cookies.set(OIDC_CALLBACK_URI_COOKIE, '', expired);

return response;
}
Loading