Compare commits

...

8 Commits

25 changed files with 1417 additions and 170 deletions

View File

@@ -1,12 +1,85 @@
{
"permissions": {
"allow": [
"Read(./.env.example)"
"Read(./.env.example)",
"Bash(cat:*)",
"Bash(head:*)",
"Bash(tail:*)",
"Bash(less:*)",
"Bash(wc:*)",
"Bash(ls:*)",
"Bash(tree:*)",
"Bash(find:*)",
"Bash(grep:*)",
"Bash(rg:*)",
"Bash(fd:*)",
"Bash(file:*)",
"Bash(stat:*)",
"Bash(du:*)",
"Bash(diff:*)",
"Bash(which:*)",
"Bash(whoami:*)",
"Bash(echo:*)",
"Bash(pwd:*)",
"Bash(date:*)",
"Bash(uname:*)",
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(git branch:*)",
"Bash(git show:*)",
"Bash(git remote:*)",
"Bash(git tag:*)",
"Bash(git stash list:*)",
"Bash(git blame:*)",
"Bash(git shortlog:*)",
"Bash(git rev-parse:*)",
"Task",
"Edit",
"Write",
"MultiEdit",
"Bash(pnpm install:*)",
"Bash(pnpm add:*)",
"Bash(pnpm run build:*)",
"Bash(pnpm prisma generate:*)",
"Bash(pnpm run lint:*)",
"Bash(pip install:*)",
"Bash(pip3 install:*)",
"Bash(mkdir:*)",
"Bash(cp:*)",
"Bash(mv:*)",
"Bash(rm:*)",
"Bash(touch:*)",
"Bash(chmod:*)",
"Bash(chown:*)",
"Bash(ln:*)",
"Bash(tar:*)",
"Bash(unzip:*)",
"Bash(curl:*)",
"Bash(wget:*)",
"Bash(sed:*)",
"Bash(awk:*)",
"Bash(sort:*)",
"Bash(uniq:*)",
"Bash(cut:*)",
"Bash(xargs:*)",
"Bash(tee:*)",
"Bash(jq:*)",
"mcp__ide__getDiagnostics"
],
"deny": [
"Read(./.env)",
"Read(./.env.development)",
"Read(./.env.production)"
"Read(./.env.production)",
"Read(./.git/config)"
]
}
}

View File

