fix: #26 logout 修复 + auth/ldap 认证对齐

This commit is contained in:
gitadmin 2026-07-15 16:00:23 +08:00
parent e20189d5f8
commit fc8dd6f0c8
5 changed files with 72 additions and 5 deletions

View File

@ -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` | 内部 APIOA 同步用户角色x-internal-key 鉴权) |
### 工单

View File

@ -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
}

View File

@ -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 })
}
}

View File

@ -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<UserPayload | null> {
@ -37,13 +37,14 @@ export async function getCurrentUser(): Promise<UserPayload | null> {
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

View File

@ -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<boolean> {
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<boolean> {
const adminDn = process.env.LDAP_ADMIN_DN || 'uid=admin,ou=people,dc=tlyq,dc=ai'