一、方案概述

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

二、环境准备

2.1 MaxKey配置

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

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

  3. 获取以下信息:

    • Client ID

    • Client Secret

    • 配置回调地址:http://your-domain:3100/jeecgboot/sys/thirdLogin/render/MAXKEY?info=eyJtZW51IjoiL2Rhc2hib2FyZC9hbmFseXNpcyIsImRhdGEiOnsiaWQiOiIxMSJ9fQ==

为什么是使用/sys/thirdLogin/render/MAXKEY 而不是直接使用/sys/thirdLogin/MAXKEY/callback 回调接口?

sequenceDiagram participant F as 前端 participant B as JEECGBOOT后端 participant R as Redis participant M as MaxKey F->>B: GET /sys/thirdLogin/render/MAXKEY?info=... B->>B: 1. 验证source=MAXKEY B->>B: 2. 生成随机state B->>R: 3. 保存state+info(60s过期) B->>F: 4. 302重定向到MaxKey F->>M: 5. 访问MaxKey授权页面

info参数的作用

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

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

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

// 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中添加:

<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-json</artifactId>
    <version>5.8.20</version>
</dependency>

3.2 配置MaxKey连接

application.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/render/MAXKEY?info=eyJtZW51IjoiL2Rhc2hib2FyZC9hbmFseXNpcyIsImRhdGEiOnsiaWQiOiIxMSJ9fQ==

3.3 扩展工具类

修改SpringContextUtils.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

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

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

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());
                yield new AuthMaxKeyRequest(builder.build(), STATE_CACHE);
            default:
                yield FACTORY.get(source);
        };
        return authRequest;
    }
}

五、修改控制器

5.1 修改ThirdLoginController.java

@RestController
@RequestMapping("/sys/thirdLogin")
public class ThirdLoginController {
    
    @RequestMapping("/render/{source}")
    public void render(@PathVariable("source") String source,
					   @RequestParam(value = "info", required = false) String info,
					   HttpServletResponse response) throws IOException {
		AuthRequest authRequest = SocialUtils.getAuthRequest(source);
		String state = AuthStateUtils.createState();
		String authorizeUrl = authRequest.authorize(state);
		if (org.apache.commons.lang3.StringUtils.isNotBlank(authorizeUrl)) {
			redisUtil.set(source + "_state:" + state, info, 60);
		}
		log.info("第三方登录认证地址:" + authorizeUrl);
		response.sendRedirect(authorizeUrl);
    }

	@RequestMapping("/{source}/callback")
    public String loginThird(@PathVariable("source") String source, 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;
				}else {
					modelMap.addAttribute("token", "登录失败");
					res = "登录失败";
				}
			} 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);
					res = token;
				}else{
					modelMap.addAttribute("token", "绑定手机号,"+""+uuid);
					res = "绑定手机号,"+""+uuid;
				}
			}
        }else{
			modelMap.addAttribute("token", "登录失败");
		}
        result.setSuccess(false);
        result.setMessage("第三方登录异常,请联系管理员");
		if(source.equals("MAXKEY")){
			String key = source + "_state:" + callback.getState();
			String info = (String) redisUtil.get(key);
			redisUtil.del(key);
			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);
		}
        return "thirdLogin";
    }
}

六、前端配置

6.1 创建SSO登录页面

创建SsoTokenLogin.vue

<template>
    <div class="app-loading">
        <div class="app-loading-wrap">
            <img src="/resource/img/logo.png" class="app-loading-logo" alt="Logo">
            <div class="app-loading-dots">
                <span class="dot dot-spin"><i></i><i></i><i></i><i></i></span>
            </div>
            <div class="app-loading-title">SSO单点登录</div>
        </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 添加路由配置

// 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实践笔记。”