@@ -0,0 +1,415 @@
---
name: pku-iaaa
description: |
Integrate Peking University (PKU) IAAA unified authentication system into web applications.
Use this skill whenever the user wants to implement PKU IAAA SSO login, PKU unified identity
authentication, or connect their app to iaaa.pku.edu.cn. Also trigger when the user mentions
"北大统一认证", "IAAA认证", "北大SSO", "PKU login", or wants to add campus authentication
for a Peking University application. Covers login redirect, token validation, proxy SSO,
and account verification — for any web framework (Django, Spring Boot, Express.js, Flask, etc).
---
# 北京大学 IAAA 统一身份认证集成
本 skill 指导你将北京大学 IAAA统一安全系统身份认证集成到任意 Web 应用中。
## 前置条件
集成前需在 IAAA 注册应用系统,准备以下信息:
- **应用系统 ID**`appId`):英文字母开头,可含数字
- **Key**:由 IAAA 提供,用于生成消息摘要
- **服务器 IP 地址**:应用系统所在服务器的 IP
- **回调地址 URL**`redirectUrl`):用于接收 token 的回调端点
申请方式:管理部门用户登录校内门户 → "信息服务"或"办事大厅" → 搜索"统一身份认证应用备案申请"。
注册后可在 `https://i3a.pku.edu.cn/iaaa/index.jsp` 自行管理服务器 IP 和回调 URL。
## 开始集成前,先确认需求
在动手写代码之前,先向用户确认:
1. **使用什么框架?** Django / Spring Boot / Express.js / Flask / 其他)
2. **需要哪种级别的集成?**
- 基本认证(登录跳转 + Token 验证)— 绝大多数场景只需这个
- 代理 SSO中心应用 + 分支应用架构)— 参见 `references/proxy-sso.md`
- 账号验证(仅校验某个学号/职工号是否存在)— 参见 `references/account-verify.md`
3. **系统是否还有非校内账号?** 如果是,需要同时保留自有登录入口(影响前端跳转参数)
## 认证流程概览
```
用户点击登录 ──POST──> iaaa.pku.edu.cn/iaaa/oauth.jsp
用户输入账号密码认证
认证成功,生成 token
302 重定向到 redirectUrl?token=xxx
应用服务端收到 token
服务端调用 validate.do 验证 token
验证成功获取用户信息identityId 等)
匹配本地用户,创建会话
```
## 第一步:登录跳转(前端)
用户点击登录时,通过 POST 表单跳转到 IAAA 统一登录页面。
### 关键参数
| 参数 | 说明 |
|------|------|
| `appID` | 注册的应用系统 ID |
| `redirectUrl` | 回调地址(验证 token 的端点),若含参数需 URLEncode |
| `redirectLogonUrl` | (可选)应用自有登录地址,当系统还有非校内账号时使用 |
### 实现要点
- 必须使用 POST 方法提交表单到 `https://iaaa.pku.edu.cn/iaaa/oauth.jsp`
- `redirectUrl` 的协议必须与实际部署一致(从当前请求动态获取,不要硬编码 `http://`
- 回调 URL 的尾部斜杠很重要——IAAA 可能因缺少尾部斜杠而拒绝回调
### 参考实现JavaScript 前端通用)
```javascript
function redirectToIAAALogin(appID, callbackPath, loginPath) {
const form = document.createElement('form');
form.action = 'https://iaaa.pku.edu.cn/iaaa/oauth.jsp';
form.method = 'POST';
form.style.display = 'none';
function addField(name, value) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = name;
input.value = value;
form.appendChild(input);
}
// 使用当前页面协议,不要硬编码 http://
const baseUrl = `${location.protocol}//${location.host}`;
addField('appID', appID);
addField('redirectUrl', `${baseUrl}${callbackPath}`);
if (loginPath) {
addField('redirectLogonUrl', `${baseUrl}${loginPath}`);
}
document.body.appendChild(form);
form.submit();
}
```
## 第二步Token 验证(服务端)
IAAA 认证成功后,会重定向浏览器到 `redirectUrl?token=<TOKEN>`。服务端需要验证这个 token。
### 验证端点
```
GET https://iaaa.pku.edu.cn/iaaa/svc/token/validate.do
?remoteAddr={REMOTE_ADDR}
&appId={APP_ID}
&token={TOKEN}
&msgAbs={MSG_ABS}
```
如果不需要返回用户详细信息(只需 identityId可使用简单验证端点
```
GET https://iaaa.pku.edu.cn/iaaa/svc/token/validateSimple.do?...(参数相同)
```
### 参数说明
| 参数 | 说明 |
|------|------|
| `remoteAddr` | 访问应用系统的用户**真实 IP 地址** |
| `appId` | 应用系统 ID |
| `token` | IAAA 传回的票据 |
| `msgAbs` | MD5 消息摘要(见下方计算方法) |
### msgAbs 计算方法(极其重要)
1. 取除 `msgAbs` 以外的所有请求参数
2. 按**参数名升序**排列
3. 拼接为查询字符串格式:`appId={APP_ID}&remoteAddr={REMOTE_ADDR}&token={TOKEN}`
4. 将此字符串与 Key 直接拼接(不加分隔符):`PARA_STR + Key`
5. 对拼接结果计算 MD5UTF-8 编码32 位十六进制,大小写不敏感)
**Python 示例:**
```python
import hashlib
para_str = f"appId={app_id}&remoteAddr={remote_addr}&token={token}"
raw = para_str + key
msg_abs = hashlib.md5(raw.encode('utf-8')).hexdigest()
```
**Java 示例:**
```java
String paraStr = "appId=" + appId + "&remoteAddr=" + remoteAddr + "&token=" + token;
String raw = paraStr + key;
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(raw.getBytes(StandardCharsets.UTF_8));
String msgAbs = String.format("%032x", new BigInteger(1, digest));
```
**Node.js 示例:**
```javascript
const crypto = require('crypto');
const paraStr = `appId=${appId}&remoteAddr=${remoteAddr}&token=${token}`;
const msgAbs = crypto.createHash('md5').update(paraStr + key).digest('hex');
```
### 获取客户端真实 IP
反向代理Nginx 等)环境下,客户端 IP 通常在 `X-Forwarded-For` 头中。取第一个值(最左侧)作为真实 IP
```python
# Python / Django
remote_addr = request.META.get('HTTP_X_FORWARDED_FOR', '').split(',')[0].strip() \
or request.META.get('REMOTE_ADDR', '')
```
```java
// Java / Spring Boot
String remoteAddr = request.getHeader("X-Forwarded-For");
if (remoteAddr != null && !remoteAddr.isEmpty()) {
remoteAddr = remoteAddr.split(",")[0].trim();
} else {
remoteAddr = request.getRemoteAddr();
}
```
```javascript
// Node.js / Express
const remoteAddr = (req.headers['x-forwarded-for'] || '').split(',')[0].trim()
|| req.socket.remoteAddress;
```
### 返回结果
**成功:**
```json
{
"success": true,
"errCode": "0",
"errMsg": "认证成功",
"userInfo": {
"name": "用户姓名",
"status": "账号状态(开通/禁止)",
"identityId": "身份账号(学号/职工号)",
"deptId": "所在院系码",
"dept": "所在院系",
"identityType": "身份类别(学生/职工)",
"detailType": "身份细类",
"identityStatus": "身份状态(在校/减离)",
"campus": "校区"
}
}
```
**失败:**
```json
{
"success": false,
"errCode": "错误码",
"errMsg": "错误信息"
}
```
简单验证端点(`validateSimple.do`)成功时 `userInfo` 中只包含 `identityId`
### 服务端验证的完整参考实现
#### Django
```python
import hashlib
import logging
import requests
from django.contrib.auth import login, get_user_model
from django.http import HttpResponseRedirect
from django.shortcuts import render, reverse
logger = logging.getLogger(__name__)
User = get_user_model()
IAAA_APP_ID = "your_app_id"
IAAA_KEY = "your_key"
IAAA_USERID_FIELD = "username" # User 模型中与 identityId 对应的字段
def iaaa_callback(request):
"""IAAA 认证回调视图"""
token = request.GET.get('token')
if not token:
return render(request, 'login_failed.html', {'msg': '未收到认证票据'})
# 获取客户端真实 IP
remote_addr = (
request.META.get('HTTP_X_FORWARDED_FOR', '').split(',')[0].strip()
or request.META.get('REMOTE_ADDR', '')
)
# 计算消息摘要(参数按字母序排列)
para_str = f"appId={IAAA_APP_ID}&remoteAddr={remote_addr}&token={token}"
msg_abs = hashlib.md5((para_str + IAAA_KEY).encode('utf-8')).hexdigest()
# 调用 IAAA 验证服务
validate_url = (
f"https://iaaa.pku.edu.cn/iaaa/svc/token/validate.do"
f"?{para_str}&msgAbs={msg_abs}"
)
try:
resp = requests.get(validate_url, timeout=10)
resp.raise_for_status()
result = resp.json()
except Exception as e:
logger.error(f"IAAA token 验证请求失败: {e}")
return render(request, 'login_failed.html', {'msg': '身份认证服务暂不可用,请稍后重试'})
if not result.get('success'):
err_code = result.get('errCode', '未知')
err_msg = result.get('errMsg', '未知错误')
logger.warning(f"IAAA 认证失败: errCode={err_code}, errMsg={err_msg}")
return render(request, 'login_failed.html', {'msg': f'认证失败:{err_msg}'})
# 认证成功,提取用户身份
user_info = result.get('userInfo', {})
identity_id = user_info.get('identityId', '')
if not identity_id:
logger.warning(f"IAAA 认证成功但未返回 identityId: {result}")
return render(request, 'login_failed.html', {'msg': '无法获取您的身份账号'})
# 匹配本地用户
user = User.objects.filter(**{IAAA_USERID_FIELD: identity_id}).first()
if user is None:
logger.info(f"IAAA 用户 {identity_id} 在系统中不存在")
return render(request, 'login_failed.html', {'msg': '系统中不存在与您北大账号对应的用户'})
# 登录
login(request, user)
logger.info(f"IAAA 登录成功: {identity_id} ({user_info.get('name', '')})")
return HttpResponseRedirect(reverse('index'))
```
#### Spring Boot
```java
@GetMapping("/iaaa/callback")
public String iaaaCallback(
@RequestParam String token,
HttpServletRequest request,
HttpSession session) throws Exception {
// 获取客户端真实 IP
String remoteAddr = request.getHeader("X-Forwarded-For");
if (remoteAddr != null && !remoteAddr.isEmpty()) {
remoteAddr = remoteAddr.split(",")[0].trim();
} else {
remoteAddr = request.getRemoteAddr();
}
// 计算消息摘要
String paraStr = "appId=" + appId + "&remoteAddr=" + remoteAddr + "&token=" + token;
String msgAbs = md5(paraStr + key);
// 调用验证服务
String url = "https://iaaa.pku.edu.cn/iaaa/svc/token/validate.do?"
+ paraStr + "&msgAbs=" + msgAbs;
RestTemplate rest = new RestTemplate();
Map<String, Object> result = rest.getForObject(url, Map.class);
if (!(Boolean) result.get("success")) {
String errMsg = (String) result.getOrDefault("errMsg", "认证失败");
// 处理失败...
return "redirect:/login?error=" + URLEncoder.encode(errMsg, "UTF-8");
}
Map<String, String> userInfo = (Map<String, String>) result.get("userInfo");
String identityId = userInfo.get("identityId");
// 查找本地用户,创建会话...
return "redirect:/";
}
private String md5(String input) throws Exception {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));
return String.format("%032x", new java.math.BigInteger(1, digest));
}
```
#### Express.js
```javascript
const crypto = require('crypto');
const axios = require('axios');
app.get('/iaaa/callback', async (req, res) => {
const { token } = req.query;
if (!token) return res.redirect('/login?error=no_token');
const remoteAddr = (req.headers['x-forwarded-for'] || '').split(',')[0].trim()
|| req.socket.remoteAddress;
const paraStr = `appId=${APP_ID}&remoteAddr=${remoteAddr}&token=${token}`;
const msgAbs = crypto.createHash('md5').update(paraStr + KEY).digest('hex');
const url = `https://iaaa.pku.edu.cn/iaaa/svc/token/validate.do?${paraStr}&msgAbs=${msgAbs}`;
try {
const { data } = await axios.get(url);
if (!data.success) {
console.warn(`IAAA 认证失败: ${data.errCode} - ${data.errMsg}`);
return res.redirect(`/login?error=${encodeURIComponent(data.errMsg || '认证失败')}`);
}
const { identityId, name } = data.userInfo;
if (!identityId) {
return res.redirect('/login?error=no_identity');
}
// 查找本地用户,创建 session...
const user = await User.findOne({ where: { username: identityId } });
if (!user) {
return res.redirect('/login?error=user_not_found');
}
req.session.userId = user.id;
console.log(`IAAA 登录成功: ${identityId} (${name})`);
res.redirect('/');
} catch (err) {
console.error('IAAA 验证请求失败:', err.message);
res.redirect('/login?error=service_unavailable');
}
});
```
## 常见易错点
实现时特别注意以下几点,这些是实际集成中最常见的 bug 来源:
1. **协议硬编码**`redirectUrl` 中不要写死 `http://`,应从当前请求或配置动态获取。生产环境通常是 HTTPS协议不匹配会导致回调失败。
2. **msgAbs 参数排序**:参数必须严格按参数名字母升序拼接。当前 `validate.do` 只有三个参数(`appId`, `remoteAddr`, `token`),恰好已是字母序。但如果使用代理认证等其他端点(参数更多),一定要排序。
3. **错误响应处理**IAAA 返回 `success: false` 时,务必提取 `errCode``errMsg` 记录到日志并展示给用户,不要只返回一个笼统的"认证失败"。
4. **客户端 IP**`remoteAddr` 必须是用户的真实 IP。如果应用在反向代理之后使用 `X-Forwarded-For` 头的第一个值。IP 不匹配会导致 token 验证失败。
5. **回调 URL 尾部斜杠**IAAA 在注册时绑定了精确的回调 URL运行时传入的 `redirectUrl` 必须完全匹配(包括尾部斜杠)。
6. **服务端可达性**:应用服务器必须能访问 `iaaa.pku.edu.cn` 的 443 端口HTTPS
## 高级功能
对于需要代理 SSO中心应用 + 分支应用)或账号验证的场景,参阅:
- `references/proxy-sso.md` — 代理身份认证(单点登录多个应用)
- `references/account-verify.md` — 验证北京大学账号是否存在

