CAS单点登录

1.相同顶级域名的单点登录SSO

相同顶级域名的单点登录:SSO:SINGLE SIGN ON

单点登录可以通过基于用户会话的共享;分为两种,第一种:相同顶级域名;

原理是分布式会话完成的;关键是顶级域名的cookie值是可以共享的

比如说现在有个一级域名为 www.xxx.com,是教育类网站,但是xxx网有其他的产品线,可以通过构建二级域名提供服务给用户访问,比如:music.xxx.comshop.xxx.com,blog.xxx.com等等,分别为xx音乐,xx电商以及xx博客等,用户只需要在其中一个站点登录,那么其他站点也会随之而登录。

也就是说,用户自始至终只在某一个网站下登录后,那么他所产生的会话,就共享给了其他的网站,实现了单点网站登录后,同时间接登录了其他的网站,那么这个其实就是单点登录,他们的会话是共享的,都是同一个用户会话。

Cookie + Redis 实现 SSO

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

那么这个原理主要也是cookie和网站的依赖关系,顶级域名 www.xxx.com和*.xxx.com的cookie值是可以共享的,可以被携带至后端的,比如设置为 .xxx.com,.t.xxx.com,如此是OK的。

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

2.不同顶级域名的单点登录

如果顶级域名都不一样,咋办?比如 wwww.xxx.com要和www.yyy.com的会话实现共享,这个时候又该如何?!

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

那么遇到顶级域名不同却又要实现单点登录该如何实现呢?我们来参考下面一张图:

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

在CAS中的具体的流程参考如下时序图:

3.代码实现:

SSO-MTV;SSO-MUSIC为两个不同顶级域名的子系统;用于测试用的;运行在tomcat的8080端口;

依赖:

java 复制代码
<dependencies>
		<!--  自定义Service   -->
        <dependency>
            <groupId>com.nly</groupId>
            <artifactId>foodie-dev-service</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
        
</dependencies>

部分配置信息:

java 复制代码
############################################################
#
# web访问端口号  约定:8088
#
############################################################
server:
  tomcat:
    uri-encoding: UTF-8
  max-http-header-size: 80KB

############################################################
#
# 配置数据源信息
#
############################################################
spring:
  profiles:
    active: dev
  datasource:                                           # 数据源的相关配置
    type: com.zaxxer.hikari.HikariDataSource          # 数据源类型:HikariCP
    driver-class-name: com.mysql.jdbc.Driver          # mysql驱动
    username: root
    hikari:
      connection-timeout: 30000       # 等待连接池分配连接的最大时长(毫秒),超过这个时长还没可用的连接则发生SQLException, 默认:30秒
      minimum-idle: 5                 # 最小连接数
      maximum-pool-size: 20           # 最大连接数
      auto-commit: true               # 自动提交
      idle-timeout: 600000            # 连接超时的最大时长(毫秒),超时则被释放(retired),默认:10分钟
      pool-name: DateSourceHikariCP     # 连接池名字
      max-lifetime: 1800000           # 连接的生命时长(毫秒),超时而且没被使用则被释放(retired),默认:30分钟 1800000ms
      connection-test-query: SELECT 1

  servlet:
    multipart:
      max-file-size: 512000 #文件上传大小限制为500kb
      max-request-size: 512000 #请求大小限制为500kb
#  session:
#    store-type: redis
  thymeleaf:
    mode: HTML
    encoding: utf-8
    prefix: classpath:/templates/
    suffix: .html

