分布式会话与单点登录SSO系统CAS,包含完整示例代码实现

往期文章一览

【7W字长文】使用LVS+Keepalived实现Nginx高可用,一文搞懂Nginx

【15W字长文】主从复制高可用Redis集群,完整包含Redis所有知识点

分布式会话与单点登录SSO

分布式会话介绍与实现

什么是会话

会话Session代表的是客户端与服务器的一次交互过程,这个过程可以是连续也可以是时断时续的。会话较多用于网络上,TCP的三次握手就创建了一个会话,TCP关闭连接就是关闭会话。会话Session代表的是客户端与服务器的一次交互过程,这个过程可以是连续也可以是时断时续的。曾经的Servlet时代(jsp),一旦用户与服务端交互,服务器用户创建一个session,同时前端会有一个jsessionid,每次交互都会携带。如此一来,服务器只要在接到用户请求时候,就可以拿到jsessionid,并根据这个ID在内存中找到对应的会话session,当拿到session会话后,那么我们就可以操作会话了。

会话存活期间,我们就能认为用户一直处于正在使用着网站的状态,一旦session超期过时,就可以认为用户已经离开网站,停止交互了。用户的身份信息,我们也是通过session来判断的,在session中可以保存不同用户的信息。

session的使用之前在单体部分演示过,代码如下:

@GetMapping("/setSession")
public Object setSession(HttpServletRequest request) {
    HttpSession session = request.getSession();
    session.setAttribute("userInfo", "new user");
    session.setMaxInactiveInterval(3600);
    session.getAttribute("userInfo");
    // session.removeAttribute("userInfo");
    return "ok";
}

无状态会话

HTTP请求是无状态的,用户向服务端发起多个请求,服务端并不会知道这多次请求都是来自同一用户,这个就是无状态的 。cookie的出现就是为了有状态的记录用户。

常见的前后端分离交互,小程序与服务端交互,安卓与服务端交互,他们都是通过http请求来调用接口,每次交互服务端都不会拿到客户端的状态,我们一般会在每次请求的时候都携带userId或者token,这样后台就可以根据用户ID或者token来获取响应的请求,这样每个用户的下一次请求都能被服务端识别来自同一个用户。

有状态会话

有状态的会话也是基于无状态会话的,Tomcat的会话,就是有状态的。一旦用户和服务器交互,就有会话,会话保存了用户的信息,这样用户就有状态了。服务端会和每个客户端都保持着这样的一层关系,这个由容器来管理,这个session会话是保存到内存空间的,如此一来,当不同的用户访问服务端,那么就能通过会话知道是谁了。tomcat会话的出现也是为了让http请求变得有状态。如果用户不再和服务端交互,那么会话就会超时而消失,结束了他的生命周期,如此一来,每个用户其实都会有一个会话被维护,这就是有状态的会话。

  • 注:tomcat会话可以通过手段实现多系统之间的状态同步,但是会损耗一定的时间,一旦发生同步那么用户请求就会等待,这种做法不可取

单Tomcat会话

先来看一下单个tomcat会话,这个就有状态的,用户首次访问服务端,这个时候会话产生,并且会设置jsessionid放入cookie,后续每次请求都会携带jsessionid以保持会话状态

分布式会话与单点登录SSO系统CAS,包含完整示例代码实现_第1张图片

动静分离会话

用户请求服务端,由于前后端分离,前端发起http请求,不会携带任何状态,当用户第一次请求后,我们手动设置一个token,作为会话,放入Redis中,如此作为Redis-session,并且这个token设置后放入前端的cookie中,如此后续的交互,前端只需要传递token给后端,后端就能识别这个用户来自谁了。

分布式会话与单点登录SSO系统CAS,包含完整示例代码实现_第2张图片

集群分布系统会话

集群或者分布式系统本质是多个系统,假设这个里有两个服务器节点,分别是AB系统,一开始用户和A系统交互,那么这个时候的用户状态,我们可以保存到Redis中,作为A系统的会话信息,随后用户的请求进入B系统,那么B系统中的会话我也同样和Redis关联,如此AB系统的session就统一了。当然cookie是会随着用户的访问携带的。这个其实就是分布式会话,通过Redis来保存用户的状态

