认证中心跳转第三方应用 - 双 Token 认证体系
整体流程概述
用户登录认证中心后,点击第三方应用跳转,第三方应用通过路由拦截触发 SSO 授权流程,基于授权码模式完成双 Token(access_token/refresh_token)认证,并通过第三方用户映射表打通用户角色权限,核心涉及前端路由拦截、后端 Token 换取、用户信息映射、Shiro 认证状态同步、登录日志记录等环节。
表结构说明
第三方用户映射表(tbl_third_party_user)
用于关联认证中心用户与第三方应用用户,打通角色权限
sql
CREATE TABLE tbl_third_party_user (
id bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
app_code varchar(64) NOT NULL COMMENT '第三方应用的唯一编码(与应用管理表对应)',
third_user_id varchar(128) NOT NULL COMMENT '第三方用户唯一ID(由第三方提供)',
third_account varchar(128) NOT NULL COMMENT '第三方登录账号(唯一标识)',
third_username varchar(128) DEFAULT NULL COMMENT '第三方用户姓名/昵称',
platform_user_id bigint NOT NULL COMMENT '主平台用户ID(user表主键)',
remark varchar(255) DEFAULT NULL COMMENT '备注',
create_time datetime DEFAULT CURRENT_TIMESTAMP,
update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_app_user (app_code, third_account) -- 保证同一应用下第三方账号唯一
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='第三方用户映射表';
前端实现 - 路由拦截与 SSO 登录
核心功能
- 拦截包含 "uia-sso" 的路由,触发 SSO 登录流程
- 解析授权码(code)、接口地址(apiUrl)、平台名称(platformName)
- 调用后端接口换取 Token,同步用户状态
- 加载动画与异常处理
javascript
运行
/**
* 路由拦截
* 拦截包含"uia-sso"的路由,触发SSO登录流程
*/
router.beforeEach(async (to, from, next) => {
if(to.path.indexOf('uia-sso') > -1){
uiaLogin(next); // 执行SSO登录逻辑
return;
}
})
/**
* SSO登录核心逻辑
* @param {Function} next - 路由跳转回调
*/
async function uiaLogin(next) {
const url = window.location.href;
showLoading(); // 显示全局加载动画
// 校验授权码是否存在,不存在则跳转到登录页
if (!url.includes("?code=")) {
hideLoading(); // 隐藏动画
next({ path: "/login" });
return;
}
// 解析URL参数:授权码、接口地址、平台名称
const query = new URLSearchParams(url.split("?")[1]);
const code = query.get("code");
const apiUrl = query.get("apiUrl") || "";
const platformName = query.get("platformName") || "";
try {
// 调用后端接口,通过授权码换取访问令牌
const res = await TokenUtil.accessTokenByOauthSso(apiUrl, platformName, code);
hideLoading(); // 隐藏加载动画
// Token获取成功:保存Token,重置路由标记,跳转到首页
if (res && res.code === 0 && res.data) {
TokenUtil.setToken(res.data); // 保存Token到本地存储
store.commit("IS_ADD_ASYNC_ROUTER", false); // 重置异步路由标记
store.commit("setSsoMenuFlag", true); // 标记SSO登录菜单状态
next({ path: "/index" }); // 跳转到应用首页
} else {
// Token获取失败:提示错误,跳转到登录页
handleError(res?.message || "授权失败,请稍后重试");
next({ path: "/login" });
}
} catch (error) {
hideLoading(); // 隐藏动画
console.error("SSO 登录异常", error);
handleError("获取令牌失败,请稍后重试");
next({ path: "/login" });
}
}
/**
* 显示全局加载动画
* 创建遮罩层、加载动画、提示文字,提升用户体验
*/
function showLoading() {
let loadingEl = document.createElement("div");
loadingEl.id = "global-loading";
loadingEl.style.position = "fixed";
loadingEl.style.top = 0;
loadingEl.style.left = 0;
loadingEl.style.width = "100%";
loadingEl.style.height = "100%";
loadingEl.style.backgroundColor = "rgba(0, 0, 0, 0.6)";
loadingEl.style.display = "flex";
loadingEl.style.alignItems = "center";
loadingEl.style.justifyContent = "center";
loadingEl.style.zIndex = 9999;
const box = document.createElement("div");
box.style.padding = "30px 50px";
box.style.backgroundColor = "#fff";
box.style.borderRadius = "12px";
box.style.boxShadow = "0 4px 15px rgba(0,0,0,0.3)";
box.style.display = "flex";
box.style.flexDirection = "column";
box.style.alignItems = "center";
// 加载提示文字(放大样式)
const text = document.createElement("div");
text.innerText = "正在加载中...";
text.style.fontSize = "24px";
text.style.fontWeight = "bold";
text.style.color = "#333";
text.style.marginTop = "10px";
// 加载动画(旋转圆圈)
const spinner = document.createElement("div");
spinner.style.border = "6px solid #f3f3f3";
spinner.style.borderTop = "6px solid #409EFF";
spinner.style.borderRadius = "50%";
spinner.style.width = "50px";
spinner.style.height = "50px";
spinner.style.animation = "spin 1s linear infinite";
// 添加旋转动画样式
const style = document.createElement("style");
style.innerHTML = `
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`;
document.head.appendChild(style);
box.appendChild(spinner);
box.appendChild(text);
loadingEl.appendChild(box);
document.body.appendChild(loadingEl);
}
/**
* 隐藏全局加载动画
* 移除页面中的加载动画元素
*/
function hideLoading() {
const loadingEl = document.getElementById("global-loading");
if (loadingEl) {
document.body.removeChild(loadingEl);
}
}
/**
* 统一错误提示处理
* @param {String} message - 错误信息
*/
function handleError(message) {
console.error(message);
//alert(message); // 可根据需求开启弹窗提示
}
后端实现 - SSO Token 获取与用户认证
核心接口:SSO 获取访问令牌
接收前端传递的授权码,完成参数校验、Token 换取、用户信息解析、Shiro 状态同步等核心逻辑
java
运行
/**
* SSO获取访问令牌接口
* 接收授权码,换取第三方应用的访问令牌,同步用户认证状态
*
* @param apiUrl 接口地址(第三方应用接口前缀)
* @param platformName 平台名称(对应第三方应用编码)
* @param code 授权码(认证中心颁发)
* @param extraParams 额外参数(预留扩展)
* @return OpenResponse<Map<String, Object>> 包含Token信息的响应
*/
@GetMapping("/accessTokenByJwtSso")
public OpenResponse<Map<String, Object>> ssoAccessToken(
@RequestParam(value = "apiUrl", required = false) String apiUrl,
@RequestParam(value = "platformName", required = false) String platformName,
@RequestParam(value = "code") String code,
@RequestParam(value = "extraParams", required = false) Map<String, String> extraParams) {
OpenResponse<Map<String, Object>> response = new OpenResponse<>();
long startTime = System.currentTimeMillis(); // 记录接口耗时
try {
// 1. 参数校验:校验授权码非空、长度、格式
OpenResponse<Map<String, Object>> validationResult = validateParameters(code);
if (validationResult != null) {
return validationResult; // 参数校验失败直接返回
}
// 2. 设置默认值(兼容空参数场景)
apiUrl = StringUtils.defaultIfBlank("", defaultApiUrl);
platformName = StringUtils.defaultIfBlank("", defaultPlatformName);
log.info("开始获取SSO令牌, code: {}", code);
// 3. 调用认证中心接口,通过授权码获取SSO令牌
String tokenResponse = getToken(code);
if (StringUtils.isBlank(tokenResponse)) {
throw new BusinessException(500, "SSO令牌获取失败");
}
// 4. 解析Token响应,处理用户信息并返回最终Token
JSONObject responseData = JSONObject.parseObject(tokenResponse);
return handleSuccessResponse(responseData, code);
} catch (BusinessException e) {
log.error("业务异常 - 获取SSO令牌失败, code: {}", code, e);
response.setCode(e.getCode());
response.setMessage(e.getMessage());
} catch (HttpException e) {
log.error("HTTP协议异常 - 获取SSO令牌失败, code: {}", code, e);
response.setCode(500);
response.setMessage("SSO服务通信异常: " + e.getMessage());
} catch (Exception e) {
log.error("系统异常 - 获取SSO令牌失败, code: {}", code, e);
response.setCode(500);
response.setMessage("系统异常: " + e.getMessage());
} finally {
long costTime = System.currentTimeMillis() - startTime;
log.info("SSO令牌获取完成, code: {}, 耗时: {}ms", code, costTime);
}
return response;
}
/**
* 获取SSO令牌(核心请求逻辑)
* 向认证中心发送请求,通过授权码换取Token
* @param code 授权码
* @return String 认证中心返回的Token响应字符串
*/
public String getToken(String code) {
try {
String url = defaultApiUrl + "/oauth/uaa/oauth/token"; // 认证中心Token接口地址
String appCode = defaultPlatformName; // 第三方应用编码
String appSecret = "12345"; // 第三方应用密钥(实际需配置化)
// 1. 构建Basic认证头(应用编码+密钥Base64编码)
String credentials = appCode + ":" + appSecret;
String auth = "Basic " + cn.hutool.core.codec.Base64.encode(credentials);
// 2. 构建表单参数(授权码模式必填参数)
Map<String, Object> formParams = new LinkedHashMap<>();
formParams.put("grant_type", "authorization_code"); // 授权类型:授权码模式
formParams.put("code", code); // 授权码
formParams.put("redirect_uri", uiaRedirectUri); // 回调地址(需与认证中心配置一致)
log.info("请求SSO令牌, URL: {}, code: {}", url, code);
// 3. 发送HTTP POST请求获取Token
HttpResponse httpResponse = HttpRequest.post(url)
.header("Authorization", auth) // Basic认证头
.header("Cookie", "JSESSIONID=5428D1CFBC807F79B4493851689F73B1") // 会话ID(实际需动态获取)
.header("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
.form(formParams) // 表单参数
.timeout(60000) // 超时时间60秒
.execute();
// 4. 处理响应:成功则返回响应体,失败则记录日志并返回null
if (httpResponse.isOk()) {
String responseBody = httpResponse.body();
log.info("SSO令牌获取成功, code: {}", code);
return responseBody;
} else {
log.error("SSO令牌请求失败, 状态码: {}, 响应: {}",
httpResponse.getStatus(), httpResponse.body());
return null;
}
} catch (Exception e) {
log.error("获取SSO令牌异常, code: {}", code, e);
return null;
}
}
/**
* 获取用户完整信息
* 通过Token调用认证中心接口,获取用户基本信息、角色、部门等数据
* @param loginAccount 登录账号
* @param appCode 应用编码
* @param accessToken 访问令牌
* @return String 用户信息响应字符串
*/
public String getUserData(String loginAccount, String appCode, String accessToken) {
try {
String url = defaultApiUrl + "/manager/user/getInfoOfUser"; // 用户信息接口地址
String defaultAppCode = defaultPlatformName;
String auth = "Bearer " + accessToken; // Bearer认证头
// 使用默认appCode(兼容空参数场景)
String actualAppCode = StringUtils.isNotBlank(appCode) ? appCode : defaultAppCode;
// 构建请求参数
Map<String, Object> queryParams = new LinkedHashMap<>();
queryParams.put("loginAccount", loginAccount); // 登录账号
queryParams.put("appCode", actualAppCode); // 应用编码
log.info("获取用户数据, loginAccount: {}, appCode: {}", loginAccount, actualAppCode);
// 发送GET请求获取用户信息
return HttpRequest.get(url)
.header("Authorization", auth) // Bearer认证头
.header("Accept", "application/json")
.form(queryParams)
.timeout(60000)
.execute()
.body();
} catch (Exception e) {
log.error("获取用户数据异常, loginAccount: {}", loginAccount, e);
return null;
}
}
/**
* 参数校验
* 校验授权码的非空、长度、格式
* @param code 授权码
* @return OpenResponse<Map<String, Object>> 校验失败则返回错误响应,成功则返回null
*/
private OpenResponse<Map<String, Object>> validateParameters(String code) {
if (StringUtils.isBlank(code)) {
return createErrorResponse(500, "授权码不能为空");
}
if (code.length() < 4 || code.length() > 12) {
return createErrorResponse(500, "授权码长度无效,应为4-12位");
}
if (!code.matches("^[A-Za-z0-9]+$")) {
return createErrorResponse(500, "授权码只能包含字母和数字");
}
return null;
}
/**
* 处理成功响应(核心逻辑)
* 解析Token响应,获取用户信息,换取应用内Token,同步Shiro认证状态
* @param responseData 认证中心返回的Token响应
* @param code 授权码
* @return OpenResponse<Map<String, Object>> 包含应用内Token的响应
*/
private OpenResponse<Map<String, Object>> handleSuccessResponse(JSONObject responseData, String code) {
try {
if (responseData == null) {
throw new BusinessException(500, "SSO返回数据为空");
}
// 1. 解析SSO响应数据:Token、过期时间、刷新Token、用户名
String uiaToken = responseData.getString("token");
String uiaExpiresIn = responseData.getString("expiresIn");
String uiaRefreshToken = responseData.getString("refreshToken");
String uiaUserName = responseData.getString("userName");
log.info("SSO响应数据 - token: {}, userName: {}", uiaToken, uiaUserName);
// 2. 获取用户完整信息(角色、部门、权限等)
String userAllInfo = getUserData(uiaUserName, "", uiaToken);
if (StringUtils.isBlank(userAllInfo)) {
throw new BusinessException(500, "获取用户信息失败");
}
JSONObject userAllJson = JSONObject.parseObject(userAllInfo);
JSONObject userObj = userAllJson.getJSONObject("user");
if (userObj == null) {
throw new BusinessException(500, "用户信息解析失败");
}
// 3. 构建用户信息Map(供后续使用)
Map<String, Object> userInfoMap = buildUserInfo(userAllJson);
// 4. 保存SSO授权码到数据库,用于后续Token校验
String userId = userObj.getString("userAcctId");
String userName = userObj.getString("loginAccount");
// todo 用户信息(临时硬编码,实际需从userObj获取)
userId = "b9b78f64f21a463191593f7b56ce8c76";
userName = "fpjc_jc";
aouth2Service.saveSsoCode(userId, userName, uiaClientId, code); // 保存授权码
// 5. 通过授权码换取应用内访问令牌
OpenResponse<Map<String, Object>> tokenResponse = getAccessToken(
userId, "authorization_code", uiaClientId,
uiaClientSecret, uiaRedirectUri, code);
if (tokenResponse.getCode() != 0) {
throw new BusinessException(tokenResponse.getCode(), tokenResponse.getMessage());
}
// 6. 设置Shiro用户信息(同步认证状态)
setCurrentUserToShiro(userObj);
// 7. 合并响应数据:应用内Token + 可选用户信息
Map<String, Object> result = new HashMap<>();
result.putAll(tokenResponse.getData());
// 可选:添加用户信息到响应中
// result.put("userInfo", userInfoMap);
return createSuccessResponse("获取令牌成功", result);
} catch (BusinessException e) {
log.error("处理SSO响应业务异常, code: {}", code, e);
throw e;
} catch (Exception e) {
log.error("处理SSO响应异常, code: {}", code, e);
throw new BusinessException(500, "处理用户信息失败: " + e.getMessage());
}
}
/**
* 构建用户信息Map
* 解析用户完整信息,提取用户ID、账号、角色、部门、区域等数据
* @param userData 用户完整信息JSON
* @return Map<String, Object> 用户信息Map
*/
private Map<String, Object> buildUserInfo(JSONObject userData) {
try {
Map<String, Object> userInfo = new HashMap<>();
// 提取用户基本信息
if (userData.containsKey("user") && userData.get("user") != null) {
JSONObject user = userData.getJSONObject("user");
userInfo.put("userId", user.getString("userAcctId"));
userInfo.put("account", user.getString("loginAccount"));
// 其他字段根据原代码注释掉(可按需扩展)
}
// 提取角色信息
if (userData.containsKey("role") && userData.get("role") != null) {
userInfo.put("roles", userData.get("role"));
}
// 提取区域信息
if (userData.containsKey("area") && userData.get("area") != null) {
userInfo.put("area", userData.get("area"));
}
// 提取部门信息
if (userData.containsKey("dept") && userData.get("dept") != null) {
userInfo.put("depts", userData.get("dept"));
}
return userInfo;
} catch (Exception e) {
log.error("构建用户信息失败", e);
throw new BusinessException(500, "用户信息解析失败");
}
}
/**
* 设置当前用户信息到Shiro
* 将SSO用户信息同步到Shiro的Principal中,保证应用内认证状态一致
* @param userData 用户信息JSON
*/
private void setCurrentUserToShiro(JSONObject userData) {
try {
// 1. 验证用户数据非空
if (userData == null) {
log.warn("用户数据为空,跳过Shiro设置");
return;
}
String userId = userData.getString("userAcctId");
String loginAccount = userData.getString("loginAccount");
String userName = userData.getString("userName");
String userTele = userData.getString("userTele");
if (StringUtils.isBlank(userId) || StringUtils.isBlank(loginAccount)) {
log.warn("用户ID或登录账号为空,无法设置Shiro用户信息");
return;
}
log.info("设置SSO用户信息到Principal, userId: {}, loginAccount: {}", userId, loginAccount);
// 2. 获取Shiro Subject(当前会话)
Subject subject = SecurityUtils.getSubject();
if (subject == null) {
log.warn("无法获取Shiro Subject");
return;
}
// 3. 获取或创建Principal(Shiro用户身份载体)
com.martin.jzfp.security.common.shiro.custom.Principal shiroPrincipal = getOrCreatePrincipal(subject);
// 4. 创建用户信息对象
User userInfo = createUserInfo(userData);
log.info("SSO用户信息设置到Principal成功: {}, UserInfo: {}",
loginAccount,
shiroPrincipal.getUserInfo() != null ? "已设置" : "null");
} catch (Exception e) {
log.error("设置Shiro用户信息失败", e);
// 不抛出异常,Shiro设置失败不影响主要业务流程
}
}
/**
* 获取或创建Principal
* 优先级:ShiroUtils -> Subject -> Session -> 新建
* @param subject Shiro Subject
* @return com.martin.jzfp.security.common.shiro.custom.Principal
*/
private com.martin.jzfp.security.common.shiro.custom.Principal getOrCreatePrincipal(Subject subject) {
// 定义查找顺序
com.martin.jzfp.security.common.shiro.custom.Principal principal = null;
// 1. 从ShiroUtils获取
try {
principal = ShiroUtils.getPrincipal();
if (principal != null) {
log.info("从ShiroUtils获取到Principal");
return principal;
}
} catch (Exception e) {
log.info("从ShiroUtils获取Principal失败: {}", e.getMessage());
}
// 2. 从Subject获取
try {
Object subjectPrincipal = subject.getPrincipal();
if (subjectPrincipal instanceof com.martin.jzfp.security.common.shiro.custom.Principal) {
principal = (com.martin.jzfp.security.common.shiro.custom.Principal) subjectPrincipal;
log.info("从subject.getPrincipal()获取到Principal");
return principal;
}
} catch (Exception e) {
log.info("从Subject获取Principal失败: {}", e.getMessage());
}
// 3. 从Session获取
Session session = null;
try {
session = subject.getSession(false);
if (session != null) {
Object sessionObj = session.getAttribute("principal");
if (sessionObj instanceof com.martin.jzfp.security.common.shiro.custom.Principal) {
principal = (com.martin.jzfp.security.common.shiro.custom.Principal) sessionObj;
log.info("从Session获取到Principal");
return principal;
}
}
} catch (Exception e) {
log.info("从Session获取Principal失败: {}", e.getMessage());
}
// 4. 创建新的Principal
log.info("所有来源都没有Principal,创建新的");
principal = new com.martin.jzfp.security.common.shiro.custom.Principal();
// 5. 保存到Session
try {
if (session == null) {
session = subject.getSession();
}
session.setAttribute("principal", principal);
log.info("新Principal已创建并保存到Session");
} catch (Exception e) {
log.warn("保存Principal到Session失败: {}", e.getMessage());
}
return principal;
}
/**
* 创建用户信息对象
* 封装用户ID、登录账号、姓名、手机号到User实体
* @param userData 用户信息JSON
* @return User
*/
private User createUserInfo(JSONObject userData) {
User userInfo = new User();
userInfo.setId(userData.getString("userAcctId"));
userInfo.setLoginName(userData.getString("loginAccount"));
userInfo.setName(userData.getString("userName"));
userInfo.setPhone(userData.getString("userTele"));
return userInfo;
}
/**
* 保存用户信息到Session
* 补充用户基本信息到Shiro Session,便于后续业务使用
* @param session Shiro Session
* @param userData 用户信息JSON
*/
private void saveUserInfoToSession(Session session, JSONObject userData) {
try {
// 保存基本信息到Session
session.setAttribute("userId", userData.getString("userAcctId"));
session.setAttribute("loginAccount", userData.getString("loginAccount"));
session.setAttribute("userName", userData.getString("userName"));
session.setAttribute("userTele", userData.getString("userTele"));
// 设置认证标记
session.setAttribute("isAuthenticated", true);
session.setAttribute("authType", "SSO");
session.setAttribute("authTime", System.currentTimeMillis());
log.info("用户信息已保存到Session");
} catch (Exception e) {
log.warn("保存用户信息到Session失败", e);
}
}
/**
* 验证用户信息是否设置成功
* 校验Shiro Principal和Session中的用户信息是否正确
* @param userData 用户信息JSON
* @return boolean 验证结果
*/
private boolean verifyUserInfoSetup(JSONObject userData) {
try {
String expectedLoginAccount = userData.getString("loginAccount");
// 方式1:检查ShiroUtils中的Principal
com.martin.jzfp.security.common.shiro.custom.Principal principal = ShiroUtils.getPrincipal();
if (principal != null) {
User userInfo = principal.getUserInfo();
if (userInfo != null && expectedLoginAccount.equals(userInfo.getLoginName())) {
log.info("验证成功:ShiroUtils中的Principal已设置");
return true;
}
}
// 方式2:检查Session中的Principal
Subject subject = SecurityUtils.getSubject();
Session session = subject.getSession(false);
if (session != null) {
Object sessionPrincipal = session.getAttribute("principal");
if (sessionPrincipal instanceof com.martin.jzfp.security.common.shiro.custom.Principal) {
User userInfo = ((com.martin.jzfp.security.common.shiro.custom.Principal) sessionPrincipal).getUserInfo();
if (userInfo != null && expectedLoginAccount.equals(userInfo.getLoginName())) {
log.info("验证成功:Session中的Principal已设置");
return true;
}
}
}
log.warn("用户信息设置验证失败");
return false;
} catch (Exception e) {
log.warn("验证用户信息设置时发生异常", e);
return false;
}
}
/**
* 根据授权码换取访问令牌(应用内Token)
* 校验授权码有效性,生成应用内的双Token(access_token/refresh_token)
* @param userId 用户ID
* @param grantType 授权类型(authorization_code)
* @param clientId 客户端ID
* @param clientSecret 客户端密钥
* @param redirectUri 回调地址
* @param code 授权码
* @return OpenResponse<Map<String, Object>> 包含应用内Token的响应
*/
public OpenResponse<Map<String, Object>> getAccessToken(String userId,
String grantType,
String clientId,
String clientSecret,
String redirectUri,
String code) {
OpenResponse<Map<String, Object>> response = new OpenResponse<>();
try {
// 1. 参数校验:客户端ID和授权码不能为空
if (StringUtils.isBlank(clientId) || StringUtils.isBlank(code)) {
return createErrorResponse(1002, "客户端ID或授权码缺失");
}
// 2. 查询授权码信息(从数据库获取已保存的授权码)
Map<String, Object> oauthCodeMap = aouth2Service.selectCodeMapByUserId(userId, clientId);
if (oauthCodeMap == null || oauthCodeMap.get("CODE") == null || oauthCodeMap.get("TIME_STAMP") == null) {
return createErrorResponse(1005, "授权码不存在或已过期");
}
// 3. 验证授权码:有效期(5分钟)、一致性
String dbCode = String.valueOf(oauthCodeMap.get("CODE"));
long codeTimestamp = Long.parseLong(String.valueOf(oauthCodeMap.get("TIME_STAMP")));
long currentTime = System.currentTimeMillis();
final long CODE_EXPIRATION_TIME = 5 * 60 * 1000; // 5分钟有效期
if (currentTime - codeTimestamp > CODE_EXPIRATION_TIME) {
return createErrorResponse(1005, "授权码已过期");
}
if (!code.equals(dbCode)) {
return createErrorResponse(1001, "授权码不正确");
}
// 4. 生成应用内访问令牌(仅支持authorization_code类型)
if ("authorization_code".equals(grantType)) {
Map<String, Object> userMap = aouth2Service.selectUserById(userId);
String userName = userMap != null && !userMap.isEmpty()
? String.valueOf(userMap.get("USER_NAME"))
: "";
// 生成JWT Token
Map<String, Object> tokenData = loginByJwtSso(userName, String.valueOf(currentTime), clientId, "read", "");
response.setData(tokenData);
response.setCode(0);
response.setMessage("获取令牌成功");
} else {
return createErrorResponse(1003, "不支持的授权类型");
}
} catch (NumberFormatException e) {
log.error("时间戳格式异常, userId: {}, code: {}", userId, code, e);
response.setCode(500);
response.setMessage("系统数据异常");
} catch (Exception e) {
log.error("获取访问令牌异常, userId: {}, code: {}", userId, code, e);
response.setCode(500);
response.setMessage("获取令牌失败: " + e.getMessage());
}
return response;
}
/**
* 创建成功响应
* 封装成功状态码、提示信息、数据
* @param message 提示信息
* @param data 响应数据
* @return OpenResponse<Map<String, Object>>
*/
private OpenResponse<Map<String, Object>> createSuccessResponse(String message, Map<String, Object> data) {
OpenResponse<Map<String, Object>> response = new OpenResponse<>();
response.setCode(0);
response.setMessage(message);
response.setData(data);
return response;
}
/**
* 创建错误响应
* 封装错误状态码、提示信息
* @param code 错误码
* @param message 错误信息
* @return OpenResponse<Map<String, Object>>
*/
private OpenResponse<Map<String, Object>> createErrorResponse(int code, String message) {
OpenResponse<Map<String, Object>> response = new OpenResponse<>();
response.setCode(code);
response.setMessage(message);
return response;
}
/**
* 生成JWT Token(核心)
* 创建应用内的JWT Token,同步Shiro登录状态
* @param userName 用户名
* @param timestamp 时间戳
* @param clientId 客户端ID
* @param scopes 权限范围
* @param loginNameSc 预留参数
* @return Map<String, Object> 包含Token信息的Map
* @throws Exception 异常
*/
private Map<String, Object> loginByJwtSso(String userName, String timestamp,
String clientId, String scopes,
String loginNameSc) throws Exception {
// 1. 获取用户信息
User user = this.getUserByJwt(userName, timestamp);
// 2. 获取客户端信息
ClientDetails clientDetails = this.oauthService.loadClientDetails(clientId);
// 3. 生成访问令牌(包含refresh_token)
AccessToken accessToken = this.oauthService.retrievePasswordAccessToken(
clientDetails,
OAuthUtils.decodeScopes(scopes),
user.getId(),
user.getName()
);
try {
// 4. 创建完整的Token对象(包含过期时间、刷新Token等)
Token tokenObj = new Token(accessToken.tokenId());
tokenObj.setTokenId(accessToken.tokenId());
tokenObj.setTokenExpiredSeconds((int) accessToken.currentTokenExpiredSeconds());
tokenObj.setUserId(user.getId());
tokenObj.setUsername(user.getLoginName());
tokenObj.setClientId(clientId);
tokenObj.setTokenType(accessToken.tokenType());
if (StringUtils.isNotEmpty(accessToken.refreshToken())) {
tokenObj.setRefreshToken(accessToken.refreshToken());
}
tokenObj.setCreateTime(new Date());
// 5. 创建完整的Principal(用户身份+Token)
Principal principal = new Principal(user, tokenObj);
principal.setClientId(clientId);
// 6. 执行Shiro登录,同步认证状态
new JwtAuthenticationToken().executeShiroLogin(user, accessToken.tokenId(), principal);
} catch (Exception e) {
log.warn("Shiro登录失败(不影响令牌返回): {}", e.getMessage());
// 降级方案:直接将Principal存入Session
try {
Subject subject = SecurityUtils.getSubject();
subject.getSession().setAttribute("CURRENT_PRINCIPAL",
new Principal(user, new Token(accessToken.tokenId())));
} catch (Exception ex) {
// 忽略异常
}
}
// 7. 构建Token响应数据
Map map = new HashMap();
map.put("access_token", accessToken.tokenId()); // 访问令牌
map.put("expires_in", accessToken.currentTokenExpiredSeconds()); // 过期时间(秒)
map.put("token_type", accessToken.tokenType()); // Token类型(Bearer)
map.put("code", 200); // 业务状态码
// 可选:添加刷新Token
String refreshToken = accessToken.refreshToken();
if (StringUtils.isNotEmpty(refreshToken)) {
map.put("refresh_token", refreshToken);
}
// 可选:添加登录账号
if (StringUtils.isNotBlank(loginNameSc)) {
map.put("loginName", loginNameSc);
}
return map;
}
授权码保存与更新
将授权码保存到数据库,用于后续 Token 换取时的校验
java
运行
/**
* 保存SSO授权码
* 存在则更新,不存在则新增
* @param userId 用户ID
* @param userName 用户名
* @param clientId 客户端ID
* @param code 授权码
*/
void saveSsoCode(String userId, String userName,String clientId, String code);
/**
* 实现类:保存SSO授权码
*/
@Override
public void saveSsoCode(String userId, String userName,String clientId, String code) {
// 查询是否已存在该用户+客户端的授权码
Map<String, Object> exist = aouth2Dao.selectByUserAndClient(userId, clientId);
long timestamp = System.currentTimeMillis(); // 记录时间戳(用于校验有效期)
if (exist != null && exist.size() > 0) {
// 已存在:更新授权码和时间戳
aouth2Dao.updateCodeByUserAndClient(code, timestamp, userId, clientId);
} else {
// 不存在:新增授权码记录
aouth2Dao.insertCode(code, userId, userName, clientId, timestamp);
}
}
/**
* 更新授权码
* @param code 新授权码
* @param timestamp 时间戳
* @param userId 用户ID
* @param clientId 客户端ID
*/
void updateCodeByUserAndClient(String code, long timestamp, String userId, String clientId);
/**
* 新增授权码
* @param code 授权码
* @param userId 用户ID
* @param userName 用户名
* @param clientId 客户端ID
* @param timestamp 时间戳
*/
void insertCode(String code, String userId, String userName, String clientId, long timestamp);
/**
* 实现类:更新授权码
*/
@Override
public void updateCodeByUserAndClient(String code, long timestamp, String userId, String clientId) {
String sql = "UPDATE OAUTH_CODE " +
"SET CODE = ?, TIME_STAMP = ? " +
"WHERE USER_ID = ? AND CLIENT_ID = ?";
jdbcTemplate.update(sql, code, String.valueOf(timestamp), userId, clientId);
}
/**
* 实现类:新增授权码
*/
@Override
public void insertCode(String code, String userId, String userName, String clientId, long timestamp) {
String sql = "INSERT INTO OAUTH_CODE " +
"(CODE, USER_ID, USERNAME, CLIENT_ID, TIME_STAMP) " +
"VALUES (?, ?, ?, ?, ?)";
jdbcTemplate.update(sql, code, userId, userName, clientId, String.valueOf(timestamp));
}
Shiro JWT 认证 Token
处理 JWT Token 的登录逻辑,同步 Shiro Subject 状态,保证应用内认证一致性
java
运行
import com.martin.jzfp.security.common.shiro.custom.Principal;
import com.martin.jzfp.security.server.modules.sys.domain.User;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.session.Session;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.subject.support.DefaultSubjectContext;
import org.apache.shiro.subject.SimplePrincipalCollection;
import org.apache.shiro.util.ThreadContext;
import java.lang.reflect.Field;
/**
* JWT认证Token实现类
* 处理SSO登录后的Shiro认证状态同步,生成JWT Token并维护认证会话
*/
@Slf4j
public class JwtAuthenticationToken implements AuthenticationToken {
private String username; // 用户名
private String jwtToken; // JWT Token
public JwtAuthenticationToken(String username, String jwtToken) {
this.username = username;
this.jwtToken = jwtToken;
}
public JwtAuthenticationToken() {
}
@Override
public Object getPrincipal() {
return username; // Shiro Principal:用户名
}
@Override
public Object getCredentials() {
return jwtToken; // Shiro Credentials:JWT Token
}
/**
* 执行JWT登录(核心)
* 同步用户信息到Shiro Subject,维护认证状态
* @param user 用户信息
* @param realJwtToken 真实JWT Token
* @param principal 用户身份载体
*/
public void executeShiroLogin(User user, String realJwtToken, Principal principal) {
try {
Subject subject = SecurityUtils.getSubject();
// 1. 登出清理:避免旧认证状态干扰
if (subject.isAuthenticated()) {
subject.logout();
}
// 2. 清理ThreadContext:重置Shiro上下文
ThreadContext.remove();
// 3. 更新Principal:补充Token和客户端信息
if (principal != null) {
principal.setAccessToken(realJwtToken);
principal.setExpiresIn(3600); // Token有效期(3600秒)
if (principal.getUserInfo() == null) {
principal.setUserInfo(user); // 设置用户信息
}
}
// 4. 获取或创建Session:保证Session存在
Session session = subject.getSession(true);
// 5. 创建PrincipalCollection:Shiro身份集合
SimplePrincipalCollection principals = new SimplePrincipalCollection();
principals.add(principal, "JWTRealm"); // 关联JWTRealm
// 6. 设置到Session:持久化身份信息
session.setAttribute(DefaultSubjectContext.PRINCIPALS_SESSION_KEY, principals);
session.setAttribute(DefaultSubjectContext.AUTHENTICATED_SESSION_KEY, Boolean.TRUE);
// 7. 强制更新Subject内部状态(反射方式)
forceUpdateSubjectState(subject, principals);
// 8. 设置业务Session属性:便于后续业务获取
session.setAttribute("CURRENT_PRINCIPAL", principal);
session.setAttribute("CURRENT_USER", user);
session.setAttribute("ACCESS_TOKEN", realJwtToken);
session.setAttribute("isAuthenticated", true);
session.setAttribute("authTime", System.currentTimeMillis());
session.setAttribute("authType", "JWT");
// 9. 重新绑定到ThreadContext:恢复Shiro上下文
ThreadContext.bind(subject);
// 10. 验证登录结果:确保状态同步成功
verifyLoginResult(subject, user.getLoginName());
log.info("JWT登录成功 - 用户: {}", user.getLoginName());
} catch (Exception e) {
log.error("JWT登录失败", e);
}
}
/**
* 强制更新Subject内部状态
* 通过反射修改Subject的私有字段,保证认证状态同步
* @param subject Shiro Subject
* @param principals 身份集合
*/
private void forceUpdateSubjectState(Subject subject, SimplePrincipalCollection principals) {
try {
// 获取真实的Subject对象(处理代理场景)
Object realSubject = getRealSubject(subject);
// 设置身份集合
setFieldValue(realSubject, "principals", principals);
setFieldValue(realSubject, "principalCollection", principals);
// 设置认证状态为已认证
setFieldValue(realSubject, "authenticated", true);
log.info("Subject内部状态已更新");
} catch (Exception e) {
log.warn("更新Subject状态失败: {}", e.getMessage());
}
}
/**
* 获取真实的Subject对象
* 处理CGLIB/JDK代理场景,获取原始Subject实例
* @param subject 代理Subject
* @return Object 真实Subject
* @throws Exception 反射异常
*/
private Object getRealSubject(Object subject) throws Exception {
// 判断是否为代理对象
if (subject.getClass().getName().contains("$Proxy") ||
subject.getClass().getName().contains("$EnhancerBy")) {
// 尝试获取代理的h字段(JDK代理)
Field hField = null;
try {
hField = subject.getClass().getSuperclass().getDeclaredField("h");
} catch (NoSuchFieldException e) {
// 尝试获取CGLIB代理的回调字段
try {
hField = subject.getClass().getDeclaredField("CGLIB$CALLBACK_0");
} catch (NoSuchFieldException e2) {
return subject; // 无代理字段,返回原对象
}
}
if (hField != null) {
hField.setAccessible(true);
Object handler = hField.get(subject);
// 获取target字段(原始对象)
try {
Field targetField = handler.getClass().getDeclaredField("target");
targetField.setAccessible(true);
return targetField.get(handler);
} catch (NoSuchFieldException e) {
return handler;
}
}
}
return subject; // 非代理对象,直接返回
}
/**
* 反射设置字段值
* 用于修改私有字段
* @param obj 目标对象
* @param fieldName 字段名
* @param value 字段值
*/
private void setFieldValue(Object obj, String fieldName, Object value) {
try {
Class<?> clazz = obj.getClass();
// 递归查找字段(包含父类)
while (clazz != null) {
try {
Field field = clazz.getDeclaredField(fieldName);
field.setAccessible(true);
field.set(obj, value);
return;
} catch (NoSuchFieldException e) {
clazz = clazz.getSuperclass();
}
}
} catch (Exception e) {
// 忽略异常(不影响核心流程)
}
}
/**
* 验证登录结果
* 校验Subject和Session中的认证状态是否正确
* @param subject Shiro Subject
* @param loginName 登录账号
*/
private void verifyLoginResult(Subject subject, String loginName) {
try {
// 校验Principal
Object principal = subject.getPrincipal();
PrincipalCollection principals = subject.getPrincipals();
boolean authenticated = subject.isAuthenticated();
log.info("验证结果 - 用户: {}", loginName);
log.info(" subject.getPrincipal(): {}", principal != null ? "有值" : "null");
log.info(" subject.getPrincipals(): {}", principals != null ? "有值" : "null");
log.info(" subject.isAuthenticated(): {}", authenticated);
// 校验Session中的Principal
Session session = subject.getSession(false);
if (session != null) {
Object sessionPrincipal = session.getAttribute("CURRENT_PRINCIPAL");
log.info(" session.CURRENT_PRINCIPAL: {}", sessionPrincipal != null ? "有值" : "null");
}
} catch (Exception e) {
log.warn("验证失败: {}", e.getMessage());
}
}
/**
* 获取当前Principal(推荐使用此方法)
* 优先级:Subject Principal -> PrincipalCollection -> Session
* @return Principal 当前用户身份
*/
public static Principal getCurrentPrincipal() {
try {
Subject subject = SecurityUtils.getSubject();
if (subject == null) {
return null;
}
// 1. 从Subject Principal获取
Object subjectPrincipal = subject.getPrincipal();
if (subjectPrincipal instanceof Principal) {
return (Principal) subjectPrincipal;
}
// 2. 从PrincipalCollection获取
PrincipalCollection principals = subject.getPrincipals();
if (principals != null && !principals.isEmpty()) {
Object primary = principals.getPrimaryPrincipal();
if (primary instanceof Principal) {
return (Principal) primary;
}
}
// 3. 从Session获取
Session session = subject.getSession(false);
if (session != null) {
Object sessionPrincipal = session.getAttribute("CURRENT_PRINCIPAL");
if (sessionPrincipal instanceof Principal) {
return (Principal) sessionPrincipal;
}
}
return null;
} catch (Exception e) {
return null;
}
}
/**
* 清理认证信息
* 登出并清理ThreadContext
*/
public static void clearAuthentication() {
try {
Subject subject = SecurityUtils.getSubject();
if (subject != null) {
subject.logout();
}
ThreadContext.remove();
} catch (Exception e) {
// 忽略异常
}
}
}
登录日志记录 - AOP 实现
通过 AOP 切面记录 SSO 登录的详细日志,包含 Token 信息、用户信息、请求信息、耗时等
日志实体
java
运行
package com.zdww.tpgg.auth.aop;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.sql.Date;
/**
* SSO登录日志实体
* 记录SSO登录的全量信息,用于审计和问题排查
*/
@Data
@TableName("tbl_sso_login_log")
public class SsoLoginLog {
@TableId(type = IdType.ASSIGN_UUID) // 主键:UUID
private String id;
private String userId; // 用户ID
private String userAccount; // 登录账号
private String userName; // 用户名
private String clientId; // 客户端ID
private String grantType; // 授权类型
private String requestCode; // 请求的授权码
private Integer codeStatus; // 授权码状态(0-有效,2-无效/过期)
private String accessToken; // 访问令牌(脱敏)
private String refreshToken; // 刷新令牌(脱敏)
private Integer tokenExpireSeconds; // Token有效期(秒)
private String redirectUri; // 回调地址
private String loginIp; // 登录IP
private String userAgent; // 客户端UA
private Integer loginStatus; // 登录状态(0-成功,1-失败)
private String failReason; // 失败原因
private Integer costTimeMs; // 接口耗时(毫秒)
@TableField(fill = FieldFill.INSERT) // 自动填充创建时间
private Date createTime;
}
AOP 切面实现
java
运行
package com.martin.tpgg.auth.aop;
import cn.hutool.system.UserInfo;
import com.martin.jzfp.security.common.shiro.tokenAuth.utils.AesUtil;
import com.martin.jzfp.security.common.shiro.tokenAuth.utils.JdbcTempateUtil;
import com.martin.jzfp.security.common.shiro.utils.ShiroUtils;
import com.martin.jzfp.security.server.modules.sys.domain.User;
import com.zdww.tpgg.auth.util.SensitiveEncryptUtil;
import com.zdww.tpgg.common.open.OpenResponse;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.SecurityUtils;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import java.util.Map;
/**
* SSO登录日志AOP切面
* 异步记录SSO登录的请求、响应、用户、Token等信息
*/
@Slf4j
@Component
@Aspect
public class SsoLoginLogAspect {
// 记录接口开始时间(ThreadLocal保证线程安全)
private static final ThreadLocal<Long> START_TIME = new ThreadLocal<>();
@Autowired
private HttpServletRequest request; // 获取HTTP请求信息
// 获取JdbcTemplate(数据库操作)
JdbcTemplate jdbcTemplate = JdbcTempateUtil.getJdbcTemplate();
/**
* 切入点:仅拦截SSO Token获取接口
*/
@Pointcut("execution(* com.zdww.tpgg.auth.controller.AuthJwtController.ssoAccessToken(..))")
public void ssoPointCut() {
}
/**
* 前置通知:记录接口开始时间
* @param joinPoint 切点
*/
@Before("ssoPointCut()")
public void beforeInvoke(JoinPoint joinPoint) {
START_TIME.set(System.currentTimeMillis());
}
/**
* 后置返回通知:记录成功日志
* @param joinPoint 切点
* @param ret 接口返回值
*/
@AfterReturning(value = "ssoPointCut()", returning = "ret")
public void afterReturning(JoinPoint joinPoint, Object ret) {
saveLog(joinPoint, ret, null);
}
/**
* 后置异常通知:记录失败日志
* @param joinPoint 切点
* @param ex 异常信息
*/
@AfterThrowing(value = "ssoPointCut()", throwing = "ex")
public void afterThrowing(JoinPoint joinPoint, Throwable ex) {
saveLog(joinPoint, null, ex);
}
/**
* 异步保存日志(不阻塞主流程)
* @param joinPoint 切点
* @param ret 接口返回值
* @param ex 异常信息
*/
@Async
public void saveLog(JoinPoint joinPoint, Object ret, Throwable ex) {
try {
// 计算接口耗时
long cost = System.currentTimeMillis() - START_TIME.get();
SsoLoginLog logEntity = new SsoLoginLog();
// 1. 解析请求参数
Object[] args = joinPoint.getArgs();
String code = request.getParameter("code"); // 授权码
String apiUrl = request.getParameter("apiUrl"); // 接口地址
String platformName = request.getParameter("platformName"); // 平台名称
logEntity.setRequestCode(code);
logEntity.setRedirectUri(apiUrl);
// 2. 解析响应数据(Token信息)
if (ret != null && ret instanceof OpenResponse) {
Object data = ((OpenResponse<?>) ret).getData();
if (data instanceof Map) {
Map<?, ?> mapData = (Map<?, ?>) data;
logEntity.setClientId(request.getParameter("clientId")); // 客户端ID
// Token信息(可按需脱敏)
Object accessToken = mapData.get("access_token");
if (accessToken != null) {
String tokenStr = String.valueOf(accessToken);
logEntity.setAccessToken(tokenStr);
// 脱敏示例:tokenStr.length() > 8 ? tokenStr.substring(0, 6) + "****" : tokenStr
}
Object refreshToken = mapData.get("refresh_token");
if (refreshToken != null) {
String r = String.valueOf(refreshToken);
logEntity.setRefreshToken(r);
// 脱敏示例:r.length() > 8 ? r.substring(0, 6) + "****" : r
}
// Token有效期
Object expires = mapData.get("expires_in");
if (expires != null) {
logEntity.setTokenExpireSeconds(Integer.parseInt(expires.toString()));
}
}
}
// 3. 登录状态
if (ex != null) {
logEntity.setLoginStatus(1); // 1-失败
logEntity.setFailReason(ex.getMessage()); // 失败原因
logEntity.setCodeStatus(2); // 授权码状态:2-无效/过期
} else {
logEntity.setLoginStatus(0); // 0-成功
logEntity.setCodeStatus(0); // 授权码状态:0-有效
}
// 4. 请求信息
logEntity.setLoginIp(getClientIp()); // 客户端IP
logEntity.setUserAgent(request.getHeader("User-Agent")); // 客户端UA
logEntity.setCostTimeMs((int) cost); // 接口耗时
// 5. 用户信息(从Shiro Session获取)
User info = null;
if (SecurityUtils.getSubject() != null && SecurityUtils.getSubject().getSession() != null) {
info = (User) SecurityUtils.getSubject().getSession().getAttribute("CURRENT_USER");
}
if (info != null) {
logEntity.setUserId(info.getId());
logEntity.setUserAccount(info.getLoginName());
logEntity.setUserName(info.getName());
}
// 6. 写入数据库(Oracle语法,可根据数据库调整)
jdbcTemplate.update(
"INSERT INTO SYS_SSO_LOG (" +
"id, user_id, user_account, user_name," +
"client_id, grant_type, request_code, code_status," +
"access_token, refresh_token, token_expire_seconds, redirect_uri," +
"login_ip, user_agent, login_status, fail_reason," +
"cost_time_ms, create_time" +
") VALUES ( REPLACE(RAWTOHEX(SYS_GUID()), '-', ''),?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,SYSDATE)",
logEntity.getUserId(),
logEntity.getUserAccount(),
logEntity.getUserName(),
logEntity.getClientId(),
logEntity.getGrantType(),
logEntity.getRequestCode(),
logEntity.getCodeStatus(),
logEntity.getAccessToken(),
logEntity.getRefreshToken(),
logEntity.getTokenExpireSeconds(),
logEntity.getRedirectUri(),
logEntity.getLoginIp(),
logEntity.getUserAgent(),
logEntity.getLoginStatus(),
logEntity.getFailReason(),
logEntity.getCostTimeMs()
);
log.info("SSO 日志写入成功:{}", logEntity.getUserId());
} catch (Exception e) {
log.error("SSO 日志记录失败", e);
} finally {
START_TIME.remove(); // 清理ThreadLocal
}
}
/**
* 获取客户端真实IP
* 处理反向代理场景(X-Forwarded-For)
* @return String 客户端IP
*/
private String getClientIp() {
String ip = request.getHeader("X-Forwarded-For");
if (ip != null && ip.length() != 0 && !"unknown".equalsIgnoreCase(ip)) {
// 多个IP时取第一个(X-Forwarded-For格式:IP1,IP2,IP3)
return ip.split(",")[0];
}
// 直接获取远程IP
return request.getRemoteAddr();
}
}
核心流程总结
- 前端拦截:检测到 SSO 路由,解析授权码并调用后端接口
- 参数校验:后端校验授权码的合法性(非空、长度、格式)
- Token 换取:调用认证中心接口,通过授权码获取 SSO Token
- 用户信息解析:通过 SSO Token 获取用户完整信息(角色、权限等)
- 授权码保存:将授权码存入数据库,用于后续应用内 Token 校验
- 应用内 Token 生成:校验授权码有效性,生成应用内双 Token
- Shiro 状态同步:将用户信息和 Token 同步到 Shiro Subject,保证认证一致性
- 日志记录:AOP 异步记录登录日志,包含请求、响应、用户、耗时等信息
- 前端跳转:Token 获取成功后跳转到应用首页,失败则返回登录页
该体系通过双 Token(认证中心 Token + 应用内 Token)实现了跨应用的认证打通,通过第三方用户映射表实现了用户角色权限的统一,同时通过完善的日志和异常处理保证了系统的可维护性。
编辑分享
后端如何实现Token换取?
如何处理用户信息映射?
如何同步Shiro认证状态?
martin1017 
![[爱了]](/js/img/d1.gif)
![[尴尬]](/js/img/d16.gif)