移动端单点登录

This commit is contained in:
2026-06-30 10:10:30 +08:00
parent b694b69549
commit 5c8fb99ff2
3 changed files with 325 additions and 0 deletions
@@ -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";
}
}
}
@@ -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);
}
@@ -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<String, String> 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<String, Object> 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<String, String> 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";
}
}