分布式会话与单点登录SSO系统CAS,包含完整示例代码实现_第3张图片

当我们后端 Web 应用扩展到多台后,我们就会碰到分布式一致性 Session 的问题,主流解决方案有四种

  • Session 复制:利用 Tomcat 等 Web 容器同步复制功能(节点一多,复制时占用带宽大,每个节点都要维护所有的会话,占用内存大,不推荐)

  • Session 前端存储:利用用户浏览器中 Cookie 保存 Session 信息,即前端存储用户信息(需要考虑加密,容易被篡改,不安全,不推荐)

  • Session 粘滞方案:利用 Nginx 可以做四层 根据IP Hash 或七层 Hash 的特性,保证用户的请求都落在同一台机器上(同一个IP只会到同一个节点上,可以做临时方案)

  • Session 后端集中存储方案:利用 Redis 集中存储会话,Web 应用重启或扩容,Session 也不会丢失(主流方案,推荐)。

参考链接:

分布式会话和基于TOKEN的分布式会话

小白对话:4种分布式Session的实现方式

基于Redis分布式会话(推荐)

  1. 修改登录接口,在用户登录完成之后,保存用户信息(基本信息,角色,权限信息等)到Redis中,设置会话过期时间,若有操作则刷新过期时间,长时间不操作系统自动清除会话,key为UserId或者UUID,并返回给前端,前端请求接口时需携带此信息(使用UUID代表当前用户会话)。

    前端请求方式

    • 前端请求只携带UserId

      这时key是UserId,可以在登录的时候判断Redis中是否已经存在这个UserId,若有可以阻止登录,保证了一个账号同一时间只能有同一个人登录系统;

    • 前端请求只携带UUID

      这时key是UUID,代表系统允许一个账号多个用户登录,因为每次登录请求过来时,都会生成一个UUID作为key,Redis中同样会再存一份相同的用户信息。

    • 前端请求携带UserId和UUID

      这时key为UserId,登录时生成的UUID和其他信息一同保存到Redis中。在验证时取出可以对比前端传的UUID和Redis中的UUID,相同则说明是同一个人操作登录的;若不同,则说明由另外的人在异地登录,可以做一个提示,让当前用户重新登录

  2. 修改退出登录接口,退出登录时需要删除Redis中保存的用户信息

  3. 修改用户信息或角色权限等信息时也要同时修改Redis中的信息

基于SpringSession分布式会话

  1. 引入session和security的依赖,因为session依赖了security的一些内容,所以必须导入security依赖

    <dependency>
        <groupId>org.springframework.sessiongroupId>
        <artifactId>spring-session-data-RedisartifactId>
    dependency>
    
    <dependency>
        <groupId>org.springframework.bootgroupId>
        <artifactId>spring-boot-starter-securityartifactId>
    dependency>
    
  2. 修改配置文件指定session存储的介质

    spring:
      session:
        store-type: Redis
    
  3. 启动类上开启session功能

    @EnableRedisHttpSession  // 开启通过Redis管理用户会话
    @SpringBootApplication(exclude = {SecurityAutoConfiguration.class}) // 移除security依赖中自带的登录,若项目中本身就使用到了security则不需要配置
    public class StartApplication {
        public static void main(String[] args) {
            SpringApplication.run(StartApplication.class, args);
        }
    }
    
  4. 在controller层使用HttpServletRequestgetSession()就能获取到HttpSession对象进行赋值和取值了,与原来的使用方式相同。不同点的是原来是由springboot内置的tomcat管理,现在是由Redis统一管理。在分布式环境下,Redis就可以统一管理所有的Session了。

    分布式会话与单点登录SSO系统CAS,包含完整示例代码实现_第4张图片

CAS系统简单实现

CAS是Central Authentication Service的缩写,中央认证服务,一种独立开放指令协议。CAS 是 耶鲁大学(Yale University)发起的一个开源项目,旨在为 Web 应用系统提供一种可靠的单点登录方法,CAS 在 2004 年 12 月正式成为 JA-SIG 的一个项目。

