新增学校统一身份认证登录
- GxmuAuthProperties 承载接入配置,认证客户端按微服务认证平台 API v2.3 调 cas.gxmu.edu.cn - /api/sso/login 与 /api/sso/available 免鉴权放行 - 校验通过后按学号认领学生,名册外的账号默认拒绝登录
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package com.gxwebsoft;
|
package com.gxwebsoft;
|
||||||
|
|
||||||
import com.gxwebsoft.common.core.config.ConfigProperties;
|
import com.gxwebsoft.common.core.config.ConfigProperties;
|
||||||
|
import com.gxwebsoft.gxmu.auth.GxmuAuthProperties;
|
||||||
import com.gxwebsoft.gxmu.openplat.GxmuOpenplatProperties;
|
import com.gxwebsoft.gxmu.openplat.GxmuOpenplatProperties;
|
||||||
import com.gxwebsoft.shop.config.WxMaProperties;
|
import com.gxwebsoft.shop.config.WxMaProperties;
|
||||||
import org.mybatis.spring.annotation.MapperScan;
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
@@ -19,7 +20,8 @@ import org.springframework.web.socket.config.annotation.EnableWebSocket;
|
|||||||
@EnableAsync
|
@EnableAsync
|
||||||
@EnableTransactionManagement
|
@EnableTransactionManagement
|
||||||
@MapperScan("com.gxwebsoft.**.mapper")
|
@MapperScan("com.gxwebsoft.**.mapper")
|
||||||
@EnableConfigurationProperties({ConfigProperties.class, WxMaProperties.class, GxmuOpenplatProperties.class})
|
@EnableConfigurationProperties({ConfigProperties.class, WxMaProperties.class, GxmuOpenplatProperties.class,
|
||||||
|
GxmuAuthProperties.class})
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
@EnableWebSocket
|
@EnableWebSocket
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
|
|||||||
.permitAll()
|
.permitAll()
|
||||||
.antMatchers(
|
.antMatchers(
|
||||||
"/api/login",
|
"/api/login",
|
||||||
|
"/api/sso/**",
|
||||||
"/api/system/user/loginByPhoneForTest",
|
"/api/system/user/loginByPhoneForTest",
|
||||||
"/api/wx-login/loginByMpWxPhone",
|
"/api/wx-login/loginByMpWxPhone",
|
||||||
"/api/register",
|
"/api/register",
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.gxwebsoft.gxmu.auth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一身份认证平台调用异常。
|
||||||
|
*
|
||||||
|
* <p>只表达"跟认证平台这一趟没谈成"(网络、报文、平台返回的业务码),
|
||||||
|
* 由上层 {@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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认证平台地址前缀。
|
||||||
|
*
|
||||||
|
* <p>学校认证平台域名 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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否允许名册中不存在、且本系统也没有账号的用户登录。
|
||||||
|
*
|
||||||
|
* <p>默认关闭:学生必须先被同步进名册(见 ADR-0001),教师按设计不建登录账号。
|
||||||
|
*/
|
||||||
|
private boolean allowUnknownUser = false;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 医科大微服务认证平台(统一身份认证)客户端。
|
||||||
|
*
|
||||||
|
* <p>对接《微服务认证平台API接口说明文档 v2.3》,只用到两个接口:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code /v2/checkPwd} 校验学号+密码</li>
|
||||||
|
* <li>{@code /v2/getUserInfo} 取用户信息</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>两处加密规则(文档 §3):
|
||||||
|
* <ul>
|
||||||
|
* <li>加密串 secret = {@code MD5(username + appsecret + yyyyMMdd)},32 位大写</li>
|
||||||
|
* <li>密码字段 = {@code Base64(Base64(明文密码) + appid)}</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>响应统一被包在 {@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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户信息。
|
||||||
|
*
|
||||||
|
* <p>该接口只需要 appid + username + secret,<b>不需要密码</b>,
|
||||||
|
* 因此调用方必须先 {@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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析用户信息。
|
||||||
|
*
|
||||||
|
* <p>文档 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。
|
||||||
|
*
|
||||||
|
* <p>2026-09-18 用真实学号实测:成功时平台把用户信息放在 {@code message} 里,
|
||||||
|
* 而且是**转义过的 JSON 字符串**(不是 JSON 对象):
|
||||||
|
* <pre>
|
||||||
|
* {"content":{"code":"0","message":"{\"YHBH\":\"320241167\",\"YHMC\":\"柏泽旭\",...}"}}
|
||||||
|
* </pre>
|
||||||
|
* 所以字符串要先按 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否操作成功。
|
||||||
|
*
|
||||||
|
* <p>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, "统一身份认证平台未返回用户信息");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把平台的失败信息翻译成给学生看的话。
|
||||||
|
*
|
||||||
|
* <p>一律以平台自己的 message 为准,因为文档的 CODE 表与实测完全对不上。
|
||||||
|
* 2026-09-18 用真实 appid + 真实学号实测到的报文:
|
||||||
|
* <pre>
|
||||||
|
* appid 不对 → code=2 "appid不存在或被禁用"
|
||||||
|
* secret 不对 → code=3 "secret加密串校验失败"
|
||||||
|
* username 空 → code=1 "username参数不能为空"
|
||||||
|
* 用户不存在 → code=4 "用户不存在"(getUserInfo)/ "没有对应的用户"(checkPwd)
|
||||||
|
* 密码错 → code=6 "账号或密码错误"
|
||||||
|
* 成功 → code=0,且 message 是转义过的 JSON 字符串
|
||||||
|
* </pre>
|
||||||
|
* 而文档 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
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用平台接口。
|
||||||
|
*
|
||||||
|
* <p>用表单 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<String, Object> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package com.gxwebsoft.gxmu.auth;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认证平台 {@code /v2/getUserInfo} 返回的用户信息。
|
||||||
|
*
|
||||||
|
* <p>字段名沿用平台的大写缩写,这里保留原样并补语义注释,
|
||||||
|
* 避免与开放平台名册({@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 用户类型。
|
||||||
|
*
|
||||||
|
* <p>文档表格标注为 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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否学生(含研究生)。
|
||||||
|
*
|
||||||
|
* <p>平台既可能返回「学生/研究生」这类名称,也可能返回文档表格里的数字码
|
||||||
|
* (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})。
|
||||||
|
*
|
||||||
|
* <p><b>注意:这个值不作为登录拦阻条件。</b>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));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学校统一身份认证登录接口。
|
||||||
|
*
|
||||||
|
* <p>小程序端不再走 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<LoginResult> login(@RequestBody SsoLoginParam param, HttpServletRequest request) {
|
||||||
|
return success("登录成功", ssoLoginService.login(param.getUsername(), param.getPassword(),
|
||||||
|
getTenantId(), request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiOperation("学校统一身份认证登录是否可用")
|
||||||
|
@GetMapping("/available")
|
||||||
|
public ApiResult<Boolean> available() {
|
||||||
|
return success(ssoLoginService.available());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.gxwebsoft.gxmu.auth;
|
||||||
|
|
||||||
|
import com.gxwebsoft.common.system.result.LoginResult;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学校统一身份认证登录服务。
|
||||||
|
*
|
||||||
|
* <p>与 {@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 已配置)。
|
||||||
|
*
|
||||||
|
* <p>前端据此决定要不要显示该登录入口。
|
||||||
|
*/
|
||||||
|
boolean available();
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学校统一身份认证登录实现。
|
||||||
|
*
|
||||||
|
* <p>流程:认证平台校验密码 → 取用户信息 → 找/建本系统账号 → 认领名册 → 签发本系统 JWT。
|
||||||
|
*
|
||||||
|
* <p>账号不重复建:名册已认领的用原账号;否则按 学号(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<GxmuStudent>()
|
||||||
|
.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<User>()
|
||||||
|
.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<Role>().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());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一身份认证客户端的**联网**验证。
|
||||||
|
*
|
||||||
|
* <p>默认<b>不执行</b>(类名以 IT 结尾,surefire 不会扫到)。手动运行:
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* ./mvnw test -Dtest=LyuapAuthClientIT -Dgxmu.auth.it=true
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <p>它存在的意义:其余测试只证明了 MD5/Base64 的密文与文档一致,
|
||||||
|
* 而 Hutool 的表单请求构造(form 编码、UTF-8、超时)从未真正打到过认证平台。
|
||||||
|
* 这个测试用真实接口把这一段补上——不需要数据库,也不需要真实学生密码,
|
||||||
|
* 只验证"请求发得出去、平台认得我们的 appid 与 secret、报文解析得回来"。
|
||||||
|
*
|
||||||
|
* <p>需要一个不存在的学号来避免误伤真实用户,示例值 {@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 必须解析出用户对象。
|
||||||
|
*
|
||||||
|
* <p>平台把用户信息放在 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());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一身份认证客户端的纯逻辑测试(不依赖数据库与网络)。
|
||||||
|
*
|
||||||
|
* <p>加密规则的期望值由 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("<html>not json</html>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2026-09-18 真实学号 320241167 的原始响应。
|
||||||
|
*
|
||||||
|
* <p>平台把用户信息放在 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());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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 掉认证平台与数据库)。
|
||||||
|
*
|
||||||
|
* <p>覆盖最容易出错的一环:同一个学生可能已经有微信账号、可能名册已认领、
|
||||||
|
* 也可能名册里根本没有——三种情况必须走到同一个账号上,且不能重复建号。
|
||||||
|
*
|
||||||
|
* @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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user