手写单点登录

概述

单点登录(Single Sign On),简称为 SSO,是比较流行的企业业务整合的解决方案之一。SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统 -百度百科

SSO设计的好处

  • 用户角度 :多个子应用只需登录一次,访问其他子应用无需再次登录,非常方便。
  • 系统管理员角度 : 管理员只需维护好一个统一的账号中心就可以了,方便。
  • 新系统开发角度: 新系统开发时只需直接对接统一的账号中心即可,简化开发流程,省时

方案设计

方案系统架构简介
方案一前端同域+同一个redis服务共享 Cookie 同步会话
方案二前端跨域+同一个redis服务URL重定向传播会话
方案三前端跨域 + 不同Redis服务session共享会话

PS:本篇暂时只演示方案一,方案二、三目前还在设计中,目前这款后端代码适配方案一、二,前端稍作改动即可。

SSO核心功能点

功能简述
登录支持跨域
注销支持跨域

架构设计

在这里插入图片描述

通信流程

在这里插入图片描述

流程说明如下表所示:

序号内容
1打开浏览器访问系统首页->http://client1.sso.com:9001/index
2进行拦截校验是否携带了令牌,没有携带重定向到SSO登录界面->http://server.sso.com/sso/login?redirect=http://client1.sso.com:9001/index
3在登录页面输入账号密码执行登录->http://server.sso.com/sso/doLogin?redirect=http://client1.sso.com:9001/index&account=zmy&password=123
4校验账号密码,确认信息无误,生成token并标记该用户已登录,返回登录成功响应->{path:http://client1.sso.com:9001/index,token:xxxx}
5客户端接受到登录成功响应,将token拼接到回调url进行跳转->跳转至系统首页:http://client1.sso.com:9001/index?token=xxxxx
6加载系统首页之前获取url携带的token信息,存储在cookies(domain=.sso.com)中,然后重定向至系统首页->http://client1.sso.com:9001/index
7换一个域名模拟另一个子系统,打开浏览器访问系统首页->http://client2.sso.com:9001/index
8从cookie中domain=.sso.com,获取到令牌,重定向到认证中心->http://server.sso.com:8888/sso/auth?redirect=http://client2.sso.com:9001/index,进行认证
9通过令牌检测到已登录,重定向系统首页->http://client2.sso.com:9001/index

功能演示

首先在host文件中配置多个二级域名

127.0.0.1 server.sso.com
127.0.0.1 client1.sso.com
127.0.0.1 client2.sso.com
127.0.0.1 client3.sso.com

访问[http://client1.sso.com:9001],由于没有进行登录,会跳转到登录页面,登录页面地址[http://server.sso.com:9002/?redirect=http://client1.sso.com:9001#/login]
在这里插入图片描述

点击登录,进入到刚刚访问的[http://client1.sso.com:9001]页面,此时说明已经成功登录了
在这里插入图片描述
在这里插入图片描述

在client1已登录的情况下,访问[http://client2.sso.com:9001],由于client1已登录,client2、client3通过cookie共享会话,无需再次进行登录,可以点击注销进行统一注销
在这里插入图片描述
在这里插入图片描述

功能实现

后端

代码目录如下
在这里插入图片描述

演示链路如下:

前端请求-->拦截器缓存请求域-->进入控制器-->执行SSO处理器-->返回结果(重定向|REST响应)

application.yml配置登录页面地址http://server.sso.com:9002

# 端口
server:
  port: 8880

spring:
  messages:
    basename: i18n/messages

# 授权登录地址
sso:
  login:
    url: http://server.sso.com:9002 # 登录页面地址
    account: zhangmuye # 模拟账号
    password: 123456 # 模拟密码

配置拦截器SSOContextInterceptor

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Autowired
    private SSOContextInterceptor ssoContextInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(ssoContextInterceptor)
                .addPathPatterns("/**");
    }
}

@Log4j2
@Component
public class SSOContextInterceptor implements HandlerInterceptor {


    @Autowired
    private SSOManager ssoManager;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 填充会话
        ssoManager.setContextSession(request,response);
        return HandlerInterceptor.super.preHandle(request, response, handler);
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        // 清理会话
        ssoManager.removeContextSession();
        HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);
    }
}

会话管理器SSOManager用于存储用户会话以及请求信息

@Controller
public class SSOManager {

    /**
     * 存储每次请求会话信息
     */
    private static final ThreadLocal<SSOContextSession> contextSession = new ThreadLocal<>();