协议特点

  1. 开源的企业级单点登录解决方案。
  2. CAS Server 为需要独立部署的 Web 应用。
  3. CAS Client 支持非常多的客户端(这里指单点登录系统中的各个 Web 应用),包括 Java, .Net, PHP, Perl, Apache, uPortal, Ruby 等语言编写的各种web应用。
  4. CAS属于Apache 2.0许可证,允许代码修改,再发布(作为开源或商业软件)。

优点

  1. 多个系统中只需要登录一次,就可以用已登录的身份访问所有相互信任的多个系统。
  2. 解决子系统登录问题,直接在父系统统一进行登录。
  3. 企业级业务系统通用账号,只需登录一次。

原理和协议

从结构上看,CAS 包含两个部分: CAS Server 和 CAS Client。CAS Server 需要独立部署,主要负责对用户的认证工作;CAS Client 负责处理对客户端受保护资源的访问请求,需要登录时,重定向到 CAS Server。图1是CAS最基本的协议过程:

分布式会话与单点登录SSO系统CAS,包含完整示例代码实现_第5张图片

CAS Client 与受保护的客户端应用部署在一起,以 Filter 方式保护受保护的资源。

对于访问受保护资源的每个 Web 请求,CAS Client 会分析该请求的 Http 请求中是否包含 Service Ticket,如果没有,则说明当前用户尚未登录,于是将请求重定向到指定好的 CAS Server 登录地址,并传递 Service (也就是要访问的目的资源地址),以便登录成功过后转回该地址。

用户在第 3 步中输入认证信息,如果登录成功,CAS Server 随机产生一个相当长度、唯一、不可伪造的 Service Ticket,并缓存以待将来验证,之后系统自动重定向到 Service 所在地址,并为客户端浏览器设置一个 Ticket Granted Cookie(TGC),CAS Client 在拿到 Service 和新产生的 Ticket 过后,在第 5,6 步中与 CAS Server 进行身份核实,以确保 Service Ticket 的合法性。

在该协议中,所有与 CAS 的交互均采用 SSL 协议,确保,ST 和 TGC 的安全性。协议工作过程中会有 2 次重定向的过程,但是 CAS Client 与 CAS Server 之间进行 Ticket 验证的过程对于用户是透明的。

另外,CAS 协议中还提供了 Proxy (代理)模式,以适应更加高级、复杂的应用场景,具体介绍可以参考 CAS 官方网站上的相关文档。

什么是单点登录

单点登录又称之为Single Sign On,简称SSO,单点登录可以通过基于用户会话的共享。利用单点登录可以实现用户只登录一次就可以访问几个不同的网站。用户自始至终只在某一个网站下登录后,那么他所产生的会话,就共享给了其他的网站,实现了单点网站登录后,同时间接登录了其他的网站,那么这个其实就是单点登录,他们的会话是共享的,都是同一个用户会话。

例如:登录了qq,那么qq音乐,qq视频之类的就都不用登录了,可以直接访问。

相同顶级域名的单点登录解决方案

因为是相同的顶级域名,顶级域名和下级域名之间cookie是共享的,这样通过cookie+Redis就可以实现单点登录。

如果分布式会话后端是基于Redis的,此会话可以在后端的任意系统都能获取到缓存中的用户数据信息,前端通过使用cookie,可以保证在同域名的一级二级站点下获取,那么这样一来,cookie中的信息userid和token是可以在发送请求的时候携带上的,这样从前端请求后端后是可以获取拿到的,这样一来,其实用户在某一端登录注册以后,其实cookie和Redis中都会带有用户信息,只要用户不退出,那么就能在任意一个站点实现登录了。

那么这个原理主要也是cookie和网站的依赖关系,顶级域名 和下级域名的cookie值是可以共享的,可以被携带至后端的。

二级域名自己的独立cookie是不能共享的,不能被其他二级域名获取,比如:a.abc.com的cookie是不能被b.abc.com共享,两者互不影响,要共享必须设置为.abc.com。

不同顶级域名的单点登录解决方案

这个时候的cookie由于顶级域名不同,就不能实现cookie跨域了,每个站点各自请求到服务端,cookie无法同步。比如,www.aaa.com下的用户发起请求后会有cookie,但是他又访问了www.bbb.com,由于cookie无法携带,所以会要你二次登录。

因为不同顶级域名,cookie直接是不共享的,所以就不能用cookid+reids这种方式来做单点登录了,这个时候我们可以用CAS来实现单点登录。

