--- 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` — 验证北京大学账号是否存在