redis + vue + springcloud 实现跨域 sso 单点登录

3,844 阅读6分钟

1、概述

SSO英文全称Single Sign On,单点登录。SSO是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。比如天猫和淘宝,都进入登录页面,都要求你登录的,现在你在淘宝处登录后,直接在天猫处刷新,你会发现,你已经登录了。

2、sso实现原理图

1、本demo原理图

2、原理图2

3、遇到的问题

1、同源策略

同源策略,它是由Netscape提出的一个著名的安全策略。 现在所有支持JavaScript 的浏览器都会使用这个策略。 所谓同源是指,域名,协议,端口相同。 当一个浏览器的两个tab页中分别打开来 百度和谷歌的页面 当浏览器的百度tab页执行一个脚本的时候会检查这个脚本是属于哪个页面的, 即检查是否同源,只有和百度同源的脚本才会被执行。 如果非同源,那么在请求数据时,浏览器会在控制台中报一个异常,提示拒绝访问。 同源策略是浏览器的行为,是为了保护本地数据不被JavaScript代码获取回来的数据污染,因此拦截的是客户端发出的请求回来的数据接收,即请求发送了,服务器响应了,但是无法被浏览器接收。

2、cookie 域

cookie 的域(通常对应网站的域名),浏览器发送 http 请求时会自动携带与该域匹配的 cookie,而不是所有 cookie。

解决方案

  • 1、使用 nginx 反向代理将所有服务同源

  • 2、认证登录成功创建所有服务的会话(资源浪费)

  • 3、跨域 cookie 重定向带参同步

    将cookie先放置在一个域下,需要登录的请求访问这个域获取到该域下的参数时携带参数重定向会原系统域下参考

    【本demo原理图】

3、跨域请求

由于浏览器安全限制访问非同域下的资源时会拒绝访问

跨域请求解决方案

springboot允许跨域请求

4、redisTemplate 写入redis 值设置过期时间后,获取数据会得到控制字符导致无法转化成Bean对象

不知道什么原因导致的,求大佬告知

解决方案

替换控制字符

replaceAll("[\\x00-\\x09\\x11\\x12\\x14-\\x1F\\x7F]","");

参考链接

5、redisTemplate使用默认的jdkSerializeable 序列化器遇到无法序列化问题

解决方案

配置redisTemplate使用StringRedisSerializer 序列化器

@Configuration
public class RedisConfig {
    @Autowired
    private RedisConnectionFactory redisConnectionFactory;

    @Bean
    public RedisTemplate<String,String> redisTemplate(){
        RedisTemplate<String,String> redisTemplate = new RedisTemplate<>();
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        return redisTemplate;
    }
}

6、spring 拦截器中无法@Autowired 注入bean

解决方案

初始化拦截器时将拦截器先交由spring 托管,此时bean就会注入到拦截器中


/**
 * 拦截器初始化配置
 */
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
    /**
     * 解决拦截器不能注入bean 问题
     * @return
     */
    @Bean
    WebInterceptor WebInterceptor(){
        return new WebInterceptor();
    }

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

4、实现过程

1、创建spring boot 集成 redisTemplate 提供redis 服务

1、修改pom增加依赖

pom 主要增加依赖

   <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

cloud 服务统一依赖

 <!-- Spring Cloud eureka Begin -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
    </dependency>
    <!-- zipkin begin-->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-zipkin</artifactId>
    </dependency>
    <!-- Spring Cloud eureka End -->
    <!-- config begin-->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-config</artifactId>
    </dependency>
    <!-- admin begin-->
    <dependency>
        <groupId>org.jolokia</groupId>
        <artifactId>jolokia-core</artifactId>
    </dependency>
    <dependency>
        <groupId>de.codecentric</groupId>
        <artifactId>spring-boot-admin-starter-client</artifactId>
        <version>${spring-cloud-admin.version}</version>
    </dependency>
    <!--feign Begin-->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-openfeign</artifactId>
    </dependency>
    <!--feign End-->

    <!-- config获取不到配置时自动重试 begin-->
    <dependency>
        <groupId>org.springframework.retry</groupId>
        <artifactId>spring-retry</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-aop</artifactId>
    </dependency>
    <!-- config获取不到配置时自动重试 end-->
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>fastjson</artifactId>
        <version>${fastjson.version}</version>
    </dependency>

