Jeecgboot通过Maxkey实现单点登录完整方案

一、方案概述

本方案通过集成JustAuth框架,实现Jeecgboot与MaxKey统一身份认证系统的单点登录集成,基于OAuth2协议完成用户认证与授权。

文章原文: Jeecgboot通过Maxkey实现单点登录完整方案

二、环境准备

2.1 MaxKey配置

  1. 确保MaxKey服务正常运行(默认地址:http://maxkey-server:8080/maxkey

  2. 在MaxKey管理后台创建OAuth2客户端应用

  3. 获取以下信息:

info参数的作用

  1. 页面状态保持:从MAXKEY单点登录后能跳转到指定的页面

  2. 业务参数传递:需要携带原始的业务数据

  3. 用户体验:无缝的登录后跳转体验

text 复制代码
// Base64解码前
eyJtZW51IjoiL2Rhc2hib2FyZC9hbmFseXNpcyIsImRhdGEiOnsiaWQiOiIxMSJ9fQ==

// Base64解码后
{
  "menu": "/dashboard/analysis",
  "data": {
    "id": "11"
  }
}

2.2 Jeecgboot环境

  1. 确保Jeecgboot项目可正常运行

  2. Redis服务正常启动(用于存储state状态)

  3. JDK 17+环境

三、后端配置

3.1 添加依赖

jeecg-module-system/jeecg-system-biz/pom.xml中添加:

xml 复制代码
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-json</artifactId>
    <version>5.8.20</version>
</dependency>

3.2 配置MaxKey连接

application.yml中配置:

yml 复制代码
justauth:
  enabled: true
  type:
    MAXKEY:
      server-url: http://maxkey-server:8080/maxkey
      client-id: your-client-id
      client-secret: your-client-secret
      redirect-uri: http://your-domain:3100/jeecgboot/sys/thirdLogin/MAXKEY/callback?info=eyJtZW51IjoiL2Rhc2hib2FyZC9hbmFseXNpcyIsImRhdGEiOnsiaWQiOiIxMSJ9fQ==

3.3 扩展工具类

修改SpringContextUtils.java,添加方法:

java 复制代码
public static String getProperty(String key) {
    return null == getApplicationContext() ? null : 
           getApplicationContext().getEnvironment().getProperty(key);
}

四、实现MaxKey适配器

4.1 创建适配器包

创建目录:src/main/java/org/jeecg/modules/system/maxkey

4.2 创建AuthMaxKeySource

java 复制代码
package org.jeecg.modules.system.maxkey;

import me.zhyd.oauth.config.AuthSource;
import me.zhyd.oauth.request.AuthDefaultRequest;

/**
 * Oauth2 默认接口说明
 *
 */
public enum AuthMaxKeySource implements AuthSource {

    /**
     * 自己搭建的 maxkey 私服
     */
    MAXKEY {

        /**
         * 授权的api
         */
        @Override
        public String authorize() {
            return AuthMaxKeyRequest.SERVER_URL + "/sign/authz/oauth/v20/authorize";
        }

        /**
         * 获取accessToken的api
         */
        @Override
        public String accessToken() {
            return AuthMaxKeyRequest.SERVER_URL + "/sign/authz/oauth/v20/token";
        }

        /**
         * 获取用户信息的api
         */
        @Override
        public String userInfo() {
            return AuthMaxKeyRequest.SERVER_URL + "/sign/api/oauth/v20/me";
        }
    };
}

4.3 创建AuthMaxKeyRequest

java 复制代码
package org.jeecg.modules.system.maxkey;

import cn.hutool.core.lang.Dict;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.exc.MismatchedInputException;
import me.zhyd.oauth.cache.AuthStateCache;
import me.zhyd.oauth.config.AuthConfig;
import me.zhyd.oauth.exception.AuthException;
import me.zhyd.oauth.model.AuthCallback;
import me.zhyd.oauth.model.AuthToken;
import me.zhyd.oauth.model.AuthUser;
import me.zhyd.oauth.request.AuthDefaultRequest;
import org.apache.commons.lang3.StringUtils;
import org.jeecg.common.util.SpringContextUtils;

import java.io.IOException;

/**
 *  maxkey
 */
public class AuthMaxKeyRequest extends AuthDefaultRequest {

    private static final ObjectMapper OBJECT_MAPPER = SpringContextUtils.getBean(ObjectMapper.class);


    public static final String SERVER_URL = SpringContextUtils.getProperty("justauth.type.maxkey.server-url");

    /**
     * 设定归属域
     */
    public AuthMaxKeyRequest(AuthConfig config) {
        super(config, AuthMaxKeySource.MAXKEY);
    }

    public AuthMaxKeyRequest(AuthConfig config, AuthStateCache authStateCache) {
        super(config, AuthMaxKeySource.MAXKEY, authStateCache);
    }

    @Override
    public AuthToken getAccessToken(AuthCallback authCallback) {
        String body = doPostAuthorizationCode(authCallback.getCode());
        Dict object = this.parseMap(body);
        // oauth/token 验证异常
        if (object.containsKey("error")) {
            throw new AuthException(object.getStr("error_description"));
        }
        // user 验证异常
        if (object.containsKey("message")) {
            throw new AuthException(object.getStr("message"));
        }
        return AuthToken.builder()
                .accessToken(object.getStr("access_token"))
                .refreshToken(object.getStr("refresh_token"))
                .idToken(object.getStr("id_token"))
                .tokenType(object.getStr("token_type"))
                .scope(object.getStr("scope"))
                .build();
    }

    @Override
    public AuthUser getUserInfo(AuthToken authToken) {
        String body = doGetUserInfo(authToken);
        Dict object = this.parseMap(body);
        // oauth/token 验证异常
        if (object.containsKey("error")) {
            throw new AuthException(object.getStr("error_description"));
        }
        // user 验证异常
        if (object.containsKey("message")) {
            throw new AuthException(object.getStr("message"));
        }
        return AuthUser.builder()
                .uuid(object.getStr("userId"))
                .username(object.getStr("username"))
                .nickname(object.getStr("displayName"))
                .avatar(object.getStr("avatar_url"))
                .blog(object.getStr("web_url"))
                .company(object.getStr("organization"))
                .location(object.getStr("location"))
                .email(object.getStr("email"))
                .remark(object.getStr("bio"))
                .token(authToken)
                .source(source.toString())
                .build();
    }

    public Dict parseMap(String text) {
        if (StringUtils.isBlank(text)) {
            return null;
        }
        try {
            return OBJECT_MAPPER.readValue(text, OBJECT_MAPPER.getTypeFactory().constructType(Dict.class));
        } catch (MismatchedInputException e) {
            // 类型不匹配说明不是json
            return null;
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

4.4 扩展SocialUtils.java

.ignoreCheckState(true) 忽略掉State校验是因为我们后端只负责使用code获取用户信息。

java 复制代码
package org.jeecg.modules.system.maxkey;

import cn.hutool.core.util.ObjectUtil;
import com.xkcoding.justauth.AuthRequestFactory;
import com.xkcoding.justauth.autoconfigure.JustAuthProperties;
import me.zhyd.oauth.config.AuthConfig;
import me.zhyd.oauth.exception.AuthException;
import me.zhyd.oauth.model.AuthCallback;
import me.zhyd.oauth.model.AuthResponse;
import me.zhyd.oauth.model.AuthUser;
import me.zhyd.oauth.request.*;
import org.jeecg.common.util.SpringContextUtils;
import org.jeecg.modules.system.cache.AuthStateRedisCache;

/**
 * 认证授权工具类
 *
 */
public class SocialUtils {

    private static final AuthStateRedisCache STATE_CACHE = SpringContextUtils.getBean(AuthStateRedisCache.class);

    private static final JustAuthProperties JUST_AUTH_PROPERTIES = SpringContextUtils.getBean(JustAuthProperties.class);

    private static final AuthRequestFactory FACTORY = SpringContextUtils.getBean(AuthRequestFactory.class);
    @SuppressWarnings("unchecked")
    public static AuthResponse<AuthUser> loginAuth(String source, String code, String state) throws AuthException {
        AuthRequest authRequest = getAuthRequest(source);
        AuthCallback callback = new AuthCallback();
        callback.setCode(code);
        callback.setState(state);
        return authRequest.login(callback);
    }

    public static AuthRequest getAuthRequest(String source) throws AuthException {
        AuthConfig authConfig = JUST_AUTH_PROPERTIES.getType().get(source);
        if (ObjectUtil.isNull(authConfig)) {
            throw new AuthException("不支持的第三方登录类型");
        }
        AuthRequest authRequest = switch (source.toLowerCase()) {
            case "maxkey":
                AuthConfig.AuthConfigBuilder builder = AuthConfig.builder()
                        .clientId(authConfig.getClientId())
                        .clientSecret(authConfig.getClientSecret())
                        .redirectUri(authConfig.getRedirectUri())
                        .scopes(authConfig.getScopes())
                        .ignoreCheckState(true)
                        ;
                yield new AuthMaxKeyRequest(builder.build(), STATE_CACHE);
            default:
                yield FACTORY.get(source);
        };
        return authRequest;
    }
}

五、修改控制器

5.1 修改ThirdLoginController.java

java 复制代码
@RestController
@RequestMapping("/sys/thirdLogin")
public class ThirdLoginController {
    
    @RequestMapping("/render/{source}")
    public void render(@PathVariable("source") String source,
					   HttpServletResponse response) throws IOException {
		AuthRequest authRequest = SocialUtils.getAuthRequest(source);
		String state = AuthStateUtils.createState();
		String authorizeUrl = authRequest.authorize(state);
		log.info("第三方登录认证地址:" + authorizeUrl);
		response.sendRedirect(authorizeUrl);
    }

	@RequestMapping("/{source}/callback")
    public String loginThird(@PathVariable("source") String source,
							 @RequestParam(value = "info", required = false) String info,
							 AuthCallback callback,
							 ModelMap modelMap,
							 HttpServletResponse httpServletResponse) throws IOException {
		log.info("第三方登录进入callback:" + source + " params:" + JSONObject.toJSONString(callback));
		AuthResponse response = SocialUtils.loginAuth(source, callback.getCode(),callback.getState());
        log.info(JSONObject.toJSONString(response));
        Result<JSONObject> result = new Result<JSONObject>();
		String res = "";
        if(response.getCode()==2000) {

        	JSONObject data = JSONObject.parseObject(JSONObject.toJSONString(response.getData()));
        	String username = data.getString("username");
        	String avatar = data.getString("avatar");
        	String uuid = data.getString("uuid");
			// 如果从Maxkey登录直接查询用户表
			if(source.equals("MAXKEY")) {
				List<SysUser> userList = sysUserService.list(new LambdaQueryWrapper<SysUser>()
						.eq(SysUser::getUsername, username));
				if(userList!=null && userList.size()>0) {
					SysUser sysUser = userList.get(0);
					String token = saveToken(sysUser);
					modelMap.addAttribute("token", token);
					res = token;
					log.info("maxkey info:" + info);
					// Base64 解密
					info = new String(Base64.getDecoder().decode(info));

					// 动态获取前端地址(从配置文件中读取)
					String frontendUrl = jeecgBaseConfig.getDomainUrl() != null
							? jeecgBaseConfig.getDomainUrl().getPc()
							: "http://localhost:3100"; // 默认值

					// URL 编码 info 参数
					String encodedInfo = URLEncoder.encode(info, "UTF-8");
					String redirectUrl = frontendUrl + "/ssoTokenLogin?source=maxkey&loginToken=" + res + "&info=" + encodedInfo;

					log.info("MAXKEY 登录重定向地址: {}", redirectUrl);
					httpServletResponse.sendRedirect(redirectUrl);
				}else {
					modelMap.addAttribute("token", "登录失败");
				}
			} else {
				//构造第三方登录信息存储对象
				ThirdLoginModel tlm = new ThirdLoginModel(source, uuid, username, avatar);
				//判断有没有这个人
				// 代码逻辑说明: 修改成查询第三方账户表
				LambdaQueryWrapper<SysThirdAccount> query = new LambdaQueryWrapper<SysThirdAccount>();
				query.eq(SysThirdAccount::getThirdType, source);
				// 代码逻辑说明: 【QQYUN-6667】敲敲云,线上解绑重新绑定一直提示这个---
				query.eq(SysThirdAccount::getTenantId, CommonConstant.TENANT_ID_DEFAULT_VALUE);
				query.and(q -> q.eq(SysThirdAccount::getThirdUserUuid, uuid).or().eq(SysThirdAccount::getThirdUserId, uuid));
				List<SysThirdAccount> thridList = sysThirdAccountService.list(query);
				SysThirdAccount user = null;
				if(thridList==null || thridList.size()==0) {
					//否则直接创建新账号
					user = sysThirdAccountService.saveThirdUser(tlm,CommonConstant.TENANT_ID_DEFAULT_VALUE);
				}else {
					//已存在 只设置用户名 不设置头像
					user = thridList.get(0);
				}
				// 生成token
				// 代码逻辑说明: 从第三方登录查询是否存在用户id,不存在绑定手机号
				if(oConvertUtils.isNotEmpty(user.getSysUserId())) {
					String sysUserId = user.getSysUserId();
					SysUser sysUser = sysUserService.getById(sysUserId);
					String token = saveToken(sysUser);
					modelMap.addAttribute("token", token);
				}else{
					modelMap.addAttribute("token", "绑定手机号,"+""+uuid);
				}
			}
        }else{
			modelMap.addAttribute("token", "登录失败");
		}
        result.setSuccess(false);
        result.setMessage("第三方登录异常,请联系管理员");
        return "thirdLogin";
    }
}

六、前端配置

6.1 创建SSO登录页面

创建SsoTokenLogin.vue

vue 复制代码
<template>
    <div class="app-loading">
        <div class="app-loading-title">正在跳转业务系统,请稍候...</div>
    </div>
</template>


<script lang="ts">
  /**
   * 地址中携带token,跳转至此页面进行登录操作
   */
  import { useRoute, useRouter } from 'vue-router';
  import { useMessage } from '/@/hooks/web/useMessage';
  import { useUserStore } from '/@/store/modules/user';
  import { useI18n } from '/@/hooks/web/useI18n';
  
  export default {
    name: "ssoTokenLogin",
    setup(){
      const route = useRoute();
      let router = useRouter();
      const {createMessage, notification} = useMessage()
      const {t} = useI18n();
      const routeQuery:any = route.query;
      if(!routeQuery){
        createMessage.warning('参数无效')
      }
     
      const token = routeQuery['loginToken'];
      if(!token){
        createMessage.warning('token无效')
      }
      const userStore = useUserStore();
      userStore.ThirdLogin({ token, thirdType:'email', goHome: false }).then(res => {
        console.log("res====>doThirdLogin",res)
        if(res && res.userInfo){
          requestSuccess(res)
        }else{
          requestFailed(res)
        }
      });

      function requestFailed (err) {
        notification.error({
          message: '登录失败',
          description: ((err.response || {}).data || {}).message || err.message || "请求出现错误,请稍后再试",
          duration: 4,
        });
      }
      
      function requestSuccess(res){
        let info = routeQuery.info;
        if(info){
          let query = JSON.parse(info);
          let path = query.menu;
          let params = query.data ? query.data : {};
          router.replace({ path, params });
          notification.success({
            message: t('sys.login.loginSuccessTitle'),
            description: `${t('sys.login.loginSuccessDesc')}: ${res.userInfo.realname}`,
            duration: 3,
          });
        }else{
          notification.error({
            message: '参数失效',
            description: "页面跳转参数丢失,请查看日志",
            duration: 4,
          });
        }
      }
    }
  }
</script>

6.2 添加路由配置

ts 复制代码
// src/router/routes/index.ts
export const SsoTokenLoginRoute: AppRouteRecordRaw = {
  path: '/ssoTokenLogin',
  name: 'SsoTokenLoginRoute',
  component: () => import('/@/views/sys/login/SsoTokenLogin.vue'),
  meta: {
    title: 'SSO单点登录',
    ignoreAuth: true,
  },
};
// Basic routing without permission
export const basicRoutes = [
 // ...其他路由
 SsoTokenLoginRoute
];

写在结尾

"如果你遇到过类似问题或有不同解法,欢迎在评论区交流。关注 里奥圭,持续获取Java实践笔记。"

相关推荐
咩咩啃树皮10 小时前
第40篇:Vue3组件化开发精讲——组件拆分、复用、父子通信、工程化架构
java·前端·架构
鱟鲥鳚10 小时前
Spring Boot 集成 LangChain4j:从模型调用到 Tool Calling(Demo版)
java·spring boot
大模型码小白12 小时前
【Python零基础教程】继承、多态与魔法函数:面向对象编程三大核心特性详解
java·大数据·开发语言·人工智能·python·ai编程
腾渊信息科技公司12 小时前
Spring Boot对接MES实战:视觉检测数据自动同步方案
java·人工智能·spring boot·后端·计算机视觉·ai·软件需求
爱笑的源码基地13 小时前
高并发 Redis 缓存门诊HIS系统源码,含财务统计药房进销存
java·程序·门诊系统·诊所系统·云诊所源码
wuqingshun31415913 小时前
TCP超时重传机制是为了解决什么问题?
java
莫逸风16 小时前
【AgentScope 2.0】 0. 学习指南
java·llm·agent·agentscope
隔窗听雨眠16 小时前
Spring Boot在云原生时代的编程范式革新研究
spring boot·后端·云原生
z1234567898616 小时前
2026最新两款AI编程工具深度对比实测
java·数据库·ai编程
yaoxin52112317 小时前
470. Java 反射 - Member 接口与 AccessFlag
java·开发语言·python