各个系统之间的登录会通过一个独立的登录系统去做验证,它就相当于是一个中介公司,整合了所有人,你要看房经过中介允许拿钥匙就行,实现了统一的登录。那么这个就称之为CAS系统,CAS全称为Central Authentication Service即中央认证服务,是一个单点登录的解决方案,可以用于不同顶级域名之间的单点登录。

CAS系统详解

CAS全称为Central Authentication Service即中央认证服务,是一个单点登录的解决方案,可以用于不同顶级域名之间的单点登录。

CAS登录流程图

分布式会话与单点登录SSO系统CAS,包含完整示例代码实现_第6张图片

核心票据

用户会话(分布式会话)

TGT(Ticket Grangting Ticket):TGT是CAS为用户签发的登录票据,有TGT就表明用户在CAS上成功登录过。用户在CAS认证成功后,会生成一个TGT对象,放入自己的缓存中(Session),同时生成TGC以cookie的形式写入浏览器。当再次访问CAS时,会先看cookie中是否存在TGC,如果存在则通过TGC获取TGT,如果获取到了TGT则代表用户之前登录过,通过TGT及访问来源生成针对来源的ST,用户就不用再次登录,以此来实现单点登录。

全局门票

TGC(Ticket-granting cookie):TGC就是TGT的唯一标识,以cookie的形式存在在CAS Server三级域名下,是CAS Server 用来明确用户身份的凭证。

临时门票

ST(Service Ticket):ST是CAS为用户签发的访问某一客户端的服务票据。用户访问service时,service发现用户没有ST,就会重定向到 CAS Server 去获取ST。CAS Server 接收到请求后,会先看cookie中是否存在TGC,如果存在则通过TGC获取TGT,如果获取到了TGT则代表用户之前登录过,通过TGT及访问来源生成针对来源的ST。用户凭借ST去访问service,service拿ST 去CAS Server 上进行验证,验证通过service 生成 用户session,并返回资源。

初次登录流程解析

  1. 当用户访问到A网站的时候,网站会首先判断他是否在CAS系统登录过,主要是在cookie中看是否有登录信息。如果没有登录,网站会携带上自己的url去访问CAS系统登录接口。

  2. 当CAS系统接受到这样一个请求会,会查看自己是否有cookie,如果没有会弹出登录窗口让用户登录。

  3. 如果用户登录成功后,CAS系统会进行三步处理

    1. 创建用户会话,把用户的信息作为值,把用户的id作为健存入到Redis或者其他数据库中,用来在用户登录进来后获取用户信息

    2. 创建用户全局门票,把用户id作为值,随机数当作健存入Redis和cookie中,用以表示在CAS端是否登录

    3. 创建临时门票,把随机数当作健值存入Redis中,用于回跳回传

  4. 将临时票据作为参数回调回A网站

  5. 当A网站有临时票据后会把拿着这个票据访问CAS的兑换票据接口

  6. CAS兑换票据接口会做这样几件事

    1. CAS会先判断这个票据是否是合法票据
    2. 如果是合法票据,就把这个临时票据销毁,然后通过CAS中的cookie信息去获取到用户id
    3. 在通过用户id获取到全局会话,把会话传递会A界面
  7. 当A界面获取到会话后,存入自己的cookie中,这样以后就不用再去请求CAS了

二次访问流程

  1. 当用户访问到B网站后,B网站也会判断他是否在CAS系统登录过,如果没有cookie信息(注意:这个时候会有两个cooklie信息,一个是用户的全局会话,一个是CAS的全局门票,这里说的没有cookie信息,说的是没有全局会话),网站会携带上自己的url去访问CAS系统登录接口。

  2. 因为这个时候CAS系统发现有cookie信息,证明用户已经登录过了,那就只要在创建一个临时票据回传给B网站就好了。

  3. 其他剩下操作与第一次访问相同。

参考链接:

一篇文章教你学会单点登录与CAS

CAS 中央认证服务 实现 单点登录(SSO)

代码实现

CAS系统简单实现,service层代码是随便写的,主要起一个抛砖引玉的作用

项目结构

分布式会话与单点登录SSO系统CAS,包含完整示例代码实现_第7张图片

