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上,有兴趣的可以关注一下)

相关推荐
我要洋人死4 分钟前
导航栏及下拉菜单的实现
前端·css·css3
科技探秘人15 分钟前
Chrome与火狐哪个浏览器的隐私追踪功能更好
前端·chrome
科技探秘人16 分钟前
Chrome与傲游浏览器性能与功能的深度对比
前端·chrome
JerryXZR21 分钟前
前端开发中ES6的技术细节二
前端·javascript·es6
七星静香23 分钟前
laravel chunkById 分块查询 使用时的问题
java·前端·laravel
Jacob程序员24 分钟前
java导出word文件(手绘)
java·开发语言·word
ZHOUPUYU24 分钟前
IntelliJ IDEA超详细下载安装教程(附安装包)
java·ide·intellij-idea
q24985969326 分钟前
前端预览word、excel、ppt
前端·word·excel
stewie628 分钟前
在IDEA中使用Git
java·git
小华同学ai31 分钟前
wflow-web:开源啦 ,高仿钉钉、飞书、企业微信的审批流程设计器,轻松打造属于你的工作流设计器
前端·钉钉·飞书