From 6f04a640845d6bc872c5eed8d0b9b0a173c4a9df Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Fri, 18 Sep 2026 23:31:00 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=AD=A6=E6=A0=A1=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E8=BA=AB=E4=BB=BD=E8=AE=A4=E8=AF=81=E7=99=BB=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GxmuAuthProperties 承载接入配置,认证客户端按微服务认证平台 API v2.3 调 cas.gxmu.edu.cn - /api/sso/login 与 /api/sso/available 免鉴权放行 - 校验通过后按学号认领学生,名册外的账号默认拒绝登录 --- .../com/gxwebsoft/WebSoftApplication.java | 4 +- .../common/core/security/SecurityConfig.java | 1 + .../gxmu/auth/GxmuAuthException.java | 42 ++ .../gxmu/auth/GxmuAuthProperties.java | 50 +++ .../gxwebsoft/gxmu/auth/LyuapAuthClient.java | 390 ++++++++++++++++++ .../com/gxwebsoft/gxmu/auth/LyuapUser.java | 113 +++++ .../gxmu/auth/SsoLoginController.java | 48 +++ .../gxwebsoft/gxmu/auth/SsoLoginParam.java | 27 ++ .../gxwebsoft/gxmu/auth/SsoLoginService.java | 36 ++ .../gxmu/auth/SsoLoginServiceImpl.java | 292 +++++++++++++ .../gxmu/auth/LyuapAuthClientIT.java | 122 ++++++ .../gxmu/auth/LyuapAuthClientTest.java | 230 +++++++++++ .../gxmu/auth/SsoLoginServiceImplTest.java | 293 +++++++++++++ 13 files changed, 1647 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/gxwebsoft/gxmu/auth/GxmuAuthException.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/auth/GxmuAuthProperties.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/auth/LyuapAuthClient.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/auth/LyuapUser.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/auth/SsoLoginController.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/auth/SsoLoginParam.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/auth/SsoLoginService.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/auth/SsoLoginServiceImpl.java create mode 100644 src/test/java/com/gxwebsoft/gxmu/auth/LyuapAuthClientIT.java create mode 100644 src/test/java/com/gxwebsoft/gxmu/auth/LyuapAuthClientTest.java create mode 100644 src/test/java/com/gxwebsoft/gxmu/auth/SsoLoginServiceImplTest.java diff --git a/src/main/java/com/gxwebsoft/WebSoftApplication.java b/src/main/java/com/gxwebsoft/WebSoftApplication.java index db9c1c2..d7f586c 100644 --- a/src/main/java/com/gxwebsoft/WebSoftApplication.java +++ b/src/main/java/com/gxwebsoft/WebSoftApplication.java @@ -1,6 +1,7 @@ package com.gxwebsoft; import com.gxwebsoft.common.core.config.ConfigProperties; +import com.gxwebsoft.gxmu.auth.GxmuAuthProperties; import com.gxwebsoft.gxmu.openplat.GxmuOpenplatProperties; import com.gxwebsoft.shop.config.WxMaProperties; import org.mybatis.spring.annotation.MapperScan; @@ -19,7 +20,8 @@ import org.springframework.web.socket.config.annotation.EnableWebSocket; @EnableAsync @EnableTransactionManagement @MapperScan("com.gxwebsoft.**.mapper") -@EnableConfigurationProperties({ConfigProperties.class, WxMaProperties.class, GxmuOpenplatProperties.class}) +@EnableConfigurationProperties({ConfigProperties.class, WxMaProperties.class, GxmuOpenplatProperties.class, + GxmuAuthProperties.class}) @SpringBootApplication @EnableScheduling @EnableWebSocket diff --git a/src/main/java/com/gxwebsoft/common/core/security/SecurityConfig.java b/src/main/java/com/gxwebsoft/common/core/security/SecurityConfig.java index 2a3bc06..aca9b21 100644 --- a/src/main/java/com/gxwebsoft/common/core/security/SecurityConfig.java +++ b/src/main/java/com/gxwebsoft/common/core/security/SecurityConfig.java @@ -39,6 +39,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter { .permitAll() .antMatchers( "/api/login", + "/api/sso/**", "/api/system/user/loginByPhoneForTest", "/api/wx-login/loginByMpWxPhone", "/api/register", diff --git a/src/main/java/com/gxwebsoft/gxmu/auth/GxmuAuthException.java b/src/main/java/com/gxwebsoft/gxmu/auth/GxmuAuthException.java new file mode 100644 index 0000000..d9300ee --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/auth/GxmuAuthException.java @@ -0,0 +1,42 @@ +package com.gxwebsoft.gxmu.auth; + +/** + * 统一身份认证平台调用异常。 + * + *

只表达"跟认证平台这一趟没谈成"(网络、报文、平台返回的业务码), + * 由上层 {@link SsoLoginService} 翻译成给终端用户看的话术。 + * + * @author Codex + * @since 2026-09-15 + */ +public class GxmuAuthException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + /** + * 平台返回的 CODE 响应码,网络/解析失败时为 null + */ + private final String code; + + public GxmuAuthException(String message) { + this(null, message, null); + } + + public GxmuAuthException(String message, Throwable cause) { + this(null, message, cause); + } + + public GxmuAuthException(String code, String message) { + this(code, message, null); + } + + public GxmuAuthException(String code, String message, Throwable cause) { + super(message, cause); + this.code = code; + } + + public String getCode() { + return code; + } + +} diff --git a/src/main/java/com/gxwebsoft/gxmu/auth/GxmuAuthProperties.java b/src/main/java/com/gxwebsoft/gxmu/auth/GxmuAuthProperties.java new file mode 100644 index 0000000..f50efa7 --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/auth/GxmuAuthProperties.java @@ -0,0 +1,50 @@ +package com.gxwebsoft.gxmu.auth; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 医科大统一身份认证(微服务认证平台 API)对接配置 + * + * @author Codex + * @since 2026-09-15 + */ +@Data +@ConfigurationProperties(prefix = "gxmu.auth") +public class GxmuAuthProperties { + + /** + * 是否启用统一身份认证登录 + */ + private boolean enabled = true; + + /** + * 认证平台地址前缀。 + * + *

学校认证平台域名 2026-09-15 由校方提供:{@code https://cas.gxmu.edu.cn/lyuapServer} + */ + private String baseUrl = "https://cas.gxmu.edu.cn/lyuapServer"; + + /** + * 接入应用 appid,在认证平台注册后产生,由学校认证管理员提供。 + */ + private String appId; + + /** + * 应用私钥 appsecret,与 appid 一同由学校认证管理员提供。 + */ + private String appSecret; + + /** + * 请求超时时间(毫秒) + */ + private Integer timeoutMs = 10000; + + /** + * 是否允许名册中不存在、且本系统也没有账号的用户登录。 + * + *

默认关闭:学生必须先被同步进名册(见 ADR-0001),教师按设计不建登录账号。 + */ + private boolean allowUnknownUser = false; + +} diff --git a/src/main/java/com/gxwebsoft/gxmu/auth/LyuapAuthClient.java b/src/main/java/com/gxwebsoft/gxmu/auth/LyuapAuthClient.java new file mode 100644 index 0000000..80b38cc --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/auth/LyuapAuthClient.java @@ -0,0 +1,390 @@ +package com.gxwebsoft.gxmu.auth; + +import cn.hutool.core.codec.Base64; +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.SecureUtil; +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import javax.annotation.Resource; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 医科大微服务认证平台(统一身份认证)客户端。 + * + *

对接《微服务认证平台API接口说明文档 v2.3》,只用到两个接口: + *

+ * + *

两处加密规则(文档 §3): + *

+ * + *

响应统一被包在 {@code {"content":{...}}} 里,业务码在 {@code content.code}。 + * 文档里 code 的取值前后矛盾(2.1 写 0 成功、示例却是 1;appid 不存在 + * 表格写 5、实测返回 2),因此这里一律以"有没有拿到用户对象"为准, + * 不把 code 字面值当唯一判据。 + * + * @author Codex + * @since 2026-09-15 + */ +@Component +public class LyuapAuthClient { + + private static final Logger logger = LoggerFactory.getLogger(LyuapAuthClient.class); + + /** + * 校验密码 + */ + static final String PATH_CHECK_PWD = "/v2/checkPwd"; + + /** + * 获取用户信息 + */ + static final String PATH_GET_USER_INFO = "/v2/getUserInfo"; + + /** + * 平台「操作成功」业务码 + */ + static final String CODE_SUCCESS = "0"; + + @Resource + private GxmuAuthProperties properties; + + /** + * 校验学号/工号 + 密码,通过则正常返回,否则抛 {@link GxmuAuthException}。 + * + * @param username 学号/工号 + * @param rawPassword 明文密码 + */ + public void checkPassword(String username, String rawPassword) { + JSONObject content = call(PATH_CHECK_PWD, username, rawPassword); + String code = stringValue(content, "code"); + Object message = content == null ? null : content.get("message"); + if (isSuccess(code, message)) { + return; + } + String text = message == null ? null : StrUtil.toString(message); + throw new GxmuAuthException(code, describeCheckPwdFailure(code, text)); + } + + /** + * 获取用户信息。 + * + *

该接口只需要 appid + username + secret,不需要密码, + * 因此调用方必须先 {@link #checkPassword} 验明身份再取信息。 + * + * @param username 学号/工号 + * @return 用户信息,平台查无此人时返回 null + */ + public LyuapUser getUserInfo(String username) { + JSONObject content = call(PATH_GET_USER_INFO, username, null); + LyuapUser user = parseUser(content); + if (user == null && !isSuccess(stringValue(content, "code"), + content == null ? null : content.get("message"))) { + String code = stringValue(content, "code"); + Object message = content == null ? null : content.get("message"); + String text = message == null ? null : StrUtil.toString(message); + throw new GxmuAuthException(code, describeUserInfoFailure(code, text)); + } + return user; + } + + // ------------------------------------------------------------------ + // 加密规则(文档 §3.1、§3.2) + // ------------------------------------------------------------------ + + /** + * 加密串:{@code MD5(username + appsecret + yyyyMMdd)} 转 32 位大写。 + * + * @param username 学号/工号 + * @param appSecret 应用私钥 + * @param date 参与加密的日期,取服务器当天 + */ + public static String secret(String username, String appSecret, Date date) { + if (StrUtil.hasBlank(username, appSecret) || date == null) { + return null; + } + String raw = username + appSecret + DateUtil.format(date, "yyyyMMdd"); + return SecureUtil.md5(raw).toUpperCase(); + } + + /** + * 密码编码:{@code Base64(Base64(明文) + appid)}。 + * + * @param rawPassword 明文密码 + * @param appId 应用 appid + */ + public static String encodePassword(String rawPassword, String appId) { + if (rawPassword == null || appId == null) { + return null; + } + return Base64.encode(Base64.encode(rawPassword) + appId); + } + + // ------------------------------------------------------------------ + // 报文解析(响应结构在文档里前后不一致,这里做兼容) + // ------------------------------------------------------------------ + + /** + * 从响应体里取出 {@code content} 对象。 + */ + static JSONObject unwrapContent(String body) { + if (StrUtil.isBlank(body)) { + return null; + } + JSONObject root; + try { + root = JSON.parseObject(body); + } catch (Exception e) { + return null; + } + if (root == null) { + return null; + } + JSONObject content = root.getJSONObject("content"); + return content == null ? root : content; + } + + /** + * 解析用户信息。 + * + *

文档 2.1.3 把 {@code message} 和 {@code data} 都标注为「用户数据」, + * 示例里用户字段直接躺在 {@code message} 对象里,因此两个位置都找一遍。 + */ + static LyuapUser parseUser(JSONObject content) { + JSONObject source = firstUserObject(content); + if (source == null) { + return null; + } + LyuapUser user = new LyuapUser(); + user.setYhbh(value(source, "YHBH")); + user.setYhmc(value(source, "YHMC")); + user.setBm(value(source, "bm")); + user.setBmmc(value(source, "BMMC")); + user.setYhlxmc(value(source, "YHLXMC")); + user.setZt(value(source, "ZT")); + user.setSfzh(value(source, "SFZH")); + user.setSjh(value(source, "SJH")); + user.setDzyx(value(source, "DZYX")); + user.setSfbdsj(value(source, "SFBDSJ")); + user.setSfbdyx(value(source, "SFBDYX")); + if (StrUtil.isBlank(user.getYhbh())) { + return null; + } + return user; + } + + /** + * 在 {@code data} / {@code message} 里找第一个像用户对象的 JSONObject。 + * + *

2026-09-18 用真实学号实测:成功时平台把用户信息放在 {@code message} 里, + * 而且是**转义过的 JSON 字符串**(不是 JSON 对象): + *

+     * {"content":{"code":"0","message":"{\"YHBH\":\"320241167\",\"YHMC\":\"柏泽旭\",...}"}}
+     * 
+ * 所以字符串要先按 JSON 再解一层,否则每次登录都会误判成"未返回用户信息"。 + */ + private static JSONObject firstUserObject(JSONObject content) { + if (content == null) { + return null; + } + Object[] candidates = {content.get("data"), content.get("message"), content}; + for (Object candidate : candidates) { + JSONObject object = asUserObject(candidate); + if (object != null && StrUtil.isNotBlank(value(object, "YHBH"))) { + return object; + } + } + return null; + } + + /** + * 把候选值转成 JSONObject:本身是对象直接用,是字符串则尝试再解一层 JSON。 + * + * @return 不是用户对象(比如错误提示文本)时返回 null + */ + private static JSONObject asUserObject(Object candidate) { + if (candidate instanceof JSONObject) { + return (JSONObject) candidate; + } + if (candidate instanceof CharSequence) { + String text = StrUtil.trim(candidate.toString()); + if (text.isEmpty() || !text.startsWith("{")) { + return null; + } + try { + return JSON.parseObject(text); + } catch (Exception e) { + return null; + } + } + return null; + } + + /** + * 取值:文档字段名统一大写,但为防平台版本差异,原样/大写/小写都试一遍。 + */ + private static String value(JSONObject object, String key) { + if (object == null || key == null) { + return null; + } + Object raw = object.get(key); + if (raw == null) { + raw = object.get(key.toUpperCase()); + } + if (raw == null) { + raw = object.get(key.toLowerCase()); + } + if (raw == null) { + return null; + } + String text = StrUtil.trim(StrUtil.toString(raw)); + if (text.isEmpty() || "null".equalsIgnoreCase(text) || "NULL".equals(text)) { + return null; + } + return text; + } + + private static String stringValue(JSONObject content, String key) { + if (content == null) { + return null; + } + Object raw = content.get(key); + return raw == null ? null : StrUtil.trim(StrUtil.toString(raw)); + } + + /** + * 是否操作成功。 + * + *

code=0 是文档口径;个别接口的示例又用 code=1 表示成功, + * 所以再认一次 message 文本。 + */ + static boolean isSuccess(String code, Object message) { + if (CODE_SUCCESS.equals(code)) { + return true; + } + if (message instanceof CharSequence) { + String text = StrUtil.trim(message.toString()); + return text.startsWith("操作成功") || text.startsWith("成功"); + } + return false; + } + + private static String describeCheckPwdFailure(String code, String message) { + return describeFailure(code, message, "统一身份认证失败,请稍后重试"); + } + + private static String describeUserInfoFailure(String code, String message) { + return describeFailure(code, message, "统一身份认证平台未返回用户信息"); + } + + /** + * 把平台的失败信息翻译成给学生看的话。 + * + *

一律以平台自己的 message 为准,因为文档的 CODE 表与实测完全对不上。 + * 2026-09-18 用真实 appid + 真实学号实测到的报文: + *

+     *   appid 不对      → code=2 "appid不存在或被禁用"
+     *   secret 不对     → code=3 "secret加密串校验失败"
+     *   username 空     → code=1 "username参数不能为空"
+     *   用户不存在      → code=4 "用户不存在"(getUserInfo)/ "没有对应的用户"(checkPwd)
+     *   密码错          → code=6 "账号或密码错误"
+     *   成功            → code=0,且 message 是转义过的 JSON 字符串
+     * 
+ * 而文档 2.4.4 写的是 1=加密串校验失败、2=用户名不能为空、3=密码不能为空、4=用户不存在、5=appid不存在。 + * code 只在 message 缺失时兜底。 + */ + static String describeFailure(String code, String message, String fallback) { + String text = StrUtil.trimToNull(message); + if (text != null && !"操作失败!".equals(text) && !"操作失败".equals(text)) { + if (StrUtil.containsIgnoreCase(text, "appid")) { + return "统一身份认证应用未注册或已停用,请联系管理员"; + } + if (text.contains("加密串") || text.contains("校验串") || StrUtil.containsIgnoreCase(text, "secret")) { + return "统一身份认证平台校验失败,请稍后重试"; + } + if (text.contains("username") || text.contains("账号不能为空")) { + return "请输入学号"; + } + if (text.contains("密码不能为空") || text.contains("密码不能空")) { + return "请输入密码"; + } + if (text.contains("用户不能为空") || text.contains("用户名不能为空") + || text.contains("用户不存在") || text.contains("没有对应的用户")) { + return "学号不存在,请确认后重试"; + } + if (text.contains("密码")) { + return "学号或密码错误"; + } + return text; + } + if ("4".equals(code)) { + return "学号不存在,请确认后重试"; + } + if ("6".equals(code)) { + return "学号或密码错误"; + } + return fallback; + } + + // ------------------------------------------------------------------ + // HTTP + // ------------------------------------------------------------------ + + /** + * 调用平台接口。 + * + *

用表单 POST 而不是 GET:文档标注 POST/GET 皆可,说明平台是按 + * {@code getParameter} 读参数的,表单体会被同样读到,同时避免密码出现在 URL、日志里。 + */ + private JSONObject call(String path, String username, String rawPassword) { + if (StrUtil.isBlank(properties.getAppId()) || StrUtil.isBlank(properties.getAppSecret())) { + throw new GxmuAuthException("统一身份认证未配置 appid/appsecret,请联系管理员"); + } + Map params = new LinkedHashMap<>(); + params.put("appid", properties.getAppId()); + params.put("username", username); + params.put("secret", secret(username, properties.getAppSecret(), new Date())); + if (rawPassword != null) { + params.put("password", encodePassword(rawPassword, properties.getAppId())); + } + String url = properties.getBaseUrl() + path; + String body; + try { + HttpResponse response = HttpRequest.post(url) + .form(params) + .timeout(properties.getTimeoutMs()) + .execute(); + body = response.body(); + if (!response.isOk()) { + throw new GxmuAuthException("统一身份认证平台返回 " + response.getStatus() + + ",请稍后重试"); + } + } catch (GxmuAuthException e) { + throw e; + } catch (Exception e) { + logger.error("调用统一身份认证平台失败: url={}, username={}, err={}", url, username, e.getMessage()); + throw new GxmuAuthException("无法连接学校统一身份认证平台,请稍后重试", e); + } + logger.info("统一身份认证平台响应: path={}, username={}, body={}", path, username, body); + JSONObject content = unwrapContent(body); + if (content == null) { + throw new GxmuAuthException("统一身份认证平台返回内容无法解析"); + } + return content; + } + +} diff --git a/src/main/java/com/gxwebsoft/gxmu/auth/LyuapUser.java b/src/main/java/com/gxwebsoft/gxmu/auth/LyuapUser.java new file mode 100644 index 0000000..3774680 --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/auth/LyuapUser.java @@ -0,0 +1,113 @@ +package com.gxwebsoft.gxmu.auth; + +import cn.hutool.core.util.StrUtil; +import lombok.Data; + +/** + * 认证平台 {@code /v2/getUserInfo} 返回的用户信息。 + * + *

字段名沿用平台的大写缩写,这里保留原样并补语义注释, + * 避免与开放平台名册({@code GxmuStudent} 的 xh/xm/sjh)混淆。 + * + * @author Codex + * @since 2026-09-15 + */ +@Data +public class LyuapUser { + + /** + * YHBH 用户账号(工号/学号) + */ + private String yhbh; + + /** + * YHMC 用户名称(姓名) + */ + private String yhmc; + + /** + * bm 别名 + */ + private String bm; + + /** + * BMMC 部门名称,多个部门用逗号隔开 + */ + private String bmmc; + + /** + * YHLXMC 用户类型。 + * + *

文档表格标注为 Int 并给出 (1管理员2教师3学生4研究生5测试人员6领导), + * 但文档自带的响应示例里是「教职工」这样的名称,故按字符串保存,由 + * {@link #isStudent()} 两种口径都认。 + */ + private String yhlxmc; + + /** + * ZT 用户状态:1激活 0禁用 + */ + private String zt; + + /** + * SFZH 身份证号 + */ + private String sfzh; + + /** + * SJH 手机号 + */ + private String sjh; + + /** + * DZYX 邮箱 + */ + private String dzyx; + + /** + * SFBDSJ 是否绑定手机(1绑定,0未绑定) + */ + private String sfbdsj; + + /** + * SFBDYX 是否绑定邮箱(1绑定,0未绑定) + */ + private String sfbdyx; + + /** + * 是否学生(含研究生)。 + * + *

平台既可能返回「学生/研究生」这类名称,也可能返回文档表格里的数字码 + * (3 学生、4 研究生),两种都认。 + */ + public boolean isStudent() { + String type = StrUtil.trimToEmpty(yhlxmc); + if (type.isEmpty()) { + return false; + } + if (type.contains("学生") || type.contains("研究生")) { + return true; + } + return "3".equals(type) || "4".equals(type); + } + + /** + * 认证平台侧账号是否处于激活状态({@code ZT=1})。 + * + *

注意:这个值不作为登录拦阻条件。2026-09-18 观测到已毕业学生的 {@code ZT=0}, + * 而毕业生同样需要进系统(团组织关系转接等),是否放行由本系统账号的 {@code status} 决定。 + * 这里只用于记录日志、便于排查。 + */ + public boolean isActive() { + String status = StrUtil.trimToNull(zt); + return status == null || !"0".equals(status); + } + + /** + * 取姓名,拿不到时返回空串而不是 null,便于直接写库。 + */ + public String displayName() { + return StrUtil.nullToEmpty(StrUtil.trim(yhmc)); + } + +} diff --git a/src/main/java/com/gxwebsoft/gxmu/auth/SsoLoginController.java b/src/main/java/com/gxwebsoft/gxmu/auth/SsoLoginController.java new file mode 100644 index 0000000..ead40e6 --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/auth/SsoLoginController.java @@ -0,0 +1,48 @@ +package com.gxwebsoft.gxmu.auth; + +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.BaseController; +import com.gxwebsoft.common.system.result.LoginResult; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.annotation.Resource; +import javax.servlet.http.HttpServletRequest; + +/** + * 学校统一身份认证登录接口。 + * + *

小程序端不再走 CAS 网页跳转,而是直接收集 学号 + 统一身份认证密码交给后端, + * 由后端调认证平台 {@code /v2/checkPwd}、{@code /v2/getUserInfo} 完成校验。 + * 密码只用于当次校验,本系统不保存。 + * + * @author Codex + * @since 2026-09-15 + */ +@RestController +@RequestMapping("/api/sso") +@Api(tags = "学校统一身份认证登录API") +public class SsoLoginController extends BaseController { + + @Resource + private SsoLoginService ssoLoginService; + + @ApiOperation("学校统一身份认证登录(学号 + 统一身份认证密码)") + @PostMapping("/login") + public ApiResult login(@RequestBody SsoLoginParam param, HttpServletRequest request) { + return success("登录成功", ssoLoginService.login(param.getUsername(), param.getPassword(), + getTenantId(), request)); + } + + @ApiOperation("学校统一身份认证登录是否可用") + @GetMapping("/available") + public ApiResult available() { + return success(ssoLoginService.available()); + } + +} diff --git a/src/main/java/com/gxwebsoft/gxmu/auth/SsoLoginParam.java b/src/main/java/com/gxwebsoft/gxmu/auth/SsoLoginParam.java new file mode 100644 index 0000000..aeca847 --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/auth/SsoLoginParam.java @@ -0,0 +1,27 @@ +package com.gxwebsoft.gxmu.auth; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import java.io.Serializable; + +/** + * 学校统一身份认证登录参数 + * + * @author Codex + * @since 2026-09-15 + */ +@Data +@ApiModel(description = "统一身份认证登录参数") +public class SsoLoginParam implements Serializable { + + private static final long serialVersionUID = 1L; + + @ApiModelProperty(value = "学号/工号", required = true) + private String username; + + @ApiModelProperty(value = "统一身份认证密码", required = true) + private String password; + +} diff --git a/src/main/java/com/gxwebsoft/gxmu/auth/SsoLoginService.java b/src/main/java/com/gxwebsoft/gxmu/auth/SsoLoginService.java new file mode 100644 index 0000000..f5d9e1c --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/auth/SsoLoginService.java @@ -0,0 +1,36 @@ +package com.gxwebsoft.gxmu.auth; + +import com.gxwebsoft.common.system.result.LoginResult; + +import javax.servlet.http.HttpServletRequest; + +/** + * 学校统一身份认证登录服务。 + * + *

与 {@code MainController#login}(本地账号密码)、{@code WxLoginController#loginByMpWxPhone}(微信) + * 并列的第三条登录入口:学号 + 统一身份认证密码,密码只用于向认证平台校验,本系统不保存。 + * + * @author Codex + * @since 2026-09-15 + */ +public interface SsoLoginService { + + /** + * 学号 + 统一身份认证密码登录。 + * + * @param username 学号/工号 + * @param password 明文密码(只透传给认证平台,不落库) + * @param tenantId 租户ID + * @param request 用于记录登录日志 + * @return 与其它登录入口完全一致的登录结果 + */ + LoginResult login(String username, String password, Integer tenantId, HttpServletRequest request); + + /** + * 统一身份认证登录是否已可用(开关打开且 appid/appsecret 已配置)。 + * + *

前端据此决定要不要显示该登录入口。 + */ + boolean available(); + +} diff --git a/src/main/java/com/gxwebsoft/gxmu/auth/SsoLoginServiceImpl.java b/src/main/java/com/gxwebsoft/gxmu/auth/SsoLoginServiceImpl.java new file mode 100644 index 0000000..8124984 --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/auth/SsoLoginServiceImpl.java @@ -0,0 +1,292 @@ +package com.gxwebsoft.gxmu.auth; + +import cn.hutool.core.util.StrUtil; +import com.alibaba.fastjson.JSONObject; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.gxwebsoft.common.core.config.ConfigProperties; +import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.common.core.security.JwtSubject; +import com.gxwebsoft.common.core.security.JwtUtil; +import com.gxwebsoft.common.core.utils.CacheClient; +import com.gxwebsoft.common.core.utils.CommonUtil; +import com.gxwebsoft.common.system.entity.LoginRecord; +import com.gxwebsoft.common.system.entity.Role; +import com.gxwebsoft.common.system.entity.User; +import com.gxwebsoft.common.system.entity.UserRole; +import com.gxwebsoft.common.system.result.LoginResult; +import com.gxwebsoft.common.system.service.LoginRecordService; +import com.gxwebsoft.common.system.service.RoleService; +import com.gxwebsoft.common.system.service.UserRoleService; +import com.gxwebsoft.common.system.service.UserService; +import com.gxwebsoft.gxmu.openplat.OpenplatSyncConstants; +import com.gxwebsoft.gxmu.openplat.claim.StudentClaimService; +import com.gxwebsoft.gxmu.openplat.entity.GxmuStudent; +import com.gxwebsoft.gxmu.openplat.mapper.GxmuStudentMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.annotation.Resource; +import javax.servlet.http.HttpServletRequest; + +/** + * 学校统一身份认证登录实现。 + * + *

流程:认证平台校验密码 → 取用户信息 → 找/建本系统账号 → 认领名册 → 签发本系统 JWT。 + * + *

账号不重复建:名册已认领的用原账号;否则按 学号(username / user_code)、 + * 手机号依次找既有账号(学生可能先用微信注册过);都没有才新建,并顺手认领名册。 + * + * @author Codex + * @since 2026-09-15 + */ +@Service +public class SsoLoginServiceImpl implements SsoLoginService { + + private static final Logger logger = LoggerFactory.getLogger(SsoLoginServiceImpl.class); + + /** + * 新建账号时挂的学生角色编码,与微信登录保持一致 + */ + private static final String ROLE_CODE_STUDENT = "student"; + + @Resource + private GxmuAuthProperties properties; + + @Resource + private LyuapAuthClient authClient; + + @Resource + private UserService userService; + + @Resource + private RoleService roleService; + + @Resource + private UserRoleService userRoleService; + + @Resource + private GxmuStudentMapper studentMapper; + + @Resource + private StudentClaimService studentClaimService; + + @Resource + private ConfigProperties configProperties; + + @Resource + private CacheClient cacheClient; + + @Resource + private LoginRecordService loginRecordService; + + @Override + public boolean available() { + return properties.isEnabled() + && StrUtil.isNotBlank(properties.getAppId()) + && StrUtil.isNotBlank(properties.getAppSecret()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public LoginResult login(String username, String password, Integer tenantId, HttpServletRequest request) { + String studentNo = StrUtil.trimToNull(username); + Integer tid = tenantId == null ? OpenplatSyncConstants.TENANT_ID : tenantId; + if (studentNo == null) { + throw new BusinessException("请输入学号"); + } + if (StrUtil.isBlank(password)) { + throw new BusinessException("请输入密码"); + } + if (!properties.isEnabled()) { + throw new BusinessException("学校统一身份认证登录未开启"); + } + if (!available()) { + throw new BusinessException("学校统一身份认证未配置,请联系管理员"); + } + + // 1. 认证平台校验密码。密码只透传,不落库、不打日志。 + try { + authClient.checkPassword(studentNo, password); + } catch (GxmuAuthException e) { + loginRecordService.saveAsync(studentNo, LoginRecord.TYPE_ERROR, e.getMessage(), tid, request); + throw new BusinessException(e.getMessage()); + } + + // 2. 取用户信息 + LyuapUser info; + try { + info = authClient.getUserInfo(studentNo); + } catch (GxmuAuthException e) { + loginRecordService.saveAsync(studentNo, LoginRecord.TYPE_ERROR, e.getMessage(), tid, request); + throw new BusinessException(e.getMessage()); + } + if (info == null) { + throw new BusinessException("统一身份认证平台未返回用户信息,请稍后重试"); + } + // 认证平台的 ZT=0(2026-09-18 观测到的是已毕业学生)不拦登录: + // 毕业生仍需要进系统办团组织关系转接等事项,是否放行由本系统账号 status 决定。 + if (!info.isActive()) { + logger.info("统一身份认证账号状态非激活,仍放行登录: xh={}, zt={}", studentNo, info.getZt()); + } + // 认证平台返回的账号与入参不一致时,以入参为准(名册索引的就是学号) + info.setYhbh(studentNo); + + // 3. 名册 + 本系统账号 + GxmuStudent student = findStudent(studentNo, tid); + User user = resolveAccount(info, student, tid); + if (user == null) { + String message = student != null || info.isStudent() + ? "学号未在学生名册中,请联系团委" + : "该账号未在本系统开通,教师请使用 PC 后台登录"; + loginRecordService.saveAsync(studentNo, LoginRecord.TYPE_ERROR, message, tid, request); + throw new BusinessException(message); + } + if (!Integer.valueOf(0).equals(user.getStatus())) { + String message = "账号被冻结,请联系团委"; + loginRecordService.saveAsync(studentNo, LoginRecord.TYPE_ERROR, message, tid, request); + throw new BusinessException(message); + } + + // 4. 认领名册(统一身份认证已验密码,学号属可信来源,不再校验姓名) + if (student != null && !user.getUserId().equals(student.getUserId())) { + boolean claimed = studentClaimService.claimByXh(user, studentNo); + if (!claimed) { + logger.warn("统一身份认证登录后认领未成功: userId={}, xh={}", user.getUserId(), studentNo); + } + } + + // 5. 签发本系统 token + String accessToken = issueToken(user, tid); + loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, tid, request); + logger.info("统一身份认证登录成功: userId={}, username={}, xh={}", user.getUserId(), user.getUsername(), studentNo); + return new LoginResult(accessToken, reload(user, tid)); + } + + // ------------------------------------------------------------------ + + /** + * 名册里按学号找学生 + */ + private GxmuStudent findStudent(String studentNo, Integer tenantId) { + return studentMapper.selectOne(new LambdaQueryWrapper() + .eq(GxmuStudent::getTenantId, tenantId) + .eq(GxmuStudent::getXh, studentNo) + .last("limit 1")); + } + + /** + * 找本系统账号;找不到且允许时新建。 + * + * @return null 表示这个身份不允许进本系统 + */ + private User resolveAccount(LyuapUser info, GxmuStudent student, Integer tenantId) { + // ① 名册已认领 → 用原来那个账号(微信注册的账号也在这里被复用) + if (student != null && student.getUserId() != null) { + User claimed = userService.getById(student.getUserId()); + if (claimed != null) { + return claimed; + } + logger.warn("名册已认领但账号不存在: studentId={}, userId={}", student.getId(), student.getUserId()); + } + // ② 学号即账号 + User user = userService.getByUsername(info.getYhbh(), tenantId); + if (user != null) { + return user; + } + // ③ 认证时回填的 user_code + user = findByUserCode(info.getYhbh()); + if (user != null) { + return user; + } + // ④ 手机号命中:学生先用微信注册过、名册还没认领的场景 + String phone = StrUtil.trimToNull(info.getSjh()); + if (phone != null) { + user = userService.getByPhone(phone); + if (user != null) { + return user; + } + } + // ⑤ 名册里有这个人 → 建号 + if (student != null) { + return createAccount(info, tenantId); + } + // ⑥ 名册里没有:只有显式放开、且确实是学生才建号 + if (properties.isAllowUnknownUser() && info.isStudent()) { + return createAccount(info, tenantId); + } + return null; + } + + private User findByUserCode(String userCode) { + if (StrUtil.isBlank(userCode)) { + return null; + } + return userService.getOne(new LambdaQueryWrapper() + .eq(User::getUserCode, userCode) + .last("limit 1")); + } + + /** + * 新建学生账号:username 用学号,密码随机(本人只走统一身份认证,不用本地密码)。 + */ + private User createAccount(LyuapUser info, Integer tenantId) { + User addUser = new User(); + addUser.setStatus(0); + addUser.setUsername(info.getYhbh()); + addUser.setUserCode(info.getYhbh()); + addUser.setRealName(info.displayName()); + addUser.setNickname(StrUtil.blankToDefault(info.displayName(), "学生")); + addUser.setPassword(userService.encodePassword(CommonUtil.randomUUID16())); + addUser.setTenantId(tenantId); + String phone = StrUtil.trimToNull(info.getSjh()); + if (phone != null) { + addUser.setPhone(phone); + } + String email = StrUtil.trimToNull(info.getDzyx()); + if (email != null) { + addUser.setEmail(email); + } + Role role = roleService.getOne(new QueryWrapper().eq("role_code", ROLE_CODE_STUDENT), false); + if (role != null) { + addUser.setRoleId(role.getRoleId()); + } + if (userService.saveUser(addUser) && role != null) { + UserRole userRole = new UserRole(); + userRole.setUserId(addUser.getUserId()); + userRole.setTenantId(addUser.getTenantId()); + userRole.setRoleId(role.getRoleId()); + userRoleService.save(userRole); + } + logger.info("统一身份认证首次登录,已建账号: userId={}, username={}", addUser.getUserId(), addUser.getUsername()); + return addUser; + } + + /** + * 重新读一遍用户,带上角色与权限,供前端渲染菜单。 + */ + private User reload(User user, Integer tenantId) { + User fresh = userService.getByUsername(user.getUsername(), tenantId); + return fresh == null ? user : fresh; + } + + /** + * 签发本系统 token,有效期与 {@code /api/login} 保持同一套口径。 + */ + private String issueToken(User user, Integer tenantId) { + Long expire = configProperties.getTokenExpireTime(); + try { + JSONObject register = cacheClient.getSettingInfo("register", tenantId); + if (register != null && register.getString("tokenExpireTime") != null) { + expire = Long.valueOf(register.getString("tokenExpireTime")); + } + } catch (Exception e) { + logger.warn("读取租户 token 有效期配置失败,使用默认值: {}", e.getMessage()); + } + return JwtUtil.buildToken(new JwtSubject(user.getUsername(), tenantId), expire, + configProperties.getTokenKey()); + } + +} diff --git a/src/test/java/com/gxwebsoft/gxmu/auth/LyuapAuthClientIT.java b/src/test/java/com/gxwebsoft/gxmu/auth/LyuapAuthClientIT.java new file mode 100644 index 0000000..3c435d7 --- /dev/null +++ b/src/test/java/com/gxwebsoft/gxmu/auth/LyuapAuthClientIT.java @@ -0,0 +1,122 @@ +package com.gxwebsoft.gxmu.auth; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 统一身份认证客户端的**联网**验证。 + * + *

默认不执行(类名以 IT 结尾,surefire 不会扫到)。手动运行: + * + *

+ *   ./mvnw test -Dtest=LyuapAuthClientIT -Dgxmu.auth.it=true
+ * 
+ * + *

它存在的意义:其余测试只证明了 MD5/Base64 的密文与文档一致, + * 而 Hutool 的表单请求构造(form 编码、UTF-8、超时)从未真正打到过认证平台。 + * 这个测试用真实接口把这一段补上——不需要数据库,也不需要真实学生密码, + * 只验证"请求发得出去、平台认得我们的 appid 与 secret、报文解析得回来"。 + * + *

需要一个不存在的学号来避免误伤真实用户,示例值 {@code 0}。 + * + * @author Codex + * @since 2026-09-18 + */ +@EnabledIfSystemProperty(named = "gxmu.auth.it", matches = "true") +class LyuapAuthClientIT { + + /** + * 与 application.yml 的默认值保持一致;可用环境变量覆盖。 + */ + private static final String APP_ID = System.getenv().getOrDefault( + "GXMU_AUTH_APPID", "7D8F6695611309E0"); + private static final String APP_SECRET = System.getenv().getOrDefault( + "GXMU_AUTH_APPSECRET", "24f9dab5-3319-42cc-908d-2748417b7f92"); + + /** + * 一定不存在的学号,避免拿真实用户做实验 + */ + private static final String UNKNOWN_USERNAME = "0"; + + /** + * 真人在册学号(2026-09-18 由校方提供),用于验证成功分支的解析 + */ + private static final String REAL_STUDENT_NO = "320241167"; + + private LyuapAuthClient newClient() { + GxmuAuthProperties properties = new GxmuAuthProperties(); + properties.setAppId(APP_ID); + properties.setAppSecret(APP_SECRET); + properties.setTimeoutMs(15000); + LyuapAuthClient client = new LyuapAuthClient(); + ReflectionTestUtils.setField(client, "properties", properties); + return client; + } + + /** + * appid 与 secret 被平台认下:查询一个不存在的学号,得到的是"用户不存在", + * 而不是"appid 不存在"或"secret 校验失败"。 + */ + @Test + void getUserInfo_appIdAndSecretAreAccepted() { + LyuapAuthClient client = newClient(); + GxmuAuthException error = assertThrows(GxmuAuthException.class, + () -> client.getUserInfo(UNKNOWN_USERNAME)); + assertEquals("学号不存在,请确认后重试", error.getMessage()); + } + + /** + * 成功分支:真实学号的 getUserInfo 必须解析出用户对象。 + * + *

平台把用户信息放在 message 里且是转义 JSON 字符串——这一段只有打到真接口才能发现, + * mock 出来的报文永远是"我以为的样子"。 + */ + @Test + void getUserInfo_parsesRealStudent() { + LyuapAuthClient client = newClient(); + LyuapUser user = client.getUserInfo(REAL_STUDENT_NO); + assertNotNull(user, "真实学号应能取到用户信息"); + assertEquals(REAL_STUDENT_NO, user.getYhbh()); + assertNotNull(user.getYhmc(), "姓名不应为空"); + assertTrue(user.isStudent(), "YHLXMC 实际为: " + user.getYhlxmc()); + assertNotNull(user.getSjh(), "手机号不应为空,否则认领的手机号匹配分支不可用"); + System.out.println("真实用户: " + user.getYhbh() + " / " + user.getYhmc() + + " / 类型=" + user.getYhlxmc() + " / 状态ZT=" + user.getZt() + + " / 部门=" + user.getBmmc() + " / 在册=" + user.isActive()); + } + + + @Test + void secret_computedByClientIsAcceptedByPlatform() { + String secret = LyuapAuthClient.secret(UNKNOWN_USERNAME, APP_SECRET, new java.util.Date()); + assertNotNull(secret); + assertEquals(32, secret.length()); + + // 只要平台回的是业务码(而不是 secret 校验失败),就说明加密串是对的 + LyuapAuthClient client = newClient(); + GxmuAuthException error = assertThrows(GxmuAuthException.class, + () -> client.getUserInfo(UNKNOWN_USERNAME)); + assertTrue(error.getMessage().contains("学号不存在"), "实际: " + error.getMessage()); + } + + /** + * 密码字段的编码规则也要被平台接受:假密码 + 不存在的用户, + * 平台应当回"用户不存在"而不是"加密串/参数"类错误。 + */ + @Test + void checkPassword_encodingIsAcceptedByPlatform() { + LyuapAuthClient client = newClient(); + GxmuAuthException error = assertThrows(GxmuAuthException.class, + () -> client.checkPassword(UNKNOWN_USERNAME, "not-a-real-password")); + assertTrue(error.getMessage().contains("学号不存在") + || error.getMessage().contains("学号或密码错误"), + "实际: " + error.getMessage()); + } + +} diff --git a/src/test/java/com/gxwebsoft/gxmu/auth/LyuapAuthClientTest.java b/src/test/java/com/gxwebsoft/gxmu/auth/LyuapAuthClientTest.java new file mode 100644 index 0000000..4c3a948 --- /dev/null +++ b/src/test/java/com/gxwebsoft/gxmu/auth/LyuapAuthClientTest.java @@ -0,0 +1,230 @@ +package com.gxwebsoft.gxmu.auth; + +import cn.hutool.core.date.DateUtil; +import com.alibaba.fastjson.JSONObject; +import org.junit.jupiter.api.Test; + +import java.util.Date; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 统一身份认证客户端的纯逻辑测试(不依赖数据库与网络)。 + * + *

加密规则的期望值由 python hashlib/base64 独立算出,与《微服务认证平台API接口说明文档 v2.3》§3 对齐。 + * + * @author Codex + * @since 2026-09-15 + */ +class LyuapAuthClientTest { + + private static final String APP_SECRET = "709ca5e8410c447b93f9672b2bb8112a"; + + @Test + void secret_matchesDocumentRule() { + Date date = DateUtil.parse("2026-09-15", "yyyy-MM-dd"); + assertEquals("60E9C2F39FE790BE79CE98FAD9B90379", + LyuapAuthClient.secret("lyadmin", APP_SECRET, date)); + } + + @Test + void secret_isThirtyTwoUpperCaseChars() { + Date date = DateUtil.parse("2026-01-01", "yyyy-MM-dd"); + String secret = LyuapAuthClient.secret("201510051", APP_SECRET, date); + assertEquals("901F8C2461C5F7729B743D2F7372C931", secret); + assertEquals(32, secret.length()); + assertEquals(secret.toUpperCase(), secret); + } + + @Test + void secret_returnsNullWhenInputIncomplete() { + Date date = new Date(); + assertNull(LyuapAuthClient.secret(null, APP_SECRET, date)); + assertNull(LyuapAuthClient.secret("test", null, date)); + assertNull(LyuapAuthClient.secret("test", APP_SECRET, null)); + } + + /** + * 文档 §3.1:{@code Base64(Base64(数据) + appid)} + */ + @Test + void encodePassword_matchesDocumentRule() { + assertEquals("TVRJek5EVTJteWFwcGlk", LyuapAuthClient.encodePassword("123456", "myappid")); + } + + @Test + void encodePassword_returnsNullWhenInputIncomplete() { + assertNull(LyuapAuthClient.encodePassword(null, "myappid")); + assertNull(LyuapAuthClient.encodePassword("123456", null)); + } + + @Test + void unwrapContent_returnsInnerContentObject() { + JSONObject content = LyuapAuthClient.unwrapContent( + "{\"content\":{\"code\":\"2\",\"message\":\"appid不存在或被禁用\"}}"); + assertEquals("2", content.getString("code")); + assertEquals("appid不存在或被禁用", content.getString("message")); + } + + @Test + void unwrapContent_fallsBackToRootAndNull() { + assertEquals("1", LyuapAuthClient.unwrapContent("{\"code\":\"1\"}").getString("code")); + assertNull(LyuapAuthClient.unwrapContent("")); + assertNull(LyuapAuthClient.unwrapContent("not json")); + } + + /** + * 2026-09-18 真实学号 320241167 的原始响应。 + * + *

平台把用户信息放在 message 里,而且是**转义过的 JSON 字符串**。 + * 之前只认 "message 是 JSONObject" 的写法,真实调用会一路走到 + * "统一身份认证平台未返回用户信息"。这条用例就是钉住这个 bug。 + */ + @Test + void parseUser_readsUserFromEscapedJsonStringInMessage() { + String body = "{\"content\":{\"code\":\"0\",\"message\":\"{\\\"XBM\\\":\\\"1\\\",\\\"SFZH\\\":\\\"431126200608030379\\\"," + + "\\\"YHLXMC\\\":\\\"学生\\\",\\\"BM\\\":\\\"gxmu320241167\\\",\\\"YHMC\\\":\\\"柏泽旭\\\"," + + "\\\"SJH\\\":\\\"19114820589\\\",\\\"DZYX\\\":\\\"320241167@gxmu.edu.cn\\\",\\\"SFBDSJ\\\":1," + + "\\\"ZT\\\":\\\"1\\\",\\\"YHBH\\\":\\\"320241167\\\",\\\"BMMC\\\":\\\"2024级康复治疗技术18班\\\"}\"}}"; + JSONObject content = LyuapAuthClient.unwrapContent(body); + assertEquals("0", content.getString("code")); + + LyuapUser user = LyuapAuthClient.parseUser(content); + assertEquals("320241167", user.getYhbh()); + assertEquals("柏泽旭", user.getYhmc()); + assertEquals("19114820589", user.getSjh()); + assertEquals("2024级康复治疗技术18班", user.getBmmc()); + assertEquals("320241167@gxmu.edu.cn", user.getDzyx()); + assertEquals("431126200608030379", user.getSfzh()); + assertTrue(user.isStudent()); + assertTrue(user.isActive()); + } + + /** + * 2026-09-18 真实学号 20205280988(ZT=0,已停用/毕业)。 + */ + @Test + void parseUser_readsInactiveUserAndMarksItInactive() { + String body = "{\"content\":{\"code\":\"0\",\"message\":\"{\\\"YHLXMC\\\":\\\"学生\\\",\\\"YHMC\\\":\\\"陈燕梅\\\"," + + "\\\"SJH\\\":\\\"18178009874\\\",\\\"ZT\\\":\\\"0\\\",\\\"YHBH\\\":\\\"20205280988\\\"}\"}}"; + LyuapUser user = LyuapAuthClient.parseUser(LyuapAuthClient.unwrapContent(body)); + assertEquals("20205280988", user.getYhbh()); + assertEquals("陈燕梅", user.getYhmc()); + assertTrue(user.isStudent()); + assertFalse(user.isActive()); + } + + /** + * 错误提示文本不能被当成用户对象。 + */ + @Test + void parseUser_ignoresPlainTextMessage() { + assertNull(LyuapAuthClient.parseUser(LyuapAuthClient.unwrapContent( + "{\"content\":{\"code\":\"4\",\"message\":\"用户不存在\"}}"))); + assertNull(LyuapAuthClient.parseUser(LyuapAuthClient.unwrapContent( + "{\"content\":{\"code\":\"3\",\"message\":\"secret加密串校验失败\"}}"))); + } + + /** + * 文档 2.1.4 的示例:用户字段直接躺在 message 对象里。 + */ + @Test + void parseUser_readsUserFromMessageObject() { + JSONObject content = LyuapAuthClient.unwrapContent("{\"content\":{\"code\":\"1\",\"message\":{" + + "\"YHBH\":\"test0001\",\"YHMC\":\"张三\",\"YHLXMC\":\"学生\",\"ZT\":\"1\"," + + "\"SJH\":\"1358787454\",\"BMMC\":\"第一临床医学院\"}}}"); + LyuapUser user = LyuapAuthClient.parseUser(content); + assertEquals("test0001", user.getYhbh()); + assertEquals("张三", user.getYhmc()); + assertEquals("1358787454", user.getSjh()); + assertTrue(user.isStudent()); + assertTrue(user.isActive()); + } + + /** + * 文档 2.1.3 的表格:用户字段在 data 里。两种都要认。 + */ + @Test + void parseUser_readsUserFromDataObject() { + JSONObject content = LyuapAuthClient.unwrapContent("{\"content\":{\"code\":\"0\",\"data\":{" + + "\"YHBH\":\"201510051\",\"YHMC\":\"李四\",\"YHLXMC\":\"4\"}}}"); + LyuapUser user = LyuapAuthClient.parseUser(content); + assertEquals("201510051", user.getYhbh()); + assertTrue(user.isStudent()); + } + + @Test + void parseUser_returnsNullWhenNoUserObject() { + assertNull(LyuapAuthClient.parseUser(LyuapAuthClient.unwrapContent( + "{\"content\":{\"code\":\"4\",\"message\":\"用户不存在\"}}"))); + assertNull(LyuapAuthClient.parseUser(null)); + } + + @Test + void isSuccess_acceptsCodeZeroAndSuccessText() { + assertTrue(LyuapAuthClient.isSuccess("0", "操作成功!")); + // 文档 2.1.4 自相矛盾:CODE 表写 0 成功,示例却是 1 + assertTrue(LyuapAuthClient.isSuccess("1", "操作成功!")); + assertFalse(LyuapAuthClient.isSuccess("6", "密码错误")); + assertFalse(LyuapAuthClient.isSuccess(null, null)); + } + + /** + * 2026-09-18 用真实 appid 实测到的报文,与文档 2.4.4 的 CODE 表完全不同: + * 1=username参数不能为空、2=appid不存在或被禁用、3=secret加密串校验失败、4=用户不存在。 + */ + @Test + void describeFailure_prefersPlatformMessage() { + assertEquals("统一身份认证应用未注册或已停用,请联系管理员", + LyuapAuthClient.describeFailure("2", "appid不存在或被禁用", "兜底")); + assertEquals("统一身份认证平台校验失败,请稍后重试", + LyuapAuthClient.describeFailure("3", "secret加密串校验失败", "兜底")); + assertEquals("请输入学号", + LyuapAuthClient.describeFailure("1", "username参数不能为空", "兜底")); + assertEquals("请输入密码", + LyuapAuthClient.describeFailure("3", "密码不能为空", "兜底")); + assertEquals("学号不存在,请确认后重试", + LyuapAuthClient.describeFailure(null, "用户不存在", "兜底")); + assertEquals("学号不存在,请确认后重试", + LyuapAuthClient.describeFailure("4", "没有对应的用户", "兜底")); + } + + @Test + void describeFailure_fallsBackToCodeThenDefault() { + assertEquals("学号或密码错误", + LyuapAuthClient.describeFailure("6", "操作失败!", "兜底")); + assertEquals("兜底", LyuapAuthClient.describeFailure(null, null, "兜底")); + } + + @Test + void lyuapUser_studentAndActiveJudgement() { + LyuapUser teacher = new LyuapUser(); + teacher.setYhlxmc("教职工"); + assertFalse(teacher.isStudent()); + + LyuapUser numeric = new LyuapUser(); + numeric.setYhlxmc("3"); + assertTrue(numeric.isStudent()); + + LyuapUser graduate = new LyuapUser(); + graduate.setYhlxmc("研究生"); + assertTrue(graduate.isStudent()); + + // ZT=0 只是平台侧的状态值。注意它**不是登录闸门**(已毕业学生就是 ZT=0,仍要放行), + // 登录放行与否看本系统账号的 status,见 SsoLoginServiceImplTest。 + LyuapUser inactive = new LyuapUser(); + inactive.setZt("0"); + assertFalse(inactive.isActive()); + + LyuapUser active = new LyuapUser(); + active.setZt("1"); + assertTrue(active.isActive()); + + // 平台没给状态时算激活 + assertTrue(new LyuapUser().isActive()); + } + +} diff --git a/src/test/java/com/gxwebsoft/gxmu/auth/SsoLoginServiceImplTest.java b/src/test/java/com/gxwebsoft/gxmu/auth/SsoLoginServiceImplTest.java new file mode 100644 index 0000000..e2116fe --- /dev/null +++ b/src/test/java/com/gxwebsoft/gxmu/auth/SsoLoginServiceImplTest.java @@ -0,0 +1,293 @@ +package com.gxwebsoft.gxmu.auth; + +import com.gxwebsoft.common.core.config.ConfigProperties; +import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.common.core.utils.CacheClient; +import com.gxwebsoft.common.system.entity.Role; +import com.gxwebsoft.common.system.entity.User; +import com.gxwebsoft.common.system.entity.UserRole; +import com.gxwebsoft.common.system.result.LoginResult; +import com.gxwebsoft.common.system.service.LoginRecordService; +import com.gxwebsoft.common.system.service.RoleService; +import com.gxwebsoft.common.system.service.UserRoleService; +import com.gxwebsoft.common.system.service.UserService; +import com.gxwebsoft.gxmu.openplat.claim.StudentClaimService; +import com.gxwebsoft.gxmu.openplat.entity.GxmuStudent; +import com.gxwebsoft.gxmu.openplat.mapper.GxmuStudentMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import javax.servlet.http.HttpServletRequest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 统一身份认证登录的账号映射测试(mock 掉认证平台与数据库)。 + * + *

覆盖最容易出错的一环:同一个学生可能已经有微信账号、可能名册已认领、 + * 也可能名册里根本没有——三种情况必须走到同一个账号上,且不能重复建号。 + * + * @author Codex + * @since 2026-09-15 + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class SsoLoginServiceImplTest { + + private static final Integer TENANT_ID = 10049; + private static final String STUDENT_NO = "201510051"; + private static final String TOKEN_KEY = "WLgNsWJ8rPjRtnjzX/Gx2RGS80Kwnm/ZeLbvIL+NrBs="; + + @Mock + private GxmuAuthProperties properties; + + @Mock + private LyuapAuthClient authClient; + + @Mock + private UserService userService; + + @Mock + private RoleService roleService; + + @Mock + private UserRoleService userRoleService; + + @Mock + private GxmuStudentMapper studentMapper; + + @Mock + private StudentClaimService studentClaimService; + + @Mock + private ConfigProperties configProperties; + + @Mock + private CacheClient cacheClient; + + @Mock + private LoginRecordService loginRecordService; + + @Mock + private HttpServletRequest request; + + @InjectMocks + private SsoLoginServiceImpl ssoLoginService; + + @BeforeEach + void setUp() { + when(properties.isEnabled()).thenReturn(true); + when(properties.getAppId()).thenReturn("gxmu-app"); + when(properties.getAppSecret()).thenReturn("app-secret"); + when(configProperties.getTokenExpireTime()).thenReturn(3600L); + when(configProperties.getTokenKey()).thenReturn(TOKEN_KEY); + when(cacheClient.getSettingInfo(anyString(), anyInt())).thenReturn(null); + // 默认:认证平台认得这个学号,且是在读学生 + when(authClient.getUserInfo(anyString())).thenReturn(userInfo("学生", "1")); + } + + /** + * 名册已认领:直接复用已关联的账号(学生先用微信注册、认领过的那种)。 + */ + @Test + void login_reusesAccountAlreadyClaimedByRoster() { + GxmuStudent student = student(1, 7); + when(studentMapper.selectOne(any())).thenReturn(student); + + User existing = new User(); + existing.setUserId(7); + existing.setUsername("wx_abc123456789"); + existing.setStatus(0); + when(userService.getById(7)).thenReturn(existing); + when(userService.getByUsername("wx_abc123456789", TENANT_ID)).thenReturn(existing); + + LoginResult result = ssoLoginService.login(STUDENT_NO, "pwd", TENANT_ID, request); + + assertNotNull(result.getAccess_token()); + assertEquals("wx_abc123456789", result.getUser().getUsername()); + verify(userService, never()).saveUser(any()); + // 已是当前账号认领的,无需再次认领 + verify(studentClaimService, never()).claimByXh(any(), anyString()); + } + + /** + * 名册有、未认领、账号也没有:建号 + 认领。 + */ + @Test + void login_createsAccountAndClaimsWhenRosterUnclaimed() { + GxmuStudent student = student(2, null); + when(studentMapper.selectOne(any())).thenReturn(student); + + when(userService.getByUsername(anyString(), any())).thenReturn(null); + when(userService.getOne(any())).thenReturn(null); + when(userService.getByPhone(anyString())).thenReturn(null); + Role role = new Role(); + role.setRoleId(5); + when(roleService.getOne(any(), anyBoolean())).thenReturn(role); + when(userService.saveUser(any())).thenAnswer(invocation -> { + User saved = invocation.getArgument(0); + saved.setUserId(99); + return true; + }); + when(studentClaimService.claimByXh(any(), eq(STUDENT_NO))).thenReturn(true); + + LoginResult result = ssoLoginService.login(STUDENT_NO, "pwd", TENANT_ID, request); + + assertNotNull(result.getAccess_token()); + assertEquals(STUDENT_NO, result.getUser().getUsername()); + assertEquals(99, result.getUser().getUserId()); + verify(userService).saveUser(any()); + verify(userRoleService).save(any(UserRole.class)); + verify(studentClaimService).claimByXh(any(), eq(STUDENT_NO)); + } + + /** + * 名册没有、本系统也没有账号、且未放开"名册外登录":拒绝,并说明原因。 + */ + @Test + void login_rejectsStudentMissingFromRoster() { + when(studentMapper.selectOne(any())).thenReturn(null); + when(userService.getByUsername(anyString(), any())).thenReturn(null); + when(userService.getOne(any())).thenReturn(null); + when(userService.getByPhone(anyString())).thenReturn(null); + when(properties.isAllowUnknownUser()).thenReturn(false); + + BusinessException error = assertThrows(BusinessException.class, + () -> ssoLoginService.login(STUDENT_NO, "pwd", TENANT_ID, request)); + assertEquals("学号未在学生名册中,请联系团委", error.getMessage()); + verify(userService, never()).saveUser(any()); + } + + /** + * 教师(非学生)没有账号时拒绝,按 ADR-0001 教师不建登录账号。 + */ + @Test + void login_rejectsTeacherWithoutAccount() { + when(studentMapper.selectOne(any())).thenReturn(null); + when(userService.getByUsername(anyString(), any())).thenReturn(null); + when(userService.getOne(any())).thenReturn(null); + when(userService.getByPhone(anyString())).thenReturn(null); + when(properties.isAllowUnknownUser()).thenReturn(false); + when(authClient.getUserInfo(STUDENT_NO)).thenReturn(userInfo("教职工", "1")); + + BusinessException error = assertThrows(BusinessException.class, + () -> ssoLoginService.login(STUDENT_NO, "pwd", TENANT_ID, request)); + assertEquals("该账号未在本系统开通,教师请使用 PC 后台登录", error.getMessage()); + } + + @Test + void login_propagatesPasswordFailure() { + doThrow(new GxmuAuthException("6", "学号或密码错误")) + .when(authClient).checkPassword(STUDENT_NO, "bad"); + + BusinessException error = assertThrows(BusinessException.class, + () -> ssoLoginService.login(STUDENT_NO, "bad", TENANT_ID, request)); + assertEquals("学号或密码错误", error.getMessage()); + verify(authClient, never()).getUserInfo(anyString()); + } + + /** + * 认证平台 ZT=0(已毕业)**不拦登录**:毕业生也要能进系统办团组织关系转接。 + * 2026-09-18 真实学号 20205280988(2020 级,已毕业)就是 ZT=0。 + */ + @Test + void login_allowsGraduateWhoseUnifiedAuthStatusIsInactive() { + doNothing().when(authClient).checkPassword(anyString(), anyString()); + when(authClient.getUserInfo(STUDENT_NO)).thenReturn(userInfo("学生", "0")); + + GxmuStudent student = student(3, 7); + when(studentMapper.selectOne(any())).thenReturn(student); + User existing = new User(); + existing.setUserId(7); + existing.setUsername("wx_graduate0001"); + existing.setStatus(0); + when(userService.getById(7)).thenReturn(existing); + when(userService.getByUsername("wx_graduate0001", TENANT_ID)).thenReturn(existing); + + LoginResult result = ssoLoginService.login(STUDENT_NO, "pwd", TENANT_ID, request); + assertNotNull(result.getAccess_token()); + assertEquals(7, result.getUser().getUserId()); + } + + /** + * 认证平台放行了,但本系统把账号冻结了:仍然拒绝(status 才是本系统的闸门)。 + */ + @Test + void login_stillRejectsAccountFrozenLocally() { + doNothing().when(authClient).checkPassword(anyString(), anyString()); + when(authClient.getUserInfo(STUDENT_NO)).thenReturn(userInfo("学生", "1")); + + GxmuStudent student = student(4, 8); + when(studentMapper.selectOne(any())).thenReturn(student); + User frozen = new User(); + frozen.setUserId(8); + frozen.setUsername("wx_frozen000001"); + frozen.setStatus(1); + when(userService.getById(8)).thenReturn(frozen); + + BusinessException error = assertThrows(BusinessException.class, + () -> ssoLoginService.login(STUDENT_NO, "pwd", TENANT_ID, request)); + assertEquals("账号被冻结,请联系团委", error.getMessage()); + } + + @Test + void login_rejectsBlankInput() { + assertEquals("请输入学号", assertThrows(BusinessException.class, + () -> ssoLoginService.login(" ", "pwd", TENANT_ID, request)).getMessage()); + assertEquals("请输入密码", assertThrows(BusinessException.class, + () -> ssoLoginService.login(STUDENT_NO, "", TENANT_ID, request)).getMessage()); + } + + @Test + void available_requiresSwitchAndCredentials() { + assertTrue(ssoLoginService.available()); + + when(properties.getAppId()).thenReturn(""); + assertFalse(ssoLoginService.available()); + + when(properties.getAppId()).thenReturn("gxmu-app"); + when(properties.isEnabled()).thenReturn(false); + assertFalse(ssoLoginService.available()); + } + + // ------------------------------------------------------------------ + + private GxmuStudent student(Integer id, Integer userId) { + GxmuStudent student = new GxmuStudent(); + student.setId(id); + student.setXh(STUDENT_NO); + student.setXm("张三"); + student.setUserId(userId); + return student; + } + + private LyuapUser userInfo(String type, String status) { + LyuapUser info = new LyuapUser(); + info.setYhbh(STUDENT_NO); + info.setYhmc("张三"); + info.setYhlxmc(type); + info.setZt(status); + return info; + } + +}