View File

@@ -0,0 +1,78 @@
# 北京大学账号验证
用于验证某个学号/职工号是否为有效的北京大学账号,不涉及登录流程。
## API
```
GET https://iaaa.pku.edu.cn/iaaa/svc/pub/validate.do
?userId={USERID}
&userName={USERNAME}
&appId={APPID}
&msgAbs={MD5MSG}
```
## 参数
| 参数 | 说明 |
|------|------|
| `userId` | 学号或职工号 |
| `userName` | 账号姓名(传输时需 URLEncode但计算 MD5 摘要时使用原始值,不做 URLEncode |
| `appId` | 应用 ID |
| `msgAbs` | MD5 消息摘要 |
## msgAbs 计算
与其他端点规则一致:除 `msgAbs` 外所有参数按参数名升序排列拼接,再拼接 Key取 MD5。
PARA_STR 排序后为:`appId={APPID}&userId={USERID}&userName={USERNAME}`
注意:`userName` 在 PARA_STR 中使用**原始值**(不做 URLEncode只有在 URL 传输时才 URLEncode。
## 返回
**正常:**
```json
{
"success": true,
"valid": true,
"userType": "用户身份类别"
}
```
`valid``true` 表示账号存在,`false` 表示不存在。
**异常:**
```json
{
"success": false,
"errCode": "错误代码",
"errMsg": "错误信息"
}
```
## Python 示例
```python
import hashlib
import urllib.parse
import requests
def verify_pku_account(user_id: str, user_name: str, app_id: str, key: str) -> dict:
"""验证北大账号是否存在"""
# msgAbs 中 userName 使用原始值
para_str = f"appId={app_id}&userId={user_id}&userName={user_name}"
msg_abs = hashlib.md5((para_str + key).encode('utf-8')).hexdigest()
# URL 中 userName 需要 URLEncode
url = (
f"https://iaaa.pku.edu.cn/iaaa/svc/pub/validate.do"
f"?appId={app_id}"
f"&userId={user_id}"
f"&userName={urllib.parse.quote(user_name)}"
f"&msgAbs={msg_abs}"
)
resp = requests.get(url, timeout=10)
resp.raise_for_status()
return resp.json()
```

View File

@@ -0,0 +1,120 @@
# 代理身份认证Proxy SSO
适用场景:应用 O中心应用登录后用户点击进入应用 A、B、C分支应用时无需重新登录。
## 流程
```
用户登录中心应用 O
├─ O 使用 validateWithProxy.do 验证 token → 获得 grantToken
├─ 用户点击分支应用 A
│ ├─ O 调用 validateProxyGrantToken.do → 获得分支应用 A 的 token
│ └─ 将 token 传给分支应用 A → A 用 validate.do 验证
└─ 用户退出中心应用 O
└─ O 调用 expireProxy.do 注销 grantToken
```
## API 详情
### 1. 中心应用验证(获取 grantToken
```
GET https://iaaa.pku.edu.cn/iaaa/svc/token/validateWithProxy.do
?remoteAddr={REMOTE_ADDR}
&appId={APP_ID}
&token={TOKEN}
&msgAbs={MSG_ABS}
```
参数和 msgAbs 计算方式与标准 `validate.do` 相同。
**成功返回:**
```json
{
"success": true,
"errCode": "0",
"errMsg": "认证成功",
"userInfo": {
"name": "用户姓名",
"status": "账号状态",
"identityId": "身份账号",
"deptId": "所在院系码",
"dept": "所在院系",
"identityType": "身份类别",
"detailType": "身份细类",
"identityStatus": "身份状态"
},
"grantToken": "代理票据"
}
```
### 2. 获取分支应用 Token
```
GET https://iaaa.pku.edu.cn/iaaa/svc/token/validateProxyGrantToken.do
?remoteAddr={REMOTE_ADDR}
&appId={APP_ID}
&targetAppId={TARGET_APP_ID}
&grantToken={GRANT_TOKEN}
&timestamp={TIMESTAMP}
&msgAbs={MSG_ABS}
```
**参数:**
| 参数 | 说明 |
|------|------|
| `remoteAddr` | 客户端 IP |
| `appId` | 中心应用 ID |
| `targetAppId` | 分支应用 ID |
| `grantToken` | 上一步获得的代理票据 |
| `timestamp` | 当前时间戳long 型整数) |
| `msgAbs` | MD5 摘要PARA_STR 为除 msgAbs 外所有参数按参数名升序拼接 |
注意此端点参数较多PARA_STR 排序后为:
`appId={}&grantToken={}&remoteAddr={}&targetAppId={}&timestamp={}`
**成功返回:**
```json
{
"success": true,
"errCode": "0",
"errMsg": "验证成功",
"token": "分支应用访问票据"
}
```
**错误码 12需要手机验证** 重定向用户到:
```
https://iaaa.pku.edu.cn/iaaa/ma4proxy.jsp
?proxyAppId={中心应用ID}
&appId={分支应用ID}
&userId={用户账号}
&grantToken={GRANT_TOKEN}
&redirectUrl={分支应用回调地址需URLEncode}
```
### 3. 分支应用验证 Token
分支应用收到 token 后,使用标准 `validate.do``validateSimple.do` 验证,与普通认证流程相同。
### 4. 注销代理 Token
中心应用退出时或必要时调用:
```
GET https://iaaa.pku.edu.cn/iaaa/svc/token/expireProxy.do
?appId={APP_ID}
&grantToken={GRANT_TOKEN}
&msgAbs={MSG_ABS}
```
msgAbs 的 PARA_STR`appId={}&grantToken={}`(按参数名升序)
**返回:**
```json
{"success": true, "errCode": "0", "errMsg": "注销成功"}
```

View File

@@ -50,6 +50,8 @@ RUN apt-get update && apt-get install -y \
imagemagick \
zsh \
vim \
rsync \
openjdk-17-jdk \
&& rm -rf /var/lib/apt/lists/*
# 安装 Python 3.12
@@ -118,7 +120,7 @@ RUN code-server --install-extension ms-ceintl.vscode-language-pack-zh-hans \
&& code-server --install-extension dbaeumer.vscode-eslint \
&& code-server --install-extension prisma.prisma \
&& code-server --install-extension ecmel.vscode-html-css \
&& code-server --install-extension cweijan.vscode-redis-client \
&& code-server --install-extension cweijan.vscode-database-client2 \
&& code-server --install-extension anthropic.claude-code
# 配置 code-server (密码将在启动时设置)

View File

@@ -23,6 +23,8 @@ services:
- claude-code-router:/root/.claude-code-router
# SSH 配置
- ssh:/root/.ssh
# 零碎文件持久化
- persist:/root/.hair-keeper-persist
environment:
- NODE_ENV=development
- TZ=Asia/Shanghai
@@ -40,6 +42,12 @@ services:
memory: 4G
volumes:
code-server-config:
code-server-data:
claude:
claude-code-router:
ssh:
persist:
# node_modules 卷,避免主机和容器之间的文件系统差异
node_modules:
# pnpm store 卷,加速依赖安装

View File

@@ -1,5 +1,40 @@
#!/bin/bash
# ============================================
# 文件持久化:启动时恢复 + 后台定期同步到命名卷
# ============================================
PERSIST_DIR="/root/.hair-keeper-persist"
PERSIST_FILES=(
"/root/.claude.json"
"/root/.zsh_history"
"/root/.bash_history"
)
mkdir -p "$PERSIST_DIR"
# 启动时恢复:持久化目录中的文件优先覆盖到原路径
for filepath in "${PERSIST_FILES[@]}"; do
filename=$(basename "$filepath")
persist_path="$PERSIST_DIR/$filename"
if [ -f "$persist_path" ] && [ -s "$persist_path" ]; then
cp "$persist_path" "$filepath"
fi
done
# 后台定期同步将原路径文件同步回持久化目录每60秒
(
while true; do
sleep 60
for filepath in "${PERSIST_FILES[@]}"; do
filename=$(basename "$filepath")
persist_path="$PERSIST_DIR/$filename"
if [ -f "$filepath" ]; then
rsync -a "$filepath" "$persist_path"
fi
done
done
) &
# 设置默认密码
DEV_PASSWORD=${DEV_PASSWORD:-clouddev}
@@ -42,5 +77,20 @@ echo ""
echo "提示: 可通过环境变量 DEV_PASSWORD 自定义密码"
echo ""
# 容器停止时执行最终同步
cleanup() {
echo "Syncing persistent files before shutdown..."
for filepath in "${PERSIST_FILES[@]}"; do
filename=$(basename "$filepath")
persist_path="$PERSIST_DIR/$filename"
if [ -f "$filepath" ]; then
rsync -a "$filepath" "$persist_path"
fi
done
exit 0
}
trap cleanup SIGTERM SIGINT
# 保持容器运行
tail -f /dev/null
tail -f /dev/null &
wait $!

View File

@@ -51,6 +51,14 @@ NEXTAUTH_SECRET=
PKUAI_API_KEY=
PKUAI_API_BASE=
# 北京大学 IAAA 统一认证(可选,不配置则不启用)
## 在 IAAA 注册的应用系统 ID
IAAA_APP_ID=
## IAAA 提供的密钥,用于生成消息摘要
IAAA_KEY=
## 设为 true 则进入登录页后自动跳转到统一认证(默认 false
IAAA_AUTO_REDIRECT=

3
.gitignore vendored
View File

@@ -52,3 +52,6 @@ tasks.md
# lock 一般的项目需要用git管理但这个是模板项目就不管理
package-lock.json
pnpm-lock.yaml
# claude
.claude/*.local.*

View File

@@ -3,7 +3,7 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目说明
本项目模板Hair Keeper v1.2.0是一个高度集成、深度定制、约定优于配置的全栈Web应用模板旨在保持灵活性的同时提供一套基于成熟架构的开发底座自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能提供AI开发辅助免于纠结功能如何实现可快速上手专注于业务逻辑。
本项目模板Hair Keeper v1.3.0是一个高度集成、深度定制、约定优于配置的全栈Web应用模板旨在保持灵活性的同时提供一套基于成熟架构的开发底座自带身份认证支持SSO单点登录、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能提供AI开发辅助免于纠结功能如何实现可快速上手专注于业务逻辑。
Hair Keeper是个诙谐有趣的名称和项目内容毫无关系。
@@ -77,6 +77,8 @@ Hair Keeper是个诙谐有趣的名称和项目内容毫无关系。
- `src/server/queues/`消息队列和worker通过其中的index.ts统一导出任务状态更新采用trpc SSE subscription接口定义在`server/routers/jobs.ts`
- `src/server/agents`LLM的对接和使用
- `src/server/service/`:服务层模块集合,封装后端业务逻辑和系统服务
- `src/server/service/session.ts`会话管理服务基于Redis实现权限变更后强制重新登录
- `src/server/service/iaaa.ts`:北京大学统一认证(IAAA)对接服务,通过环境变量配置即可启用
- `src/server/service/dev/`:开发模式下的辅助功能需要的后台服务
- `src/server/utils/`:服务端专用工具函数库,为后端业务逻辑提供基础设施支持
- `src/api/dev/`开发模式下的辅助功能需要的api

View File

@@ -1,5 +1,9 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目说明
本项目模板Hair Keeper v1.2.0是一个高度集成、深度定制、约定优于配置的全栈Web应用模板旨在保持灵活性的同时提供一套基于成熟架构的开发底座自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能提供AI开发辅助免于纠结功能如何实现可快速上手专注于业务逻辑。
本项目模板Hair Keeper v1.3.0是一个高度集成、深度定制、约定优于配置的全栈Web应用模板旨在保持灵活性的同时提供一套基于成熟架构的开发底座自带身份认证支持SSO单点登录、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能提供AI开发辅助免于纠结功能如何实现可快速上手专注于业务逻辑。
Hair Keeper是个诙谐有趣的名称和项目内容毫无关系。
@@ -73,6 +77,8 @@ Hair Keeper是个诙谐有趣的名称和项目内容毫无关系。
- `src/server/queues/`消息队列和worker通过其中的index.ts统一导出任务状态更新采用trpc SSE subscription接口定义在`server/routers/jobs.ts`
- `src/server/agents`LLM的对接和使用
- `src/server/service/`:服务层模块集合,封装后端业务逻辑和系统服务
- `src/server/service/session.ts`会话管理服务基于Redis实现权限变更后强制重新登录
- `src/server/service/iaaa.ts`:北京大学统一认证(IAAA)对接服务,通过环境变量配置即可启用
- `src/server/service/dev/`:开发模式下的辅助功能需要的后台服务
- `src/server/utils/`:服务端专用工具函数库,为后端业务逻辑提供基础设施支持
- `src/api/dev/`开发模式下的辅助功能需要的api

View File

@@ -1,6 +1,6 @@
{
"name": "hair-keeper",
"version": "1.2.0",
"version": "1.3.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000 --turbo",

View File

@@ -316,7 +316,11 @@ EOF
## 项目说明
本项目基于 Hair Keeper 模板构建(详见 @TEMPLATE_README.md目前尚未实现业务功能
本项目基于 Hair Keeper 模板构建(详见 @TEMPLATE_README.md目前尚未实现业务功能
## 重要提示
- 完成用户的任务后,务必运行`pnpm run lint`检查代码错误并修复不要运行build
- 尽量使用pnpm执行npm相关的命令
EOF
print_success "新的 CLAUDE.md 已创建"
@@ -347,6 +351,7 @@ EOF
"api_key": "${PKUAI_API_KEY}",
"models": [
"claude-opus-4-6",
"claude-sonnet-4-6",
"claude-sonnet-4-5-20250929",
"claude-opus-4-5-20251101",
"claude-haiku-4-5-20251001"
@@ -371,7 +376,7 @@ EOF
"Router": {
"default": "pku-anthropic,claude-opus-4-6",
"background": "pku-anthropic,claude-haiku-4-5-20251001",
"think": "pku-anthropic,claude-opus-4-6",
"think": "pku-anthropic,-claude-opus4-6",
"longContext": "pku-anthropic,claude-opus-4-6",
"longContextThreshold": 80000,
"webSearch": "",
@@ -402,6 +407,7 @@ EOF
echo ""
git init
git config init.defaultBranch main
git config core.pager "less -F -X"
print_info "设置 Git 用户配置..."
git config user.email "$GIT_USER_EMAIL"
git config user.name "$GIT_USER_NAME"

View File

@@ -0,0 +1,243 @@
'use client'
import { useEffect, useRef, useState } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { signIn } from "next-auth/react"
import { useRouter, useSearchParams } from "next/navigation"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { AlertTriangle, ExternalLink } from "lucide-react"
import type { IaaaClientConfig } from "./page"
// 登录表单验证 schema
const loginSchema = z.object({
id: z.string().min(1, "请输入用户ID"),
password: z.string().min(1, "请输入密码"),
})
type LoginFormData = z.infer<typeof loginSchema>
/** IAAA 回调错误码到用户提示的映射 */
const IAAA_ERROR_MESSAGES: Record<string, string> = {
iaaa_no_token: '未收到认证票据,请重试',
iaaa_validate_failed: '统一认证验证失败,请重试',
iaaa_user_not_found: '系统中不存在与您北大账号对应的用户,请联系管理员',
iaaa_server_error: '服务器内部错误,请稍后重试',
}
/** 创建隐藏表单 POST 跳转到 IAAA 统一认证页面 */
function redirectToIaaa(appId: string, callbackPath: string) {
const form = document.createElement('form')
form.action = 'https://iaaa.pku.edu.cn/iaaa/oauth.jsp'
form.method = 'POST'
form.style.display = 'none'
function addField(name: string, value: string) {
const input = document.createElement('input')
input.type = 'hidden'
input.name = name
input.value = value
form.appendChild(input)
}
const baseUrl = `${location.protocol}//${location.host}`
addField('appID', appId)
addField('redirectUrl', `${baseUrl}${callbackPath}`)
// 保留应用自有登录入口,以便用户可以选择回到密码登录
addField('redirectLogonUrl', `${baseUrl}/login`)
document.body.appendChild(form)
form.submit()
}
export function LoginForm({ iaaaConfig }: { iaaaConfig: IaaaClientConfig }) {
const router = useRouter()
const searchParams = useSearchParams()
const autoRedirected = useRef(false)
const [showDevHint, setShowDevHint] = useState(false)
const form = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
defaultValues: {
id: "",
password: "",
},
})
// IAAA 回调错误提示(使用 iaaa_error 参数名,避免与 next-auth 的 error 参数冲突)
const iaaaErrorCode = searchParams.get('iaaa_error')
const iaaaError = iaaaErrorCode ? IAAA_ERROR_MESSAGES[iaaaErrorCode] : null
// 自动跳转:配置开启且 IAAA 已启用时,进入登录页自动跳转到统一认证
// 仅在非错误回调场景下触发(避免验证失败后死循环)
useEffect(() => {
if (
iaaaConfig.enabled &&
iaaaConfig.autoRedirect &&
!iaaaErrorCode &&
!autoRedirected.current
) {
autoRedirected.current = true
redirectToIaaa(iaaaConfig.appId, iaaaConfig.callbackPath)
}
}, [iaaaConfig, iaaaErrorCode])
const onSubmit = async (data: LoginFormData) => {
try {
const result = await signIn("credentials", {
id: data.id,
password: data.password,
redirect: false,
})
if (result?.error) {
form.setError("root", {
type: "manual",
message: "用户ID或密码错误"
})
} else if (result?.ok) {
router.push("/")
router.refresh()
}
} catch (error) {
form.setError("root", {
type: "manual",
message: "登录失败,请重试"
})
console.error("Login error:", error)
}
}
// IAAA 是否可见:已启用,或开发模式下需要显示配置提醒
const showIaaa = iaaaConfig.enabled || iaaaConfig.showInDev
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl text-center"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* IAAA 回调错误提示 */}
{iaaaError && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{iaaaError}</AlertDescription>
</Alert>
)}
{/* 密码登录表单 */}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="id"
render={({ field }) => (
<FormItem>
<FormLabel>ID</FormLabel>
<FormControl>
<Input
type="text"
placeholder="请输入用户ID"
disabled={form.formState.isSubmitting}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input
type="password"
placeholder="请输入密码"
disabled={form.formState.isSubmitting}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{form.formState.errors.root && (
<div className="text-sm text-red-600 text-center">
{form.formState.errors.root.message}
</div>
)}
<Button
type="submit"
className="w-full"
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? "登录中..." : "登录"}
</Button>
</form>
</Form>
{/* IAAA 统一认证登录 */}
{showIaaa && (
<>
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => {
if (iaaaConfig.enabled) {
redirectToIaaa(iaaaConfig.appId, iaaaConfig.callbackPath)
} else {
setShowDevHint(true)
}
}}
>
<ExternalLink className="mr-2 h-4 w-4" />
</Button>
</>
)}
</CardContent>
</Card>
{/* 开发模式下未配置 IAAA 的提示对话框(仅开发模式渲染,避免泄露内部架构) */}
{iaaaConfig.showInDev && <Dialog open={showDevHint} onOpenChange={setShowDevHint}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
IAAA 使
</DialogDescription>
</DialogHeader>
<div className="space-y-3 text-sm">
<p className="font-medium">1. IAAA </p>
<div className="rounded-md bg-muted p-3 font-mono text-xs space-y-1">
<div>URL<span className="text-muted-foreground">https://你的域名</span></div>
<div>IP<span className="text-muted-foreground">IP</span></div>
<div>URL<span className="text-muted-foreground">https://你的域名/api/auth/iaaa/callback</span></div>
</div>
<p className="font-medium">2. <code className="bg-muted px-1 py-0.5 rounded">.env</code> </p>
<div className="rounded-md bg-muted p-3 font-mono text-xs space-y-1">
<div>IAAA_APP_ID=ID</div>
<div>IAAA_KEY=</div>
<div className="text-muted-foreground"># </div>
<div>IAAA_AUTO_REDIRECT=true</div>
</div>
<p className="text-muted-foreground">
</p>
</div>
</DialogContent>
</Dialog>}
</div>
)
}

