From 5c8fb99ff2af38d1a24bf2268a77556547218f92 Mon Sep 17 00:00:00 2001 From: zhangrui Date: Tue, 30 Jun 2026 10:10:30 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A7=BB=E5=8A=A8=E7=AB=AF=E5=8D=95=E7=82=B9?= =?UTF-8?q?=E7=99=BB=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/SysMobileSsoController.java | 83 +++++++ .../app/sys/services/SysMobileSsoService.java | 37 ++++ .../impl/SysMobileSsoServiceImpl.java | 205 ++++++++++++++++++ 3 files changed, 325 insertions(+) create mode 100644 src/main/java/com/budwk/app/sys/controller/SysMobileSsoController.java create mode 100644 src/main/java/com/budwk/app/sys/services/SysMobileSsoService.java create mode 100644 src/main/java/com/budwk/app/sys/services/impl/SysMobileSsoServiceImpl.java diff --git a/src/main/java/com/budwk/app/sys/controller/SysMobileSsoController.java b/src/main/java/com/budwk/app/sys/controller/SysMobileSsoController.java new file mode 100644 index 0000000..4481278 --- /dev/null +++ b/src/main/java/com/budwk/app/sys/controller/SysMobileSsoController.java @@ -0,0 +1,83 @@ +package com.budwk.app.sys.controller; + +import cn.hutool.core.util.StrUtil; +import com.budwk.app.base.exception.BaseException; +import com.budwk.app.base.result.Result; +import com.budwk.app.sys.services.SysMobileSsoService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.log.Log; +import org.nutz.log.Logs; +import org.nutz.mvc.annotation.At; +import org.nutz.mvc.annotation.Ok; +import org.nutz.mvc.annotation.Param; + +import javax.servlet.http.HttpServletRequest; +import java.util.Map; + +/** + * 移动端单点登录控制器,提供授权跳转、授权地址获取和移动门户回调入口。 + */ +@IocBean +@At("/platform/h5/sso") +@Api(tags = "移动端单点登录") +public class SysMobileSsoController { + private static final Log log = Logs.get(); + + @Inject + private SysMobileSsoService sysMobileSsoService; + + /** + * 直接跳转到移动门户授权页面,适合浏览器访问发起单点登录。 + * + * @param state 透传参数,可传本系统登录成功后的目标路径 + * @return 移动门户授权页重定向地址 + */ + @At("/authorize") + @Ok("re") + @ApiOperation("移动端单点登录授权跳转") + public String authorize(@Param("state") String state) { + return ">>:" + sysMobileSsoService.buildAuthorizeUrl(state); + } + + /** + * 返回移动门户授权地址,适合前端自行控制跳转时调用。 + * + * @param state 透传参数,可传本系统登录成功后的目标路径 + * @return 包含授权地址的 JSON 结果 + */ + @At("/authUrl") + @Ok("json") + @ApiOperation("获取移动端单点登录授权地址") + public Object authUrl(@Param("state") String state) { + return Result.success(Map.of("url", sysMobileSsoService.buildAuthorizeUrl(state))); + } + + /** + * 移动门户授权回调入口,接收 code 后换取用户学工号并登录本系统。 + * + * @param code 移动门户返回的 authorization code + * @param state 移动门户原样带回的透传参数 + * @param request 当前 HTTP 请求 + * @return 登录成功或失败后的重定向地址 + */ + @At("/callback") + @Ok("re") + @ApiOperation("移动端单点登录回调") + public String callback(@Param("code") String code, @Param("state") String state, HttpServletRequest request) { + try { + if (StrUtil.isBlank(code)) { + return ">>:/platform/home/500"; + } + return ">>:" + sysMobileSsoService.loginByAuthorizationCode(code, state, request); + } catch (BaseException e) { + log.error("移动端单点登录失败:" + e.getMessage(), e); + return ">>:/platform/home/500"; + } catch (Exception e) { + log.error("移动端单点登录异常", e); + return ">>:/platform/home/500"; + } + } +} diff --git a/src/main/java/com/budwk/app/sys/services/SysMobileSsoService.java b/src/main/java/com/budwk/app/sys/services/SysMobileSsoService.java new file mode 100644 index 0000000..ddde90e --- /dev/null +++ b/src/main/java/com/budwk/app/sys/services/SysMobileSsoService.java @@ -0,0 +1,37 @@ +package com.budwk.app.sys.services; + +import cn.hutool.json.JSONObject; + +import javax.servlet.http.HttpServletRequest; + +/** + * 移动端单点登录服务,负责对接移动门户 Authorization Code 授权码方案。 + */ +public interface SysMobileSsoService { + + /** + * 生成移动门户授权地址,供前端或浏览器跳转获取授权码。 + * + * @param state 授权回调透传参数,可用于保存登录成功后的本系统跳转地址 + * @return 移动门户 OAuth 授权地址 + */ + String buildAuthorizeUrl(String state); + + /** + * 使用移动门户回调的授权码完成本系统登录。 + * + * @param code 移动门户回调返回的 authorization code + * @param state 移动门户原样透传的 state 参数 + * @param request 当前登录请求,用于写入 Sa-Token 登录态和记录登录日志 + * @return 登录成功后的本系统跳转地址 + */ + String loginByAuthorizationCode(String code, String state, HttpServletRequest request); + + /** + * 根据授权码换取 access_token,并从返回结果中读取当前登录用户学工号。 + * + * @param code 移动门户回调返回的 authorization code + * @return 移动门户 token 接口返回的 JSON 数据 + */ + JSONObject exchangeAccessToken(String code); +} diff --git a/src/main/java/com/budwk/app/sys/services/impl/SysMobileSsoServiceImpl.java b/src/main/java/com/budwk/app/sys/services/impl/SysMobileSsoServiceImpl.java new file mode 100644 index 0000000..f9101be --- /dev/null +++ b/src/main/java/com/budwk/app/sys/services/impl/SysMobileSsoServiceImpl.java @@ -0,0 +1,205 @@ +package com.budwk.app.sys.services.impl; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import com.budwk.app.base.enums.LoginType; +import com.budwk.app.base.exception.BaseException; +import com.budwk.app.sys.models.Sys_user; +import com.budwk.app.sys.services.SysMobileSsoService; +import com.budwk.app.sys.services.SysUserService; +import com.budwk.app.web.commons.base.Globals; +import org.nutz.ioc.impl.PropertiesProxy; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.log.Log; +import org.nutz.log.Logs; + +import javax.servlet.http.HttpServletRequest; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 移动端单点登录服务实现,按移动门户文档方案一完成授权码换取用户身份并登录本系统。 + */ +@IocBean +public class SysMobileSsoServiceImpl implements SysMobileSsoService { + private static final Log log = Logs.get(); + + private static final String DEFAULT_AUTHORIZE_URL = "http://app.jsahvc.edu.cn/interface/oauth/authorize.htm"; + private static final String DEFAULT_TOKEN_URL = "http://app.jsahvc.edu.cn/interface/oauth/token.htm"; + private static final String GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code"; + private static final String RESPONSE_TYPE_CODE = "code"; + + @Inject + private SysUserService sysUserService; + @Inject + private PropertiesProxy conf; + + /** + * 生成移动门户 OAuth 授权地址,授权成功后移动门户会携带 code 回调 redirect_uri。 + * + * @param state 透传字段,通常用于保存本系统登录后的目标页面 + * @return 拼接完成的移动门户授权地址 + */ + @Override + public String buildAuthorizeUrl(String state) { + String clientId = getRequiredConfig("mobile.sso.client-id", "移动端单点登录 client_id 未配置"); + String redirectUri = getRedirectUri(); + String authorizeUrl = conf.get("mobile.sso.authorize-url", DEFAULT_AUTHORIZE_URL); + + Map params = new LinkedHashMap<>(); + params.put("response_type", RESPONSE_TYPE_CODE); + params.put("client_id", clientId); + params.put("redirect_uri", redirectUri); + if (StrUtil.isNotBlank(state)) { + params.put("state", state); + } + return authorizeUrl + "?" + buildQuery(params); + } + + /** + * 根据授权码换取移动门户 access_token,并使用返回的 username 登录本系统。 + * + * @param code 移动门户回调返回的 authorization code + * @param state 移动门户原样透传的 state 参数 + * @param request 当前 HTTP 请求 + * @return 登录完成后的跳转地址 + */ + @Override + public String loginByAuthorizationCode(String code, String state, HttpServletRequest request) { + if (StrUtil.isBlank(code)) { + throw new BaseException("移动端单点登录授权码不能为空"); + } + + JSONObject tokenJson = exchangeAccessToken(code); + String loginName = tokenJson.getStr("username"); + if (StrUtil.isBlank(loginName)) { + log.error("移动端单点登录未返回 username,返回内容:" + tokenJson); + throw new BaseException("移动端单点登录未返回用户学工号"); + } + + // 校验第三方返回的学工号必须存在于本系统,避免创建未知登录态。 + sysUserService.checkThirdPlatformLoginName(loginName); + Sys_user sysUser = sysUserService.loginByLoginName(loginName); + + // 复用系统统一登录增强逻辑,写入 Sa-Token 会话、用户扩展信息和登录日志。 + sysUserService.loginPlus(sysUser, LoginType.CAS, request); + return resolveRedirectUrl(state); + } + + /** + * 请求移动门户 token 接口,将 authorization code 换成 access_token 和 username。 + * + * @param code 移动门户授权码 + * @return 移动门户 token 接口 JSON 响应 + */ + @Override + public JSONObject exchangeAccessToken(String code) { + String clientId = getRequiredConfig("mobile.sso.client-id", "移动端单点登录 client_id 未配置"); + String clientSecret = getRequiredConfig("mobile.sso.client-secret", "移动端单点登录 client_secret 未配置"); + String tokenUrl = conf.get("mobile.sso.token-url", DEFAULT_TOKEN_URL); + + Map form = new LinkedHashMap<>(); + form.put("grant_type", GRANT_TYPE_AUTHORIZATION_CODE); + form.put("client_id", clientId); + form.put("client_secret", clientSecret); + form.put("code", code); + form.put("redirect_uri", getRedirectUri()); + + try (HttpResponse response = HttpRequest.post(tokenUrl) + .basicAuth(clientId, clientSecret) + .form(form) + .timeout(conf.getInt("mobile.sso.timeout", 10000)) + .execute()) { + String body = response.body(); + if (!response.isOk()) { + log.error("移动端单点登录 token 接口请求失败,状态码:" + response.getStatus() + ",返回内容:" + body); + throw new BaseException("移动端单点登录 token 接口请求失败"); + } + if (!JSONUtil.isTypeJSON(body)) { + log.error("移动端单点登录 token 接口返回非 JSON 内容:" + body); + throw new BaseException("移动端单点登录 token 接口返回格式错误"); + } + JSONObject tokenJson = JSONUtil.parseObj(body); + if (StrUtil.isBlank(tokenJson.getStr("access_token"))) { + log.error("移动端单点登录 token 接口未返回 access_token,返回内容:" + tokenJson); + throw new BaseException("移动端单点登录 token 获取失败"); + } + return tokenJson; + } catch (BaseException e) { + throw e; + } catch (Exception e) { + log.error("移动端单点登录 token 接口调用异常", e); + throw new BaseException("移动端单点登录 token 接口调用异常"); + } + } + + /** + * 获取移动门户回调地址,默认使用当前系统域名拼接本控制器 callback 地址。 + * + * @return 与授权请求、token 请求保持一致的 redirect_uri + */ + private String getRedirectUri() { + String redirectUri = conf.get("mobile.sso.redirect-uri", ""); + if (StrUtil.isNotBlank(redirectUri)) { + return redirectUri; + } + return Globals.AppDomain + "/platform/h5/sso/callback"; + } + + /** + * 读取必填配置,缺失时给出明确业务异常,避免使用空密钥请求移动门户。 + * + * @param key 配置键 + * @param errorMessage 配置缺失提示 + * @return 配置值 + */ + private String getRequiredConfig(String key, String errorMessage) { + String value = conf.get(key, ""); + if (StrUtil.isBlank(value)) { + throw new BaseException(errorMessage); + } + return value; + } + + /** + * 拼接 URL 查询参数,统一做 UTF-8 编码防止 redirect_uri、state 中特殊字符破坏授权地址。 + * + * @param params 查询参数 + * @return 编码后的查询字符串 + */ + private String buildQuery(Map params) { + return params.entrySet().stream() + .map(entry -> encode(entry.getKey()) + "=" + encode(entry.getValue())) + .reduce((left, right) -> left + "&" + right) + .orElse(""); + } + + /** + * 对 URL 参数进行 UTF-8 编码。 + * + * @param value 参数原始值 + * @return 编码后的参数值 + */ + private String encode(String value) { + return URLEncoder.encode(StrUtil.nullToEmpty(value), StandardCharsets.UTF_8); + } + + /** + * 根据 state 解析登录后的跳转地址,只允许本系统内路径,避免开放重定向风险。 + * + * @param state 移动门户透传参数 + * @return 登录成功后的跳转地址 + */ + private String resolveRedirectUrl(String state) { + if (StrUtil.isNotBlank(state) && state.startsWith("/") && !state.startsWith("//")) { + return Globals.AppDomain + state; + } + return Globals.AppDomain + "/platform/h5"; + } +}