双 Token 认证、SSO 单点登录、第三方权限打通、实战落地附详细代码

首页 编程分享 PHP丨JAVA丨OTHER 正文

martin1017 转载 编程分享 2025-12-20 22:09:22

简介 认证中心跳转第三方应用 - 双 Token 认证体系 整体流程概述 用户登录认证中心后,点击第三方应用跳转,第三方应用通过路由拦截触发 SSO 授权流程,基于授权码模式完成双 Token


认证中心跳转第三方应用 - 双 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();
    }
}

核心流程总结

  1. 前端拦截:检测到 SSO 路由,解析授权码并调用后端接口
  2. 参数校验:后端校验授权码的合法性(非空、长度、格式)
  3. Token 换取:调用认证中心接口,通过授权码获取 SSO Token
  4. 用户信息解析:通过 SSO Token 获取用户完整信息(角色、权限等)
  5. 授权码保存:将授权码存入数据库,用于后续应用内 Token 校验
  6. 应用内 Token 生成:校验授权码有效性,生成应用内双 Token
  7. Shiro 状态同步:将用户信息和 Token 同步到 Shiro Subject,保证认证一致性
  8. 日志记录:AOP 异步记录登录日志,包含请求、响应、用户、耗时等信息
  9. 前端跳转:Token 获取成功后跳转到应用首页,失败则返回登录页

该体系通过双 Token(认证中心 Token + 应用内 Token)实现了跨应用的认证打通,通过第三方用户映射表实现了用户角色权限的统一,同时通过完善的日志和异常处理保证了系统的可维护性。

编辑分享

后端如何实现Token换取?

如何处理用户信息映射?

如何同步Shiro认证状态?

转载链接:https://juejin.cn/post/7585097023747178511


Tags:


本篇评论 —— 揽流光,涤眉霜,清露烈酒一口话苍茫。


    声明:参照站内规则,不文明言论将会删除,谢谢合作。


      最新评论




ABOUT ME

Blogger:袅袅牧童 | Arkin

Ido:PHP攻城狮

WeChat:nnmutong

Email:nnmutong@icloud.com

标签云