    /**
     * 存储用户会话
     * key -> 令牌信息token
     * value -> 用户信息SSOUserSession
     */
    private static final Map<String,SSOUserSession> userSession = new HashMap<>();


    /**
     * 填充当前会话
     *
     * @author wei.chen
     * @date 2022/7/5 22:47
     * @param request
     * @param response
     * @return void
     */
    @SneakyThrows
    public void setContextSession(HttpServletRequest request, HttpServletResponse response){
        SSOContextSession ssoContextSession = new SSOContextSession();
        ssoContextSession.setRequest(request);
        ssoContextSession.setResponse(response);

        String accessToken = Optional.ofNullable(request.getHeader(SSOConstants.Properties.AUTHORIZATION))
                .orElse(request.getParameter(SSOConstants.Properties.ACCESS_TOKEN));
        String refreshToken = request.getParameter(SSOConstants.Properties.REFRESH_TOKEN);
        String account = request.getParameter(SSOConstants.Properties.ACCOUNT);
        String password = request.getParameter(SSOConstants.Properties.PASSWORD);
        String redirect = request.getParameter(SSOConstants.Properties.REDIRECT);
        String pathPostfix = request.getRequestURI();
        if ("POST".equals(request.getMethod())){
            JSONObject requestJsonObject = GetRequestJsonUtil.getRequestJsonObject(request);
            if (ObjectUtil.isNotEmpty(requestJsonObject)) {
                // 提取POST请求参数(账号、密码)
                BeanUtil.copyProperties(requestJsonObject, ssoContextSession);
                account = ssoContextSession.getAccount();
                password = ssoContextSession.getPassword();
            }
            // doLogin时 redirect可能为空,从请求头获取redirect
            if (StrUtil.isBlank(redirect)){
                String referer = request.getHeader(SSOConstants.Properties.REFERER);
                if (StrUtil.isNotBlank(referer)){
                    redirect = URLUtil.url(referer).getQuery().substring(9);
                }
            }
        }

        ssoContextSession.setAccessToken(accessToken);
        ssoContextSession.setRefreshToken(refreshToken);
        ssoContextSession.setAccount(account);
        ssoContextSession.setPassword(password);
        ssoContextSession.setRedirect(redirect);
        ssoContextSession.setPostfixPath(pathPostfix);
        contextSession.set(ssoContextSession);
    }

    /**
     *  填充用户会话
     *
     * @author wei.chen
     * @date 2022/7/5 23:28
     * @param key
     * @param ssoUserSession
     * @return void
     */
    public void setSSOUserSession(String key, SSOUserSession ssoUserSession){
        userSession.put(key, ssoUserSession);
    }

    /**
     * 获取当前会话
     *
     * @author wei.chen
     * @date 2022/7/7 9:56
     * @return {@link SSOContextSession }
     */
    public SSOContextSession getContextSession(){
        return contextSession.get();
    }

    /**
     * 获取用户信息
     *
     * @author wei.chen
     * @date 2022/7/7 9:56
     * @param key
     * @return {@link SSOUserSession }
     */
    public SSOUserSession getSSOUserSession(String key){
        return userSession.get(key);
    }

    /**
     * 清理会话
     * @author wei.chen
     * @date 2022/7/7 10:40
     * @param key
     * @return {@link  }
     */
    public void removeUserSession(String key){
        userSession.remove(key);
    }

    /**
     * 移除当前会话
     *
     * @author wei.chen
     * @date 2022/7/5 22:46
     * @param
     * @return void
     */
    public void removeContextSession(){
        contextSession.remove();
    }

}

SSOClientController用于接收来自/sso/xx的请求,通过执行ssoProcessor.ssoProcess()方法进行统一处理

@RestController
public class SSOClientController {

    @Autowired
    private SSOProcessor ssoProcessor;

    /**
     * 处理SSO请求
     *
     * @author wei.chen
     * @date 2022/7/6 8:38
     * @return {@link Object }
     */
    @RequestMapping(value = "/sso/*")
    public Object ssoHandle(){
         return ssoProcessor.ssoProcess();
    }

}

SSO处理器SSOProcessor,包括(认证、登录、注销)

@Log4j2
@Component
public class SSOProcessor {

    @Autowired
    private SSOManager manager;

    @Autowired
    private SSOToken ssoToken;

    @Autowired
    private SSOYamlConfig ssoYamlConfig;