View File

@@ -1,122 +1,23 @@
'use client'
import { getIaaaClientConfig } from "@/server/service/iaaa"
import { LoginForm } from "./login-form"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { signIn } from "next-auth/react"
import { useRouter } from "next/navigation"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
// 登录表单验证 schema
const loginSchema = z.object({
id: z.string().min(1, "请输入用户ID"),
password: z.string().min(1, "请输入密码"),
})
type LoginFormData = z.infer<typeof loginSchema>
export interface IaaaClientConfig {
enabled: boolean
appId: string
callbackPath: string
autoRedirect: boolean
/** 开发模式下未配置 IAAA需要显示配置提醒 */
showInDev: boolean
}
export default function LoginPage() {
const router = useRouter()
const config = getIaaaClientConfig()
const isDev = process.env.NODE_ENV === 'development'
const form = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
defaultValues: {
id: "",
password: "",
},
})
const onSubmit = async (data: LoginFormData) => {
try {
const result = await signIn("credentials", {
id: data.id,
password: data.password,
redirect: false,
})
if (result?.error) {
form.setError("root", {
type: "manual",
message: "用户ID或密码错误"
})
} else if (result?.ok) {
// 登录成功,重定向到首页
router.push("/")
router.refresh()
}
} catch (error) {
form.setError("root", {
type: "manual",
message: "登录失败,请重试"
})
console.error("Login error:", error)
}
const iaaaConfig: IaaaClientConfig = {
...config,
showInDev: isDev && !config.enabled,
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl text-center"></CardTitle>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="id"
render={({ field }) => (
<FormItem>
<FormLabel>ID</FormLabel>
<FormControl>
<Input
type="text"
placeholder="请输入用户ID"
disabled={form.formState.isSubmitting}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input
type="password"
placeholder="请输入密码"
disabled={form.formState.isSubmitting}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{form.formState.errors.root && (
<div className="text-sm text-red-600 text-center">
{form.formState.errors.root.message}
</div>
)}
<Button
type="submit"
className="w-full"
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? "登录中..." : "登录"}
</Button>
</form>
</Form>
</CardContent>
</Card>
</div>
)
}
return <LoginForm iaaaConfig={iaaaConfig} />
}