Gradle依赖
plugins {
    id 'java'
    id 'org.springframework.boot' version '2.6.7'

}

apply plugin: 'io.spring.dependency-management'

group 'cn.maolinyuan'
version '1.0-build'

sourceCompatibility = 1.8
targetCompatibility = 1.8

repositories {
    maven {
        url 'https://maven.aliyun.com/repository/central'
    }
    maven {
        url 'https://maven.aliyun.com/repository/public'
    }
    maven {
        url 'https://maven.aliyun.com/repository/jcenter'
    }
    maven {
        url 'https://maven.aliyun.com/repository/google'
    }
    maven {
        url 'https://maven.aliyun.com/repository/spring'
    }
    maven {
        url 'https://maven.aliyun.com/repository/spring-plugin'
    }
    maven {
        url 'https://maven.aliyun.com/repository/grails-core'
    }
    maven {
        url 'https://maven.aliyun.com/repository/apache-snapshots'
    }
    maven {
        url 'https://maven.aliyun.com/repository/gradle-plugin'
    }
    mavenCentral()
}


dependencies {
    implementation(
            "junit:junit:4.12",
            "org.springframework.boot:spring-boot-starter",
            "org.springframework.boot:spring-boot-starter-web",
            "org.springframework.boot:spring-boot-starter-aop",
            "org.springframework.boot:spring-boot-starter-data-redis",
            "org.springframework.boot:spring-boot-starter-thymeleaf"
    )
    testImplementation(
            "org.springframework.boot:spring-boot-starter-test"

    )

    annotationProcessor(
            "org.projectlombok:lombok:1.18.24"
    )

    compileOnly(
            "org.projectlombok:lombok:1.18.24",
            "org.springframework.boot:spring-boot-dependencies:2.6.7"

    )
}

配置文件

application.yml

server:
  port: 8989
  servlet:
    context-path: /sso
spring:
  thymeleaf:
    mode: HTML
    encoding: UTF-8
    prefix: classpath:/templates/
    suffix: .html
  profiles:
    include: redis

application-redis.yml

spring:
  # redis 配置
  redis:
    # 地址
    host: 192.168.5.223
    # 端口,默认为6379
    port: 6379
    # 数据库索引
    database: 0
    # 密码
    password: imooc
    # 连接超时时间
    timeout: 10s
    lettuce:
      pool:
        # 连接池中的最小空闲连接
        min-idle: 0
        # 连接池中的最大空闲连接
        max-idle: 8
        # 连接池的最大数据库连接数
        max-active: 8
        # #连接池最大阻塞等待时间(使用负值表示没有限制)
        max-wait: -1ms
资源文件

templates/login.html

DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>SSO LOGINtitle>
head>
<body>
    <h1>CENTRAL AUTHENTICATION SERVICEh1>
    <form action="doLogin" method="post">
        <label>
            <input type="text" name="username" placeholder="请输入用户名"/>
        label>
        <label>
            <input type="password" name="password" placeholder="请输入密码"/>
        label>
        <input type="hidden" name="returnUrl" th:value="${returnUrl}"/>
        <input type="submit" value="提交登录"/>
    form>
    <span style="color: red" th:text="${errmsg}">span>
body>
html>
controller

SSOController

package cn.maolinyuan.controller;

import cn.maolinyuan.po.User;
import cn.maolinyuan.service.IUserService;
import cn.maolinyuan.vo.UserVo;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.BeanUtils;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import javax.annotation.Resource;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

/**
 * sso 认证服务
 *
 * @author maolinyuan
 * @version 1.0
 * @date 2022/5/5 16:27
 */
@Controller
@RequestMapping("/auth")
public class SSOController {

    @Resource
    private IUserService service;

    @Resource
    private RedisTemplate<Object, Object> redisTemplate;

    private final String REDIS_USER_TOKEN = "redis_user_token";

    private final String REDIS_USER_TICKET = "redis_user_ticket";

    private final String REDIS_TMP_TICKET = "redis_tmp_ticket";

    private final String COOKIE_USER_TICKET = "cookie_user_ticket";

    private final ObjectMapper objectMapper = new ObjectMapper();


