添加北京大学统一认证接入支持skill

This commit is contained in:
2026-03-17 16:58:29 +08:00
parent c76e7a7529
commit a6ae3b8845
5 changed files with 619 additions and 11 deletions

View File

@@ -12,8 +12,8 @@
"Bash(tree:*)",
"Bash(find:*)",
"Bash(grep:*)",
"Bash(rg:*)", // ripgrep
"Bash(fd:*)", // fd-find
"Bash(rg:*)",
"Bash(fd:*)",
"Bash(file:*)",
"Bash(stat:*)",
"Bash(du:*)",
@@ -25,7 +25,6 @@
"Bash(date:*)",
"Bash(uname:*)",
// ===== Git 只读操作 =====
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(git log:*)",
@@ -38,15 +37,12 @@
"Bash(git shortlog:*)",
"Bash(git rev-parse:*)",
// ===== 子代理 =====
"Task",
// ===== 文件编辑 =====
"Edit", // 编辑已有文件
"Write", // 创建/覆写文件
"MultiEdit", // 批量编辑
"Edit",
"Write",
"MultiEdit",
// ===== 包管理 =====
"Bash(pnpm install:*)",
"Bash(pnpm add:*)",
"Bash(pnpm run build:*)",
@@ -55,7 +51,6 @@
"Bash(pip install:*)",
"Bash(pip3 install:*)",
// 相对安全的写操作
"Bash(mkdir:*)",
"Bash(cp:*)",
"Bash(mv:*)",
@@ -77,7 +72,6 @@
"Bash(tee:*)",
"Bash(jq:*)",
// MCP
"mcp__ide__getDiagnostics"
],
"deny": [

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

@@ -407,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"