子系统 SSO 单点登录接入配置指南
一、整体架构
统一门户(Portal) ──sso_token──> 子系统A(EEMS) ──自动登录──> 系统首页
统一门户(Portal) ──sso_token──> 子系统B(WEMS) ──自动登录──> 系统首页
统一门户(Portal) ──sso_token──> 子系统C(...) ──自动登录──> 系统首页
- 用户在门户登录后,门户将 JWT Token 存入
localStorage("sso_token") - 点击子系统入口时,门户拼接 URL:
http://子系统地址/?sso_token=JWT_TOKEN - 子系统前端检测到
sso_token参数,自动设置 Token 并完成登录 - 子系统保留原有独立登录能力,无
sso_token时走正常登录流程
二、前端修改清单(共 4 个文件)
文件 1:index.html --- 添加 SSO Token 预写入脚本
在 <script type="module" src="/src/main.js"></script> 之前,添加以下脚本:
html
<script>
window.__SSO_DOMAIN__ = '';
(function() {
var params = new URLSearchParams(window.location.search);
var ssoToken = params.get('sso_token');
if (ssoToken) {
localStorage.setItem('Admin-Token', ssoToken);
}
})();
</script>
完整 index.html 示例(以 EEMS 为例,其他系统替换 title 即可):
html
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="renderer" content="webkit">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<link rel="icon" href="/favicon.ico">
<title>西安建筑科技大学电能监管平台</title>
<!--[if lt IE 11]><script>window.location.href='/html/ie.html';</script><![endif]-->
<style>
html,
body,
#app {
height: 100%;
margin: 0;
padding: 0;
}
.chromeframe {
margin: 0.2em 0;
background: #ccc;
color: #000;
padding: 0.2em 0;
}
#loader-wrapper {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 999999;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
#loader-gif {
width: 80px;
height: 80px;
opacity: 1;
transition: opacity 3s ease-out;
}
#loader-wrapper .loader-section {
position: fixed;
top: 0;
width: 51%;
height: 100%;
background: #fff;
z-index: 1000;
}
#loader-wrapper .loader-section.section-left {
left: 0;
}
#loader-wrapper .loader-section.section-right {
right: 0;
}
.loaded #loader-wrapper .loader-section.section-left {
transform: translateX(-100%);
transition: all 7s 3s cubic-bezier(0.645, 0.045, 0.355, 1.000);
}
.loaded #loader-wrapper .loader-section.section-right {
transform: translateX(100%);
transition: all 7s 3s cubic-bezier(0.645, 0.045, 0.355, 1.000);
}
.loaded #loader-gif {
opacity: 0;
transition: opacity 3s ease-out;
}
.loaded #loader-wrapper {
visibility: hidden;
transition: none;
}
.no-js #loader-wrapper {
display: none;
}
#loader-wrapper .load_title {
font-family: 'Open Sans', sans-serif;
color: #333;
font-size: 16px;
margin-top: 15px;
opacity: 0.7;
}
</style>
</head>
<body>
<div id="app">
<div id="loader-wrapper">
<img id="loader-gif" src="/loader.gif" alt="加载中...">
<div class="load_title">正在加载系统资源,请耐心等待</div>
</div>
</div>
<script>
window.__SSO_DOMAIN__ = '';
(function() {
var params = new URLSearchParams(window.location.search);
var ssoToken = params.get('sso_token');
if (ssoToken) {
localStorage.setItem('Admin-Token', ssoToken);
}
})();
</script>
<script type="module" src="/src/main.js"></script>
<script>
const MIN_LOADING_TIME = 3000;
let startTime;
window.addEventListener('load', function () {
startTime = performance.now();
const elapsedTime = performance.now() - startTime;
const remainingTime = Math.max(0, MIN_LOADING_TIME - elapsedTime);
setTimeout(function () {
var appEl = document.getElementById('app');
if (appEl) {
appEl.className = 'loaded';
}
}, 5000);
});
</script>
</body>
</html>
说明:
__SSO_DOMAIN__:如果门户和子系统部署在同一根域名下(如xxx.com),设置为根域名以实现 Cookie 跨子域共享;本地开发或不同域名时设为空字符串- 预写入脚本在 Vue 加载前执行,确保即使 Cookie 被浏览器拦截(iframe 第三方 Cookie 限制),Token 也能被读取
文件 2:src/utils/auth.js --- Token 存储 Cookie + localStorage 双写
完整替换为以下内容:
javascript
import Cookies from 'js-cookie'
const TokenKey = 'Admin-Token'
const SSO_DOMAIN = window.__SSO_DOMAIN__ || ''
export function getToken() {
let token = Cookies.get(TokenKey)
if (!token) {
token = localStorage.getItem(TokenKey)
}
return token
}
export function setToken(token) {
const options = { path: '/', SameSite: 'Lax' }
if (SSO_DOMAIN) {
options.domain = SSO_DOMAIN
}
Cookies.set(TokenKey, token, options)
localStorage.setItem(TokenKey, token)
return
}
export function removeToken() {
const options = { path: '/', SameSite: 'Lax' }
if (SSO_DOMAIN) {
options.domain = SSO_DOMAIN
}
Cookies.remove(TokenKey, options)
localStorage.removeItem(TokenKey)
}
关键改动:
getToken():先读 Cookie,读不到则从 localStorage 回退读取setToken():同时写入 Cookie 和 localStorage(双写),确保两种方式都能获取removeToken():同时清除 Cookie 和 localStorage- 添加
SameSite: 'Lax':解决 iframe 中 Cookie 被浏览器阻止的问题
文件 3:src/permission.js --- 路由守卫 SSO 适配
完整替换为以下内容:
javascript
import router from './router'
import { ElMessage } from 'element-plus'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
import { getToken, setToken, removeToken } from '@/utils/auth'
import { isHttp } from '@/utils/validate'
import { isRelogin } from '@/utils/request'
import useUserStore from '@/store/modules/user'
import useSettingsStore from '@/store/modules/settings'
import usePermissionStore from '@/store/modules/permission'
NProgress.configure({ showSpinner: false });
const whiteList = ['/login', '/auth-redirect', '/bind', '/register'];
router.beforeEach(async (to) => {
NProgress.start()
const ssoToken = to.query.sso_token
if (ssoToken) {
setToken(ssoToken)
}
if (getToken()) {
to.meta.title && useSettingsStore().setTitle(to.meta.title)
if (to.path === '/login') {
NProgress.done()
return { path: '/' }
} else {
if (useUserStore().roles.length === 0) {
isRelogin.show = true
try {
await useUserStore().getInfo()
isRelogin.show = false
const accessRoutes = await usePermissionStore().generateRoutes()
accessRoutes.forEach(route => {
if (!isHttp(route.path)) {
router.addRoute(route)
}
})
return { ...to, replace: true }
} catch (err) {
removeToken()
useUserStore().token = ''
useUserStore().roles = []
useUserStore().permissions = []
isRelogin.show = false
ElMessage.error('登录已过期,请重新登录')
NProgress.done()
return { path: '/login', query: { redirect: to.fullPath } }
}
} else {
return true
}
}
} else {
if (whiteList.indexOf(to.path) !== -1) {
return true
} else {
NProgress.done()
return { path: `/login?redirect=${to.fullPath}` }
}
}
})
router.afterEach(() => {
NProgress.done()
})
关键改动:
- SSO Token 检测 :在守卫开头检测
to.query.sso_token,存在则调用setToken() - async/await 返回值模式 :使用
return代替next()回调。Vue Router 4 中async函数不能混用next()回调,否则会报Invalid navigation guard错误导致白屏 - 错误处理 :
getInfo()失败时直接清空 Token 和 Store,跳转登录页,不再调用可能卡住的logOut()
文件 4:src/api/login.js --- 添加 SSO 登录 API(可选)
在原有 login.js 中添加 ssoLogin 函数(当前前端直传 Token 模式下未使用,但保留备用):
javascript
import request from '@/utils/request'
export function login(username, password, code, uuid) {
const data = {
username,
password,
code,
uuid
}
return request({
url: '/login',
headers: {
isToken: false
},
method: 'post',
data: data
})
}
export function ssoLogin(ssoToken) {
return request({
url: '/sso/login',
headers: {
isToken: false
},
method: 'post',
data: { token: ssoToken }
})
}
export function register(data) {
return request({
url: '/register',
headers: {
isToken: false
},
method: 'post',
data: data
})
}
export function getInfo() {
return request({
url: '/getInfo',
method: 'get'
})
}
export function logout() {
return request({
url: '/logout',
method: 'post'
})
}
export function getCodeImg() {
return request({
url: '/captchaImage',
headers: {
isToken: false
},
method: 'get',
timeout: 20000
})
}
三、后端修改清单
场景 A:子系统与门户共享同一个后端(如 EEMS)
后端无需修改。EEMS 前端直传 Token 到 EEMS 后端,Token 本身就是 EEMS 后端签发的,可直接使用。
场景 B:子系统有独立后端(如 WEMS)
需要添加 3 个后端文件 + 修改 2 个现有文件。
文件 1(新建):SsoLoginController.java
路径 :baier-xxxweb/src/main/java/com/baier/web/controller/system/SsoLoginController.java
与门户共享后端的版本 (如 EEMS,支持 /sso/login 和 /sso/verify):
java
package com.baier.web.controller.system;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.baier.common.constant.Constants;
import com.baier.common.core.domain.AjaxResult;
import com.baier.common.core.domain.model.LoginUser;
import com.baier.framework.web.service.SsoLoginService;
import com.baier.framework.web.service.TokenService;
@RestController
public class SsoLoginController
{
@Autowired
private SsoLoginService ssoLoginService;
@Autowired
private TokenService tokenService;
@PostMapping("/sso/login")
public AjaxResult ssoLogin(@RequestBody SsoTokenBody ssoTokenBody)
{
if (ssoTokenBody == null || ssoTokenBody.getToken() == null || ssoTokenBody.getToken().isEmpty())
{
return AjaxResult.error("SSO Token不能为空");
}
LoginUser loginUser = ssoLoginService.validateSsoToken(ssoTokenBody.getToken());
if (loginUser == null)
{
return AjaxResult.error("SSO Token验证失败");
}
String token = ssoLoginService.createLocalToken(loginUser);
AjaxResult ajax = AjaxResult.success("SSO登录成功");
ajax.put(Constants.TOKEN, token);
return ajax;
}
@GetMapping("/sso/verify")
public AjaxResult verifySsoToken(@RequestParam("token") String token)
{
if (token == null || token.isEmpty())
{
return AjaxResult.error("Token不能为空");
}
LoginUser loginUser = ssoLoginService.validateSsoToken(token);
if (loginUser == null)
{
return AjaxResult.error("Token验证失败");
}
AjaxResult ajax = AjaxResult.success("Token验证成功");
ajax.put("user", loginUser.getUser());
return ajax;
}
public static class SsoTokenBody
{
private String token;
public String getToken()
{
return token;
}
public void setToken(String token)
{
this.token = token;
}
}
}
独立后端的版本 (如 WEMS,只有 /sso/login,无 /sso/verify):
java
package com.baier.web.controller.system;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import com.baier.common.constant.Constants;
import com.baier.common.core.domain.AjaxResult;
import com.baier.common.core.domain.model.LoginUser;
import com.baier.framework.web.service.SsoLoginService;
@RestController
public class SsoLoginController
{
@Autowired
private SsoLoginService ssoLoginService;
@PostMapping("/sso/login")
public AjaxResult ssoLogin(@RequestBody SsoTokenBody ssoTokenBody)
{
if (ssoTokenBody == null || ssoTokenBody.getToken() == null || ssoTokenBody.getToken().isEmpty())
{
return AjaxResult.error("SSO Token不能为空");
}
LoginUser loginUser = ssoLoginService.validateSsoToken(ssoTokenBody.getToken());
if (loginUser == null)
{
return AjaxResult.error("SSO Token验证失败");
}
String token = ssoLoginService.createLocalToken(loginUser);
AjaxResult ajax = AjaxResult.success("SSO登录成功");
ajax.put(Constants.TOKEN, token);
return ajax;
}
public static class SsoTokenBody
{
private String token;
public String getToken()
{
return token;
}
public void setToken(String token)
{
this.token = token;
}
}
}
文件 2(新建):SsoLoginService.java
路径 :baier-framework/src/main/java/com/baier/framework/web/service/SsoLoginService.java
与门户共享后端的版本(如 EEMS,直接从本地 Redis 查询):
java
package com.baier.framework.web.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.baier.common.core.domain.entity.SysUser;
import com.baier.common.core.domain.model.LoginUser;
import com.baier.common.exception.ServiceException;
import com.baier.common.utils.StringUtils;
import com.baier.framework.web.service.TokenService;
import io.jsonwebtoken.Claims;
@Service
public class SsoLoginService
{
@Autowired
private TokenService tokenService;
@Autowired
private SysLoginService loginService;
public LoginUser validateSsoToken(String ssoToken)
{
try
{
Claims claims = tokenService.parseTokenPublic(ssoToken);
if (claims == null)
{
return null;
}
String uuid = (String) claims.get(com.baier.common.constant.Constants.LOGIN_USER_KEY);
if (StringUtils.isEmpty(uuid))
{
return null;
}
String userKey = com.baier.common.constant.Constants.LOGIN_TOKEN_KEY + uuid;
LoginUser loginUser = tokenService.getLoginUserByUuid(uuid);
return loginUser;
}
catch (Exception e)
{
return null;
}
}
public String createLocalToken(LoginUser loginUser)
{
SysUser user = loginUser.getUser();
if (user == null || StringUtils.isEmpty(user.getUserName()))
{
throw new ServiceException("SSO用户信息不完整");
}
String token = loginService.ssoLogin(user.getUserName());
return token;
}
}
独立后端的版本(如 WEMS,先尝试本地解析,失败则远程调用门户验证):
java
package com.baier.framework.web.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import com.baier.common.constant.Constants;
import com.baier.common.core.domain.entity.SysUser;
import com.baier.common.core.domain.model.LoginUser;
import com.baier.common.exception.ServiceException;
import com.baier.common.utils.StringUtils;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import io.jsonwebtoken.Claims;
@Service
public class SsoLoginService
{
@Autowired
private TokenService tokenService;
@Autowired
private SysLoginService loginService;
@Value("${sso.portal.url:http://localhost:8090}")
private String portalUrl;
public LoginUser validateSsoToken(String ssoToken)
{
try
{
Claims claims = tokenService.parseTokenPublic(ssoToken);
if (claims != null)
{
String uuid = (String) claims.get(Constants.LOGIN_USER_KEY);
if (StringUtils.isNotEmpty(uuid))
{
LoginUser loginUser = tokenService.getLoginUserByUuid(uuid);
if (loginUser != null)
{
return loginUser;
}
}
}
}
catch (Exception e)
{
}
return validateSsoTokenRemote(ssoToken);
}
private LoginUser validateSsoTokenRemote(String ssoToken)
{
try
{
String url = portalUrl + "/sso/verify?token=" + ssoToken;
String response = com.baier.common.utils.http.HttpUtils.sendGet(url);
if (StringUtils.isEmpty(response))
{
return null;
}
JSONObject json = JSON.parseObject(response);
if (json.getIntValue("code") != 200)
{
return null;
}
JSONObject userObj = json.getJSONObject("user");
if (userObj == null)
{
return null;
}
String username = userObj.getString("userName");
if (StringUtils.isEmpty(username))
{
return null;
}
SysUser sysUser = new SysUser();
sysUser.setUserName(username);
sysUser.setUserId(userObj.getLong("userId"));
LoginUser loginUser = new LoginUser();
loginUser.setUser(sysUser);
return loginUser;
}
catch (Exception e)
{
return null;
}
}
public String createLocalToken(LoginUser loginUser)
{
SysUser user = loginUser.getUser();
if (user == null || StringUtils.isEmpty(user.getUserName()))
{
throw new ServiceException("SSO用户信息不完整");
}
String token = loginService.ssoLogin(user.getUserName());
return token;
}
}
注意 :WEMS 使用 fastjson(v1),EEMS 使用 fastjson2。请根据项目实际依赖选择:
- fastjson v1:
com.alibaba.fastjson.JSON/com.alibaba.fastjson.JSONObject - fastjson2:
com.alibaba.fastjson2.JSON/com.alibaba.fastjson2.JSONObject
文件 3(修改):SysLoginService.java --- 添加 ssoLogin 方法
路径 :baier-framework/src/main/java/com/baier/framework/web/service/SysLoginService.java
在原有类中新增以下方法(不改动原有代码):
java
public String ssoLogin(String username)
{
SysUser sysUser = userService.selectUserByUserName(username);
if (sysUser == null)
{
throw new ServiceException("SSO用户不存在: " + username);
}
LoginUser loginUser = new LoginUser(sysUser.getUserId(), sysUser.getDeptId(), sysUser, null);
recordLoginInfo(loginUser.getUserId());
return tokenService.createToken(loginUser);
}
完整 SysLoginService.java (EEMS 版本,WEMS 版本仅 import 中的 com.baier.eems 改为 com.baier.wems):
java
package com.baier.framework.web.service;
import javax.annotation.Resource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
import com.baier.common.constant.Constants;
import com.baier.common.core.domain.entity.SysUser;
import com.baier.common.core.domain.model.LoginUser;
import com.baier.common.core.redis.RedisCache;
import com.baier.common.exception.ServiceException;
import com.baier.common.exception.user.CaptchaException;
import com.baier.common.exception.user.CaptchaExpireException;
import com.baier.common.exception.user.UserPasswordNotMatchException;
import com.baier.common.utils.DateUtils;
import com.baier.common.utils.MessageUtils;
import com.baier.common.utils.ServletUtils;
import com.baier.common.utils.ip.IpUtils;
import com.baier.eems.service.ISysConfigService;
import com.baier.eems.service.ISysUserService;
import com.baier.framework.manager.AsyncManager;
import com.baier.framework.manager.factory.AsyncFactory;
@Component
public class SysLoginService
{
@Autowired
private TokenService tokenService;
@Resource
private AuthenticationManager authenticationManager;
@Autowired
private RedisCache redisCache;
@Autowired
private ISysUserService userService;
@Autowired
private ISysConfigService configService;
public String login(String username, String password, String code, String uuid)
{
boolean captchaOnOff = configService.selectCaptchaOnOff();
if (captchaOnOff)
{
validateCaptcha(username, code, uuid);
}
Authentication authentication = null;
try
{
authentication = authenticationManager
.authenticate(new UsernamePasswordAuthenticationToken(username, password));
}
catch (Exception e)
{
if (e instanceof BadCredentialsException)
{
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
throw new UserPasswordNotMatchException();
}
else
{
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, e.getMessage()));
throw new ServiceException(e.getMessage());
}
}
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
recordLoginInfo(loginUser.getUserId());
return tokenService.createToken(loginUser);
}
public void validateCaptcha(String username, String code, String uuid)
{
String verifyKey = Constants.CAPTCHA_CODE_KEY + uuid;
String captcha = redisCache.getCacheObject(verifyKey);
redisCache.deleteObject(verifyKey);
if (captcha == null)
{
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire")));
throw new CaptchaExpireException();
}
if (!code.equalsIgnoreCase(captcha))
{
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.error")));
throw new CaptchaException();
}
}
public String ssoLogin(String username)
{
SysUser sysUser = userService.selectUserByUserName(username);
if (sysUser == null)
{
throw new ServiceException("SSO用户不存在: " + username);
}
LoginUser loginUser = new LoginUser(sysUser.getUserId(), sysUser.getDeptId(), sysUser, null);
recordLoginInfo(loginUser.getUserId());
return tokenService.createToken(loginUser);
}
public void recordLoginInfo(Long userId)
{
SysUser sysUser = new SysUser();
sysUser.setUserId(userId);
sysUser.setLoginIp(IpUtils.getIpAddr(ServletUtils.getRequest()));
sysUser.setLoginDate(DateUtils.getNowDate());
userService.updateUserProfile(sysUser);
}
}
文件 4(修改):TokenService.java --- 添加 parseTokenPublic 和 getLoginUserByUuid
路径 :baier-framework/src/main/java/com/baier/framework/web/service/TokenService.java
在原有类中新增以下两个方法(不改动原有代码):
java
public Claims parseTokenPublic(String token)
{
try
{
return parseToken(token);
}
catch (Exception e)
{
return null;
}
}
public LoginUser getLoginUserByUuid(String uuid)
{
String userKey = getTokenKey(uuid);
return redisCache.getCacheObject(userKey);
}
完整 TokenService.java:
java
package com.baier.framework.web.service;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import javax.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import com.baier.common.constant.Constants;
import com.baier.common.core.domain.model.LoginUser;
import com.baier.common.core.redis.RedisCache;
import com.baier.common.utils.ServletUtils;
import com.baier.common.utils.StringUtils;
import com.baier.common.utils.ip.AddressUtils;
import com.baier.common.utils.ip.IpUtils;
import com.baier.common.utils.uuid.IdUtils;
import eu.bitwalker.useragentutils.UserAgent;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
@Component
public class TokenService
{
@Value("${token.header}")
private String header;
@Value("${token.secret}")
private String secret;
@Value("${token.expireTime}")
private int expireTime;
protected static final long MILLIS_SECOND = 1000;
protected static final long MILLIS_MINUTE = 60 * MILLIS_SECOND;
private static final Long MILLIS_MINUTE_TEN = 20 * 60 * 1000L;
@Autowired
private RedisCache redisCache;
public LoginUser getLoginUser(HttpServletRequest request)
{
String token = getToken(request);
if (StringUtils.isNotEmpty(token))
{
try
{
Claims claims = parseToken(token);
String uuid = (String) claims.get(Constants.LOGIN_USER_KEY);
String userKey = getTokenKey(uuid);
LoginUser user = redisCache.getCacheObject(userKey);
return user;
}
catch (Exception e)
{
}
}
return null;
}
public void setLoginUser(LoginUser loginUser)
{
if (StringUtils.isNotNull(loginUser) && StringUtils.isNotEmpty(loginUser.getToken()))
{
refreshToken(loginUser);
}
}
public void delLoginUser(String token)
{
if (StringUtils.isNotEmpty(token))
{
String userKey = getTokenKey(token);
redisCache.deleteObject(userKey);
}
}
public String createToken(LoginUser loginUser)
{
String token = IdUtils.fastUUID();
loginUser.setToken(token);
setUserAgent(loginUser);
refreshToken(loginUser);
Map<String, Object> claims = new HashMap<>();
claims.put(Constants.LOGIN_USER_KEY, token);
return createToken(claims);
}
public void verifyToken(LoginUser loginUser)
{
long expireTime = loginUser.getExpireTime();
long currentTime = System.currentTimeMillis();
if (expireTime - currentTime <= MILLIS_MINUTE_TEN)
{
refreshToken(loginUser);
}
}
public void refreshToken(LoginUser loginUser)
{
loginUser.setLoginTime(System.currentTimeMillis());
loginUser.setExpireTime(loginUser.getLoginTime() + expireTime * MILLIS_MINUTE);
String userKey = getTokenKey(loginUser.getToken());
redisCache.setCacheObject(userKey, loginUser, expireTime, TimeUnit.MINUTES);
}
public void setUserAgent(LoginUser loginUser)
{
UserAgent userAgent = UserAgent.parseUserAgentString(ServletUtils.getRequest().getHeader("User-Agent"));
String ip = IpUtils.getIpAddr(ServletUtils.getRequest());
loginUser.setIpaddr(ip);
loginUser.setLoginLocation(AddressUtils.getRealAddressByIP(ip));
loginUser.setBrowser(userAgent.getBrowser().getName());
loginUser.setOs(userAgent.getOperatingSystem().getName());
}
private String createToken(Map<String, Object> claims)
{
String token = Jwts.builder()
.setClaims(claims)
.signWith(SignatureAlgorithm.HS512, secret).compact();
return token;
}
private Claims parseToken(String token)
{
return Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
}
public Claims parseTokenPublic(String token)
{
try
{
return parseToken(token);
}
catch (Exception e)
{
return null;
}
}
public LoginUser getLoginUserByUuid(String uuid)
{
String userKey = getTokenKey(uuid);
return redisCache.getCacheObject(userKey);
}
public String getUsernameFromToken(String token)
{
Claims claims = parseToken(token);
return claims.getSubject();
}
private String getToken(HttpServletRequest request)
{
String token = request.getHeader(header);
if (StringUtils.isNotEmpty(token) && token.startsWith(Constants.TOKEN_PREFIX))
{
token = token.replace(Constants.TOKEN_PREFIX, "");
}
return token;
}
private String getTokenKey(String uuid)
{
return Constants.LOGIN_TOKEN_KEY + uuid;
}
}
文件 5(修改):SecurityConfig.java --- 添加 SSO 接口白名单
路径 :baier-framework/src/main/java/com/baier/framework/config/SecurityConfig.java
找到 antMatchers 匿名访问配置行,添加 SSO 接口:
与门户共享后端的版本 (如 EEMS,添加 /sso/login 和 /sso/verify):
java
.antMatchers("/login", "/register", "/captchaImage", "/sso/login", "/sso/verify").anonymous()
独立后端的版本 (如 WEMS,只添加 /sso/login):
java
.antMatchers("/login", "/register", "/captchaImage", "/sso/login").anonymous()
文件 6(修改,仅独立后端需要):application.yml --- 添加 SSO 配置
路径 :baier-xxxweb/src/main/resources/application.yml
在文件末尾添加:
yaml
# SSO配置
sso:
portal:
url: http://localhost:8090
url 指向门户(主系统)的后端地址,用于远程调用 /sso/verify 接口验证 Token。
文件 7(修改):application.yml --- Token 过期时间配置
路径 :baier-xxxweb/src/main/resources/application.yml
Token 过期时间在 application.yml 的 token 节中配置:
yaml
# token配置
token:
# 令牌自定义标识
header: Authorization
# 令牌密钥
secret: abcdefghijklmnopqrstuvwxyz
# 令牌有效期(单位:分钟,默认30分钟)
expireTime: 30
expireTime :令牌有效期,单位为分钟 ,默认值 30(即30分钟)。
该值控制 JWT Token 在 Redis 中的缓存时间,在 TokenService.java 的 refreshToken() 方法中使用:
java
redisCache.setCacheObject(userKey, loginUser, expireTime, TimeUnit.MINUTES);
常见配置参考:
| expireTime 值 | 有效期 |
|---|---|
| 30 | 30分钟(默认) |
| 120 | 2小时 |
| 480 | 8小时(一个工作日) |
| 1440 | 24小时 |
注意:
- 修改后需重启后端服务才能生效
- 所有子系统如果希望 SSO 登录态保持一致,建议各后端的
expireTime设置相同值 - Token 过期后,用户在子系统的操作会触发
getInfo()失败,前端会自动跳转到登录页
四、门户端修改清单
文件 1:html/js/login.js --- 登录成功后存储 Token
javascript
function login(){
var username = $("input[name='username']").val();
var password = $("input[name='password']").val();
var data = {
"username":username,
"password":password,
"code":null,
"uuid":""
};
$.ajax({
type: "POST",
async:false,
headers: {
'Content-Type':'application/json;charset=utf-8'
},
url: "http://localhost:8090/login",
data: JSON.stringify(data),
dataType: "json",
success: function(result) {
if (result.code == 200) {
setCookie("Admin-Token", result.token, 100);
setCookie("username", username, 100);
localStorage.setItem("sso_token", result.token);
localStorage.setItem("sso_username", username);
top.location.href=Win10._sysPathUrl
} else {
layer.msg('用户名或密码错误!', {
icon : 1,
time : 3000
});
}
}
});
}
改动点 :登录成功后添加 localStorage.setItem("sso_token", result.token) 和 localStorage.setItem("sso_username", username)
文件 2:html/js/mac.js --- 子系统入口 URL 拼接
新增 ssoLogin 函数(放在文件末尾):
javascript
var ssoLogin = function(port){
var ssoToken = localStorage.getItem("sso_token");
if(!ssoToken){
ssoToken = getCookie("Admin-Token");
}
if(!ssoToken){
layer.msg("未检测到登录态,请先登录", {icon: 2, time: 2000});
return false;
}
var ssoUrl = "";
if(port == "10001"){
ssoUrl = Win10._ModulePathUrl + port + "/?sso_token=" + encodeURIComponent(ssoToken);
}else if(port == "10002"){
ssoUrl = Win10._ModulePathUrl + port + "/?sso_token=" + encodeURIComponent(ssoToken);
}
return ssoUrl;
}
修改 openUrl 函数(在原有 openUrl 函数开头添加 SSO 判断):
javascript
openUrl: function (url, title,areaAndOffset) {
let port = url.substring(url.lastIndexOf(':')+1,url.length);
if((/(^[1-9]\d*$)/.test(port))){
var ssoUrl = ssoLogin(port);
if(!ssoUrl){
return false;
}
url = ssoUrl;
}
// ... 后续原有代码不变
修改 exit 函数(退出时清除 SSO Token):
javascript
exit:function () {
layer.confirm('确认要退出该账号吗?', {icon: 3, title:'提示'}, function(index){
setCookie("Admin-Token", "", 1);
localStorage.removeItem("sso_token");
localStorage.removeItem("sso_username");
document.body.onbeforeunload = function(){};
window.location.href=Win10._sysPathUrl;
layer.close(index);
});
},
五、新增子系统接入步骤
以新增子系统 C(前端端口 10003,后端端口 8030)为例:
1. 前端(4 个文件)
按第二节修改 index.html、auth.js、permission.js、login.js,内容完全一致,无需改动。
2. 后端
- 如果与门户共享后端:无需修改
- 如果是独立后端 :按第三节场景 B 添加
SsoLoginController.java、SsoLoginService.java,修改SysLoginService.java、TokenService.java、SecurityConfig.java、application.yml
3. 门户端
在 mac.js 的 ssoLogin 函数中添加:
javascript
else if(port == "10003"){
ssoUrl = Win10._ModulePathUrl + port + "/?sso_token=" + encodeURIComponent(ssoToken);
}
在 index.html 中添加子系统入口按钮(参照已有按钮格式)。
六、踩坑记录
1. Vue Router 4 中 async + next() 不兼容(白屏根因)
错误 :router.beforeEach(async (to, from, next) => { ... next() }) 会导致 Invalid navigation guard 错误。
原因 :Vue Router 4 中,async 函数应使用 return 返回导航目标,不应使用 next() 回调。两者混用时,Vue Router 会认为守卫从未调用 next,导致导航中断页面白屏。
正确写法:
javascript
router.beforeEach(async (to) => {
return true // 允许导航
return { path: '/login' } // 重定向
return { ...to, replace: true } // 替换当前导航
})
2. iframe 中第三方 Cookie 被阻止
现象 :门户通过 layer.open({ type: 2 }) 以 iframe 方式打开子系统,子系统设置的 Cookie 可能被浏览器(Chrome 80+)阻止。
解决:
auth.js中setToken同时写入 Cookie 和 localStorageindex.html中在 Vue 加载前预写 localStorage- Cookie 添加
SameSite: 'Lax'属性
3. JWT Token 中的特殊字符
JWT Token 包含 . 和可能的 _ - 等字符,在 URL 传递时必须使用 encodeURIComponent() 编码,否则可能被截断。
4. fastjson 版本差异
WEMS 使用 fastjson v1(com.alibaba.fastjson),EEMS 使用 fastjson2(com.alibaba.fastjson2)。在 SsoLoginService.java 中使用 JSON 解析时,请根据项目实际依赖选择正确的 import。