    @GetMapping("/login")
    public String login(String returnUrl,
                        Model model,
                        HttpServletRequest request,
                        HttpServletResponse response) {


        model.addAttribute("returnUrl", returnUrl);

        // 获取userTicket门票,如果cookie中能够获取说明用户登录过,此时签发一个一次性的临时门票
        String userTicket = getCookie(COOKIE_USER_TICKET, request);
        boolean isVerified = verifyUserTicket(userTicket);
        if (isVerified) {
            String tmpTicket = createTmpTicket();
            return "redirect:" + returnUrl + "?tmpTicket=" + tmpTicket;
        }

        // 用户从未登录过跳转到CAS的统一认证页面
        return "login";
    }

    /**
     * 验证用户全局门票是否有效
     *
     * @param userTicket
     * @return
     */
    private boolean verifyUserTicket(String userTicket) {
        if (null == userTicket || "".equals(userTicket)) {
            return false;
        }
        // 获取用户会话
        String userId = String.valueOf(redisTemplate.opsForValue().get(REDIS_USER_TICKET + ":" + userTicket));
        if (null == userId || "".equals(userId)) {
            return false;
        }
        String userVoStr = String.valueOf(redisTemplate.opsForValue().get(REDIS_USER_TOKEN + ":" + userId));
        return null != userVoStr && !"".equals(userVoStr);
    }


    /**
     * CAS的统一登录接口
     * 1. 登陆后创建用户全局会话 uniqueToken
     * 2. 创建用户全局门票,用以表示在CAS端是否登录 userTicket
     * 3. 创建用户的临时门票,用于会跳回传 tmpTicket
     * 用临时票据加上cookie的全局门票才可以获取到用户会话信息
     *
     * @param username
     * @param password
     * @param returnUrl
     * @param model
     * @param request
     * @param response
     * @return
     * @throws JsonProcessingException
     */
    @PostMapping("/doLogin")
    public String doLogin(String username,
                          String password,
                          String returnUrl,
                          Model model,
                          HttpServletRequest request,
                          HttpServletResponse response) throws JsonProcessingException {

        model.addAttribute("returnUrl", returnUrl);

        // 判断参数不能为空
        if (null != username && !"".equals(username) && null != password && !"".equals(password)) {
            model.addAttribute("errmsg", "用户名和密码不能为空");
            return "login";
        }

        // 判断用户
        User user = service.findUserByUsername(username);
        assert password != null;
        if (!password.equals(user.getPassword())) {
            model.addAttribute("errmsg", "用户名或密码错误");
            return "login";
        }

        // redis用户会话 用uuid来标识 使用用户id关联用户信息和创建用户会话的uuid
        String uniqueToken = UUID.randomUUID().toString().trim();
        UserVo userVo = new UserVo();
        BeanUtils.copyProperties(user, userVo);
        userVo.setUniqueToken(uniqueToken);
        redisTemplate.opsForValue().set(REDIS_USER_TOKEN + ":" + user.getId(), objectMapper.writeValueAsString(userVo));


        // 生成ticket门票,全局门票,代表用户在CAS端登录过
        String userTicket = UUID.randomUUID().toString().trim();

        // 用户全局门票需要放入CAS端的cookie中
        setCookie(COOKIE_USER_TICKET, userTicket, response);

        // userTicket关联用户id,并且放入redis中,代表用户有门票了,可以访问各个网站
        redisTemplate.opsForValue().set(REDIS_USER_TICKET + ":" + userTicket, user.getId());

        // 生成临时票据,会跳到调用端网站,是由CAS签发的一个一次性的临时ticket
        String tmpTicket = createTmpTicket();


        return "redirect:" + returnUrl + "?tmpTicket=" + tmpTicket;

    }