View File

@@ -0,0 +1,93 @@
import { NextRequest, NextResponse } from 'next/server'
import { encode } from 'next-auth/jwt'
import { db } from '@/server/db'
import { authOptions, buildUserJwtPayload, userAuthInclude } from '@/server/auth'
import { validateIaaaToken } from '@/server/service/iaaa'
import { clearSessionInvalidation } from '@/server/service/session'
/**
* IAAA 统一认证回调路由
*
* IAAA 认证成功后会 302 重定向到此路由,携带 token 参数。
* 本路由验证 token、查找本地用户、生成 JWT session cookie然后重定向到首页。
*/
export async function GET(req: NextRequest) {
const token = req.nextUrl.searchParams.get('token')
const loginUrl = new URL('/login', req.url)
if (!token) {
loginUrl.searchParams.set('iaaa_error', 'iaaa_no_token')
return NextResponse.redirect(loginUrl)
}
// 获取客户端真实 IP反向代理场景取 X-Forwarded-For 第一个值)
const remoteAddr =
req.headers.get('x-forwarded-for')?.split(',')[0].trim() ||
req.headers.get('x-real-ip') ||
'127.0.0.1'
// 调用 IAAA 验证
const userInfo = await validateIaaaToken(token, remoteAddr)
if (!userInfo) {
loginUrl.searchParams.set('iaaa_error', 'iaaa_validate_failed')
return NextResponse.redirect(loginUrl)
}
// 用 identityId学号/职工号)匹配本地用户
const user = await db.user.findUnique({
where: { id: userInfo.identityId },
include: userAuthInclude,
})
if (!user) {
console.warn(`[IAAA] 用户 ${userInfo.identityId}${userInfo.name})在系统中不存在`)
loginUrl.searchParams.set('iaaa_error', 'iaaa_user_not_found')
return NextResponse.redirect(loginUrl)
}
// 更新最近登录时间
await db.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
})
// 清除会话失效标记
await clearSessionInvalidation(user.id)
// 构建 JWT payload与密码登录完全一致
const payload = buildUserJwtPayload(user)
// 生成 next-auth 兼容的 JWT
const secret = authOptions.secret || process.env.NEXTAUTH_SECRET
if (!secret) {
console.error('[IAAA] NEXTAUTH_SECRET 未配置')
loginUrl.searchParams.set('iaaa_error', 'iaaa_server_error')
return NextResponse.redirect(loginUrl)
}
const maxAge = authOptions.session?.maxAge ?? 30 * 24 * 60 * 60
const encodedToken = await encode({
token: { ...payload, sub: payload.id },
secret,
maxAge,
})
// 设置 session cookie 并重定向到首页
// next-auth 在 HTTPS 环境下使用 __Secure- 前缀
const useSecureCookie = req.nextUrl.protocol === 'https:'
const cookieName = useSecureCookie
? '__Secure-next-auth.session-token'
: 'next-auth.session-token'
const response = NextResponse.redirect(new URL('/', req.url))
response.cookies.set(cookieName, encodedToken, {
httpOnly: true,
secure: useSecureCookie,
sameSite: 'lax',
path: '/',
maxAge,
})
console.info(`[IAAA] 登录成功: ${userInfo.identityId}${userInfo.name}`)
return response
}

