feat: SSO 集成 — 共享 JWT + LDAP 认证 + 跨站点用户管理 API
- 新增 src/lib/jwt-shared.ts:共享 JWT 签发/验证(与 OA 共用密钥) - 新增 src/lib/ldap.ts:LDAP 认证与用户存在性检查 - 新增 src/app/api/internal/roles/route.ts:内部 API 供 OA 查询角色 - 重构 auth.ts:SSO 共享 JWT 验证 - 重构 middleware.ts:SSO 优先 + 本地认证回退 - 更新 docker-compose.yml:挂载 docker.sock 用于运行时 LLDAP 密码获取 - 更新 next.config.ts:serverExternalPackages 添加 ldapts - 更新 Dockerfile:生产依赖安装优化
This commit is contained in:
parent
01a717e8b2
commit
4b6bee1868
31
CHANGELOG.md
31
CHANGELOG.md
|
|
@ -1,5 +1,36 @@
|
|||
# 变更日志
|
||||
|
||||
## 2026-05-14
|
||||
|
||||
- [修复] SSO 新用户免登录失败:根页面 `getCurrentUser()` 改为直接 `redirect('/dashboard')`,由中间件统一处理共享 JWT 认证(与 assets-ai 行为一致)
|
||||
- [修复] `next.config.ts` 添加 `ldapts` 到 `serverExternalPackages`,确保 Next.js standalone 构建包含 LLDAP 客户端模块,避免 `ldapUserExists()` 因模块缺失失败导致 SSO 自动创建用户静默中断
|
||||
- [调整] 全局证书切换:Cloudflare Origin CA → Let's Encrypt(`/etc/letsencrypt/live/www.tlyq.ai/`),覆盖全部 7 个子域名,nginx 8 个站点配置同步更新
|
||||
|
||||
## 2026-05-12
|
||||
|
||||
- [部署] 云端 JWT 密钥统一为 oa-shared-jwt-secret-tlyq-2026,三站点密钥一致
|
||||
- [部署] docker-compose 移除 AUTHELIA_URL,添加 LDAP/COOKIE/INTERNAL_API_KEY 环境变量
|
||||
- [部署] LLDAP admin 密码更新为 3Vm!Y!@RCiPs
|
||||
- [部署] nginx 移除 auth_request,恢复纯反向代理;改为运行时 DNS 解析
|
||||
- [新增] `src/lib/db-schema.ts`:users 表新增 last_login_at / last_active_at 列
|
||||
- [新增] `src/lib/auth.ts`:getCurrentUser() 更新 last_active_at
|
||||
- [新增] `src/app/api/auth/login/route.ts`:登录时更新 last_login_at 和 last_active_at
|
||||
|
||||
## 2026-05-11
|
||||
|
||||
- [新增] `src/lib/ldap.ts`:`ldapUserExists()` 函数,检查 LLDAP 中用户是否存在(admin bind 搜索,不可达时容错放行)
|
||||
- [新增] `src/lib/jwt-shared.ts`:共享 JWT 签发/验证(`tlyq_session` cookie,HS256,与 OA/assets 共用密钥)
|
||||
- [调整] `src/lib/auth.ts`:`getCurrentUser()` 优先 `tlyq_session`,加入 LLDAP 存在性检查,用户被删除后自动清除 cookie 踢出
|
||||
- [调整] `src/app/api/auth/login/route.ts`:LDAP 优先认证 + 本地密码缓存回退 + localadmin 应急用户直连
|
||||
- [调整] `src/app/api/auth/logout/route.ts`:同时清除 `session_issue` 和 `tlyq_session`
|
||||
- [调整] `src/app/api/auth/me/route.ts`:移除 SSO header 路径,改用 `getCurrentUser()` 统一获取
|
||||
- [调整] `src/middleware.ts`:优先 `tlyq_session` → 回退 `session_issue`,移除 SSO 代理路径,放行 `/api/internal/`
|
||||
- [新增] `src/app/api/internal/roles/route.ts`:内部 API,返回站点可用角色列表(INTERNAL_API_KEY 鉴权)
|
||||
- [新增] `src/app/api/users/[id]/route.ts`:admin/localadmin 用户禁止删除和修改角色
|
||||
- [修复] `src/components/tickets/TicketList.tsx`:已办工单点击业务IP/节点名称跳转到待办页面,改用 `usePathname()` 保持当前页面并正确筛选
|
||||
- [调整] `src/app/(app)/settings/users/page.tsx`:admin/localadmin 用户隐藏删除按钮,编辑时角色字段显示为只读
|
||||
- [新增] `src/lib/db-schema.ts`:预置 localadmin 应急用户(admin 角色,纯本地 BCrypt 认证)
|
||||
|
||||
## 2026-05-07
|
||||
|
||||
- [新增] 月报跨月进行中工单支持:第一章折线图覆盖未结单离线天数,第二章标注"处理中",第三章显示"进行中"/"—",第四章标注"仅计本月部分"
|
||||
|
|
|
|||
21
CLAUDE.md
21
CLAUDE.md
|
|
@ -99,11 +99,16 @@ npm run import # 导入工单
|
|||
|
||||
### 认证
|
||||
|
||||
登录逻辑(v2.1):LDAP 优先 + 本地密码缓存回退 + localadmin 应急用户。
|
||||
登录成功签发两个 cookie:`session_issue`(本地 JWT,7 天)+ `tlyq_session`(共享 JWT,7 天,domain=.tlyq.ai)。
|
||||
中间件优先检查 `tlyq_session`,回退 `session_issue`。`getCurrentUser()` 每次验证时检查 LLDAP 用户是否存在(已删除则清除 cookie 踢出)。
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | `/api/auth/login` | 登录(username + password → JWT cookie) |
|
||||
| POST | `/api/auth/logout` | 登出 |
|
||||
| POST | `/api/auth/login` | 登录(LDAP 优先 + 本地回退) |
|
||||
| POST | `/api/auth/logout` | 登出(清除两个 cookie) |
|
||||
| GET | `/api/auth/me` | 当前用户信息 |
|
||||
| GET | `/api/internal/roles` | 内部 API:返回角色列表(x-internal-key 鉴权) |
|
||||
|
||||
### 工单
|
||||
|
||||
|
|
@ -133,6 +138,14 @@ npm run import # 导入工单
|
|||
|
||||
---
|
||||
|
||||
## 认证机制
|
||||
|
||||
- **Web UI(v2.1)**:`middleware.ts` 优先检查 `tlyq_session`(共享 JWT,OA 统一签发)→ 回退 `session_issue`(本地 JWT)。`getCurrentUser()` 每次请求时检查 LLDAP 用户是否存在,已删除则清除 cookie 踢出
|
||||
- **localadmin**:纯本地 BCrypt 认证,不依赖 LLDAP,用于 LLDAP 故障时应急登录(DB 预置,admin 角色)
|
||||
- **API Key**:`Bearer ak_<32位十六进制>`,存储时 SHA-256 hash,由 `ALLOWED_API_KEYS` 控制
|
||||
|
||||
---
|
||||
|
||||
## 环境配置
|
||||
|
||||
### 本地与云端差异
|
||||
|
|
@ -225,7 +238,9 @@ NEXT_PUBLIC_ASSETS_URL=https://assets.tlyq.ai
|
|||
- **新增 API**:在 `src/app/api/` 下创建路由 → 顶部调用 `initDatabase()` → `getCurrentUser()` 验证 → `hasPermission()` 校验
|
||||
- **新增页面**:在 `src/app/(app)/` 下创建 → 布局由 `(app)/layout.tsx` 提供
|
||||
- **权限格式**:`resource:action`,如 `hasPermission(user, 'tickets:write')`
|
||||
- **日期处理**:禁止使用 `Date.toISOString()` 格式化本地日期。`toISOString()` 返回 UTC 时间,在中国时区(UTC+8)下 `new Date('2026-04-01T00:00:00').toISOString()` 会返回 `"2026-03-31T16:00:00.000Z"`,日期偏移一天。应使用本地时间方法拼接:`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
|
||||
- **日期处理(时区规范)**:整个系统统一使用 UTC+8(北京时间)。两处必须遵守:
|
||||
1. **JavaScript/TypeScript**:禁止使用 `Date.toISOString()` 格式化本地日期。`toISOString()` 返回 UTC 时间,在中国时区(UTC+8)下 `new Date('2026-04-01T00:00:00').toISOString()` 会返回 `"2026-03-31T16:00:00.000Z"`,日期偏移一天。应使用本地时间方法拼接:`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
|
||||
2. **SQLite**:所有 `datetime('now')` 必须写成 `datetime('now', '+8 hours')`,包括 CREATE TABLE 的 DEFAULT 值、UPDATE/SET 语句、以及查询条件中的时间比较。禁止使用不含时区偏移的 `datetime('now')`。
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -33,8 +33,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||
libcairo2 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=builder /app/package.json /app/package-lock.json ./
|
||||
RUN npm install --omit=dev
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
RUN npm install --omit=dev && \
|
||||
npm rebuild better-sqlite3 && \
|
||||
rm -rf node_modules/@img/sharp-linuxmusl-x64 node_modules/@img/sharp-libvips-linuxmusl-x64 \
|
||||
node_modules/@next/swc-linux-x64-musl
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/public ./public
|
||||
RUN mkdir -p /app/data /app/uploads /app/reports
|
||||
|
|
|
|||
|
|
@ -10,14 +10,21 @@ services:
|
|||
- issue-reports:/app/reports
|
||||
# .next 目录从主机挂载,npm run build 后直接生效,无需重建镜像
|
||||
- ./.next:/app/.next
|
||||
# 运行时从 LLDAP 容器动态读取 admin 密码
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
- DATABASE_PATH=/app/data/issue.db
|
||||
- JWT_SECRET=${ISSUE_JWT_SECRET:-change-me-in-production}
|
||||
- JWT_SECRET=oa-shared-jwt-secret-tlyq-2026
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
||||
- ASSETS_API_URL=${ASSETS_API_URL:-https://assets.tlyq.ai/api}
|
||||
- ASSETS_API_KEY=${ASSETS_API_KEY}
|
||||
- ALLOWED_API_KEYS=${ALLOWED_API_KEYS}
|
||||
- NODE_ENV=production
|
||||
- COOKIE_DOMAIN=.tlyq.ai
|
||||
- AUTHELIA_URL=${AUTHELIA_URL:-https://sso.tlyq.ai}
|
||||
- LDAP_URL=ldap://lldap:3890
|
||||
- LDAP_BASE_DN=dc=tlyq,dc=ai
|
||||
- LDAP_ADMIN_DN=uid=admin,ou=people,dc=tlyq,dc=ai
|
||||
- TZ=Asia/Shanghai
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ const nextConfig: NextConfig = {
|
|||
images: { unoptimized: true },
|
||||
eslint: { ignoreDuringBuilds: true },
|
||||
typescript: { ignoreBuildErrors: true },
|
||||
serverExternalPackages: ['better-sqlite3'],
|
||||
// better-sqlite3: 原生模块,必须 external
|
||||
// ldapts: SSO 自动创建用户依赖 LLDAP 验证,缺少则新用户无法免登录进入系统
|
||||
serverExternalPackages: ['better-sqlite3', 'ldapts'],
|
||||
// 确保 fs.readFileSync 加载的文件也被追踪到 standalone 输出中
|
||||
// 防止 Docker 镜像中 npm install --omit=dev 漏装时缺失依赖
|
||||
outputFileTracingIncludes: {
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
|
|
@ -15,6 +15,7 @@
|
|||
"echarts": "^5.5.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"ldapts": "^8.1.7",
|
||||
"lucide-react": "^1.8.0",
|
||||
"next": "^15.1.0",
|
||||
"puppeteer": "^23.0.0",
|
||||
|
|
@ -3592,6 +3593,18 @@
|
|||
"json-buffer": "3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ldapts": {
|
||||
"version": "8.1.7",
|
||||
"resolved": "https://registry.npmjs.org/ldapts/-/ldapts-8.1.7.tgz",
|
||||
"integrity": "sha512-TJl6T92eIwMf/OJ0hDfKVa6ISwzo+lqCWCI5Mf//ARlKa3LKQZaSrme/H2rCRBhW0DZCQlrsV+fgoW5YHRNLUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"strict-event-emitter-types": "2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||
|
|
@ -4978,6 +4991,12 @@
|
|||
"text-decoder": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strict-event-emitter-types": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz",
|
||||
"integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
"echarts": "^5.5.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"ldapts": "^8.1.7",
|
||||
"lucide-react": "^1.8.0",
|
||||
"next": "^15.1.0",
|
||||
"puppeteer": "^23.0.0",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ interface User {
|
|||
role: string
|
||||
is_active: number
|
||||
created_at: string
|
||||
last_login_at: string | null
|
||||
is_online: number
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
|
|
@ -85,7 +87,7 @@ export default function UsersPage() {
|
|||
{loading ? (
|
||||
<div className="text-center py-12 text-slate-500 dark:text-slate-400">加载中...</div>
|
||||
) : (
|
||||
<Table headers={['用户名', '显示名称', '邮箱', '角色', '状态', '创建时间', '操作']}>
|
||||
<Table headers={['用户名', '显示名称', '邮箱', '角色', '状态', '在线', '最后登录', '创建时间', '操作']}>
|
||||
{users.map(u => (
|
||||
<tr key={u.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
|
||||
<td className="px-4 py-3 font-medium text-slate-900 dark:text-slate-100">{u.username}</td>
|
||||
|
|
@ -97,11 +99,20 @@ export default function UsersPage() {
|
|||
<Badge variant={u.is_active ? 'success' : 'danger'}>{u.is_active ? '启用' : '禁用'}</Badge>
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className={`inline-block w-2 h-2 rounded-full ${u.is_online ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'}`} />
|
||||
<span className="text-xs text-slate-500">{u.is_online ? '在线' : '离线'}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-slate-500 dark:text-slate-400">{u.last_login_at ? u.last_login_at : <span className="text-slate-400">从未登录</span>}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-500 dark:text-slate-400">{u.created_at || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(u)}><Pencil size={14} /></Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDelete(u.id)}><Trash2 size={14} className="text-red-500" /></Button>
|
||||
{u.username !== 'admin' && u.username !== 'localadmin' && (
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDelete(u.id)}><Trash2 size={14} className="text-red-500" /></Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -115,7 +126,14 @@ export default function UsersPage() {
|
|||
<Input label="显示名称" value={form.display_name} onChange={e => setForm(p => ({ ...p, display_name: e.target.value }))} required />
|
||||
<Input label={editUser ? '新密码(留空不修改)' : '密码'} type="password" value={form.password} onChange={e => setForm(p => ({ ...p, password: e.target.value }))} />
|
||||
<Input label="邮箱" type="email" value={form.email} onChange={e => setForm(p => ({ ...p, email: e.target.value }))} />
|
||||
<Select label="角色" options={[{ value: 'viewer', label: '查看者' }, { value: 'operator', label: '运维人员' }, { value: 'admin', label: '管理员' }]} value={form.role} onChange={e => setForm(p => ({ ...p, role: e.target.value }))} />
|
||||
{editUser && (editUser.username === 'admin' || editUser.username === 'localadmin') ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">角色</label>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 py-2">{editUser.role === 'admin' ? '管理员' : '管理员(系统保留)'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<Select label="角色" options={[{ value: 'viewer', label: '查看者' }, { value: 'operator', label: '运维人员' }, { value: 'admin', label: '管理员' }]} value={form.role} onChange={e => setForm(p => ({ ...p, role: e.target.value }))} />
|
||||
)}
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={handleSave}>{editUser ? '保存' : '创建'}</Button>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,93 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { login } from '@/lib/auth'
|
||||
import { createToken } from '@/lib/auth'
|
||||
import { initDatabase } from '@/lib/db-schema'
|
||||
import { signSharedJwt, sharedCookieConfig } from '@/lib/jwt-shared'
|
||||
import { ldapAuth } from '@/lib/ldap'
|
||||
import { getDb } from '@/lib/db'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
initDatabase()
|
||||
const { username, password } = await request.json()
|
||||
if (!username || !password) return NextResponse.json({ error: '请输入用户名和密码' }, { status: 400 })
|
||||
const result = await login(username, password)
|
||||
if (!result) return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 })
|
||||
const response = NextResponse.json({ user: result.user })
|
||||
response.cookies.set('session_issue', result.token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 7 * 24 * 60 * 60, path: '/' })
|
||||
|
||||
let userId: number
|
||||
let role: string
|
||||
let displayName: string
|
||||
const db = getDb()
|
||||
|
||||
// 1. localadmin:纯本地 BCrypt,不依赖 LLDAP
|
||||
if (username === 'localadmin') {
|
||||
const localUser = db.prepare(
|
||||
'SELECT * FROM users WHERE username = ? AND is_active = 1'
|
||||
).get(username) as { id: number; username: string; display_name: string; role: string; password_hash: string } | undefined
|
||||
if (!localUser || !bcrypt.compareSync(password, localUser.password_hash)) {
|
||||
return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 })
|
||||
}
|
||||
userId = localUser.id
|
||||
role = localUser.role
|
||||
displayName = localUser.display_name || username
|
||||
} else {
|
||||
// 2. 其他用户:LLDAP 优先
|
||||
const ldapResult = await ldapAuth(username, password)
|
||||
|
||||
if (ldapResult.success) {
|
||||
// LDAP 认证成功 → 更新本地密码缓存 + 自动创建用户
|
||||
displayName = ldapResult.displayName || username
|
||||
const pwHash = bcrypt.hashSync(password, 10)
|
||||
const existing = db.prepare(
|
||||
'SELECT id, role FROM users WHERE username = ? AND is_active = 1'
|
||||
).get(username) as { id: number; role: string } | undefined
|
||||
|
||||
if (existing) {
|
||||
db.prepare('UPDATE users SET password_hash = ?, display_name = ? WHERE id = ?')
|
||||
.run(pwHash, displayName, existing.id)
|
||||
userId = existing.id
|
||||
role = existing.role
|
||||
} else {
|
||||
db.prepare(
|
||||
"INSERT INTO users (username, password_hash, display_name, role, is_active, created_at, updated_at) VALUES (?, ?, ?, 'viewer', 1, datetime('now', '+8 hours'), datetime('now', '+8 hours'))"
|
||||
).run(username, pwHash, displayName)
|
||||
const created = db.prepare(
|
||||
'SELECT id, role FROM users WHERE username = ?'
|
||||
).get(username) as { id: number; role: string } | undefined
|
||||
if (!created) return NextResponse.json({ error: '用户创建失败' }, { status: 500 })
|
||||
userId = created.id
|
||||
role = created.role
|
||||
}
|
||||
} else if (ldapResult.unreachable) {
|
||||
// LLDAP 不可达 → 回退本地密码缓存
|
||||
const localUser = db.prepare(
|
||||
'SELECT * FROM users WHERE username = ? AND is_active = 1'
|
||||
).get(username) as { id: number; username: string; display_name: string; role: string; password_hash: string } | undefined
|
||||
if (!localUser || !bcrypt.compareSync(password, localUser.password_hash)) {
|
||||
return NextResponse.json({ error: '认证服务不可用,且本地密码不匹配' }, { status: 401 })
|
||||
}
|
||||
userId = localUser.id
|
||||
role = localUser.role
|
||||
displayName = localUser.display_name || username
|
||||
} else {
|
||||
return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 })
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 更新最后登录时间和活跃时间
|
||||
db.prepare("UPDATE users SET last_login_at = datetime('now', '+8 hours'), last_active_at = datetime('now', '+8 hours') WHERE id = ?").run(userId)
|
||||
|
||||
// 4. 签发两个 cookie
|
||||
const localToken = await createToken({ id: userId, username, display_name: displayName, role })
|
||||
const sharedToken = signSharedJwt({ username, displayName })
|
||||
const sharedCfg = sharedCookieConfig()
|
||||
|
||||
const response = NextResponse.json({
|
||||
user: { id: userId, username, display_name: displayName, role },
|
||||
})
|
||||
response.cookies.set('session_issue', localToken, {
|
||||
httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax',
|
||||
maxAge: 7 * 24 * 60 * 60, path: '/',
|
||||
})
|
||||
response.cookies.set(sharedCfg.name, sharedToken, sharedCfg)
|
||||
return response
|
||||
} catch (e) { console.error('Login error:', e); return NextResponse.json({ error: '登录失败' }, { status: 500 }) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ import { NextResponse } from 'next/server'
|
|||
export async function POST() {
|
||||
const r = NextResponse.json({ success: true })
|
||||
r.cookies.set('session_issue', '', { maxAge: 0, path: '/' })
|
||||
r.cookies.set('session', '', { maxAge: 0, path: '/' })
|
||||
// 清除 Authelia SSO cookie
|
||||
r.cookies.set('authelia_session', '', { maxAge: 0, path: '/', domain: '127.0.0.1' })
|
||||
r.cookies.set('tlyq_session', '', { maxAge: 0, path: '/' })
|
||||
return r
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,45 +1,8 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies, headers } from 'next/headers'
|
||||
import { getDb } from '@/lib/db'
|
||||
import { getCurrentUser, createToken } from '@/lib/auth'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const cookieStore = await cookies()
|
||||
|
||||
// 路径 1:SSO(来自 nginx auth_request 代理)
|
||||
let ssoUsername = ''
|
||||
try {
|
||||
const ssoSession = cookieStore.get('session')?.value
|
||||
if (ssoSession) ssoUsername = JSON.parse(ssoSession).username || ''
|
||||
} catch { }
|
||||
if (!ssoUsername) {
|
||||
const headersList = await headers()
|
||||
ssoUsername = headersList.get('x-remote-user') || ''
|
||||
}
|
||||
|
||||
if (ssoUsername) {
|
||||
const db = getDb()
|
||||
db.prepare(
|
||||
"INSERT OR IGNORE INTO users (username, password_hash, display_name, role, is_active) VALUES (?, '__SSO__', ?, ?, 1)"
|
||||
).run(ssoUsername, ssoUsername, 'viewer')
|
||||
const user = db.prepare(
|
||||
'SELECT id, username, display_name, role FROM users WHERE username = ? AND is_active = 1'
|
||||
).get(ssoUsername) as Record<string, unknown> | undefined
|
||||
if (user) {
|
||||
const jwt = await createToken({
|
||||
id: user.id as number,
|
||||
username: user.username as string,
|
||||
display_name: (user.display_name as string) || '',
|
||||
role: user.role as string,
|
||||
})
|
||||
const response = NextResponse.json({ user })
|
||||
response.cookies.set('session_issue', jwt, { httpOnly: true, sameSite: 'lax', path: '/', maxAge: 7 * 24 * 60 * 60 })
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
// 路径 2:JWT cookie(本地开发 / @fallback 紧急绕过)
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
return NextResponse.json({ user })
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
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 roles = db.prepare('SELECT name, display_name FROM roles ORDER BY name').all() as { name: string; display_name: string }[]
|
||||
return NextResponse.json({ roles })
|
||||
}
|
||||
|
|
@ -90,7 +90,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
|||
if (fields.length > 0) {
|
||||
fields.push('updated_by = ?')
|
||||
values.push(user.id)
|
||||
fields.push("updated_at = datetime('now')")
|
||||
fields.push("updated_at = datetime('now', '+8 hours')")
|
||||
values.push(id)
|
||||
db.prepare(`UPDATE tickets SET ${fields.join(', ')} WHERE id = ?`).run(...values)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ export async function PUT(request: NextRequest) {
|
|||
}
|
||||
|
||||
if (fields.length === 0) continue
|
||||
fields.push("updated_at = datetime('now')")
|
||||
fields.push("updated_at = datetime('now', '+8 hours')")
|
||||
fields.push('updated_by = ?')
|
||||
values.push(user.id)
|
||||
values.push(item.id)
|
||||
|
|
|
|||
|
|
@ -15,9 +15,14 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
|||
const body = await request.json()
|
||||
const db = getDb()
|
||||
|
||||
const existing = db.prepare('SELECT id FROM users WHERE id = ?').get(id)
|
||||
const existing = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id) as { id: number; username: string } | undefined
|
||||
if (!existing) return NextResponse.json({ error: '用户不存在' }, { status: 404 })
|
||||
|
||||
// 禁止修改系统保留用户的角色
|
||||
if (body.role && (existing.username === 'admin' || existing.username === 'localadmin')) {
|
||||
return NextResponse.json({ error: '不能修改系统保留用户的角色' }, { status: 400 })
|
||||
}
|
||||
|
||||
const fields: string[] = []
|
||||
const values: unknown[] = []
|
||||
|
||||
|
|
@ -28,7 +33,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
|||
if (body.password) { fields.push('password_hash = ?'); values.push(hashPassword(body.password)) }
|
||||
|
||||
if (fields.length > 0) {
|
||||
fields.push("updated_at = datetime('now')")
|
||||
fields.push("updated_at = datetime('now', '+8 hours')")
|
||||
values.push(id)
|
||||
db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values)
|
||||
}
|
||||
|
|
@ -52,8 +57,11 @@ export async function DELETE(_request: NextRequest, { params }: { params: Promis
|
|||
if (String(id) === String(user.id)) return NextResponse.json({ error: '不能删除自己' }, { status: 400 })
|
||||
|
||||
const db = getDb()
|
||||
const existing = db.prepare('SELECT id FROM users WHERE id = ?').get(id)
|
||||
const existing = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id) as { id: number; username: string } | undefined
|
||||
if (!existing) return NextResponse.json({ error: '用户不存在' }, { status: 404 })
|
||||
if (existing.username === 'admin' || existing.username === 'localadmin') {
|
||||
return NextResponse.json({ error: '不能删除系统保留用户' }, { status: 400 })
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(id)
|
||||
return NextResponse.json({ success: true })
|
||||
|
|
|
|||
|
|
@ -13,7 +13,10 @@ export async function GET() {
|
|||
if (!hasPermission(user, 'users:read')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
|
||||
const db = getDb()
|
||||
const users = db.prepare('SELECT id, username, display_name, email, role, is_active, created_at, updated_at FROM users ORDER BY id').all()
|
||||
const users = db.prepare(`SELECT id, username, display_name, email, role, is_active, created_at, updated_at,
|
||||
last_login_at,
|
||||
CASE WHEN last_active_at IS NOT NULL AND datetime(last_active_at, '+5 minutes') > datetime('now', '+8 hours') THEN 1 ELSE 0 END AS is_online
|
||||
FROM users ORDER BY id`).all()
|
||||
return NextResponse.json({ users })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '查询失败'
|
||||
|
|
@ -41,7 +44,7 @@ export async function POST(request: NextRequest) {
|
|||
|
||||
const hash = hashPassword(password)
|
||||
const result = db.prepare(
|
||||
'INSERT INTO users (username, password_hash, display_name, email, role) VALUES (?, ?, ?, ?, ?)'
|
||||
"INSERT INTO users (username, password_hash, display_name, email, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?, datetime('now', '+8 hours'), datetime('now', '+8 hours'))"
|
||||
).run(username, hash, display_name, email || null, role || 'viewer')
|
||||
|
||||
const newUser = db.prepare('SELECT id, username, display_name, email, role, is_active, created_at FROM users WHERE id = ?').get(result.lastInsertRowid)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,5 @@
|
|||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { initDatabase } from '@/lib/db-schema'
|
||||
|
||||
export default async function Home() {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (user) redirect('/dashboard')
|
||||
else redirect('/login')
|
||||
export default function Home() {
|
||||
redirect('/dashboard')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client'
|
||||
import { useState, useEffect, useCallback, useRef, Suspense } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useRouter, useSearchParams, usePathname } from 'next/navigation'
|
||||
import { Button, Badge, Pagination } from '@/components/ui'
|
||||
import { Search, Download, Eye, Pencil, Trash2, Filter, ArrowUpDown, ChevronsUpDown, ChevronUp, ChevronDown, Check, X, ExternalLink } from 'lucide-react'
|
||||
import SelectWithInput from '@/components/ui/SelectWithInput'
|
||||
|
|
@ -148,6 +148,7 @@ interface TicketListInnerProps {
|
|||
function TicketListInner({ onPaginationChange, defaultStatusFilter, showSlaColumn, showActions = true, hideDefaultFilterChips }: TicketListInnerProps) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const pathname = usePathname()
|
||||
const [tickets, setTickets] = useState<Ticket[]>([])
|
||||
const [pagination, setPagination] = useState({ page: 1, pageSize: 20, total: 0, totalPages: 0 })
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
|
@ -652,12 +653,12 @@ function TicketListInner({ onPaginationChange, defaultStatusFilter, showSlaColum
|
|||
<td className="px-4 py-3 font-medium"><Link href={`/tickets/${t.id}`} className="text-blue-600 dark:text-blue-400 hover:underline">{t.id}</Link></td>
|
||||
<td className="px-4 py-3">
|
||||
{t.device_ip ? (
|
||||
<Link href={`/tickets?device_ip=${encodeURIComponent(t.device_ip)}`} className="text-blue-600 dark:text-blue-400 hover:underline">{t.device_ip}</Link>
|
||||
<Link href={`${pathname}?device_ip=${encodeURIComponent(t.device_ip)}`} className="text-blue-600 dark:text-blue-400 hover:underline">{t.device_ip}</Link>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{t.device_name ? (
|
||||
<Link href={`/tickets?device_name=${encodeURIComponent(t.device_name)}`} className="text-blue-600 dark:text-blue-400 hover:underline">{t.device_name}</Link>
|
||||
<Link href={`${pathname}?device_name=${encodeURIComponent(t.device_name)}`} className="text-blue-600 dark:text-blue-400 hover:underline">{t.device_name}</Link>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 max-w-xs truncate">{t.content || '-'}</td>
|
||||
|
|
|
|||
|
|
@ -6,11 +6,51 @@ import { createToken, verifyToken, type UserPayload } from './jwt'
|
|||
|
||||
export { createToken, verifyToken, type UserPayload }
|
||||
|
||||
import { verifySharedJwt } from './jwt-shared'
|
||||
import { ldapUserExists } from './ldap'
|
||||
|
||||
export async function getCurrentUser(): Promise<UserPayload | null> {
|
||||
const cookieStore = await cookies()
|
||||
|
||||
// 优先 tlyq_session(共享 JWT,LDAP 用户)
|
||||
const sharedToken = cookieStore.get('tlyq_session')?.value
|
||||
if (sharedToken) {
|
||||
const sharedPayload = verifySharedJwt(sharedToken)
|
||||
if (sharedPayload) {
|
||||
// Q1: 检查 LLDAP 中用户是否仍存在(已删除则强制退出)
|
||||
if (!(await ldapUserExists(sharedPayload.username))) {
|
||||
cookieStore.set('tlyq_session', '', { maxAge: 0, path: '/' })
|
||||
cookieStore.set('session_issue', '', { maxAge: 0, path: '/' })
|
||||
return null
|
||||
}
|
||||
const db = getDb()
|
||||
const row = db.prepare(
|
||||
'SELECT id, username, display_name, role FROM users WHERE username = ? AND is_active = 1'
|
||||
).get(sharedPayload.username) as UserPayload | undefined
|
||||
if (row) {
|
||||
db.prepare("UPDATE users SET last_active_at = datetime('now', '+8 hours') WHERE id = ?").run(row.id)
|
||||
return row
|
||||
}
|
||||
// SSO 免登录:LLDAP 验证通过但本地无记录 → 自动创建(viewer 角色)
|
||||
db.prepare(
|
||||
"INSERT OR IGNORE INTO users (username, display_name, role, is_active, created_at, updated_at) VALUES (?, ?, 'viewer', 1, datetime('now', '+8 hours'), datetime('now', '+8 hours'))"
|
||||
).run(sharedPayload.username, sharedPayload.displayName)
|
||||
const newRow = db.prepare(
|
||||
'SELECT id, username, display_name, role FROM users WHERE username = ? AND is_active = 1'
|
||||
).get(sharedPayload.username) as UserPayload | undefined
|
||||
if (newRow) return newRow
|
||||
}
|
||||
}
|
||||
|
||||
// 回退 session_issue(本地 JWT,admin 账号或紧急绕过)
|
||||
const token = cookieStore.get('session_issue')?.value
|
||||
if (!token) return null
|
||||
return verifyToken(token)
|
||||
const payload = await verifyToken(token)
|
||||
if (payload) {
|
||||
const db2 = getDb()
|
||||
db2.prepare("UPDATE users SET last_active_at = datetime('now', '+8 hours') WHERE id = ?").run(payload.id)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
export async function login(username: string, password: string) {
|
||||
|
|
@ -52,6 +92,6 @@ export function verifyApiKey(key: string): ApiKeyInfo | null {
|
|||
.get(keyHash) as { id: number; name: string; permissions: string; expires_at: string | null } | undefined
|
||||
if (!row) return null
|
||||
if (row.expires_at && new Date(row.expires_at) < new Date()) return null
|
||||
db.prepare("UPDATE api_keys SET last_used_at = datetime('now') WHERE id = ?").run(row.id)
|
||||
db.prepare("UPDATE api_keys SET last_used_at = datetime('now', '+8 hours') WHERE id = ?").run(row.id)
|
||||
return { id: row.id, name: row.name, permissions: JSON.parse(row.permissions) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,14 +4,14 @@ import bcrypt from 'bcryptjs'
|
|||
export function initDatabase(): void {
|
||||
const db = getDb()
|
||||
const schema = [
|
||||
"CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, display_name TEXT NOT NULL, email TEXT, role TEXT NOT NULL DEFAULT 'viewer', is_active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')));",
|
||||
"CREATE TABLE IF NOT EXISTS roles (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, permissions TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL DEFAULT (datetime('now')));",
|
||||
"CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')));",
|
||||
"CREATE TABLE IF NOT EXISTS tickets (id INTEGER PRIMARY KEY, device_ip TEXT, device_sn TEXT, device_name TEXT, content TEXT, assign_time TEXT, close_time TEXT, duration_minutes INTEGER, availability REAL, process_summary TEXT, conclusion TEXT, fault_category TEXT, fault_subcategory TEXT, parts_replaced TEXT, parts_name TEXT, current_status TEXT NOT NULL DEFAULT 'open', counted_in_sla INTEGER NOT NULL DEFAULT 1, responsibility TEXT, created_by INTEGER REFERENCES users(id), updated_by INTEGER REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')));",
|
||||
"CREATE TABLE IF NOT EXISTS ticket_steps (id INTEGER PRIMARY KEY AUTOINCREMENT, ticket_id INTEGER NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, step_order INTEGER NOT NULL, time_node TEXT, handler TEXT, description TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')));",
|
||||
"CREATE TABLE IF NOT EXISTS reports (id INTEGER PRIMARY KEY AUTOINCREMENT, report_type TEXT NOT NULL, period_start TEXT, period_end TEXT, format TEXT NOT NULL DEFAULT 'pdf', file_path TEXT, file_name TEXT, status TEXT NOT NULL DEFAULT 'pending', error_message TEXT, created_by INTEGER REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now')));",
|
||||
"CREATE TABLE IF NOT EXISTS audit_logs (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER REFERENCES users(id), action TEXT NOT NULL, entity_type TEXT NOT NULL, entity_id INTEGER, details TEXT, ip_address TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')));",
|
||||
"CREATE TABLE IF NOT EXISTS api_keys (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, key_hash TEXT NOT NULL, permissions TEXT NOT NULL DEFAULT '[\"tickets:read\"]', last_used_at TEXT, expires_at TEXT, is_active INTEGER NOT NULL DEFAULT 1, created_by INTEGER REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now')));"
|
||||
"CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, display_name TEXT NOT NULL, email TEXT, role TEXT NOT NULL DEFAULT 'viewer', is_active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours')), updated_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours')));",
|
||||
"CREATE TABLE IF NOT EXISTS roles (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, permissions TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours')));",
|
||||
"CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours')));",
|
||||
"CREATE TABLE IF NOT EXISTS tickets (id INTEGER PRIMARY KEY, device_ip TEXT, device_sn TEXT, device_name TEXT, content TEXT, assign_time TEXT, close_time TEXT, duration_minutes INTEGER, availability REAL, process_summary TEXT, conclusion TEXT, fault_category TEXT, fault_subcategory TEXT, parts_replaced TEXT, parts_name TEXT, current_status TEXT NOT NULL DEFAULT 'open', counted_in_sla INTEGER NOT NULL DEFAULT 1, responsibility TEXT, created_by INTEGER REFERENCES users(id), updated_by INTEGER REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours')), updated_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours')));",
|
||||
"CREATE TABLE IF NOT EXISTS ticket_steps (id INTEGER PRIMARY KEY AUTOINCREMENT, ticket_id INTEGER NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, step_order INTEGER NOT NULL, time_node TEXT, handler TEXT, description TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours')));",
|
||||
"CREATE TABLE IF NOT EXISTS reports (id INTEGER PRIMARY KEY AUTOINCREMENT, report_type TEXT NOT NULL, period_start TEXT, period_end TEXT, format TEXT NOT NULL DEFAULT 'pdf', file_path TEXT, file_name TEXT, status TEXT NOT NULL DEFAULT 'pending', error_message TEXT, created_by INTEGER REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours')));",
|
||||
"CREATE TABLE IF NOT EXISTS audit_logs (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER REFERENCES users(id), action TEXT NOT NULL, entity_type TEXT NOT NULL, entity_id INTEGER, details TEXT, ip_address TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours')));",
|
||||
"CREATE TABLE IF NOT EXISTS api_keys (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, key_hash TEXT NOT NULL, permissions TEXT NOT NULL DEFAULT '[\"tickets:read\"]', last_used_at TEXT, expires_at TEXT, is_active INTEGER NOT NULL DEFAULT 1, created_by INTEGER REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours')));"
|
||||
]
|
||||
for (const sql of schema) db.exec(sql)
|
||||
|
||||
|
|
@ -29,6 +29,10 @@ export function initDatabase(): void {
|
|||
db.prepare("UPDATE tickets SET ticket_type = 'OEM维修' WHERE ticket_type IS NULL AND fault_category IN ('硬件故障', '网络故障', '存储故障', '电源故障')").run()
|
||||
} catch { /* 迁移失败则保持原样 */ }
|
||||
|
||||
// 迁移:添加 last_login_at 和 last_active_at 列
|
||||
try { db.exec('ALTER TABLE users ADD COLUMN last_login_at TEXT') } catch { /* 列已存在 */ }
|
||||
try { db.exec('ALTER TABLE users ADD COLUMN last_active_at TEXT') } catch { /* 列已存在 */ }
|
||||
|
||||
// 迁移:metadata 列(报告元数据 JSON)
|
||||
try { db.exec('ALTER TABLE reports ADD COLUMN metadata TEXT') } catch { /* 已存在 */ }
|
||||
|
||||
|
|
@ -45,7 +49,13 @@ export function initDatabase(): void {
|
|||
if (!existing) {
|
||||
const defaultPassword = process.env.ADMIN_PASSWORD || 'admin123'
|
||||
const hash = bcrypt.hashSync(defaultPassword, 10)
|
||||
db.prepare('INSERT INTO users (username, password_hash, display_name, role) VALUES (?, ?, ?, ?)').run('admin', hash, '系统管理员', 'admin')
|
||||
db.prepare("INSERT INTO users (username, password_hash, display_name, role, created_at, updated_at) VALUES (?, ?, ?, ?, datetime('now', '+8 hours'), datetime('now', '+8 hours'))").run('admin', hash, '系统管理员', 'admin')
|
||||
}
|
||||
const existingLocalAdmin = db.prepare('SELECT id FROM users WHERE username = ?').get('localadmin')
|
||||
if (!existingLocalAdmin) {
|
||||
const localPassword = process.env.LOCALADMIN_PASSWORD || 'admin123'
|
||||
const localHash = bcrypt.hashSync(localPassword, 10)
|
||||
db.prepare("INSERT INTO users (username, password_hash, display_name, role, created_at, updated_at) VALUES (?, ?, ?, ?, datetime('now', '+8 hours'), datetime('now', '+8 hours'))").run('localadmin', localHash, '本地管理员', 'admin')
|
||||
}
|
||||
const roles = [
|
||||
{ name: 'admin', display_name: '管理员', permissions: '["*"]' },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
import crypto from 'crypto'
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-same-across-all-sites'
|
||||
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || ''
|
||||
|
||||
export interface SharedSession {
|
||||
username: string
|
||||
displayName: string
|
||||
iat: number
|
||||
exp: number
|
||||
}
|
||||
|
||||
function base64url(str: string): string {
|
||||
return Buffer.from(str).toString('base64url')
|
||||
}
|
||||
|
||||
export function signSharedJwt(
|
||||
payload: { username: string; displayName: string },
|
||||
expiresIn: number = 7 * 24 * 60 * 60
|
||||
): string {
|
||||
const header = { alg: 'HS256', typ: 'JWT' }
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const body = { ...payload, iat: now, exp: now + expiresIn }
|
||||
const segments = [base64url(JSON.stringify(header)), base64url(JSON.stringify(body))]
|
||||
const signingInput = segments.join('.')
|
||||
segments.push(
|
||||
crypto.createHmac('sha256', JWT_SECRET).update(signingInput).digest('base64url')
|
||||
)
|
||||
return segments.join('.')
|
||||
}
|
||||
|
||||
export function verifySharedJwt(token: string): SharedSession | null {
|
||||
try {
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 3) return null
|
||||
const signingInput = parts.slice(0, 2).join('.')
|
||||
const expectedSig = crypto.createHmac('sha256', JWT_SECRET)
|
||||
.update(signingInput).digest('base64url')
|
||||
if (parts[2] !== expectedSig) return null
|
||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString())
|
||||
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) return null
|
||||
return {
|
||||
username: payload.username,
|
||||
displayName: payload.displayName,
|
||||
iat: payload.iat,
|
||||
exp: payload.exp,
|
||||
}
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
export function sharedCookieConfig(maxAge: number = 7 * 24 * 60 * 60) {
|
||||
return {
|
||||
name: 'tlyq_session',
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
domain: COOKIE_DOMAIN,
|
||||
path: '/',
|
||||
maxAge,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import { Client, InvalidCredentialsError } from 'ldapts'
|
||||
import { execFileSync } from 'child_process'
|
||||
|
||||
const LDAP_URL = process.env.LDAP_URL || 'ldap://localhost:3890'
|
||||
const LDAP_BASE_DN = process.env.LDAP_BASE_DN || 'dc=tlyq,dc=ai'
|
||||
|
||||
// 运行时从 LLDAP 容器动态获取 admin 密码,避免明文存于多个 .env
|
||||
// 需要容器挂载 /var/run/docker.sock
|
||||
function getLdapAdminPassword(): string {
|
||||
try {
|
||||
return execFileSync('docker', ['exec', 'lldap', 'printenv', 'LLDAP_ADMIN_PASSWORD'],
|
||||
{ timeout: 3000 }).toString().trim()
|
||||
} catch { return 'admin123' }
|
||||
}
|
||||
|
||||
export interface LdapResult {
|
||||
success: boolean
|
||||
unreachable: boolean
|
||||
username?: string
|
||||
displayName?: string
|
||||
}
|
||||
|
||||
export async function ldapAuth(
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<LdapResult> {
|
||||
const userDn = `uid=${username},ou=people,${LDAP_BASE_DN}`
|
||||
const client = new Client({ url: LDAP_URL, timeout: 5000 })
|
||||
|
||||
try {
|
||||
await client.bind(userDn, password)
|
||||
try {
|
||||
const { searchEntries } = await client.search(LDAP_BASE_DN, {
|
||||
scope: 'sub',
|
||||
filter: `(uid=${username})`,
|
||||
attributes: ['displayName'],
|
||||
timeLimit: 3,
|
||||
})
|
||||
const displayName = (searchEntries[0] as any)?.displayName || username
|
||||
return { success: true, unreachable: false, username, displayName }
|
||||
} catch {
|
||||
return { success: true, unreachable: false, username, displayName: username }
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof InvalidCredentialsError) {
|
||||
return { success: false, unreachable: false }
|
||||
}
|
||||
return { success: false, unreachable: true }
|
||||
} finally {
|
||||
await client.unbind()
|
||||
}
|
||||
}
|
||||
|
||||
// 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'
|
||||
const adminPass = getLdapAdminPassword()
|
||||
const client = new Client({ url: LDAP_URL, timeout: 5000 })
|
||||
|
||||
try {
|
||||
await client.bind(adminDn, adminPass)
|
||||
const { searchEntries } = await client.search(LDAP_BASE_DN, {
|
||||
scope: 'sub', filter: `(uid=${username})`, timeLimit: 3,
|
||||
})
|
||||
return searchEntries.length > 0
|
||||
} catch {
|
||||
return true // LLDAP 不可达 → 不阻断,容错放行
|
||||
} finally {
|
||||
await client.unbind()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,81 +1,84 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyToken } from '@/lib/jwt'
|
||||
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 3) return null
|
||||
let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/')
|
||||
while (payload.length % 4) payload += '='
|
||||
return JSON.parse(atob(payload))
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
function isValidPayload(payload: Record<string, unknown> | null): boolean {
|
||||
if (!payload) return false
|
||||
return !(payload.exp && (payload.exp as number) < Math.floor(Date.now() / 1000))
|
||||
}
|
||||
|
||||
function verifyApiKey(key: string): boolean {
|
||||
const allowedKeys = process.env.ALLOWED_API_KEYS || ''
|
||||
if (!allowedKeys) return false
|
||||
const keys = allowedKeys.split(',').map(k => k.trim())
|
||||
return keys.includes(key)
|
||||
return allowedKeys.split(',').map(k => k.trim()).includes(key)
|
||||
}
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
function buildLoginRedirect(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl
|
||||
const loginUrl = new URL('/login', request.url)
|
||||
const dest = pathname + (request.nextUrl.search || '')
|
||||
loginUrl.searchParams.set('redirect', dest)
|
||||
return NextResponse.redirect(loginUrl)
|
||||
}
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl
|
||||
|
||||
// 清除外部注入的 trust proxy headers(防伪造)
|
||||
const requestHeaders = new Headers(request.headers)
|
||||
requestHeaders.delete('x-remote-user')
|
||||
requestHeaders.delete('x-remote-groups')
|
||||
|
||||
// SSO 代理认证路径:检测 X-Remote-User header + 代理密钥验证
|
||||
const remoteUser = request.headers.get('x-remote-user')
|
||||
const proxyKey = request.headers.get('x-auth-proxy-key')
|
||||
const isFromNginx = proxyKey === 'internal-auth-key-tlyq-2026'
|
||||
|
||||
if (remoteUser && isFromNginx) {
|
||||
// logout 路径不设置 SSO session(避免清除后又重新设置)
|
||||
if (pathname === '/api/auth/logout') return NextResponse.next()
|
||||
|
||||
const response = pathname === '/login' || pathname === '/'
|
||||
? NextResponse.redirect(new URL('/tickets', request.url))
|
||||
: NextResponse.next()
|
||||
|
||||
response.cookies.set('session', JSON.stringify({ username: remoteUser }), {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
})
|
||||
if (pathname.startsWith('/api/')) {
|
||||
response.headers.set('x-original-pathname', pathname + (request.nextUrl.search || ''))
|
||||
}
|
||||
return response
|
||||
// 登录/退出路径 + 内部 API 放行(自有 key 认证)
|
||||
if (pathname.startsWith('/login') || pathname === '/' ||
|
||||
pathname === '/api/auth/login' || pathname === '/api/auth/logout' ||
|
||||
pathname.startsWith('/api/internal/')) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
// 回退:现有 JWT 认证路径
|
||||
if (pathname.startsWith('/login') || pathname === '/') return NextResponse.next()
|
||||
if (pathname === '/api/auth/login') return NextResponse.next()
|
||||
|
||||
// API Key 认证(外部系统调用)
|
||||
const authHeader = request.headers.get('authorization')
|
||||
|
||||
if (authHeader?.startsWith('Bearer ak_')) {
|
||||
const key = authHeader.slice(7)
|
||||
if (verifyApiKey(key)) return NextResponse.next()
|
||||
if (pathname.startsWith('/api/')) return NextResponse.next()
|
||||
}
|
||||
|
||||
const token = request.cookies.get('session_issue')?.value
|
||||
|
||||
function buildLoginRedirect() {
|
||||
const loginUrl = new URL('/login', request.url)
|
||||
const dest = pathname + (request.nextUrl.search || '')
|
||||
loginUrl.searchParams.set('redirect', dest)
|
||||
const response = NextResponse.redirect(loginUrl)
|
||||
if (token) response.cookies.delete('session_issue')
|
||||
// 优先检查 tlyq_session(共享 JWT)
|
||||
const sharedToken = request.cookies.get('tlyq_session')?.value
|
||||
const sharedPayload = sharedToken ? decodeJwtPayload(sharedToken) : null
|
||||
if (isValidPayload(sharedPayload)) {
|
||||
const response = pathname.startsWith('/api/') ? NextResponse.next() : NextResponse.next()
|
||||
response.cookies.set('session', JSON.stringify({ username: sharedPayload.username }), {
|
||||
httpOnly: true, sameSite: 'lax', path: '/',
|
||||
})
|
||||
return response
|
||||
}
|
||||
|
||||
// 回退 session_issue(本地 JWT)
|
||||
const localToken = request.cookies.get('session_issue')?.value
|
||||
const localPayload = localToken ? decodeJwtPayload(localToken) : null
|
||||
|
||||
if (pathname.startsWith('/api/')) {
|
||||
if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
const valid = await verifyToken(token)
|
||||
if (!valid) return NextResponse.json({ error: '会话已过期' }, { status: 401 })
|
||||
if (!isValidPayload(localPayload)) {
|
||||
return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
}
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
if (!token) return buildLoginRedirect()
|
||||
const valid = await verifyToken(token)
|
||||
if (!valid) return buildLoginRedirect()
|
||||
if (!isValidPayload(localPayload)) {
|
||||
const response = buildLoginRedirect(request)
|
||||
if (localToken) response.cookies.delete('session_issue')
|
||||
return response
|
||||
}
|
||||
|
||||
const response = NextResponse.next()
|
||||
response.headers.set('x-original-pathname', pathname + (request.nextUrl.search || ''))
|
||||
response.cookies.set('session', JSON.stringify({ username: localPayload.username }), {
|
||||
httpOnly: true, sameSite: 'lax', path: '/',
|
||||
})
|
||||
return response
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue