diff --git a/.claude/settings.json b/.claude/settings.json index 0289485..b39a8b8 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -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": [ diff --git a/.claude/skills/pku-iaaa/SKILL.md b/.claude/skills/pku-iaaa/SKILL.md new file mode 100644 index 0000000..b0ce390 --- /dev/null +++ b/.claude/skills/pku-iaaa/SKILL.md @@ -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。 + +### 验证端点 + +``` +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. 对拼接结果计算 MD5(UTF-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 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 userInfo = (Map) 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` — 验证北京大学账号是否存在 diff --git a/.claude/skills/pku-iaaa/references/account-verify.md b/.claude/skills/pku-iaaa/references/account-verify.md new file mode 100644 index 0000000..326861b --- /dev/null +++ b/.claude/skills/pku-iaaa/references/account-verify.md @@ -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() +``` diff --git a/.claude/skills/pku-iaaa/references/proxy-sso.md b/.claude/skills/pku-iaaa/references/proxy-sso.md new file mode 100644 index 0000000..18e34ab --- /dev/null +++ b/.claude/skills/pku-iaaa/references/proxy-sso.md @@ -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} + ×tamp={TIMESTAMP} + &msgAbs={MSG_ABS} +``` + +**参数:** + +| 参数 | 说明 | +|------|------| +| `remoteAddr` | 客户端 IP | +| `appId` | 中心应用 ID | +| `targetAppId` | 分支应用 ID | +| `grantToken` | 上一步获得的代理票据 | +| `timestamp` | 当前时间戳(long 型整数) | +| `msgAbs` | MD5 摘要,PARA_STR 为除 msgAbs 外所有参数按参数名升序拼接 | + +注意:此端点参数较多,PARA_STR 排序后为: +`appId={}&grantToken={}&remoteAddr={}&targetAppId={}×tamp={}` + +**成功返回:** +```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": "注销成功"} +``` diff --git a/quickstart.sh b/quickstart.sh index 8df347a..3727815 100755 --- a/quickstart.sh +++ b/quickstart.sh @@ -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"