    /**
     * 服务端SSO请求进行统一处理
     *
     * @param
     * @return java.lang.Object
     * @author wei.chen
     * @date 2022/7/5 23:34
     */
    public Object ssoProcess() {

        log.info("[ ssoProcess ] ===>服务端SSO请求进行统一处理");
        SSOContextSession contextSession = manager.getContextSession();
        if (ObjectUtil.isEmpty(contextSession)) {
            throw new SSORunException(SSOExceptionStatus.IS_NULL_SESSION);
        }
        /* ---------------------------路由分发------------------------------ */
        if (contextSession.isSamePostfixPath(SSOConstants.SSOUrl.SSO_AUTH)) {
            // 进行统一认证
            return ssoAuth();
        }
        if (contextSession.isSamePostfixPath(SSOConstants.SSOUrl.SSO_LOGIN)) {
            // 重定向至登录页面
            return ssoLogin();
        }
        if (contextSession.isSamePostfixPath(SSOConstants.SSOUrl.SSO_DO_LOGIN)) {
            // 执行登录:rest请求->ajax请求不支持重定向
            return ssoDoLogin();
        }
        if (contextSession.isSamePostfixPath(SSOConstants.SSOUrl.SSO_DO_LOGOUT)) {
            // 执行注销
            return ssoDoLogout();
        }

        log.error("[ ssoProcess ] ===>服务端SSO请求进行统一处理:{}",SSOExceptionStatus.INVALID_ACCESS.getMessage());
        return Result.failure(SSOExceptionStatus.INVALID_ACCESS.getCode(), SSOExceptionStatus.INVALID_ACCESS.getMessage());
    }

    /**
     * 统一认证
     *
     * @param
     * @return java.lang.Object
     * @author wei.chen
     * @date 2022/7/5 23:17
     */
    @SneakyThrows
    private Object ssoAuth() {
        log.info("[ ssoAuth ] ===>进入认证中心");
        SSOContextSession contextSession = manager.getContextSession();
        HttpServletResponse response = contextSession.getResponse();
        // 是否合法的重定向地址
        if (!Validator.isUrl(contextSession.getRedirect())) {
            throw new SSORunException(SSOExceptionStatus.ILLEGALITY_REDIRECT);
        }
        // 校验令牌
        if (!ssoToken.verifyToken(contextSession.getAccessToken())) {
            // 认证未通过进行登录
            return ssoLogin();
        }
        // 注册客户端地址
        SSOUserSession ssoUserSession = manager.getSSOUserSession(contextSession.getAccessToken());
        Set<String> clientUrls = Optional.ofNullable(ssoUserSession.getClientUrls()).orElse(new HashSet<String>());
        String host = URLUtil.url(contextSession.getRedirect()).getHost();
        if (!clientUrls.contains(host)){
            clientUrls.add(host);
            ssoUserSession.setClientUrls(clientUrls);
            manager.setSSOUserSession(contextSession.getAccessToken(), ssoUserSession);
            log.info("[ ssoAuth ] ===>成功注册HOST[{}]", host);
        }
        // 重定向至客户端
        response.sendRedirect(contextSession.getRedirect());
        return null;
    }

    /**
     * 重定向至登录页面
     *
     * @param
     * @return java.lang.Object
     * @author wei.chen
     * @date 2022/7/5 23:20
     */
    @SneakyThrows
    private Object ssoLogin() {
        log.info("[ ssoLogin ] ===>重定向至登录页面");
        SSOContextSession contextSession = manager.getContextSession();
        HttpServletResponse response = manager.getContextSession().getResponse();
        // 重定向至认证页面
        response.sendRedirect(StrUtil.format(
                "{}?{}={}",
                ssoYamlConfig.getLoginUrl(),
                SSOConstants.Properties.REDIRECT,
                contextSession.getRedirect()));
        return null;
    }

