diff --git a/CLAUDE.md b/CLAUDE.md index 61cd601..7a6c77f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -128,6 +128,8 @@ npm run import # 导入工单 | POST | `/api/auth/logout` | 登出(清除两个 cookie) | | GET | `/api/auth/me` | 当前用户信息 | | GET | `/api/internal/roles` | 内部 API:返回角色列表(x-internal-key 鉴权) | +| GET | `/api/internal/users` | 内部 API:返回用户列表(x-internal-key 鉴权) | +| POST | `/api/internal/users` | 内部 API:OA 同步用户角色(x-internal-key 鉴权) | ### 工单 diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts index c78ef6d..72669fd 100644 --- a/src/app/api/auth/logout/route.ts +++ b/src/app/api/auth/logout/route.ts @@ -11,7 +11,7 @@ function getSiteUrl(): string { return process.env.NEXT_PUBLIC_SITE_URL || 'https://issue.tlyq.ai' } -/** 清除 tlyq_session + session cookie → 302 跳转 /login */ +/** 清除 tlyq_session + session + oidc_id_token + session_issue cookie → 302 跳转 /login */ function logoutResponse(): NextResponse { const response = NextResponse.redirect(new URL('/login', getSiteUrl())) response.cookies.set('tlyq_session', '', { @@ -19,6 +19,11 @@ function logoutResponse(): NextResponse { sameSite: 'lax', domain: cookieDomain, path: '/', maxAge: 0, }) response.cookies.set('session', '', { path: '/', maxAge: 0 }) + response.cookies.set('oidc_id_token', '', { + httpOnly: true, secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', domain: cookieDomain, path: '/', maxAge: 0, + }) + response.cookies.set('session_issue', '', { path: '/', maxAge: 0 }) return response } diff --git a/src/app/api/internal/users/route.ts b/src/app/api/internal/users/route.ts new file mode 100644 index 0000000..476411b --- /dev/null +++ b/src/app/api/internal/users/route.ts @@ -0,0 +1,43 @@ +// GET /api/internal/users — 供 OA 查询 issue 用户列表 +// POST /api/internal/users — 供 OA 同步用户角色 +import { NextRequest, NextResponse } from 'next/server' +import { getDb } from '@/lib/db' + +const INTERNAL_KEY = process.env.INTERNAL_API_KEY || 'oa-internal-key-tlyq-2026' + +export async function GET(request: NextRequest) { + const key = request.headers.get('x-internal-key') + if (key !== INTERNAL_KEY) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + + const db = getDb() + const users = db.prepare( + 'SELECT username, display_name, role FROM users WHERE is_active = 1 ORDER BY username' + ).all() as { username: string; display_name: string; role: string }[] + return NextResponse.json({ users }) +} + +export async function POST(request: NextRequest) { + const key = request.headers.get('x-internal-key') + if (key !== INTERNAL_KEY) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + + const body = await request.json() + const { username, displayName, role } = body + if (!username) { + return NextResponse.json({ error: 'username 必填' }, { status: 400 }) + } + const VALID_ROLES = ['admin', 'editor', 'viewer'] + const safeRole = VALID_ROLES.includes(role) ? role : 'viewer' + + try { + const db = getDb() + db.prepare( + `INSERT INTO users (username, password_hash, display_name, role, is_active, created_at, updated_at) + VALUES (?, '', ?, ?, 1, datetime('now', '+8 hours'), datetime('now', '+8 hours')) + ON CONFLICT(username) DO UPDATE SET role = ?, display_name = ?, + updated_at = datetime('now', '+8 hours')` + ).run(username, displayName || username, safeRole, safeRole, displayName || username) + return NextResponse.json({ success: true }) + } catch { + return NextResponse.json({ error: '同步失败' }, { status: 500 }) + } +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 226189b..ef16931 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -7,7 +7,7 @@ import { createToken, verifyToken, type UserPayload } from './jwt' export { createToken, verifyToken, type UserPayload } import { verifySharedJwt } from './jwt-shared' -import { ldapUserExists, ldapGetUserInfo } from './ldap' +import { ldapUserExists, ldapGetUserInfo, ldapIsAdmin } from './ldap' import { getUserPermissions } from './permissions' export async function getCurrentUser(): Promise { @@ -37,13 +37,14 @@ export async function getCurrentUser(): Promise { row.permissions = getUserPermissions(row.role) return row } - // SSO 免登录:LLDAP 验证通过但本地无记录 → 自动创建(viewer 角色) + // SSO 免登录:LLDAP 验证通过但本地无记录 → 自动创建(从 LLDAP 判断角色,oa-ai 同步更新) const ldapInfo = await ldapGetUserInfo(sharedPayload.username) const displayName = ldapInfo?.displayName || sharedPayload.displayName const email = ldapInfo?.email ?? null + const role = (await ldapIsAdmin(sharedPayload.username)) ? 'admin' : 'viewer' db.prepare( - "INSERT OR IGNORE INTO users (username, display_name, email, role, is_active, created_at, updated_at) VALUES (?, ?, ?, 'viewer', 1, datetime('now', '+8 hours'), datetime('now', '+8 hours'))" - ).run(sharedPayload.username, displayName, email) + "INSERT OR IGNORE INTO users (username, display_name, email, role, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, 1, datetime('now', '+8 hours'), datetime('now', '+8 hours'))" + ).run(sharedPayload.username, displayName, email, role) const newRow = db.prepare( 'SELECT id, username, display_name, role FROM users WHERE username = ? AND is_active = 1' ).get(sharedPayload.username) as UserPayload | undefined diff --git a/src/lib/ldap.ts b/src/lib/ldap.ts index 4871a1c..e534055 100644 --- a/src/lib/ldap.ts +++ b/src/lib/ldap.ts @@ -65,6 +65,22 @@ export async function ldapGetUserInfo(username: string): Promise<{ displayName: finally { await client.unbind() } } +// 检查 LLDAP 用户是否为 lldap_admin 组成员 +export async function ldapIsAdmin(username: string): Promise { + if (username === 'admin') return true + const adminDn = process.env.LDAP_ADMIN_DN || 'uid=admin,ou=people,dc=tlyq,dc=ai' + const adminPass = getLdapAdminPassword() + const client = new Client({ url: LDAP_URL, timeout: 5000 }) + try { + await client.bind(adminDn, adminPass) + const { searchEntries } = await client.search(`ou=groups,${LDAP_BASE_DN}`, { + scope: 'sub', filter: `(&(cn=lldap_admin)(member=uid=${username},ou=people,${LDAP_BASE_DN}))`, timeLimit: 3, + }) + return searchEntries.length > 0 + } catch { return false } + finally { try { await client.unbind() } catch { /* */ } } +} + // Q1: 检查 LLDAP 中用户是否存在(用 admin bind 搜索,不在/不可达均返回 true 保证容错) export async function ldapUserExists(username: string): Promise { const adminDn = process.env.LDAP_ADMIN_DN || 'uid=admin,ou=people,dc=tlyq,dc=ai'