2、application.yml 配置redis 参数

#redis配置
spring:
  redis:
    host: xxx.xxx.xxx.xxx
    port: 6379
    lettuce:
      pool:
        max-active: 8
        max-idle: 8
        max-wait: -1ms
        min-idle: 0

3、修改redisTemplate 默认序列化器

demo中由于使用默认的jdkSerializeable 序列化器遇到无法序列化问题所以更换序列化器

StringRedisSerializer 只在 byte 与 String 之间转化

@Configuration
public class RedisConfig {
    @Autowired
    private RedisConnectionFactory redisConnectionFactory;

    @Bean
    public RedisTemplate<String,String> redisTemplate(){
        RedisTemplate<String,String> redisTemplate = new RedisTemplate<>();
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        return redisTemplate;
    }
}

4、创建redis restfull服务

@RestController
public class RedisController {
    @Autowired
    private RedisTemplate redisTemplate;
    /**
     * 取值
     * @param key
     * @return
     */
    @RequestMapping(value = "get")
    public String get(String key){
        String value;
        try {
            value = (String) redisTemplate.opsForValue().get(key);
            if(StringUtils.isNotBlank(value)){
                //替换控制字符
                value = value.replaceAll("[\\x00-\\x09\\x11\\x12\\x14-\\x1F\\x7F]","");
            }
        }catch (Exception e){
            e.printStackTrace();
            return null;
        }
        return value;
    }

    /**
     * 写值
     * @param key
     * @param value
     * @param seconds
     * @return
     */
    @RequestMapping(value = "put")
    public String put(String key,String value,@RequestParam(required = false) Long seconds){
        try {
            if (seconds == null){
                redisTemplate.opsForValue().set(key,value);
            }else {
                redisTemplate.opsForValue().set(key,value,seconds);
            }
        }catch (Exception e){
            e.printStackTrace();
            return "ERROR";
        }
        return "OK";
    }


}

2、创建sso统一认证中心

1、登录方法实现

由于vue 与 认证中心不再同域下 cookie 无法共享所以 token 放置在参数中直接返回

 /**
     * 登录
     *
     * @param sysUser
     * @return
     */
    @RequestMapping(value = "login")
    public Map<String, Object> login(@RequestBody SysUser sysUser, HttpServletRequest request, HttpServletResponse response) {
        Map<String, Object> resultMap = new HashMap<>();
        try {
            if (sysUser != null && StringUtils.isNotBlank(sysUser.getUserName()) && StringUtils.isNotBlank(sysUser.getPassword())) {
                SysUser result = sysUserService.getUserByLoginName(sysUser);
                //登录成功
                if (result != null && StringUtils.isNotBlank(result.getPassword()) && sysUser.getPassword().equals(result.getPassword())) {
                    //登录信息存入redis
                    String token = UUID.randomUUID().toString();
                    String userJson = JSON.toJSONString(result);
                    String flag = loginService.redisPut(token, userJson, 60*60*2L);
                    if("ERROR".equals(flag)){
                        throw new RuntimeException("redis调用异常1");
                    }
                    resultMap.put("code", "1");
                    resultMap.put("data", result);
                    //返回token 值
                    resultMap.put("token",token);
                } else {
                    resultMap.put("code", "-1");
                    resultMap.put("message", "用户或密码错误");
                }
            } else {
                resultMap.put("code", "-99");
                resultMap.put("message", "参数错误");
            }
            return resultMap;
        } catch (Exception e) {
            e.printStackTrace();
            resultMap.clear();
            resultMap.put("code", "-999");
            resultMap.put("message", "系统错误稍后重试");
            return resultMap;
        }
    }

