子系统 SSO 单点登录接入配置指南

子系统 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 也能被读取

完整替换为以下内容:

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.ymltoken 节中配置:

yaml 复制代码
# token配置
token:
    # 令牌自定义标识
    header: Authorization
    # 令牌密钥
    secret: abcdefghijklmnopqrstuvwxyz
    # 令牌有效期(单位:分钟,默认30分钟)
    expireTime: 30

expireTime :令牌有效期,单位为分钟 ,默认值 30(即30分钟)。

该值控制 JWT Token 在 Redis 中的缓存时间,在 TokenService.javarefreshToken() 方法中使用:

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.htmlauth.jspermission.jslogin.js,内容完全一致,无需改动。

2. 后端

  • 如果与门户共享后端:无需修改
  • 如果是独立后端 :按第三节场景 B 添加 SsoLoginController.javaSsoLoginService.java,修改 SysLoginService.javaTokenService.javaSecurityConfig.javaapplication.yml

3. 门户端

mac.jsssoLogin 函数中添加:

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 } // 替换当前导航
})

现象 :门户通过 layer.open({ type: 2 }) 以 iframe 方式打开子系统,子系统设置的 Cookie 可能被浏览器(Chrome 80+)阻止。

解决

  • auth.jssetToken 同时写入 Cookie 和 localStorage
  • index.html 中在 Vue 加载前预写 localStorage
  • Cookie 添加 SameSite: 'Lax' 属性

3. JWT Token 中的特殊字符

JWT Token 包含 . 和可能的 _ - 等字符,在 URL 传递时必须使用 encodeURIComponent() 编码,否则可能被截断。

4. fastjson 版本差异

WEMS 使用 fastjson v1(com.alibaba.fastjson),EEMS 使用 fastjson2com.alibaba.fastjson2)。在 SsoLoginService.java 中使用 JSON 解析时,请根据项目实际依赖选择正确的 import。

相关推荐
电商API_180079052472 小时前
淘宝商品评论数据获取指南|批量自动化|api应用
java·爬虫·spring·性能优化·自动化
梦梦代码精2 小时前
Likeshop一个开源商城到底有哪些功能模块?
java·低代码·开源·php
java1234_小锋2 小时前
Spring AI 2.0 开发Java Agent智能体 - 对话与提示词工程(Prompt)
java·人工智能·spring
Frank_refuel2 小时前
C++之STL->string类的使用和实现
java·开发语言·c++
小凡子空白在线学习2 小时前
工作拆分so总结
java·jvm·算法
手揽回忆怎么睡2 小时前
java打包无效的发行版:xx,临时修复当前窗口指定 JDK21
java·开发语言
一直有一个ac的梦想2 小时前
cmu15445 2025fall lec15 query optimiaztion Pt1
java·服务器·数据库
郝学胜-神的一滴2 小时前
干货版《算法导论》03:动态数组 × 链表的极致平衡艺术
java·数据结构·c++·python·算法·链表
SamDeepThinking2 小时前
IntelliJ IDEA 中有什么让你相见恨晚的技巧?
java·后端·程序员