    /**
     * 登录:rest请求
     *
     * @param
     * @return java.lang.Object
     * @author wei.chen
     * @date 2022/7/5 23:23
     */
    @SneakyThrows
    private Object ssoDoLogin() {
        log.info("[ ssoDoLogin ] ===>执行登录");
        SSOContextSession contextSession = manager.getContextSession();
        // 进行认证
        if (!ssoYamlConfig.getAccount().equals(contextSession.getAccount())
                || !ssoYamlConfig.getPassword().equals(contextSession.getPassword())) {
            // 认证失败
            log.error("[ ssoDoLogin ] ===>执行登录:{}",SSOExceptionStatus.ACCOUNT_OR_PASSWORD_ERROR.getMessage());
            throw new SSORunException(SSOExceptionStatus.ACCOUNT_OR_PASSWORD_ERROR);
        }

        // TODO 使用三方缓存Redis代替本机缓存
        SSOUserSession ssoUserSession = new SSOUserSession();
        // 模拟生成令牌,存储到缓存中
        String accessToken = UUID.fastUUID().toString();
        String refreshToken = UUID.fromString(accessToken).toString();
        String host = URLUtil.url(contextSession.getRedirect()).getHost();
        Set<String> clients = new HashSet<>();
        clients.add(host);
        ssoUserSession.setAccessToken(accessToken);
        ssoUserSession.setRefreshToken(refreshToken);
        ssoUserSession.setAccount(contextSession.getAccount());
        ssoUserSession.setPassword(contextSession.getPassword());
        ssoUserSession.setClientUrls(clients);
        manager.setSSOUserSession(accessToken, ssoUserSession);

        log.info("[ ssoDoLogin ] ===>成功注册HOST[{}]", host);
        log.info("[ ssoDoLogin ] ===>登录成功,下发令牌");
        // 下发令牌
        return Result.success(new Dict()
                .set(SSOConstants.Properties.PATH_POSTFIX, contextSession.getRedirect())
                .set(SSOConstants.Properties.ACCESS_TOKEN, ssoUserSession.getAccessToken())
                .set(SSOConstants.Properties.REFRESH_TOKEN, ssoUserSession.getRefreshToken())
        );
    }

    /**
     * 注销
     *
     * @return {@link Object }
     * @author wei.chen
     * @date 2022/7/7 10:39
     */
    public Object ssoDoLogout() {
        log.info("[ ssoDoLogout ] ===>执行注销");
        SSOContextSession contextSession = manager.getContextSession();
        // 拦截非法注销
        if (!ssoToken.verifyToken(contextSession.getAccessToken())) {
            log.error("[ ssoDoLogout ] ===>执行注销:{}",SSOExceptionStatus.ILLEGALITY_TOKEN.getMessage());
            throw new SSORunException(SSOExceptionStatus.ILLEGALITY_TOKEN);
        }
        manager.removeUserSession(contextSession.getAccessToken());
        log.info("[ ssoDoLogout ] ===>已成功注销");
        return Result.success();
    }
}
前端
sso-client

核心代码,未获取到令牌进入登录页面,否则进行认证令牌是否合法,合法重定向到当前访问界面

/**
 * 挂载路由导航守卫
 * @param {*} to 将要访问的路径
 * @param {*} from 从哪个路径跳转而来
 * @param {*} next 是一个函数,表示放行,next()放行,next('/login')表强制跳转
 */
router.beforeEach((to, from, next) => {

  let token = VueCookie.get('token');
 
  if(!token){
    let redirectUrl = window.location.origin;
    window.location.href="http://server.sso.com:8880/sso/login?redirect="+redirectUrl;
  }else{
    window.location.href="http://server.sso.com:8880/sso/auth?redirect="+redirectUrl;
  }
})
sso-auth

核心代码,进行登录,登录成功拿到响应数据redirect地址与token令牌,将token令牌存储在Cookies domain=.sso.com中,达到同域共享令牌,紧接着重定向至要访问的页面

<template>
  ... 登录布局
</template>


<script>


export default {
  data() {
    return {
      ,
    };
  },
  methods: {
    //提交表单
    login() {
      this.loading = true;
      console.log(window.location.href);
      this.$http({
        url: this.$http.adornUrl("sso/doLogin"),
        method: "post",
        data: this.$http.adornData(this.loginForm, false),
      })
        .then(({ data }) => {
          data = data.data;
          this.loading = false;
          console.log("[ token ]=", data.access_token);
          // 设置30分钟过期
          var millisecond = new Date().getTime();
          var expiresTime = new Date(millisecond + 60 * 30000);
          this.$cookies.set("token", data.access_token, {
            expires: expiresTime,domain: '.sso.com' 
          });
          debugger
          
          this.$message({
            message: "登录成功",
            type: "success",
            duration: 1000,
            onClose: function () {
              window.location.href = data.path;
            },
          });
        })
        .catch((e) => {
          this.$message({
            message: e.data.message,
            type: "error",
            duration: 1000,
          });
          this.loading = false;
        });
    }
  },
};
</script>


  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值