View File

@@ -1,7 +1,32 @@
'use client'
import { SessionProvider as NextAuthSessionProvider } from "next-auth/react"
import { SessionProvider as NextAuthSessionProvider, signOut, useSession } from "next-auth/react"
import { useEffect, useRef } from "react"
import { usePathname } from "next/navigation"
/** 检测会话失效并自动登出 */
function SessionInvalidationGuard({ children }: { children: React.ReactNode }) {
const { data: session, status } = useSession()
const pathname = usePathname()
const signingOut = useRef(false)
useEffect(() => {
// 已认证但 session 中没有 user会话被标记失效自动登出清除 cookie
if (status === 'authenticated' && !(session as any)?.user?.id && !signingOut.current && pathname !== '/login') {
signingOut.current = true
signOut({ callbackUrl: '/login' })
}
}, [status, session, pathname])
return <>{children}</>
}
export function SessionProvider({ children }: { children: React.ReactNode }) {
return <NextAuthSessionProvider>{children}</NextAuthSessionProvider>
}
return (
<NextAuthSessionProvider>
<SessionInvalidationGuard>
{children}
</SessionInvalidationGuard>
</NextAuthSessionProvider>
)
}

View File

@@ -3,4 +3,4 @@
*/
export const SITE_NAME = 'Hair Keeper'
export const SITE_DESCRIPTION = '高度集成、深度定制、约定优于配置的全栈Web应用模板旨在保持灵活性的同时提供一套基于成熟架构的开发底座自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能提供AI开发辅助免于纠结功能如何实现可快速上手专注于业务逻辑'
export const SITE_VERSION = 'v1.2.0'
export const SITE_VERSION = 'v1.3.0'