3、vue 关键代码

vue全局方法

//设置cookie
Vue.prototype.setCookie = function(c_name,value,expiredays) {
  var exdate=new Date()
  exdate.setDate(exdate.getDate()+expiredays)
  document.cookie=c_name+ "=" +escape(value)+
    ((expiredays==null) ? "" : ";expires="+exdate.toGMTString())
};

//获取cookie
Vue.prototype.getCookie=function(c_name) {
  if (document.cookie.length>0)
  {
    var  c_start=document.cookie.indexOf(c_name + "=")
    if (c_start!=-1)
    {
      c_start=c_start + c_name.length+1
      var c_end=document.cookie.indexOf(";",c_start)
      if (c_end==-1) c_end=document.cookie.length
      return unescape(document.cookie.substring(c_start,c_end))
    }
  }
  return ""
};

//获取url中的参数
Vue.prototype.getUrlKey=function(name) {
    return decodeURIComponent((new RegExp('[?|&]' + name + '=' + '([^&;]+?)(&|#|;|$)').exec(location.href) || [, ""])[1].replace(/\+/g, '%20')) || null
};

将认证中心返回的token写入到cookie中

 //将token 写入cookie
this.setCookie("token",repos.data.token);

vue 关键核心代码

<template>
    
</template>

<script>
    export default {
      name: "SsoIndex",
      //钩子函数用于同步不同域之间的cookie 同步
      beforeCreate:function () {
        let token = this.getCookie("token");
        let url = this.getUrlKey("redirect");
        //如果有存在token则直接响应给后台
        if(token){
          location.href = url+"?token="+token;
        }
        //否则返回不存在
        else{
          location.href = url+"?token=not";
        }

      }
    }
</script>

<style scoped>

</style>

4、系统A、系统B 拦截器代码

config初始化拦截器代码

**
 * 拦截器初始化配置
 */
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
    /**
     * 解决拦截器不能注入bean 问题
     * @return
     */
    @Bean
    WebInterceptor WebInterceptor(){
        return new WebInterceptor();
    }

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

拦截器代码


/***
 * 未登录请求拦截
 */
@Component
public class WebInterceptor implements HandlerInterceptor {

    @Autowired
    private RedisService redisService;
    /**
     * 未执行请求方法前拦截
     * @param request
     * @param response
     * @param handler
     * @return
     * @throws IOException
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
        SysUser sysUser = (SysUser) request.getSession().getAttribute("loginUser");
        //子系统不存在局部会话   尝试获取统一认证中心会话信息
        if(sysUser == null){
            String token = request.getParameter("token");
            //如果没有token到统一认证页获取
            if(StringUtils.isBlank(token)){
                response.sendRedirect("http://localhost:8080/ssoIndex?redirect="+request.getRequestURL());
                return false;
            }
            //如果token 等于not 说明未登录 跳转sso登录
            else if("not".equals(token)){
                response.sendRedirect("http://localhost:8080/login?redirect="+request.getRequestURL());
                return false;
            }
            //根据 token 获取redis 登录数据
            String json = redisService.redisGet(token);
            //token 有效已登录
            if(StringUtils.isNotBlank(json)){
                try {
                    SysUser user = MapperUtils.json2pojo(json,SysUser.class);
                    //创建局部会话信息
                    request.getSession().setAttribute("loginUser",user);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
            //验证局部会话是否创建完毕
            sysUser = (SysUser) request.getSession().getAttribute("loginUser");
            //没有局部会话说明认证失效跳转sso重新认证
            if(sysUser == null){
                response.sendRedirect("http://localhost:8080/login?redirect="+request.getRequestURL());
                return false;
            }
        }
        return true;
    }
}

5、实现效果

1、系统1

2、系统2

由于系统1已经登录直接跳转登录成功