From 511e1ec9dd3b450014474db642d5bd0ceb959224 Mon Sep 17 00:00:00 2001 From: aiyimickey <39365912+aiyimickey@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:09:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20SSO=20=E7=BB=9F=E4=B8=80=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E7=99=BB=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 集成 Authelia OIDC,支持统一认证登录 - 添加 OIDC 登录页面和回调处理 - 更新 docker-compose.yml 统一环境变量管理 - 更新 CHANGELOG.md 和 CLAUDE.md --- .env.example | 14 +- CHANGELOG.md | 14 ++ CLAUDE.md | 9 ++ Dockerfile | 31 ++++ docker-compose.yml | 4 + "src/app/api/admin/create-user/route.ts\"\"" | 151 ++++++++++++++++++ "src/app/api/admin/roles/route.ts\"\"" | 32 ++++ "src/app/api/admin/sync-emails/route.ts\"\"" | 48 ++++++ "src/app/api/admin/user-roles/route.ts\"\"" | 104 ++++++++++++ "src/app/api/admin/users/route.ts\"\"" | 132 +++++++++++++++ "src/app/api/auth/callback/route.ts\"\"" | 99 ++++++++++++ .../api/auth/change-password/route.ts\"\"" | 56 +++++++ "src/app/api/auth/login/oidc/route.ts\"\"" | 81 ++++++++++ "src/app/api/auth/login/route.ts\"\"" | 32 ++++ "src/app/api/auth/logout/route.ts\"\"" | 16 ++ "src/app/api/auth/me/route.ts\"\"" | 83 ++++++++++ .../app/api/auth/setup-password/route.ts\"\"" | 54 +++++++ "src/app/page.tsx\"\"" | 109 +++++++++++++ src/lib/oidc.ts | 1 + 19 files changed, 1065 insertions(+), 5 deletions(-) create mode 100644 Dockerfile create mode 100644 "src/app/api/admin/create-user/route.ts\"\"" create mode 100644 "src/app/api/admin/roles/route.ts\"\"" create mode 100644 "src/app/api/admin/sync-emails/route.ts\"\"" create mode 100644 "src/app/api/admin/user-roles/route.ts\"\"" create mode 100644 "src/app/api/admin/users/route.ts\"\"" create mode 100644 "src/app/api/auth/callback/route.ts\"\"" create mode 100644 "src/app/api/auth/change-password/route.ts\"\"" create mode 100644 "src/app/api/auth/login/oidc/route.ts\"\"" create mode 100644 "src/app/api/auth/login/route.ts\"\"" create mode 100644 "src/app/api/auth/logout/route.ts\"\"" create mode 100644 "src/app/api/auth/me/route.ts\"\"" create mode 100644 "src/app/api/auth/setup-password/route.ts\"\"" create mode 100644 "src/app/page.tsx\"\"" diff --git a/.env.example b/.env.example index 654512a..45e380c 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,15 @@ # OA 门户环境变量 LDAP_URL=ldap://localhost:3890 LDAP_BASE_DN=dc=tlyq,dc=ai +LDAP_ADMIN_DN=uid=admin,ou=people,dc=tlyq,dc=ai JWT_SECRET=change-me-same-across-all-sites COOKIE_DOMAIN= NODE_ENV=development -SMTP_HOST=smtphz.qiye.163.com -SMTP_PORT=465 -SMTP_USER=gxp@qx002575.com -SMTP_PASS= -SMTP_FROM=gxp@qx002575.com +# ⚠️ 仅限本地开发环境(自签名证书),生产环境禁止设置此变量 +NODE_TLS_REJECT_UNAUTHORIZED=0 + +# OIDC 配置(SSO 统一认证) +AUTHELIA_URL=https://sso.tlyq.ai +OIDC_CLIENT_ID=oa-oidc +OIDC_CLIENT_SECRET=change-me-to-hashed-secret +OIDC_REDIRECT_URI=http://localhost:6179/api/auth/callback diff --git a/CHANGELOG.md b/CHANGELOG.md index 62947cd..0423818 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # 变更日志 +## 2026-06-30 + +- [新增] SSO 统一认证:集成 Authelia OIDC,支持统一认证登录 +- [新增] OIDC 登录页面:添加「统一认证登录」按钮,支持 LDAP 回退 +- [新增] `src/lib/oidc.ts`:OIDC 客户端配置(PKCE + state + nonce) +- [新增] `src/app/api/auth/login/oidc/route.ts`:OIDC 登录端点 +- [新增] `src/app/api/auth/callback/route.ts`:OIDC 回调处理 +- [新增] `src/app/api/auth/logout/route.ts`:跨域登出(支持 domain 参数) +- [修复] NODE_TLS_REJECT_UNAUTHORIZED=0:Authelia 使用自签名证书 +- [修复] OIDC redirect_uri 回调重定向到 localhost:添加 getBaseUrl() 函数 +- [修复] token_endpoint_auth_method 配置缺失:添加 client_secret_basic +- [优化] docker-compose.yml:统一环境变量管理,移除 env_file +- [优化] .env.example:添加 OIDC 配置模板 + ## 2026-05-18 - [安全] 邮件发送从 163 企业邮箱 SMTP → Resend API(Sending Access 权限),凭证从邮箱完整密码降级为仅可发信的 API Key diff --git a/CLAUDE.md b/CLAUDE.md index 5f62667..6202743 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,6 +78,11 @@ OA 本身**不存储用户数据**(无本地 users 表),纯 LLDAP 认证 | `JWT_SECRET` | `dev-secret-key-local` | 强随机值(与 assets/issue 相同) | | `COOKIE_DOMAIN` | `""`(空) | `.tlyq.ai` | | `RESEND_API_KEY` | `re_xxxxxxxxxxxx` | Resend API Key(Sending Access 权限) | +| `AUTHELIA_URL` | `https://sso.tlyq.ai` | 同 | +| `OIDC_CLIENT_ID` | `oa-oidc` | 同 | +| `OIDC_CLIENT_SECRET` | 本地生成的哈希值 | 服务器生成的哈希值 | +| `OIDC_REDIRECT_URI` | `http://localhost:6179/api/auth/callback` | `https://oa.tlyq.ai/api/auth/callback` | +| `NODE_TLS_REJECT_UNAUTHORIZED` | 不需要 | `0`(Authelia 使用自签名证书) | ### `.env` 示例 @@ -89,6 +94,10 @@ JWT_SECRET=dev-secret-key-local COOKIE_DOMAIN= NODE_ENV=development RESEND_API_KEY=re_xxxxxxxxxxxx +AUTHELIA_URL=https://sso.tlyq.ai +OIDC_CLIENT_ID=oa-oidc +OIDC_CLIENT_SECRET=<本地生成的哈希值> +OIDC_REDIRECT_URI=http://localhost:6179/api/auth/callback ``` --- diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a2c0d6f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +FROM node:20-alpine AS builder + +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM node:20-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +# Install docker-cli for container management +RUN apk add --no-cache docker-cli + +USER nextjs + +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["node", "server.js"] diff --git a/docker-compose.yml b/docker-compose.yml index 66c2fc1..e2e7f0e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,10 @@ services: - ASSETS_DB_PATH=/data/other-sites/assets/assets.db - ISSUE_DB_PATH=/data/other-sites/issue/issue.db - RESEND_API_KEY=${RESEND_API_KEY} + - AUTHELIA_URL=${AUTHELIA_URL:-https://sso.tlyq.ai} + - OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-oa-oidc} + - OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET} + - OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-https://oa.tlyq.ai/api/auth/callback} volumes: - ./.next:/app/.next - /var/run/docker.sock:/var/run/docker.sock diff --git "a/src/app/api/admin/create-user/route.ts\"\"" "b/src/app/api/admin/create-user/route.ts\"\"" new file mode 100644 index 0000000..b6d3ad6 --- /dev/null +++ "b/src/app/api/admin/create-user/route.ts\"\"" @@ -0,0 +1,151 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import { exec } from 'child_process' +import { promisify } from 'util' +import { verifySharedJwt } from '@/lib/jwt' +import { isLldapAdmin } from '@/lib/ldap' +import { sendSetupLinkEmail } from '@/lib/email' +import { signSetupToken } from '@/lib/setup-token' + +const execAsync = promisify(exec) + +const INTERNAL_KEY = 'oa-internal-key-tlyq-2026' + +function generatePassword(): string { + const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ' + const lower = 'abcdefghjkmnpqrstuvwxyz' + const digits = '23456789' + const special = '!@#$%&*' + const all = upper + lower + digits + special + const crypto = globalThis.crypto + const pick = (s: string) => s[crypto.getRandomValues(new Uint32Array(1))[0] % s.length] + // 确保每种类型至少一个,其余随机填充到 12 位 + let pwd = pick(upper) + pick(lower) + pick(digits) + pick(special) + for (let i = 4; i < 12; i++) pwd += pick(all) + // 打乱顺序 + return pwd.split('').sort(() => crypto.getRandomValues(new Uint32Array(1))[0] - 0x80000000).join('') +} + +async function fetchRoles(siteUrl: string): Promise { + try { + const res = await fetch(`${siteUrl}/api/internal/roles`, { + headers: { 'x-internal-key': INTERNAL_KEY }, + signal: AbortSignal.timeout(5000), + }) + const data = await res.json() + return (data.roles || []).map((r: { name: string }) => r.name) + } catch { + return [] + } +} + +async function syncToSite(siteUrl: string, username: string, password: string): Promise { + try { + const res = await fetch(`${siteUrl}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + signal: AbortSignal.timeout(10000), + }) + return res.ok + } catch { + return false + } +} + +// 直接更新站点 SQLite 数据库中的用户角色 +function setRoleSQL(dbPath: string, username: string, role: string): string { + return `sqlite3 "${dbPath}" "UPDATE users SET role = '${role}', updated_at = datetime('now', '+8 hours') WHERE username = '${username}';"` +} + +export async function POST(request: Request) { + try { + const cookieStore = await cookies() + const token = cookieStore.get('tlyq_session')?.value + if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 }) + const session = verifySharedJwt(token) + if (!session || !(await isLldapAdmin(session.username))) { + return NextResponse.json({ error: '仅管理员可创建用户' }, { status: 403 }) + } + + const { username, displayName, assetsRole, issueRole, email } = await request.json() + if (!username) return NextResponse.json({ error: '用户名不能为空' }, { status: 400 }) + if (!/^[a-z][a-z0-9_.@-]*$/i.test(username)) return NextResponse.json({ error: '用户名格式不合法' }, { status: 400 }) + + const password = generatePassword() + + // 从各站点实时获取可用角色列表 + const [assetsRoles, issueRoles] = await Promise.all([ + fetchRoles('http://assets-ai:3000'), + fetchRoles('http://issue-ai:3000'), + ]) + + const ar = (assetsRole && assetsRoles.includes(assetsRole)) ? assetsRole : 'viewer' + const ir = (issueRole && issueRoles.includes(issueRole)) ? issueRole : 'viewer' + + const safeName = (displayName || username).replace(/'/g, "'\\''") + const safeUser = username.replace(/'/g, "'\\''") + const lldapEmail = email || '' + const d = new Date() + const now = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}` + const userUuid = crypto.randomUUID() + + // 1. LLDAP SQLite 插入用户 + const insertSQL = `INSERT OR IGNORE INTO users (user_id, email, display_name, creation_date, uuid, lowercase_email, modified_date, password_modified_date) VALUES ('${username}', '${lldapEmail}', '${safeName}', '${now}', '${userUuid}', LOWER('${lldapEmail}'), '${now}', '${now}');` + await execAsync(`docker exec lldap /bin/sh -c "cat > /tmp/iu.sql <<'EOSQL'\n${insertSQL}\nEOSQL\nsqlite3 /data/users.db < /tmp/iu.sql"`, { timeout: 5000 }) + + // 2. 从 LLDAP 容器动态获取 admin 密码(不硬编码,admin 改密码后无需改 OA 配置) + const { stdout: adminPassOut } = await execAsync('docker exec lldap printenv LLDAP_ADMIN_PASSWORD', { timeout: 3000 }) + const adminPass = (adminPassOut.trim() || 'admin123').replace(/'/g, "'\\''") + + // 3. LLDAP 设置密码 —— 通过 base64 传输避免 shell 特殊字符问题 + const b64Pass = Buffer.from(password).toString('base64') + await execAsync(`docker exec lldap /bin/sh -c "echo '${b64Pass}' | base64 -d > /tmp/userpwd.txt"`, { timeout: 3000 }) + const pwdCmd = `LLDAP_USER_PASSWORD=$(cat /tmp/userpwd.txt) ./lldap_set_password --base-url http://localhost:17170 --admin-username admin --admin-password '${adminPass}' --username '${safeUser}'` + await execAsync(`docker exec lldap /bin/sh -c '${pwdCmd}'`, { timeout: 10000 }) + + // 3. 自动登录各站点触发用户同步 + const [assetsOk, issueOk] = await Promise.all([ + syncToSite('http://assets-ai:3000', username, password), + syncToSite('http://issue-ai:3000', username, password), + ]) + + // 4. 直接更新各站点 SQLite 的角色(覆盖 viewer 默认值) + const assetsDb = process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db' + const issueDb = process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db' + const roleResults = { assets: false, issue: false } + if (assetsOk) { + try { await execAsync(setRoleSQL(assetsDb, username, ar), { timeout: 3000 }); roleResults.assets = true } catch {} + } + if (issueOk) { + try { await execAsync(setRoleSQL(issueDb, username, ir), { timeout: 3000 }); roleResults.issue = true } catch {} + } + + // 5. 如果提供了邮箱,发送密码设置链接(不再在邮件中发送明文密码) + let emailSent = false + if (email && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + try { + const setupToken = signSetupToken(username) + const setupUrl = `https://oa.tlyq.ai/setup-password?token=${setupToken}` + await sendSetupLinkEmail(email, username, setupUrl, displayName || username) + emailSent = true + } catch (e) { + console.error('发送邮件失败:', e) + } + } + + return NextResponse.json({ + success: true, + password: emailSent ? undefined : password, + synced: { assets: assetsOk, issue: issueOk }, + roles: { assets: ar, issue: ir, applied: roleResults }, + emailSent, + message: emailSent + ? `用户已创建,密码设置链接已发送至 ${email}` + : '用户已创建并同步至所有站点', + }) + } catch (e) { + const msg = e instanceof Error ? e.message : '创建失败' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git "a/src/app/api/admin/roles/route.ts\"\"" "b/src/app/api/admin/roles/route.ts\"\"" new file mode 100644 index 0000000..ae6c4f5 --- /dev/null +++ "b/src/app/api/admin/roles/route.ts\"\"" @@ -0,0 +1,32 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import { verifySharedJwt } from '@/lib/jwt' +import { isLldapAdmin } from '@/lib/ldap' + +const INTERNAL_KEY = 'oa-internal-key-tlyq-2026' + +async function fetchRoles(url: string): Promise<{ name: string; display_name: string }[]> { + try { + const res = await fetch(`${url}/api/internal/roles`, { + headers: { 'x-internal-key': INTERNAL_KEY }, + signal: AbortSignal.timeout(5000), + }) + const data = await res.json() + return data.roles || [] + } catch { return [] } +} + +export async function GET() { + const cookieStore = await cookies() + const token = cookieStore.get('tlyq_session')?.value + if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 }) + const session = verifySharedJwt(token) + if (!session || !(await isLldapAdmin(session.username))) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + + const [assetsRoles, issueRoles] = await Promise.all([ + fetchRoles('http://assets-ai:3000'), + fetchRoles('http://issue-ai:3000'), + ]) + + return NextResponse.json({ assets: assetsRoles, issue: issueRoles }) +} diff --git "a/src/app/api/admin/sync-emails/route.ts\"\"" "b/src/app/api/admin/sync-emails/route.ts\"\"" new file mode 100644 index 0000000..eb7cab9 --- /dev/null +++ "b/src/app/api/admin/sync-emails/route.ts\"\"" @@ -0,0 +1,48 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import { exec } from 'child_process' +import { promisify } from 'util' +import { verifySharedJwt } from '@/lib/jwt' +import { isLldapAdmin } from '@/lib/ldap' + +const execAsync = promisify(exec) +const ASSETS_DB = process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db' +const ISSUE_DB = process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db' + +export async function POST() { + try { + const cookieStore = await cookies() + const token = cookieStore.get('tlyq_session')?.value + if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 }) + const session = verifySharedJwt(token) + if (!session || !(await isLldapAdmin(session.username))) { + return NextResponse.json({ error: '仅管理员可操作' }, { status: 403 }) + } + + const { stdout } = await execAsync( + `docker exec lldap sqlite3 /data/users.db "SELECT user_id, email FROM users WHERE email != '';"`, + { timeout: 5000 } + ) + const lines = stdout.trim().split('\n').filter(Boolean) + let synced = 0 + + for (const line of lines) { + const [user, mail] = line.split('|') + const su = user.replace(/'/g, "''") + const sm = (mail || '').replace(/'/g, "''") + for (const db of [ASSETS_DB, ISSUE_DB]) { + try { + await execAsync( + `sqlite3 "${db}" "UPDATE users SET email = '${sm}', updated_at = datetime('now', '+8 hours') WHERE username = '${su}';"`, + { timeout: 3000 } + ) + } catch {} + } + synced++ + } + + return NextResponse.json({ success: true, synced }) + } catch (e) { + return NextResponse.json({ error: '同步失败' }, { status: 500 }) + } +} diff --git "a/src/app/api/admin/user-roles/route.ts\"\"" "b/src/app/api/admin/user-roles/route.ts\"\"" new file mode 100644 index 0000000..a0ff2e4 --- /dev/null +++ "b/src/app/api/admin/user-roles/route.ts\"\"" @@ -0,0 +1,104 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import { exec } from 'child_process' +import { promisify } from 'util' +import { verifySharedJwt } from '@/lib/jwt' +import { isLldapAdmin } from '@/lib/ldap' + +const execAsync = promisify(exec) +const INTERNAL_KEY = 'oa-internal-key-tlyq-2026' + +async function fetchRoles(siteUrl: string): Promise { + try { + const res = await fetch(`${siteUrl}/api/internal/roles`, { + headers: { 'x-internal-key': INTERNAL_KEY }, + signal: AbortSignal.timeout(5000), + }) + const data = await res.json() + return (data.roles || []).map((r: { name: string }) => r.name) + } catch { return [] } +} + +function queryDb(dbPath: string, sql: string): Promise { + return execAsync(`sqlite3 "${dbPath}" "${sql.replace(/"/g, '\\"')}"`, { timeout: 3000 }).then(r => r.stdout).catch(() => '') +} + +async function getSiteUsers(dbPath: string, roles: string[]): Promise<{ username: string; display_name: string; role: string }[]> { + const out = await queryDb(dbPath, 'SELECT username, display_name, role FROM users WHERE is_active=1 ORDER BY username;') + return out.trim().split('\n').filter(Boolean).map(line => { + const [username, display_name, role] = line.split('|') + return { username, display_name: display_name || username, role: roles.includes(role) ? role : 'viewer' } + }) +} + +async function checkAdmin() { + const cookieStore = await cookies() + const token = cookieStore.get('tlyq_session')?.value + if (!token) return false + const session = verifySharedJwt(token) + return session ? isLldapAdmin(session.username) : false +} + +// GET — 列出各站点用户及其角色 +export async function GET() { + if (!(await checkAdmin())) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + + try { + const [assetsRoles, issueRoles] = await Promise.all([ + fetchRoles('http://assets-ai:3000'), + fetchRoles('http://issue-ai:3000'), + ]) + + const [assetsUsers, issueUsers] = await Promise.all([ + getSiteUsers(process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db', assetsRoles), + getSiteUsers(process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db', issueRoles), + ]) + + // 从 LLDAP 获取所有用户邮箱 + let emails: Record = {} + try { + const { stdout } = await execAsync( + `docker exec lldap /bin/sh -c "echo 'SELECT user_id, email FROM users;' | sqlite3 /data/users.db"`, + { timeout: 3000 } + ) + stdout.trim().split('\n').filter(Boolean).forEach(line => { + const [uid, e] = line.split('|') + emails[uid] = e || '' + }) + } catch {} + + return NextResponse.json({ + assetsRoles, + issueRoles, + users: { assets: assetsUsers, issue: issueUsers }, + emails, + }) + } catch (e) { + return NextResponse.json({ error: '查询失败' }, { status: 500 }) + } +} + +// PUT — 更新用户角色 +export async function PUT(request: Request) { + if (!(await checkAdmin())) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + + try { + const { username, site, role } = await request.json() + if (!username || !site || !role) return NextResponse.json({ error: '参数不完整' }, { status: 400 }) + if (username === 'admin' || username === 'localadmin') return NextResponse.json({ error: '不能修改系统保留用户角色' }, { status: 400 }) + + const dbPath = site === 'assets' + ? (process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db') + : (process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db') + + // 验证角色合法性 + const roles = await fetchRoles(`http://${site}-ai:3000`) + if (!roles.includes(role)) return NextResponse.json({ error: '无效的角色' }, { status: 400 }) + + await execAsync(`sqlite3 "${dbPath}" "UPDATE users SET role='${role}', updated_at=datetime('now', '+8 hours') WHERE username='${username}';"`, { timeout: 3000 }) + + return NextResponse.json({ success: true }) + } catch (e) { + return NextResponse.json({ error: '更新失败' }, { status: 500 }) + } +} diff --git "a/src/app/api/admin/users/route.ts\"\"" "b/src/app/api/admin/users/route.ts\"\"" new file mode 100644 index 0000000..913b070 --- /dev/null +++ "b/src/app/api/admin/users/route.ts\"\"" @@ -0,0 +1,132 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import { exec } from 'child_process' +import { promisify } from 'util' +import { verifySharedJwt } from '@/lib/jwt' +import { isLldapAdmin } from '@/lib/ldap' + +const execAsync = promisify(exec) + +function checkAdmin() { + return async () => { + const cookieStore = await cookies() + const token = cookieStore.get('tlyq_session')?.value + if (!token) return false + const session = verifySharedJwt(token) + return session ? isLldapAdmin(session.username) : false + } +} + +// GET — 列出 LLDAP 中所有用户 +export async function GET() { + const isAdmin = await checkAdmin()() + if (!isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + + try { + const { stdout } = await execAsync( + `docker exec lldap /bin/sh -c "echo 'SELECT user_id, email, display_name, creation_date FROM users ORDER BY creation_date DESC;' | sqlite3 /data/users.db"`, + { timeout: 5000 } + ) + const users = stdout.trim().split('\n').filter(Boolean).map(line => { + const [user_id, email, display_name, creation_date] = line.split('|') + return { username: user_id, email, displayName: display_name || user_id, createdAt: creation_date } + }) + return NextResponse.json({ users }) + } catch (e) { + return NextResponse.json({ error: '查询失败' }, { status: 500 }) + } +} + +// DELETE — 删除用户(LLDAP + 各站点) +export async function DELETE(request: Request) { + const isAdmin = await checkAdmin()() + if (!isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + + try { + const { username } = await request.json() + if (!username) return NextResponse.json({ error: '用户名不能为空' }, { status: 400 }) + if (username === 'admin' || username === 'localadmin') { + return NextResponse.json({ error: '不能删除系统保留用户' }, { status: 400 }) + } + + const safeUser = username.replace(/'/g, "''") + + // 删除 LLDAP 用户 + const lldapSQL = `DELETE FROM users WHERE user_id='${safeUser}';` + await execAsync( + `docker exec lldap /bin/sh -c "cat > /tmp/del.sql <<'EOSQL'\n${lldapSQL}\nEOSQL\nsqlite3 /data/users.db < /tmp/del.sql"`, + { timeout: 5000 } + ) + + // 删除各站点本地用户 + const results: Record = {} + for (const [site, dbPath] of Object.entries({ + assets: process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db', + issue: process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db', + })) { + try { + await execAsync(`sqlite3 "${dbPath}" "DELETE FROM users WHERE username='${safeUser}';"`, { timeout: 3000 }) + results[site] = true + } catch { results[site] = false } + } + + return NextResponse.json({ success: true, deleted: results }) + } catch (e) { + return NextResponse.json({ error: '删除失败' }, { status: 500 }) + } +} + +// PATCH — 修改用户信息(admin 权限) +export async function PATCH(request: Request) { + const isAdmin = await checkAdmin()() + if (!isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + + try { + const { username, email, displayName } = await request.json() + if (!username) return NextResponse.json({ error: '用户名不能为空' }, { status: 400 }) + if (email === undefined && displayName === undefined) { + return NextResponse.json({ error: '至少需要 email 或 displayName' }, { status: 400 }) + } + if (email !== undefined && email !== '' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return NextResponse.json({ error: '邮箱格式不合法' }, { status: 400 }) + } + + const safeUser = username.replace(/'/g, "''") + const d = new Date() + const now = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}` + + // 更新 LLDAP + let lldapSets: string[] = [] + let siteSets: string[] = [] + if (email !== undefined) { + const safeEmail = (email || '').replace(/'/g, "''") + lldapSets.push(`email = '${safeEmail}'`, `lowercase_email = LOWER('${safeEmail}')`) + siteSets.push(`email = '${safeEmail}'`) + } + if (displayName !== undefined) { + const safeName = displayName.replace(/'/g, "''") + lldapSets.push(`display_name = '${safeName}'`) + siteSets.push(`display_name = '${safeName}'`) + } + lldapSets.push(`modified_date = '${now}'`) + siteSets.push(`updated_at = datetime('now', '+8 hours')`) + + const lldapSQL = `UPDATE users SET ${lldapSets.join(', ')} WHERE user_id = '${safeUser}';` + await execAsync( + `docker exec lldap /bin/sh -c "cat > /tmp/up.sql <<'EOSQL'\n${lldapSQL}\nEOSQL\nsqlite3 /data/users.db < /tmp/up.sql"`, + { timeout: 5000 } + ) + + // 同步更新 assets / issue + const assetsDb = process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db' + const issueDb = process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db' + const siteSQL = `UPDATE users SET ${siteSets.join(', ')} WHERE username = '${safeUser}';` + for (const dbPath of [assetsDb, issueDb]) { + try { await execAsync(`sqlite3 "${dbPath}" "${siteSQL}"`, { timeout: 3000 }) } catch {} + } + + return NextResponse.json({ success: true, username, email, displayName }) + } catch (e) { + return NextResponse.json({ error: '修改失败' }, { status: 500 }) + } +} diff --git "a/src/app/api/auth/callback/route.ts\"\"" "b/src/app/api/auth/callback/route.ts\"\"" new file mode 100644 index 0000000..10ce534 --- /dev/null +++ "b/src/app/api/auth/callback/route.ts\"\"" @@ -0,0 +1,99 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import { getOidcClient } from '@/lib/oidc' +import { signSharedJwt, sharedCookieConfig } from '@/lib/jwt' + +// 从 OIDC_REDIRECT_URI 提取 base URL(避免 request.url 使用 localhost) +function getBaseUrl(): string { + const redirectUri = process.env.OIDC_REDIRECT_URI || 'http://127.0.0.1:6179/api/auth/callback' + const url = new URL(redirectUri) + return `${url.protocol}//${url.host}` +} + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url) + const code = searchParams.get('code') + const state = searchParams.get('state') + const error = searchParams.get('error') + const baseUrl = getBaseUrl() + + const cookieStore = await cookies() + + // 1. 错误处理 + if (error) { + return NextResponse.redirect(new URL(`/login?error=${error}`, baseUrl)) + } + + // 2. 验证 state + const savedState = cookieStore.get('oidc_state')?.value + if (!savedState || savedState !== state) { + return NextResponse.redirect(new URL('/login?error=state_mismatch', baseUrl)) + } + + // 3. 取出 code_verifier + const codeVerifier = cookieStore.get('oidc_code_verifier')?.value + if (!codeVerifier) { + return NextResponse.redirect(new URL('/login?error=missing_verifier', baseUrl)) + } + + // 4. 验证 nonce + const savedNonce = cookieStore.get('oidc_nonce')?.value + + try { + // 5. 换取 token + const client = await getOidcClient() + const redirectUri = process.env.OIDC_REDIRECT_URI || 'http://127.0.0.1:6179/api/auth/callback' + const params = { code, state, iss: searchParams.get('iss') } + const checks = { + code_verifier: codeVerifier, + nonce: savedNonce, + state: savedState, + } + + const tokenSet = await client.callback(redirectUri, params, checks) + + // 6. 验证 nonce + if (savedNonce && tokenSet.claims) { + const claims = tokenSet.claims() + if (claims.nonce !== savedNonce) { + return NextResponse.redirect(new URL('/login?error=nonce_mismatch', baseUrl)) + } + } + + // 7. 获取 userinfo + const userinfo = await client.userinfo(tokenSet.access_token!) + + // 8. 使用 preferred_username 作为用户名(sub 可能是 UUID) + const username = (userinfo as any).preferred_username || userinfo.sub! + const displayName = userinfo.name || username + + // 9. 签发 tlyq_session cookie + const sharedToken = signSharedJwt({ username: username as string, displayName: displayName as string }) + const cfg = sharedCookieConfig() + + const response = NextResponse.redirect(new URL('/', baseUrl)) + response.cookies.set(cfg.name, sharedToken, cfg) + + // 10. 存储 id_token 用于登出 + if (tokenSet.id_token) { + response.cookies.set('oidc_id_token', tokenSet.id_token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 86400, + path: '/', + }) + } + + // 11. 清理 OIDC 临时 cookie + response.cookies.delete('oidc_state') + response.cookies.delete('oidc_nonce') + response.cookies.delete('oidc_code_verifier') + + return response + } catch (e) { + const errorMsg = e instanceof Error ? e.message : String(e) + console.error('OIDC callback error:', errorMsg) + return NextResponse.redirect(new URL(`/login?error=callback_error&detail=${encodeURIComponent(errorMsg)}`, baseUrl)) + } +} diff --git "a/src/app/api/auth/change-password/route.ts\"\"" "b/src/app/api/auth/change-password/route.ts\"\"" new file mode 100644 index 0000000..420b7c9 --- /dev/null +++ "b/src/app/api/auth/change-password/route.ts\"\"" @@ -0,0 +1,56 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import { exec } from 'child_process' +import { promisify } from 'util' +import { verifySharedJwt } from '@/lib/jwt' + +const execAsync = promisify(exec) + +export async function POST(request: Request) { + try { + const cookieStore = await cookies() + const token = cookieStore.get('tlyq_session')?.value + if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 }) + + const session = verifySharedJwt(token) + if (!session) return NextResponse.json({ error: '会话已过期' }, { status: 401 }) + + const { currentPassword, newPassword } = await request.json() + if (!currentPassword || !newPassword) { + return NextResponse.json({ error: '请输入当前密码和新密码' }, { status: 400 }) + } + if (newPassword.length < 8) { + return NextResponse.json({ error: '新密码至少 8 位' }, { status: 400 }) + } + // 密码复杂度:大写/小写/数字/特殊字符 4选3 + const hasUpper = /[A-Z]/.test(newPassword) + const hasLower = /[a-z]/.test(newPassword) + const hasDigit = /[0-9]/.test(newPassword) + const hasSpecial = /[^A-Za-z0-9]/.test(newPassword) + const complexityScore = [hasUpper, hasLower, hasDigit, hasSpecial].filter(Boolean).length + if (complexityScore < 3) { + return NextResponse.json({ error: '密码需包含大写字母、小写字母、数字、特殊字符中至少 3 种' }, { status: 400 }) + } + + // 从 LLDAP 容器动态获取 admin 密码(不硬编码,admin 改密码后无需改 OA 配置) + const { stdout: adminPassOut } = await execAsync('docker exec lldap printenv LLDAP_ADMIN_PASSWORD', { timeout: 3000 }) + const adminPass = (adminPassOut.trim() || 'admin123').replace(/'/g, "'\\''") + + const safeUser = session.username.replace(/'/g, "'\\''") + const safePass = newPassword.replace(/'/g, "'\\''") + const cmd = `docker exec lldap ./lldap_set_password --base-url http://localhost:17170 --admin-username admin --admin-password '${adminPass}' --username '${safeUser}' --password '${safePass}'` + + const { stdout, stderr } = await execAsync(cmd, { timeout: 10000 }) + if (stderr && !stderr.includes('Successfully')) { + return NextResponse.json({ error: stderr.trim() || '修改失败' }, { status: 500 }) + } + + return NextResponse.json({ success: true }) + } catch (err) { + const msg = err instanceof Error ? err.message : '修改失败' + if (msg.includes('command not found') || msg.includes('No such container')) { + return NextResponse.json({ error: '密码服务不可用' }, { status: 503 }) + } + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git "a/src/app/api/auth/login/oidc/route.ts\"\"" "b/src/app/api/auth/login/oidc/route.ts\"\"" new file mode 100644 index 0000000..9171a98 --- /dev/null +++ "b/src/app/api/auth/login/oidc/route.ts\"\"" @@ -0,0 +1,81 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import { getOidcClient, generatePKCE, generateState, generateNonce } from '@/lib/oidc' +import { verifySharedJwt } from '@/lib/jwt' + +export async function GET(request: Request) { + const cookieStore = await cookies() + const existingSession = cookieStore.get('tlyq_session')?.value + const url = new URL(request.url) + const switchUser = url.searchParams.get('switch') === '1' + + // 检查是否已有登录用户 + if (existingSession && !switchUser) { + const existing = verifySharedJwt(existingSession) + if (existing) { + return NextResponse.json({ + conflict: true, + currentUser: existing.username, + displayName: existing.displayName, + }) + } + } + + // 预检 Authelia 健康状态 + const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai' + try { + const healthRes = await fetch(`${autheliaUrl}/api/health`, { + signal: AbortSignal.timeout(5000), + }) + if (!healthRes.ok) { + return NextResponse.json({ fallback: 'ldap', error: 'Authelia 不可用' }, { status: 503 }) + } + } catch { + return NextResponse.json({ fallback: 'ldap', error: 'Authelia 不可达' }, { status: 503 }) + } + + // 生成 PKCE 参数 + const { codeVerifier, codeChallenge } = generatePKCE() + const state = generateState() + const nonce = generateNonce() + + // 构建授权 URL + const client = await getOidcClient() + const authorizationUrl = client.authorizationUrl({ + scope: 'openid profile email', + state, + nonce, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + ...(switchUser && { prompt: 'login' }), + }) + + // 存储到 httpOnly cookie(5 分钟过期) + const response = NextResponse.redirect(authorizationUrl) + + response.cookies.set('oidc_code_verifier', codeVerifier, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 300, + path: '/', + }) + + response.cookies.set('oidc_state', state, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 300, + path: '/', + }) + + response.cookies.set('oidc_nonce', nonce, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 300, + path: '/', + }) + + return response +} diff --git "a/src/app/api/auth/login/route.ts\"\"" "b/src/app/api/auth/login/route.ts\"\"" new file mode 100644 index 0000000..95a5acf --- /dev/null +++ "b/src/app/api/auth/login/route.ts\"\"" @@ -0,0 +1,32 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import { signSharedJwt, sharedCookieConfig } from '@/lib/jwt' +import { ldapAuth } from '@/lib/ldap' + +export async function POST(request: Request) { + try { + const { username, password } = await request.json() + if (!username || !password) { + return NextResponse.json({ error: '请输入用户名和密码' }, { status: 400 }) + } + + const result = await ldapAuth(username, password) + if (!result.success) { + if (result.unreachable) { + return NextResponse.json({ error: '认证服务暂时不可用,请稍后再试' }, { status: 503 }) + } + return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 }) + } + + const token = signSharedJwt({ username: result.username!, displayName: result.displayName! }) + const cfg = sharedCookieConfig() + const cookieStore = await cookies() + cookieStore.set(cfg.name, token, cfg) + + return NextResponse.json({ + user: { username: result.username, displayName: result.displayName }, + }) + } catch { + return NextResponse.json({ error: '登录失败' }, { status: 500 }) + } +} diff --git "a/src/app/api/auth/logout/route.ts\"\"" "b/src/app/api/auth/logout/route.ts\"\"" new file mode 100644 index 0000000..8a73281 --- /dev/null +++ "b/src/app/api/auth/logout/route.ts\"\"" @@ -0,0 +1,16 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' + +export async function POST() { + const cookieStore = await cookies() + const domain = process.env.COOKIE_DOMAIN || '' + + // 清除所有相关 cookie(必须指定 domain 以清除跨域 cookie) + cookieStore.set('tlyq_session', '', { maxAge: 0, path: '/', domain }) + cookieStore.set('session', '', { maxAge: 0, path: '/', domain }) + cookieStore.set('oidc_id_token', '', { maxAge: 0, path: '/', domain }) + + // Authelia 4.38 不支持 end_session_endpoint,直接跳转登录页 + // Authelia session 会在 cookie 过期后自动清除 + return NextResponse.redirect(new URL('/login', process.env.NEXT_PUBLIC_URL || 'http://127.0.0.1:6179')) +} diff --git "a/src/app/api/auth/me/route.ts\"\"" "b/src/app/api/auth/me/route.ts\"\"" new file mode 100644 index 0000000..642e4a8 --- /dev/null +++ "b/src/app/api/auth/me/route.ts\"\"" @@ -0,0 +1,83 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import { exec } from 'child_process' +import { promisify } from 'util' +import { verifySharedJwt } from '@/lib/jwt' +import { isLldapAdmin } from '@/lib/ldap' + +const execAsync = promisify(exec) + +async function getLldapInfo(username: string): Promise<{ email: string; displayName: string }> { + try { + const safeUser = username.replace(/'/g, "''") + const { stdout } = await execAsync( + `docker exec lldap /bin/sh -c "echo 'SELECT email, display_name FROM users WHERE user_id='\\''${safeUser}'\\'';' | sqlite3 /data/users.db"`, + { timeout: 3000 } + ) + const parts = stdout.trim().split('|') + return { email: parts[0] || '', displayName: parts[1] || username } + } catch { return { email: '', displayName: username } } +} + +export async function GET() { + try { + const cookieStore = await cookies() + const token = cookieStore.get('tlyq_session')?.value + if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 }) + + const payload = verifySharedJwt(token) + if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 }) + + const [admin, info] = await Promise.all([ + isLldapAdmin(payload.username), + getLldapInfo(payload.username), + ]) + + return NextResponse.json({ + user: { username: payload.username, displayName: info.displayName, email: info.email, isAdmin: admin }, + }) + } catch { + return NextResponse.json({ error: '获取用户信息失败' }, { status: 500 }) + } +} + +export async function PUT(request: Request) { + try { + const cookieStore = await cookies() + const token = cookieStore.get('tlyq_session')?.value + if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 }) + + const payload = verifySharedJwt(token) + if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 }) + + const { email } = await request.json() + if (email !== '' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return NextResponse.json({ error: '邮箱格式不合法' }, { status: 400 }) + } + + const safeUser = payload.username.replace(/'/g, "''") + const safeEmail = (email || '').replace(/'/g, "''") + const d = new Date() + const now = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}` + + const updateSQL = `UPDATE users SET email = '${safeEmail}', lowercase_email = LOWER('${safeEmail}'), modified_date = '${now}' WHERE user_id = '${safeUser}';` + await execAsync( + `docker exec lldap /bin/sh -c "cat > /tmp/ue.sql <<'EOSQL'\n${updateSQL}\nEOSQL\nsqlite3 /data/users.db < /tmp/ue.sql"`, + { timeout: 5000 } + ) + + // 同步更新 assets / issue 本地用户表 + const assetsDb = process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db' + const issueDb = process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db' + for (const dbPath of [assetsDb, issueDb]) { + try { + await execAsync(`sqlite3 "${dbPath}" "UPDATE users SET email = '${safeEmail}', updated_at = datetime('now', '+8 hours') WHERE username = '${safeUser}';"`, { timeout: 3000 }) + } catch {} + } + + return NextResponse.json({ success: true, email: email || '' }) + } catch (e) { + const msg = e instanceof Error ? e.message : '修改失败' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git "a/src/app/api/auth/setup-password/route.ts\"\"" "b/src/app/api/auth/setup-password/route.ts\"\"" new file mode 100644 index 0000000..312dfe2 --- /dev/null +++ "b/src/app/api/auth/setup-password/route.ts\"\"" @@ -0,0 +1,54 @@ +import { NextResponse } from 'next/server' +import { exec } from 'child_process' +import { promisify } from 'util' +import { verifySetupToken } from '@/lib/setup-token' + +const execAsync = promisify(exec) + +export async function POST(request: Request) { + try { + const { token, password } = await request.json() + if (!token || !password) { + return NextResponse.json({ error: '参数不完整' }, { status: 400 }) + } + + const payload = verifySetupToken(token) + if (!payload) { + return NextResponse.json({ error: '链接已过期或无效,请联系管理员重新创建账号' }, { status: 403 }) + } + + if (password.length < 8) { + return NextResponse.json({ error: '密码至少 8 位' }, { status: 400 }) + } + const hasUpper = /[A-Z]/.test(password) + const hasLower = /[a-z]/.test(password) + const hasDigit = /[0-9]/.test(password) + const hasSpecial = /[^A-Za-z0-9]/.test(password) + const score = [hasUpper, hasLower, hasDigit, hasSpecial].filter(Boolean).length + if (score < 3) { + return NextResponse.json({ error: '密码需包含大写字母、小写字母、数字、特殊字符中至少 3 种' }, { status: 400 }) + } + + const { stdout: adminPassOut } = await execAsync( + 'docker exec lldap printenv LLDAP_ADMIN_PASSWORD', { timeout: 3000 } + ) + const adminPass = (adminPassOut.trim() || 'admin123').replace(/'/g, "'\\''") + + const safeUser = payload.username.replace(/'/g, "'\\''") + const safePass = password.replace(/'/g, "'\\''") + const cmd = `docker exec lldap ./lldap_set_password --base-url http://localhost:17170 --admin-username admin --admin-password '${adminPass}' --username '${safeUser}' --password '${safePass}'` + + const { stderr } = await execAsync(cmd, { timeout: 10000 }) + if (stderr && !stderr.includes('Successfully')) { + return NextResponse.json({ error: stderr.trim() || '设置失败' }, { status: 500 }) + } + + return NextResponse.json({ success: true }) + } catch (err) { + const msg = err instanceof Error ? err.message : '设置失败' + if (msg.includes('command not found') || msg.includes('No such container')) { + return NextResponse.json({ error: '密码服务不可用' }, { status: 503 }) + } + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git "a/src/app/page.tsx\"\"" "b/src/app/page.tsx\"\"" new file mode 100644 index 0000000..b1d70e4 --- /dev/null +++ "b/src/app/page.tsx\"\"" @@ -0,0 +1,109 @@ +import { cookies } from 'next/headers' +import { redirect } from 'next/navigation' +import { verifySharedJwt } from '@/lib/jwt' +import Header from '@/components/Header' + +function siteUrl(url: string, domain: string): string { + if (process.env.NODE_ENV === 'production') { + return `https://${domain}` + } + return url +} + +const CORE_SITES = [ + { name: '资产管理', url: 'http://127.0.0.1:6177', desc: 'GPU 服务器、存储服务器等硬件设备信息管理与实时监控', tag: 'CMDB', dot: '#2563eb', domain: 'assets.tlyq.ai' }, + { name: '工单跟踪', url: 'http://127.0.0.1:6176', desc: '故障工单全流程管理,SLA 自动计算,月度/周度报告导出', tag: 'ITS', dot: '#7c3aed', domain: 'issue.tlyq.ai' }, +] + +const OTHER_SITES = [ + { name: '官网', url: 'http://127.0.0.1:6173', desc: 'tlyq.ai 企业官方网站', tag: 'WWW', dot: '#059669', domain: 'www.tlyq.ai' }, + { name: '云平台', url: 'http://127.0.0.1:6174', desc: '云服务登录入口与资源概览', tag: 'CLOUD', dot: '#d97706', domain: 'cloud.tlyq.ai' }, + { name: 'Token 工厂', url: 'http://127.0.0.1:6175', desc: 'Token 管理与发放平台', tag: 'TOKEN', dot: '#e11d48', domain: 'token.tlyq.ai' }, + { name: '代码仓库', url: 'https://git.tlyq.ai', desc: 'Gitea 代码托管与版本管理', tag: 'GIT', dot: '#db2777', domain: 'git.tlyq.ai' }, +] + +const COLORS: Record = { + '#2563eb': { light: 'rgba(37,99,235,0.08)', tag: '#2563eb' }, + '#7c3aed': { light: 'rgba(124,58,237,0.08)', tag: '#7c3aed' }, + '#059669': { light: 'rgba(5,150,105,0.08)', tag: '#059669' }, + '#d97706': { light: 'rgba(217,119,6,0.08)', tag: '#d97706' }, + '#e11d48': { light: 'rgba(225,29,72,0.08)', tag: '#e11d48' }, + '#db2777': { light: 'rgba(219,39,119,0.08)', tag: '#db2777' }, +} + +export default async function HomePage() { + const cookieStore = await cookies() + const sessionCookie = cookieStore.get('session')?.value + let username = '' + if (sessionCookie) { + try { username = JSON.parse(sessionCookie).username || '' } catch { } + } + if (!username) redirect('/login') + + const tlyqToken = cookieStore.get('tlyq_session')?.value + let displayName = username + if (tlyqToken) { + const shared = verifySharedJwt(tlyqToken) + if (shared && shared.displayName && shared.displayName !== shared.username) { + displayName = shared.displayName + } + } + + return ( +
+
+ +
+
+
tlyq.ai / OA PORTAL
+

欢迎回来,{displayName}

+
+ +
核心系统
+
+ {CORE_SITES.map(site => { + const c = COLORS[site.dot] + return ( + +
+
+
{site.name}
+ {site.tag} +
+
{site.desc}
+
{site.domain} →
+
+ ) + })} +
+ +
其他站点
+
+ {OTHER_SITES.map(site => { + const c = COLORS[site.dot] + return ( + +
+
+
{site.name}
+ {site.tag} +
+
{site.desc}
+
{site.domain} →
+
+ ) + })} +
+ +
+
+ ) +} diff --git a/src/lib/oidc.ts b/src/lib/oidc.ts index efa7e0f..2cb7859 100644 --- a/src/lib/oidc.ts +++ b/src/lib/oidc.ts @@ -23,6 +23,7 @@ export async function getOidcClient() { client_secret: OIDC_CLIENT_SECRET, redirect_uris: [OIDC_REDIRECT_URI], response_types: ['code'], + token_endpoint_auth_method: 'client_secret_basic', }) lastDiscovery = now return oidcClient