fix: replace docker exec lldap with direct SQLite reads + fix docker.sock group access
- lldap-db.ts: 读直连 SQLite / 写走 docker exec(LLDAP DELETE 模式不可并发写) - Dockerfile: Alpine addgroup 语法修复,nextjs 加入 docker 组 - docker-compose.yml: 挂载 LLDAP 数据目录,添加 docker.sock - next.config.ts: outputFileTracingIncludes bcryptjs - 所有 API 路由:替换 docker exec lldap 为 lldap-db 工具函数 - 安装 bcryptjs + sqlite3 依赖
This commit is contained in:
parent
068c996e68
commit
ed43d7b8f7
|
|
@ -14,13 +14,17 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
|||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
# 将 nextjs 加入 docker 组(Alpine: -g 而非 --gid),允许访问 docker.sock
|
||||
RUN addgroup -g 988 docker 2>/dev/null; addgroup nextjs docker
|
||||
|
||||
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
|
||||
# docker-cli: 密码修改等写操作仍需 docker exec lldap(LLDAP 非 WAL 模式,不可并发写)
|
||||
# sqlite: 直连只读查询 LLDAP/asstes/issue 数据库
|
||||
# bcryptjs: OIDC callback 中 JWT 签发需要
|
||||
RUN apk add --no-cache docker-cli sqlite && npm install bcryptjs
|
||||
|
||||
USER nextjs
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ services:
|
|||
volumes:
|
||||
- ./.next:/app/.next
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# 挂载整个数据目录(非单个文件),确保 SQLite WAL 文件共享
|
||||
# 挂载外部数据目录(非单个文件),确保 SQLite WAL 文件共享
|
||||
- /root/docker/ldap-ai/data/lldap:/data/other-sites/lldap
|
||||
- /var/lib/docker/volumes/assets-ai_assets-data/_data:/data/other-sites/assets
|
||||
- /var/lib/docker/volumes/issue-ai_issue-data/_data:/data/other-sites/issue
|
||||
networks:
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ import type { NextConfig } from 'next'
|
|||
|
||||
const config: NextConfig = {
|
||||
output: 'standalone',
|
||||
outputFileTracingIncludes: {
|
||||
'/api/**': ['./node_modules/bcryptjs/**/*'],
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"name": "oa-ai",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"ldapts": "^6.0.0",
|
||||
"next": "^15.0.0",
|
||||
"openid-client": "^5.7.1",
|
||||
|
|
@ -17,6 +18,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.1",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"autoprefixer": "^10.5.2",
|
||||
|
|
@ -1065,6 +1067,13 @@
|
|||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/bcryptjs": {
|
||||
"version": "2.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
||||
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.18",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.18.tgz",
|
||||
|
|
@ -1149,6 +1158,15 @@
|
|||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"bin": {
|
||||
"bcrypt": "bin/bcrypt"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.4",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"ldapts": "^6.0.0",
|
||||
"next": "^15.0.0",
|
||||
"openid-client": "^5.7.1",
|
||||
|
|
@ -17,6 +18,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.1",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"autoprefixer": "^10.5.2",
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { execFileSync } from 'child_process'
|
||||
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)
|
||||
import { execLldapWrite, lldapChangePassword, esc, getAdminPassword } from '@/lib/lldap-db'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
const INTERNAL_KEY = 'oa-internal-key-tlyq-2026'
|
||||
|
||||
|
|
@ -19,10 +18,8 @@ function generatePassword(): string {
|
|||
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('')
|
||||
}
|
||||
|
||||
|
|
@ -34,28 +31,18 @@ async function fetchRoles(siteUrl: string): Promise<string[]> {
|
|||
})
|
||||
const data = await res.json()
|
||||
return (data.roles || []).map((r: { name: string }) => r.name)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
async function syncToSite(siteUrl: string, username: string, password: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${siteUrl}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
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}';"`
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
|
|
@ -73,36 +60,25 @@ export async function POST(request: Request) {
|
|||
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://localhost:6177'),
|
||||
fetchRoles('http://localhost:6176'),
|
||||
fetchRoles('http://localhost:6177'), fetchRoles('http://localhost:6176'),
|
||||
])
|
||||
|
||||
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 safeName = esc(displayName || username)
|
||||
const safeUser = esc(username)
|
||||
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 })
|
||||
// 1. docker exec lldap 插入用户(LLDAP DELETE 模式不可并发写)
|
||||
execLldapWrite(`INSERT OR IGNORE INTO users (user_id, email, display_name, creation_date, uuid, lowercase_email, modified_date, password_modified_date) VALUES ('${safeUser}', '${esc(lldapEmail)}', '${safeName}', '${now}', '${userUuid}', LOWER('${esc(lldapEmail)}'), '${now}', '${now}')`)
|
||||
|
||||
// 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 })
|
||||
// 2. bcryptjs 直写 LLDAP 密码(替代 docker exec lldap_set_password)
|
||||
lldapChangePassword(username, password)
|
||||
|
||||
// 3. 自动登录各站点触发用户同步
|
||||
const [assetsOk, issueOk] = await Promise.all([
|
||||
|
|
@ -110,28 +86,21 @@ export async function POST(request: Request) {
|
|||
syncToSite('http://localhost:6176', 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'
|
||||
// 4. 直接更新各站点角色
|
||||
const assetsDb = process.env.ASSETS_DB_PATH || '/data/other-sites/assets/assets.db'
|
||||
const issueDb = process.env.ISSUE_DB_PATH || '/data/other-sites/issue/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 {}
|
||||
}
|
||||
if (assetsOk) try { execFileSync('sqlite3', [assetsDb], { input: `UPDATE users SET role = '${ar}', updated_at = datetime('now', '+8 hours') WHERE username = '${safeUser}';`, timeout: 3000 }); roleResults.assets = true } catch {}
|
||||
if (issueOk) try { execFileSync('sqlite3', [issueDb], { input: `UPDATE users SET role = '${ir}', updated_at = datetime('now', '+8 hours') WHERE username = '${safeUser}';`, timeout: 3000 }); roleResults.issue = true } catch {}
|
||||
|
||||
// 5. 如果提供了邮箱,发送密码设置链接(不再在邮件中发送明文密码)
|
||||
// 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)
|
||||
await sendSetupLinkEmail(email, username, `https://oa.tlyq.ai/setup-password?token=${setupToken}`, displayName || username)
|
||||
emailSent = true
|
||||
} catch (e) {
|
||||
console.error('发送邮件失败:', e)
|
||||
}
|
||||
} catch (e) { console.error('发送邮件失败:', e) }
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
|
|
@ -140,9 +109,7 @@ export async function POST(request: Request) {
|
|||
synced: { assets: assetsOk, issue: issueOk },
|
||||
roles: { assets: ar, issue: ir, applied: roleResults },
|
||||
emailSent,
|
||||
message: emailSent
|
||||
? `用户已创建,密码设置链接已发送至 ${email}`
|
||||
: '用户已创建并同步至所有站点',
|
||||
message: emailSent ? `用户已创建,密码设置链接已发送至 ${email}` : '用户已创建并同步至所有站点',
|
||||
})
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '创建失败'
|
||||
|
|
|
|||
|
|
@ -76,8 +76,8 @@ export async function POST(request: Request) {
|
|||
|
||||
// 从各站点实时获取可用角色列表
|
||||
const [assetsRoles, issueRoles] = await Promise.all([
|
||||
fetchRoles('http://assets-ai:3000'),
|
||||
fetchRoles('http://issue-ai:3000'),
|
||||
fetchRoles('http://localhost:6177'),
|
||||
fetchRoles('http://localhost:6176'),
|
||||
])
|
||||
|
||||
const ar = (assetsRole && assetsRoles.includes(assetsRole)) ? assetsRole : 'viewer'
|
||||
|
|
@ -106,8 +106,8 @@ export async function POST(request: Request) {
|
|||
|
||||
// 3. 自动登录各站点触发用户同步
|
||||
const [assetsOk, issueOk] = await Promise.all([
|
||||
syncToSite('http://assets-ai:3000', username, password),
|
||||
syncToSite('http://issue-ai:3000', username, password),
|
||||
syncToSite('http://localhost:6177', username, password),
|
||||
syncToSite('http://localhost:6176', username, password),
|
||||
])
|
||||
|
||||
// 4. 直接更新各站点 SQLite 的角色(覆盖 viewer 默认值)
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@ export async function GET() {
|
|||
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'),
|
||||
fetchRoles('http://localhost:6177'),
|
||||
fetchRoles('http://localhost:6176'),
|
||||
])
|
||||
|
||||
return NextResponse.json({ assets: assetsRoles, issue: issueRoles })
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { execFileSync } from 'child_process'
|
||||
import { verifySharedJwt } from '@/lib/jwt'
|
||||
import { isLldapAdmin } from '@/lib/ldap'
|
||||
import { queryLldap, esc } from '@/lib/lldap-db'
|
||||
|
||||
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'
|
||||
const ASSETS_DB = process.env.ASSETS_DB_PATH || '/data/other-sites/assets/assets.db'
|
||||
const ISSUE_DB = process.env.ISSUE_DB_PATH || '/data/other-sites/issue/issue.db'
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
|
|
@ -19,23 +18,20 @@ export async function POST() {
|
|||
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)
|
||||
const out = queryLldap(`SELECT user_id, email FROM users WHERE email != ''`)
|
||||
const lines = out.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, "''")
|
||||
const su = esc(user)
|
||||
const sm = esc(mail || '')
|
||||
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 }
|
||||
)
|
||||
execFileSync('sqlite3', [db], {
|
||||
input: `UPDATE users SET email = '${sm}', updated_at = datetime('now', '+8 hours') WHERE username = '${su}';`,
|
||||
timeout: 3000,
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
synced++
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { execFileSync } from 'child_process'
|
||||
import { verifySharedJwt } from '@/lib/jwt'
|
||||
import { isLldapAdmin } from '@/lib/ldap'
|
||||
import { queryLldap } from '@/lib/lldap-db'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
const INTERNAL_KEY = 'oa-internal-key-tlyq-2026'
|
||||
|
||||
async function fetchRoles(siteUrl: string): Promise<string[]> {
|
||||
|
|
@ -19,13 +18,13 @@ async function fetchRoles(siteUrl: string): Promise<string[]> {
|
|||
} catch { return [] }
|
||||
}
|
||||
|
||||
function queryDb(dbPath: string, sql: string): Promise<string> {
|
||||
return execAsync(`sqlite3 "${dbPath}" "${sql.replace(/"/g, '\\"')}"`, { timeout: 3000 }).then(r => r.stdout).catch(() => '')
|
||||
function queryDb(dbPath: string, sql: string): string {
|
||||
try { return execFileSync('sqlite3', [dbPath, sql], { timeout: 3000, encoding: 'utf8' }).trim() } catch { return '' }
|
||||
}
|
||||
|
||||
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 out = queryDb(dbPath, 'SELECT username, display_name, role FROM users WHERE is_active=1 ORDER BY username;')
|
||||
return out.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' }
|
||||
})
|
||||
|
|
@ -50,30 +49,22 @@ export async function GET() {
|
|||
])
|
||||
|
||||
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),
|
||||
getSiteUsers(process.env.ASSETS_DB_PATH || '/data/other-sites/assets/assets.db', assetsRoles),
|
||||
getSiteUsers(process.env.ISSUE_DB_PATH || '/data/other-sites/issue/issue.db', issueRoles),
|
||||
])
|
||||
|
||||
// 从 LLDAP 获取所有用户邮箱
|
||||
// 直连 LLDAP SQLite 获取邮箱
|
||||
let emails: Record<string, string> = {}
|
||||
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 out = queryLldap(`SELECT user_id, email FROM users`)
|
||||
out.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({ assetsRoles, issueRoles, users: { assets: assetsUsers, issue: issueUsers }, emails })
|
||||
} catch {
|
||||
return NextResponse.json({ error: '查询失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -88,17 +79,19 @@ export async function PUT(request: Request) {
|
|||
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')
|
||||
? (process.env.ASSETS_DB_PATH || '/data/other-sites/assets/assets.db')
|
||||
: (process.env.ISSUE_DB_PATH || '/data/other-sites/issue/issue.db')
|
||||
|
||||
// 验证角色合法性
|
||||
const roles = await fetchRoles(`http://localhost:${site === 'assets' ? 6177 : 6176}`)
|
||||
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 })
|
||||
execFileSync('sqlite3', [dbPath], {
|
||||
input: `UPDATE users SET role='${role}', updated_at=datetime('now', '+8 hours') WHERE username='${username}';`,
|
||||
timeout: 3000,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return NextResponse.json({ error: '更新失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,8 +45,8 @@ export async function GET() {
|
|||
|
||||
try {
|
||||
const [assetsRoles, issueRoles] = await Promise.all([
|
||||
fetchRoles('http://assets-ai:3000'),
|
||||
fetchRoles('http://issue-ai:3000'),
|
||||
fetchRoles('http://localhost:6177'),
|
||||
fetchRoles('http://localhost:6176'),
|
||||
])
|
||||
|
||||
const [assetsUsers, issueUsers] = await Promise.all([
|
||||
|
|
@ -92,7 +92,7 @@ export async function PUT(request: Request) {
|
|||
: (process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db')
|
||||
|
||||
// 验证角色合法性
|
||||
const roles = await fetchRoles(`http://${site}-ai:3000`)
|
||||
const roles = await fetchRoles(`http://localhost:${site === 'assets' ? 6177 : 6176}`)
|
||||
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 })
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { execFileSync } from 'child_process'
|
||||
import { verifySharedJwt } from '@/lib/jwt'
|
||||
import { isLldapAdmin } from '@/lib/ldap'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
import { queryLldap, execLldapWrite, esc } from '@/lib/lldap-db'
|
||||
|
||||
function checkAdmin() {
|
||||
return async () => {
|
||||
|
|
@ -17,22 +15,28 @@ function checkAdmin() {
|
|||
}
|
||||
}
|
||||
|
||||
function siteSQL(dbPath: string, sql: string): void {
|
||||
try { execFileSync('sqlite3', [dbPath], { input: sql, timeout: 3000 }) } catch {}
|
||||
}
|
||||
|
||||
function nowStr(): string {
|
||||
const d = new Date()
|
||||
return `${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')}`
|
||||
}
|
||||
|
||||
// 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 out = queryLldap(`SELECT user_id, email, display_name, creation_date FROM users ORDER BY creation_date DESC`)
|
||||
const users = out.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) {
|
||||
} catch {
|
||||
return NextResponse.json({ error: '查询失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -49,34 +53,24 @@ export async function DELETE(request: Request) {
|
|||
return NextResponse.json({ error: '不能删除系统保留用户' }, { status: 400 })
|
||||
}
|
||||
|
||||
const safeUser = username.replace(/'/g, "''")
|
||||
const safeUser = esc(username)
|
||||
execLldapWrite(`DELETE FROM users WHERE user_id='${safeUser}'`)
|
||||
|
||||
// 删除 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<string, boolean> = {}
|
||||
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',
|
||||
assets: process.env.ASSETS_DB_PATH || '/data/other-sites/assets/assets.db',
|
||||
issue: process.env.ISSUE_DB_PATH || '/data/other-sites/issue/issue.db',
|
||||
})) {
|
||||
try {
|
||||
await execAsync(`sqlite3 "${dbPath}" "DELETE FROM users WHERE username='${safeUser}';"`, { timeout: 3000 })
|
||||
results[site] = true
|
||||
} catch { results[site] = false }
|
||||
try { siteSQL(dbPath, `DELETE FROM users WHERE username='${safeUser}'`); results[site] = true } catch { results[site] = false }
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, deleted: results })
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return NextResponse.json({ error: '删除失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH — 修改用户信息(admin 权限)
|
||||
// PATCH — 修改用户信息
|
||||
export async function PATCH(request: Request) {
|
||||
const isAdmin = await checkAdmin()()
|
||||
if (!isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
|
@ -91,42 +85,30 @@ export async function PATCH(request: Request) {
|
|||
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[] = []
|
||||
const safeUser = esc(username)
|
||||
let lldapSets: string[] = [], siteSets: string[] = []
|
||||
if (email !== undefined) {
|
||||
const safeEmail = (email || '').replace(/'/g, "''")
|
||||
const safeEmail = esc(email || '')
|
||||
lldapSets.push(`email = '${safeEmail}'`, `lowercase_email = LOWER('${safeEmail}')`)
|
||||
siteSets.push(`email = '${safeEmail}'`)
|
||||
}
|
||||
if (displayName !== undefined) {
|
||||
const safeName = displayName.replace(/'/g, "''")
|
||||
const safeName = esc(displayName)
|
||||
lldapSets.push(`display_name = '${safeName}'`)
|
||||
siteSets.push(`display_name = '${safeName}'`)
|
||||
}
|
||||
lldapSets.push(`modified_date = '${now}'`)
|
||||
lldapSets.push(`modified_date = '${nowStr()}'`)
|
||||
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 }
|
||||
)
|
||||
execLldapWrite(`UPDATE users SET ${lldapSets.join(', ')} WHERE user_id = '${safeUser}'`)
|
||||
|
||||
// 同步更新 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 {}
|
||||
const siteSql = `UPDATE users SET ${siteSets.join(', ')} WHERE username = '${safeUser}'`
|
||||
for (const dbPath of [process.env.ASSETS_DB_PATH || '/data/other-sites/assets/assets.db', process.env.ISSUE_DB_PATH || '/data/other-sites/issue/issue.db']) {
|
||||
siteSQL(dbPath, siteSql)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, username, email, displayName })
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return NextResponse.json({ error: '修改失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,99 +1,27 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import { getOidcClient } from '@/lib/oidc'
|
||||
import { signSharedJwt, sharedCookieConfig } from '@/lib/jwt'
|
||||
// GET /api/auth/callback — OIDC callback(V2:OA 签发 tlyq_session)
|
||||
import { NextRequest } from 'next/server'
|
||||
import { handleOidcCallback } from '@shared/lib/auth/handle-callback'
|
||||
import { ldapUserExists } from '@/lib/ldap'
|
||||
|
||||
// 从 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}`
|
||||
}
|
||||
const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai'
|
||||
const oidcClientId = process.env.OIDC_CLIENT_ID || 'oa-oidc'
|
||||
const oidcClientSecret = process.env.OIDC_CLIENT_SECRET || ''
|
||||
const oidcRedirectUri = process.env.OIDC_REDIRECT_URI || 'https://oa.tlyq.ai/api/auth/callback'
|
||||
const jwtSecret = process.env.JWT_SECRET || 'oa-shared-jwt-secret-tlyq-2026'
|
||||
const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai'
|
||||
|
||||
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()
|
||||
export async function GET(request: NextRequest) {
|
||||
return handleOidcCallback(request, {
|
||||
oidc: { autheliaUrl, clientId: oidcClientId, clientSecret: oidcClientSecret, redirectUri: oidcRedirectUri },
|
||||
jwtSecret,
|
||||
cookieDomain,
|
||||
|
||||
const cookieStore = await cookies()
|
||||
// 强制验证 LLDAP 存在性(违反 §2.3 的旧行为已修正)
|
||||
getUser: async (username) => {
|
||||
const exists = await ldapUserExists(username)
|
||||
return exists ? { id: -1, role: 'admin' } : null
|
||||
},
|
||||
|
||||
// 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: '/',
|
||||
// OA 不通过 OIDC 创建/更新用户
|
||||
})
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
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)
|
||||
import { lldapChangePassword, getAdminPassword } from '@/lib/lldap-db'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
|
|
@ -22,7 +19,6 @@ export async function POST(request: Request) {
|
|||
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)
|
||||
|
|
@ -32,25 +28,12 @@ export async function POST(request: Request) {
|
|||
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 })
|
||||
}
|
||||
// 通过 bcryptjs + 直连 LLDAP SQLite 修改密码(不再 docker exec)
|
||||
lldapChangePassword(session.username, newPassword)
|
||||
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,81 +1,13 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import { getOidcClient, generatePKCE, generateState, generateNonce } from '@/lib/oidc'
|
||||
import { verifySharedJwt } from '@/lib/jwt'
|
||||
// GET /api/auth/login/oidc — OIDC SSO 重定向(V2:使用 shared handleOidcLogin 工厂)
|
||||
import { handleOidcLogin } from '@shared/lib/auth/handle-login'
|
||||
|
||||
const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai'
|
||||
const oidcClientId = process.env.OIDC_CLIENT_ID || 'oa-oidc'
|
||||
const oidcClientSecret = process.env.OIDC_CLIENT_SECRET || ''
|
||||
const oidcRedirectUri = process.env.OIDC_REDIRECT_URI || 'https://oa.tlyq.ai/api/auth/callback'
|
||||
|
||||
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
|
||||
return handleOidcLogin({ autheliaUrl, clientId: oidcClientId, clientSecret: oidcClientSecret, redirectUri: oidcRedirectUri, switchUser })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,30 @@
|
|||
// POST /api/auth/logout — 退出登录(清除 cookie + 302 跳转 /login)
|
||||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
|
||||
export async function POST() {
|
||||
const cookieStore = await cookies()
|
||||
const domain = process.env.COOKIE_DOMAIN || ''
|
||||
const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai'
|
||||
const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai'
|
||||
|
||||
// 清除所有相关 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'))
|
||||
// GET: 浏览器导航 → 302 到 Authelia end_session(带 rd 回跳 /login)
|
||||
export async function GET() {
|
||||
const returnUrl = `${process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:6179'}/login`
|
||||
const response = NextResponse.redirect(
|
||||
`${autheliaUrl}/api/oidc/end_session?rd=${encodeURIComponent(returnUrl)}`
|
||||
)
|
||||
response.cookies.set('tlyq_session', '', {
|
||||
httpOnly: true, secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax', domain: cookieDomain, path: '/', maxAge: 0,
|
||||
})
|
||||
response.cookies.set('session', '', { path: '/', maxAge: 0 })
|
||||
return response
|
||||
}
|
||||
|
||||
// POST: fetch 调用 → 清除 cookie + 返回 200 JSON
|
||||
export async function POST() {
|
||||
const response = NextResponse.json({ success: true })
|
||||
response.cookies.set('tlyq_session', '', {
|
||||
httpOnly: true, secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax', domain: cookieDomain, path: '/', maxAge: 0,
|
||||
})
|
||||
response.cookies.set('session', '', { path: '/', maxAge: 0 })
|
||||
return response
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,15 @@
|
|||
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)
|
||||
import { queryLldap, execLldapWrite, esc } from '@/lib/lldap-db'
|
||||
import { execFileSync } from 'child_process'
|
||||
|
||||
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('|')
|
||||
const safe = esc(username)
|
||||
const out = queryLldap(`SELECT email, display_name FROM users WHERE user_id = '${safe}'`)
|
||||
const parts = out.split('|')
|
||||
return { email: parts[0] || '', displayName: parts[1] || username }
|
||||
} catch { return { email: '', displayName: username } }
|
||||
}
|
||||
|
|
@ -55,23 +50,23 @@ export async function PUT(request: Request) {
|
|||
return NextResponse.json({ error: '邮箱格式不合法' }, { status: 400 })
|
||||
}
|
||||
|
||||
const safeUser = payload.username.replace(/'/g, "''")
|
||||
const safeEmail = (email || '').replace(/'/g, "''")
|
||||
const safeUser = esc(payload.username)
|
||||
const safeEmail = esc(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 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 }
|
||||
)
|
||||
// docker exec lldap 更新邮箱(LLDAP DELETE 模式不可并发写)
|
||||
execLldapWrite(`UPDATE users SET email = '${safeEmail}', lowercase_email = LOWER('${safeEmail}'), modified_date = '${now}' WHERE user_id = '${safeUser}'`)
|
||||
|
||||
// 同步更新 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 assetsDb = process.env.ASSETS_DB_PATH || '/data/other-sites/assets/assets.db'
|
||||
const issueDb = process.env.ISSUE_DB_PATH || '/data/other-sites/issue/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 })
|
||||
execFileSync('sqlite3', [dbPath], {
|
||||
input: `UPDATE users SET email = '${safeEmail}', updated_at = datetime('now', '+8 hours') WHERE username = '${safeUser}';`,
|
||||
timeout: 3000,
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { verifySetupToken } from '@/lib/setup-token'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
import { lldapChangePassword } from '@/lib/lldap-db'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
|
|
@ -29,26 +26,12 @@ export async function POST(request: Request) {
|
|||
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 })
|
||||
}
|
||||
// 通过 bcryptjs + 直连 LLDAP SQLite 修改密码(不再 docker exec)
|
||||
lldapChangePassword(payload.username, password)
|
||||
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ status: 'OK' })
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ status: 'OK' })
|
||||
}
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
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<string, { light: string; tag: string }> = {
|
||||
'#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 (
|
||||
<div style={{ minHeight: '100vh', background: 'var(--bg)' }}>
|
||||
<Header />
|
||||
|
||||
<div style={{ maxWidth: 1160, margin: '0 auto', padding: '32px 28px 60px' }}>
|
||||
<div style={{ marginBottom: 28 }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 4 }}>tlyq.ai / OA PORTAL</div>
|
||||
<h2 style={{ fontSize: 24, fontWeight: 700, color: 'var(--text)', margin: 0 }}>欢迎回来,{displayName}</h2>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', margin: '0 0 12px', paddingBottom: 8, borderBottom: '1px solid var(--border)' }}>核心系统</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 16 }}>
|
||||
{CORE_SITES.map(site => {
|
||||
const c = COLORS[site.dot]
|
||||
return (
|
||||
<a key={site.name} href={siteUrl(site.url, site.domain)} target="_blank" rel="noopener noreferrer" className="sc" style={{
|
||||
display: 'flex', flexDirection: 'column', background: 'var(--bg-card)', border: '1px solid var(--border)',
|
||||
borderRadius: 12, padding: 22, textDecoration: 'none', minHeight: 140,
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.04)', transition: 'all 0.2s',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
<div style={{ width: 7, height: 7, borderRadius: '50%', background: site.dot, flexShrink: 0 }}></div>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text)', flex: 1 }}>{site.name}</div>
|
||||
<span style={{ fontSize: 11, padding: '2px 8px', borderRadius: 10, background: c.light, color: c.tag, fontWeight: 500, letterSpacing: '0.03em' }}>{site.tag}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.5, flex: 1 }}>{site.desc}</div>
|
||||
<div className="ch" style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 'auto', paddingTop: 8, opacity: 0, transform: 'translateY(4px)', transition: 'all 0.25s ease' }}>{site.domain} →</div>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', margin: '36px 0 12px', paddingBottom: 8, borderBottom: '1px solid var(--border)' }}>其他站点</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 16 }}>
|
||||
{OTHER_SITES.map(site => {
|
||||
const c = COLORS[site.dot]
|
||||
return (
|
||||
<a key={site.name} href={siteUrl(site.url, site.domain)} target="_blank" rel="noopener noreferrer" className="sc" style={{
|
||||
display: 'flex', flexDirection: 'column', background: 'var(--bg-card)', border: '1px solid var(--border)',
|
||||
borderRadius: 12, padding: 22, textDecoration: 'none', minHeight: 140,
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.04)', transition: 'all 0.2s',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
<div style={{ width: 7, height: 7, borderRadius: '50%', background: site.dot, flexShrink: 0 }}></div>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text)', flex: 1 }}>{site.name}</div>
|
||||
<span style={{ fontSize: 11, padding: '2px 8px', borderRadius: 10, background: c.light, color: c.tag, fontWeight: 500, letterSpacing: '0.03em' }}>{site.tag}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.5, flex: 1 }}>{site.desc}</div>
|
||||
<div className="ch" style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 'auto', paddingTop: 8, opacity: 0, transform: 'translateY(4px)', transition: 'all 0.25s ease' }}>{site.domain} →</div>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<style>{`.sc:hover { border-color: #2563eb !important; box-shadow: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04) !important; transform: translateY(-1px); } .sc:hover .ch { opacity: 1 !important; transform: translateY(0) !important; } .lo:hover { background: var(--bg-hover) !important; color: var(--text) !important; border-color: var(--text-muted) !important; }`}</style>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
// lib/lldap-db.ts — LLDAP 操作(读直连 SQLite / 写走 docker exec)
|
||||
// LLDAP 使用 DELETE journal mode,不可并发写 → 写操作必须通过 docker exec 在 LLDAP 容器内执行
|
||||
import { execFileSync } from 'child_process'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
const LLDAP_DB = process.env.LLDAP_DB_PATH || '/data/other-sites/lldap/users.db'
|
||||
|
||||
/** 直连 LLDAP SQLite 只读查询 */
|
||||
export function queryLldap(sql: string): string {
|
||||
return execFileSync('sqlite3', [LLDAP_DB, sql], { timeout: 5000, encoding: 'utf8' }).trim()
|
||||
}
|
||||
|
||||
/** docker exec lldap 执行 LLDAP 容器内操作(密码/写入) */
|
||||
function dockerExecLldap(cmd: string, timeout = 5000): string {
|
||||
return execFileSync('docker', ['exec', 'lldap', 'sh', '-c', cmd], { timeout, encoding: 'utf8' }).trim()
|
||||
}
|
||||
|
||||
/** docker exec lldap sqlite3 写操作 */
|
||||
export function execLldapWrite(sql: string): void {
|
||||
dockerExecLldap(`sqlite3 /data/users.db "${sql.replace(/"/g, '\\"')}"`, 5000)
|
||||
}
|
||||
|
||||
/** 安全的 SQL 字符串转义(SQLite 标准:'' → 单引号) */
|
||||
export function esc(val: string): string {
|
||||
return val.replace(/'/g, "''")
|
||||
}
|
||||
|
||||
/** 修改 LLDAP 用户密码(docker exec lldap + lldap_set_password) */
|
||||
export function lldapChangePassword(username: string, newPassword: string): void {
|
||||
const hash = bcrypt.hashSync(newPassword, 12)
|
||||
const safeUser = esc(username)
|
||||
const now = new Date().toISOString().replace('T', ' ').slice(0, 19)
|
||||
dockerExecLldap(`sqlite3 /data/users.db "UPDATE users SET password_hash = '${hash}', password_modified_date = '${now}', modified_date = '${now}' WHERE user_id = '${safeUser}'"`, 5000)
|
||||
}
|
||||
|
||||
/** 获取 admin 密码 */
|
||||
export function getAdminPassword(): string {
|
||||
return process.env.LLDAP_ADMIN_PASSWORD || 'admin123'
|
||||
}
|
||||
Loading…
Reference in New Issue