    /**
     * 验证临时票据,返回值可以使用统一返回类包装。
     *
     * @param tmpTicket
     * @param request
     * @param response
     * @return
     */
    @PostMapping("/verifyTmpTicket")
    @ResponseBody
    public UserVo verifyTmpTicket(String tmpTicket,
                                  HttpServletRequest request,
                                  HttpServletResponse response) throws JsonProcessingException {

        // 使用一次性票据来验证用户是否登陆过,如果登录过,把用户会话返回给站点,使用完毕后销毁临时票据
        String tempTicketValue = String.valueOf(redisTemplate.opsForValue().get(REDIS_TMP_TICKET + ":" + tmpTicket));
        if (null == tempTicketValue || "".equals(tempTicketValue)) {
            // 用户票据异常 不返回用户会话信息
            return null;
        }

        // 如果临时票据有效,则需要销毁,并且拿到CAS端cookie中的全局userTicket,以此再获取用户的全局会话信息
        // 如果value值经过加密的,需要将value解密,或者将tmpTicket加密之后进行对比
        if (!tempTicketValue.equals(tmpTicket)) {
            // 用户票据异常
            return null;
        } else {
            // 销毁临时票据
            redisTemplate.delete(REDIS_TMP_TICKET + ":" + tmpTicket);
        }

        // 验证并且从cookie中获取用户的userTicket
        String userTicket = getCookie(COOKIE_USER_TICKET, request);

        // 获取用户会话
        String userId = String.valueOf(redisTemplate.opsForValue().get(REDIS_USER_TICKET + ":" + userTicket));
        if (null == userId || "".equals(userId)) {
            // 用户票据异常
            return null;
        }
        String userVoStr = String.valueOf(redisTemplate.opsForValue().get(REDIS_USER_TOKEN + ":" + userId));
        if (null == userVoStr || "".equals(userVoStr)) {
            // 用户会话信息异常
            return null;
        }

        // 返回用户会话信息到前端,前端需要保存此信息
        return objectMapper.readValue(userVoStr, UserVo.class);

    }

    /**
     * 退出登录
     *
     * 当a网站点击了退出登录之后,会清除a网站本地的用户信息和服务器的分布式会话信息,
     * 而其他网站的所保存的本地用户会话信息还存在,所以,当所有网站请求后端服务时都
     * 要做拦截器统一校验分布式会话是否存在,不存在则需要提示前端让他重新登录。
     * @param userId
     * @param request
     * @param response
     */
    @PostMapping("/logout")
    @ResponseBody
    public void logout(String userId,
                       HttpServletRequest request,
                       HttpServletResponse response) {
        // 获取CAS中的用户门票
        String userTicket = getCookie(COOKIE_USER_TICKET, request);

        // 清除redis和cookie中的userTicket用户票据和会话信息
        deleteCookie(COOKIE_USER_TICKET,response);
        redisTemplate.delete(REDIS_USER_TICKET + ":" + userTicket);
        redisTemplate.delete(REDIS_USER_TOKEN + ":" + userId);

    }


    /**
     * 创建临时票据
     *
     * @return
     */
    private String createTmpTicket() {
        String tmpTicket = UUID.randomUUID().toString().trim();
        // 其中value值可以进行加密后再保存
        redisTemplate.opsForValue().set(REDIS_TMP_TICKET + ":" + tmpTicket, tmpTicket, 600, TimeUnit.SECONDS);
        return tmpTicket;
    }


    /**
     * 设置CAS的cookie
     *
     * @param key
     * @param val
     * @param response
     */
    private void setCookie(String key, String val, HttpServletResponse response) {
        Cookie cookie = new Cookie(key, val);
        // 重要:设置CAS服务的域名或者ip,只有这样浏览器在访问CAS的时候才会自动带上 userTicket
        cookie.setDomain("sso.com");
        cookie.setPath("/");
        response.addCookie(cookie);
    }

    /**
     * 删除CAS的cookie
     *
     * @param key
     * @param response
     */
    private void deleteCookie(String key, HttpServletResponse response) {
        Cookie cookie = new Cookie(key, null);
        // 重要:设置CAS服务的域名或者ip,只有这样浏览器在访问CAS的时候才会自动带上 userTicket
        cookie.setDomain("sso.com");
        cookie.setPath("/");
        cookie.setMaxAge(-1);
        response.addCookie(cookie);
    }


    /**
     * 获取请求中的cookie
     *
     * @param key
     * @param request
     * @return
     */
    private String getCookie(String key, HttpServletRequest request) {
        Cookie[] cookies = request.getCookies();
        if (null == cookies || cookies.length == 0 || null == key) {
            return null;
        }
        for (Cookie cookie : cookies) {
            if (cookie.getName().equals(key)) {
                return cookie.getValue();
            }
        }
        return null;
    }

}