View File

@@ -9,8 +9,8 @@ export default withAuth(
const token = req.nextauth.token
const pathname = req.nextUrl.pathname
// 如果用户已登录且访问登录页面,重定向到首页
if (pathname === "/login" && token) {
// 如果用户已登录且访问登录页面,重定向到首页(会话失效的用户除外,需留在登录页)
if (pathname === "/login" && token && !token.sessionInvalid) {
return NextResponse.redirect(new URL("/", req.url))
}
@@ -49,7 +49,12 @@ export default withAuth(
if (req.nextUrl.pathname === "/login") {
return true
}
// 会话已被标记失效,强制重新登录
if (token?.sessionInvalid) {
return false
}
// 其他路由需要有效的 token
return !!token
},

View File

@@ -4,6 +4,37 @@ import type { User as NextAuthUser } from "next-auth"
import CredentialsProvider from "next-auth/providers/credentials"
import bcrypt from "bcryptjs"
import { db } from "./db"
import { clearSessionInvalidation, isSessionInvalidated } from "./service/session"
/** 用户查询时需要 include 的关联 */
export const userAuthInclude = {
roles: { include: { permissions: true } },
dept: true,
} as const
/** 从数据库用户对象构建 JWT payload供密码登录和 IAAA 登录共用) */
export function buildUserJwtPayload(user: {
id: string
name: string | null
status: string | null
deptCode: string | null
isSuperAdmin: boolean
roles: Array<{ name: string; permissions: Array<{ name: string }> }>
}) {
const roles = user.roles.map((r) => r.name)
const permissions = Array.from(
new Set(user.roles.flatMap((r) => r.permissions.map((p) => p.name)))
)
return {
id: user.id,
name: user.name,
status: user.status,
deptCode: user.deptCode,
roles,
permissions,
isSuperAdmin: user.isSuperAdmin,
}
}
export const authOptions: NextAuthOptions = {
providers: [
@@ -21,15 +52,8 @@ export const authOptions: NextAuthOptions = {
try {
// 查找用户
const user = await db.user.findUnique({
where: {
id: credentials.id
},
include: {
roles: {
include: { permissions: true }
},
dept: true
}
where: { id: credentials.id },
include: userAuthInclude,
})
if (!user) {
@@ -38,7 +62,6 @@ export const authOptions: NextAuthOptions = {
// 验证密码
const isPasswordValid = await bcrypt.compare(credentials.password, user.password)
if (!isPasswordValid) {
return null
@@ -49,23 +72,11 @@ export const authOptions: NextAuthOptions = {
where: { id: user.id },
data: { lastLoginAt: new Date() }
})
// 返回用户信息、角色和权限
const roles = user.roles.map((r) => r.name)
const permissions = Array.from(
new Set(user.roles.flatMap((r) =>
r.permissions.map((p) => p.name)
))
)
return {
id: user.id,
name: user.name,
status: user.status,
deptCode: user.deptCode,
roles,
permissions,
isSuperAdmin: user.isSuperAdmin
} as any
// 清除会话失效标记(用户已重新登录,获得最新权限
await clearSessionInvalidation(user.id)
return buildUserJwtPayload(user) as any
} catch (error) {
console.error("Auth error:", error)
return null
@@ -98,10 +109,20 @@ export const authOptions: NextAuthOptions = {
permissions: u.permissions,
isSuperAdmin: u.isSuperAdmin,
}
} else if (token.id) {
// 后续请求:检查会话是否已被标记失效
const invalidated = await isSessionInvalidated(token.id as string)
if (invalidated) {
token.sessionInvalid = true
}
}
return token
},
async session({ session, token }) {
// 会话已被标记失效返回不含用户信息的session
if (token.sessionInvalid) {
return { expires: session.expires } as any
}
// 将JWT token中的信息传递给session
if (session.user) {
const t = token as any

View File

@@ -1,7 +1,7 @@
import 'server-only'
import { Client } from 'minio';
// 初始化 MinIO 客户端
// 内部客户端:用于 statObject、removeObject 等服务端直连操作,以及 presignedPostPolicy
export const minioClient = new Client({
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
port: parseInt(process.env.MINIO_API_PORT || '9000'),
@@ -10,6 +10,32 @@ export const minioClient = new Client({
secretKey: process.env.MINIO_ROOT_PASSWORD || '',
});
/**
* 公网客户端:用于生成 presigned GET/PUT URL
*
* AWS Signature V4 会将 Host 头纳入签名计算,因此 presigned GET/PUT URL
* 必须使用与浏览器访问一致的公网地址生成,否则签名校验失败。
* 而 POST Policy 不含 Host 签名,只需替换返回的 URL 地址即可。
*/
function createPresignClient(): Client {
const serverUrl = process.env.MINIO_SERVER_URL;
if (!serverUrl) return minioClient;
const parsed = new URL(serverUrl);
const useSSL = parsed.protocol === 'https:';
const port = parsed.port ? parseInt(parsed.port) : (useSSL ? 443 : 80);
return new Client({
endPoint: parsed.hostname,
port,
useSSL,
accessKey: process.env.MINIO_ROOT_USER || '',
secretKey: process.env.MINIO_ROOT_PASSWORD || '',
});
}
const presignClient = createPresignClient();
export const BUCKET_NAME = process.env.MINIO_BUCKET || 'app-files';
/**
@@ -166,6 +192,9 @@ export async function generatePresignedPostPolicy(
// 精确匹配
policy.setContentType(allowedContentType);
}
} else {
// 未指定类型限制时,允许任意 Content-Type客户端上传时会设置此字段Policy 中必须声明)
policy.policy.conditions.push(['starts-with', '$Content-Type', '']);
}
if (allowOriginalFilename) {
@@ -255,8 +284,8 @@ export async function generatePresignedGetObject(
}
}
// 生成预签名 URL
const url = await minioClient.presignedGetObject(
// 使用公网客户端生成预签名 URL,确保 V4 签名中的 Host 与浏览器访问地址一致
const url = await presignClient.presignedGetObject(
BUCKET_NAME,
objectName,
expirySeconds,
@@ -264,7 +293,7 @@ export async function generatePresignedGetObject(
);
return {
url: replaceUrlBase(url),
url,
expiresIn: expirySeconds,
};
} catch (error) {

View File

@@ -7,6 +7,7 @@ import bcrypt from 'bcryptjs'
import { z } from 'zod'
import { inferProcedureOutput, TRPCError } from '@trpc/server'
import pLimit from 'p-limit'
import { invalidateUserSessions, invalidateSessionsByRoleId } from '@/server/service/session'
// 从环境变量获取并发限制默认为16
const dbParallelLimit = pLimit(parseInt(process.env.DB_PARALLEL_LIMIT || '16', 10))
@@ -116,7 +117,7 @@ export const usersRouter = createTRPCRouter({
throw new TRPCError({ code: 'BAD_REQUEST', message: '角色名称已被其他角色使用' })
}
return ctx.db.role.update({
const updatedRole = await ctx.db.role.update({
where: { id },
data: {
name,
@@ -129,6 +130,11 @@ export const usersRouter = createTRPCRouter({
}
}
})
// 标记持有该角色的所有用户会话失效
await invalidateSessionsByRoleId(id)
return updatedRole
}),
deleteRole: permissionRequiredProcedure(Permissions.USER_MANAGE)
@@ -205,7 +211,10 @@ export const usersRouter = createTRPCRouter({
processedCount += batch.length
}
// 标记所有受影响用户的会话失效
await invalidateUserSessions(users.map(u => u.id))
return { count: processedCount }
}),
@@ -265,7 +274,7 @@ export const usersRouter = createTRPCRouter({
updateData.password = await bcrypt.hash(password, 12)
}
return ctx.db.user.update({
const updatedUser = await ctx.db.user.update({
where: { id },
data: updateData,
include: {
@@ -273,6 +282,11 @@ export const usersRouter = createTRPCRouter({
dept: true
}
})
// 标记用户会话失效,强制重新登录以获取最新权限
await invalidateUserSessions([id])
return updatedUser
}),
getById: permissionRequiredProcedure(Permissions.USER_MANAGE).input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
@@ -300,6 +314,9 @@ export const usersRouter = createTRPCRouter({
throw new TRPCError({ code: 'BAD_REQUEST', message: '不能删除自己的账户' })
}
// 标记用户会话失效
await invalidateUserSessions([id])
// 删除用户
return ctx.db.user.delete({
where: { id },

View File

@@ -0,0 +1,89 @@
import 'server-only'
import crypto from 'crypto'
const IAAA_VALIDATE_URL = 'https://iaaa.pku.edu.cn/iaaa/svc/token/validate.do'
export interface IaaaUserInfo {
name: string
status: string
identityId: string
deptId: string
dept: string
identityType: string
detailType: string
identityStatus: string
campus: string
}
interface IaaaValidateResult {
success: boolean
errCode: string
errMsg: string
userInfo?: IaaaUserInfo
}
/** 检查 IAAA 统一认证是否已配置启用 */
export function isIaaaEnabled(): boolean {
return !!(process.env.IAAA_APP_ID && process.env.IAAA_KEY)
}
/** 获取 IAAA 配置(仅返回前端需要的非敏感信息) */
export function getIaaaClientConfig() {
return {
enabled: isIaaaEnabled(),
appId: process.env.IAAA_APP_ID || '',
callbackPath: '/api/auth/iaaa/callback',
autoRedirect: process.env.IAAA_AUTO_REDIRECT === 'true',
}
}
/**
* 验证 IAAA Token
* @param token IAAA 回调携带的 token
* @param remoteAddr 用户真实 IP 地址
* @returns 验证成功返回用户信息,失败返回 null 并记录日志
*/
export async function validateIaaaToken(
token: string,
remoteAddr: string
): Promise<IaaaUserInfo | null> {
const appId = process.env.IAAA_APP_ID
const key = process.env.IAAA_KEY
if (!appId || !key) {
console.error('[IAAA] 环境变量 IAAA_APP_ID 或 IAAA_KEY 未配置')
return null
}
// 参数按字母序拼接appId, remoteAddr, token
const paraStr = `appId=${appId}&remoteAddr=${remoteAddr}&token=${token}`
const msgAbs = crypto.createHash('md5').update(paraStr + key).digest('hex')
const url = `${IAAA_VALIDATE_URL}?${paraStr}&msgAbs=${msgAbs}`
try {
const resp = await fetch(url, { signal: AbortSignal.timeout(10000) })
if (!resp.ok) {
console.error(`[IAAA] 验证请求失败: HTTP ${resp.status}`)
return null
}
const result: IaaaValidateResult = await resp.json()
if (!result.success) {
console.warn(`[IAAA] 认证失败: errCode=${result.errCode}, errMsg=${result.errMsg}`)
return null
}
const userInfo = result.userInfo
if (!userInfo?.identityId) {
console.warn('[IAAA] 认证成功但未返回 identityId')
return null
}
return userInfo
} catch (error) {
console.error('[IAAA] Token 验证请求异常:', error)
return null
}
}

View File

@@ -0,0 +1,39 @@
import 'server-only'
import { getRedisClient } from '@/server/redis'
import { db } from '@/server/db'
const SESSION_INVALID_PREFIX = 'session:invalid:'
const SESSION_INVALID_TTL = 30 * 24 * 60 * 60 // 30天与JWT maxAge一致
/** 标记指定用户的会话失效 */
export async function invalidateUserSessions(userIds: string[]) {
if (userIds.length === 0) return
const redis = getRedisClient()
const pipeline = redis.pipeline()
for (const id of userIds) {
pipeline.set(`${SESSION_INVALID_PREFIX}${id}`, '1', 'EX', SESSION_INVALID_TTL)
}
await pipeline.exec()
}
/** 检查用户会话是否已被标记失效 */
export async function isSessionInvalidated(userId: string): Promise<boolean> {
const redis = getRedisClient()
const result = await redis.exists(`${SESSION_INVALID_PREFIX}${userId}`)
return result === 1
}
/** 用户重新登录后清除失效标记 */
export async function clearSessionInvalidation(userId: string) {
const redis = getRedisClient()
await redis.del(`${SESSION_INVALID_PREFIX}${userId}`)
}
/** 标记某角色下所有用户的会话失效 */
export async function invalidateSessionsByRoleId(roleId: number) {
const users = await db.user.findMany({
where: { roles: { some: { id: roleId } } },
select: { id: true },
})
await invalidateUserSessions(users.map(u => u.id))
}

View File

@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -19,9 +23,19 @@
}
],
"paths": {
"@/*": ["./src/*"]
"@/*": [
"./src/*"
]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"next-env.d.ts",
".next-prod/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}