一、方案概述
本方案通过集成JustAuth框架,实现Jeecgboot与MaxKey统一身份认证系统的单点登录集成,基于OAuth2协议完成用户认证与授权。
二、环境准备
2.1 MaxKey配置
确保MaxKey服务正常运行(默认地址:
http://maxkey-server:8080/maxkey)在MaxKey管理后台创建OAuth2客户端应用
获取以下信息:
Client ID
Client Secret
配置回调地址:http://your-domain:3100/jeecgboot/sys/thirdLogin/render/MAXKEY?info=eyJtZW51IjoiL2Rhc2hib2FyZC9hbmFseXNpcyIsImRhdGEiOnsiaWQiOiIxMSJ9fQ==
为什么是使用/sys/thirdLogin/render/MAXKEY 而不是直接使用/sys/thirdLogin/MAXKEY/callback 回调接口?
info参数的作用
页面状态保持:从MAXKEY单点登录后能跳转到指定的页面
业务参数传递:需要携带原始的业务数据
用户体验:无缝的登录后跳转体验
// Base64解码前
eyJtZW51IjoiL2Rhc2hib2FyZC9hbmFseXNpcyIsImRhdGEiOnsiaWQiOiIxMSJ9fQ==
// Base64解码后
{
"menu": "/dashboard/analysis",
"data": {
"id": "11"
}
}2.2 Jeecgboot环境
确保Jeecgboot项目可正常运行
Redis服务正常启动(用于存储state状态)
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实践笔记。”