service

IUserService

package cn.maolinyuan.service;

import cn.maolinyuan.po.User;

/**
 * @author maolinyuan
 * @version 1.0
 * @date 2022/5/5 16:52
 */
public interface IUserService {

    /**
     * 根据username获取用户
     * @param username
     * @return
     */
    User findUserByUsername(String username);

}

UserServiceImpl

package cn.maolinyuan.service.impl;

import cn.maolinyuan.po.User;
import cn.maolinyuan.service.IUserService;
import org.springframework.stereotype.Service;

/**
 * @author maolinyuan
 * @version 1.0
 * @date 2022/5/5 16:52
 */
@Service
public class UserServiceImpl implements IUserService {
    /**
     * 根据username获取用户
     *
     * @param username
     * @return
     */
    @Override
    public User findUserByUsername(String username) {
        User user = new User();
        user.setId(1L);
        user.setUsername("root");
        user.setPassword("admin");
        user.setState("0");
        return user;
    }
}

po

User

package cn.maolinyuan.po;

import lombok.Data;

import java.io.Serializable;

/**
 * @author maolinyuan
 * @version 1.0
 * @date 2022/5/5 16:54
 */
@Data
public class User implements Serializable {

    private Long id;

    private String username;

    private String password;

    private String state;

}

vo
package cn.maolinyuan.vo;

import lombok.Data;

import java.io.Serializable;

/**
 * @author maolinyuan
 * @version 1.0
 * @date 2022/5/6 9:34
 */
@Data
public class UserVo implements Serializable {

    private Long id;

    private String username;

    private String password;

    private String state;

    private String uniqueToken;

}

filter

CrossFilter

package cn.maolinyuan.filter;

import org.springframework.stereotype.Component;

import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * @author maolinyuan
 * @version 1.0
 * @date 2021/2/1 13:49
 */
@Component
@WebFilter("/*")
public class CrossFilter implements Filter {
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {

    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        //都允许跨域
        HttpServletResponse response = (HttpServletResponse) servletResponse;
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        response.setHeader("Access-Control-Allow-Methods", "POST, GET, PUT, OPTIONS, DELETE, PATCH");
        response.setHeader("Access-Control-Max-Age", "3600");
        response.setHeader("Access-Control-Allow-Origin", request.getHeader("Origin"));
        response.setHeader("Access-Control-Allow-Headers", "Origin, No-Cache, X-Requested-With, If-Modified-Since, Pragma, Last-Modified, Cache-Control, Expires, Content-Type, X-E4M-With, Authentication , authentication, token, Token");
        response.setHeader("Access-Control-Allow-Credentials", "true");
        //如果是OPTIONS请求就return 往后执行会到业务代码中 他不带参数会产生异常
        if ("OPTIONS".equals(request.getMethod())) {
            return;
        }
        //第二次就是POST请求 之前设置了跨域就能正常执行代码了
        filterChain.doFilter(servletRequest, servletResponse);
    }

    @Override
    public void destroy() {

    }
}

config
package cn.maolinyuan.config;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import org.springframework.cache.annotation.CachingConfigurerSupport;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

/**
 * redis配置
 */
@Configuration
@EnableCaching
public class RedisConfig extends CachingConfigurerSupport {
    @Bean
    @SuppressWarnings(value = {"unchecked", "rawtypes"})
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<Object, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);

        Jackson2JsonRedisSerializer serializer = new Jackson2JsonRedisSerializer(Object.class);

        ObjectMapper mapper = new ObjectMapper();
        mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
        serializer.setObjectMapper(mapper);

        // 使用StringRedisSerializer来序列化和反序列化redis的key值
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(serializer);

        // Hash的key也采用StringRedisSerializer的序列化方式
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(serializer);

        template.afterPropertiesSet();
        return template;
    }
}

启动类

CasStart

package cn.maolinyuan;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * @author maolinyuan
 * @version 1.0
 * @date 2022/5/5 16:21
 */
@SpringBootApplication
public class CasStart {
    public static void main(String[] args) {
        SpringApplication.run(CasStart.class,args);
    }
}

你可能感兴趣的:(高可用架构,分布式,java,架构,web安全,安全架构)