############################################################
#
# mybatis 配置
#
############################################################
mybatis:
  type-aliases-package: com.nly.pojo          # 所有POJO类所在包路径
  mapper-locations: classpath:mapper/*.xml      # mapper映射文件
  
############################################################
#
# mybatis mapper 配置
#
############################################################
# 通用 Mapper 配置
mapper:
  mappers: com.nly.my.mapper.MyMapper
  not-empty: false #在进行数据库操作的时候,判断表达式username! = null,是否追加username!=''
  identity: MYSQL
# 分页插件配置
pagehelper:
  helperDialect: mysql
  supportMethodsArguments: true
server:
  port: 8090

spring:
  datasource:                                           # 数据源的相关配置
    url: jdbc:mysql://localhost:3306/foodie-shop-dev?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true
    password: xxx
  redis:
    #redis单机单实例
    database: 2
    host: 192.168.56.102
    port: 6379
    timeout: 5000
    password: xxx

启动类

java 复制代码
@SpringBootApplication
//扫描mybatis通用的包
@MapperScan(basePackages = "com.nly.mapper")
//扫描所有包以及相关组件包
@ComponentScan(basePackages = {"com.nly","org.n3r.idworker"})

public class Application {

    public static void main(String[] args) {

        SpringApplication.run(Application.class,args);
    }
    //ApplicationListener
}

构建登录的模版页:

java 复制代码
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>SSO单点登录</title>
</head>
<body>
<h1>欢迎访问单点登录系统</h1>
<form action="doLogin" method="post">
    <input type="text" name="username" placeholder="请输入用户名"/>
    <input type="password" name="password" placeholder="请输入密码"/>
    <input type="hidden" name="returnUrl" th:value="${returnUrl}">
    <input type="submit" value="提交登录"/>
</form>
<span style="color:red" th:text="${errmsg}"></span>

</body>
</html>
java 复制代码
@Controller
public class SSOController {

    @Autowired
    private UserService userService;

    @Autowired
    private RedisOperator redisOperator;

    public static final String REDIS_USER_TOKEN = "redis_user_token";
    public static final String REDIS_USER_TICKET= "redis_user_ticket";
    public static final String REDIS_TMP_TICKET = "redis_tmp_ticket";

    public static final String COOKIE_USER_TICKET = "cookie_user_ticket";


   /*
    @RequestMapping("/hello")
    @ResponseBody
    public  Object hello(){
        return "hello,world";
    }*/

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

        model.addAttribute("returnUrl", returnUrl);
        //从cookie中获取userTicket门票,如果cookie中能够获取到,说明用户登录过,签发tmpTicket即可
        String userTicket = getCookie(request,COOKIE_USER_TICKET);

        boolean isVerified = verifyUserTicket(userTicket);

        if (isVerified) {
            String tmpTicket = createTmpTicket();
            return "redirect:" + returnUrl + "?tmpTicket=" + tmpTicket;
        }

        //用户从未登录过,第一次进入则跳转到CAS的统一登录页面
        return "login";
    }

    private boolean verifyUserTicket(String userTicket){
        if (StringUtils.isBlank(userTicket)){
            return false;
        }

        //1.验证CAS门票是否有效
        String userId = redisOperator.get(REDIS_USER_TICKET+":"+userTicket);
        if (StringUtils.isBlank(userId)){
            return false;
        }

        //2.验证门票对应的user会话是否存在
        String userRedis = redisOperator.get(REDIS_USER_TOKEN+":"+userId);
        if (StringUtils.isBlank(userRedis)){
            return  false;
        }

        return true;
    }


    /**
     * CAS的统一登录接口
     * 目的:
     *      1.登录后创建用户的全局会话------》uniqueToken
     *      2.创建用户全局门票,用以表示在CAS是否登录 ---》userTicket
     *      3.创建用户的临时票据,用于回跳回传------》tmpTicket
     */
    @PostMapping("/doLogin")
    public  String doLogin(String username,
                           String password,
                           String returnUrl,
                           Model model,
                           HttpServletRequest request,
                           HttpServletResponse response) throws Exception {

        model.addAttribute("returnUrl",returnUrl);

        //0.判断用户名和密码必须不为空
        if(StringUtils.isBlank(username)||
                StringUtils.isBlank(password)){
            model.addAttribute("errmsg", "用户名或密码不能为空");
            return "login";
        }
        //1.实现登录
        Users userResult = userService.queryUserForLogin(username, MD5Utils.getMD5Str(password));
        if (userResult == null){
            model.addAttribute("errmsg","用户名或密码不正确");
            return "login";
        }
       //2.实现用户的redis会话
        String uniqueToken = UUID.randomUUID().toString().trim();
        UsersVO usersVO = new UsersVO();
        BeanUtils.copyProperties(userResult,usersVO);
        usersVO.setUserUniqueToken(uniqueToken);
        redisOperator.set(REDIS_USER_TOKEN+":"+userResult.getId(), JsonUtils.objectToJson(usersVO));


        //3.生成ticket门票,全局门票,代表用户在CAS端登录过
        String userTicket = UUID.randomUUID().toString().trim();
        //3.1用户全局门票需要放入CAS端的cookie中
        setCookie(COOKIE_USER_TICKET,userTicket,response);
        //4.userTicket关联用户id,并且放入到redis中国,代表这个用户有门票了,可以在各个景区游玩
        redisOperator.set(REDIS_USER_TICKET+":"+userTicket,userResult.getId());
        //5.生成临时票据,回跳到调用网站,是有CAS端锁签发的一个一次性的临时ticket
        String tmpTicket = createTmpTicket();

        /**
         * userTicket:用于表示用户在CAS端的一个登录状态:已经登录
         * tmpTicket:用于颁发给用户进行一次性的验证的票据,有时效性
         */
//        return "login";

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

    @PostMapping("/verifyTmpTicket")
    @ResponseBody
    public  JSONResult verifyTmpTicket(String tmpTicket,
                         HttpServletRequest request,
                         HttpServletResponse response) throws Exception {

        //使用一次性临时票据来验证用户是否登录,如果登录过,把用户会话信息返回给站点
        //使用完毕后,需要销毁临时票据
        String tmpTicketValue = redisOperator.get(REDIS_TMP_TICKET+":"+tmpTicket);
        if (StringUtils.isBlank(tmpTicketValue)){
            return JSONResult.errorUserTicket("用户票据异常");
        }

        //0.如果临时票据ok,则需要销毁,并且拿到CAS端cookie中的全局userTicket,以此再获得用户会话
        if(!tmpTicketValue.equals(MD5Utils.getMD5Str(tmpTicket))){
            return JSONResult.errorUserTicket("用户票据异常");
        }else {
            //销毁临时票据
            redisOperator.del(REDIS_TMP_TICKET +":"+ tmpTicket);
        }

        //验证并且获取用户的userTicket
        String userTicket = getCookie(request,COOKIE_USER_TICKET);
        String userId = redisOperator.get(REDIS_USER_TICKET +":"+ userTicket);
        if (StringUtils.isBlank(userId)){
            return JSONResult.errorUserTicket("用户票据异常");
        }

        //2.验证门票对应的user会话是否存在
        String userRedis =redisOperator.get(REDIS_USER_TOKEN+":"+userId);
        if (StringUtils.isBlank(userRedis)) {
            return JSONResult.errorUserTicket("用户票据异常");
        }

        //验证成功,返回ok,携带用户会话
        return JSONResult.ok(JsonUtils.jsonToPojo(userRedis,UsersVO.class));
    }

    @PostMapping("/logout")
    @ResponseBody
    public JSONResult logout(String userId,
                             HttpServletRequest request,
                             HttpServletResponse response){
        //0.获取CAS中的用户门票
        String userTicket = getCookie(request,COOKIE_USER_TICKET);

        //1.清除userTicket票据,redis/cookie
        deleteCookie(COOKIE_USER_TICKET,response);
        redisOperator.del(REDIS_USER_TICKET+""+userTicket);

        //2.清除用户全局会话(分布式会话)
        redisOperator.del(REDIS_USER_TOKEN+""+userId);

        return JSONResult.ok();

    }


    /**
     * 创建临时票据
     * @return
     */
    private String createTmpTicket(){
        String tmpTicket =UUID.randomUUID().toString().trim();
        try{
            redisOperator.set(REDIS_TMP_TICKET+":"+tmpTicket,MD5Utils.getMD5Str(tmpTicket),600);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return tmpTicket;
    }

    private void setCookie(String key,
                           String val,
                           HttpServletResponse response){
        Cookie cookie = new Cookie(key,val);
        cookie.setDomain("sso.com");
        cookie.setPath("/");
        response.addCookie(cookie);
    }

    private String getCookie(HttpServletRequest request,String key){
        Cookie[] cookieList = request.getCookies();
        if (cookieList == null || StringUtils.isBlank(key)){
            return null;
        }

        String cookieValue  = null ;
        for (int i = 0 ; i < cookieList.length; i ++) {
            if (cookieList[i].getName().equals(key)) {
                cookieValue = cookieList[i].getValue();
                break;
            }
        }

        return cookieValue;

    }

    private void deleteCookie(String key,HttpServletResponse response){
        Cookie cookie = new Cookie(key,null);
        cookie.setDomain("sso.com");
        cookie.setPath("/");
        cookie.setMaxAge(-1);
        response.addCookie(cookie);
    }
}

那么对于SSO的整个处理流程来讲,其实我们实现起来并不是很难,主要是为的理解整个流程,因为在面试过程中有可能会被问到。如果有兴趣的同学,可以去参考一下Apereo的CAS系统,是非常牛的,地址如下:

https://github.com/apereo/cas

https://www.apereo.org/projects/cas

(备注:后续会将所有的代码传到github上,有兴趣的可以关注一下)

相关推荐
爱上语文16 分钟前
Springboot的三层架构
java·开发语言·spring boot·后端·spring
serve the people19 分钟前
springboot 单独新建一个文件实时写数据,当文件大于100M时按照日期时间做文件名进行归档
java·spring boot·后端
qmx_071 小时前
HTB-Jerry(tomcat war文件、msfvenom)
java·web安全·网络安全·tomcat
为风而战1 小时前
IIS+Ngnix+Tomcat 部署网站 用IIS实现反向代理
java·tomcat
编程零零七2 小时前
Python数据分析工具(三):pymssql的用法
开发语言·前端·数据库·python·oracle·数据分析·pymssql
技术无疆3 小时前
快速开发与维护:探索 AndroidAnnotations
android·java·android studio·android-studio·androidx·代码注入
(⊙o⊙)~哦4 小时前
JavaScript substring() 方法
前端
无心使然云中漫步5 小时前
GIS OGC之WMTS地图服务,通过Capabilities XML描述文档,获取matrixIds,origin,计算resolutions
前端·javascript
Bug缔造者5 小时前
Element-ui el-table 全局表格排序
前端·javascript·vue.js
xnian_5 小时前
解决ruoyi-vue-pro-master框架引入报错,启动报错问题
前端·javascript·vue.js