移动端单点登录
This commit is contained in:
@@ -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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user