【JAVA企业级开发】SpringCloud篇--------教你解决在SpringCloud分布式/微服务架构系统中CAS单点登录和cookie跨域请求,session跨服的方法

一级目录

二级目录

三级目录

一、什么是单点登录

单点登录的英文名叫做:Single Sign On(简称SSO)。
在初学/以前的时候,一般我们就单系统,所有的功能都在同一个系统上。
在这里插入图片描述
后来,我们为了合理利用资源和降低耦合性,于是把单系统拆分成多个子系统。
在这里插入图片描述
比如阿里系的淘宝和天猫,很明显地我们可以知道这是两个系统,但是你在使用的时候,登录了天猫,淘宝也会自动登录。
在这里插入图片描述

二、回顾单系统登录

基于session的验证
Session 方案中,登录成功后,服务端将用户的身份信息存储在 Session 里,并且服务端会存储session,并将 Session ID 通过 Cookie 传递给客户端。后续的数据请求都会带上 Cookie,服务端根据 Cookie 中携带的 Session ID 来得辨别用户身份。在session方案的单点登录中,考虑如何同步 Session 和共享 Cookie,所有应用都从身份验证服务同步 Session。注销的时候只要删掉session中对应的记录就可以,这样的操作在客户端几乎是无感知的

基于token的验证
Token 方案中,服务端根据用户身份信息生成 Token,发放给客户端,客户端收到之后,将 Token 存放到 LocalStorage/SessionStorage中,之后请求数据时,将 Token 塞到请求头的Authentication字段里带到服务端,服务端接到请求后校验并解析 Token 得出用户身份,验证token的合法性,在有效期内都具有合法性。在token方案的单点登录中,考虑如何共享 Token,可以在进入平级应用的时候通过 URL 带上 Token。注销的时候需要删除token,并且发出去的 Token 在自动过期之前都是合法的,服务端仅通过验证 Token 无法区分合法 Token 与已经作废的Token。

在我初学JavaWeb的时候,登录和注册是我做得最多的一个功能了(初学Servlet的时候做过、学SpringMVC的时候做过、跟着做项目的时候做过…),反正我也数不清我做了多少次登录和注册的功能了…这里简单讲述一下我们初学时是怎么做登录功能的。

众所周知,HTTP是无状态的协议,这意味着服务器无法确认用户的信息。于是乎,W3C就提出了:给每一个用户都发一个通行证,无论谁访问的时候都需要携带通行证,这样服务器就可以从通行证上确认用户的信息。通行证就是Cookie。

如果说Cookie是检查用户身上的”通行证“来确认用户的身份,那么Session就是通过检查服务器上的”客户明细表“来确认用户的身份的。Session相当于在服务器中建立了一份“客户明细表”。

HTTP协议是无状态的,Session不能依据HTTP连接来判断是否为同一个用户。于是乎:服务器向用户浏览器发送了一个名为JESSIONID的Cookie,它的值是Session的id值。其实Session是依据Cookie来识别是否是同一个用户。

所以,一般我们单系统实现登录会这样做:

1登录:将用户信息保存在Session对象中
2如果在Session对象中能查到,说明已经登录
3如果在Session对象中查不到,说明没登录(或者已经退出了登录)
4注销(退出登录):从Session中删除用户的信息
5记住我(关闭掉浏览器后,重新打开浏览器还能保持登录状态):配合Cookie来用

我之前Demo的代码,可以参考一下:

package baobaobaobao.controller;

import baobaobaobao.entity.Account;
import baobaobaobao.service.AccountService;
import baobaobaobao.service.SSMServiceface;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.List;

/**
 * Created by @author LiuChunhang on 2020/4/22.
 */
@Controller

public class AccountController {
    @Autowired
    public SSMServiceface accountService;

    public SSMServiceface getAccountService() {
        return accountService;
    }

    public void setAccountService(AccountService accountService) {
        this.accountService = accountService;
    }

   

    //登录验证
    @RequestMapping(value = "loginsubmit", method = {RequestMethod.POST, RequestMethod.GET})
    public ModelAndView login(Account account, HttpServletResponse response, HttpSession httpSession) {
        ModelAndView modelAndView = new ModelAndView();
        response.setContentType("text/html;charset=UTF-8");
        String s = account.getAccount();
        String s1 = account.getPassword();

        System.out.println(s);
        System.out.println(s1);

        List<Account> list = accountService.validate(s);
        if (s.equals("admin1")&&s1.equals("shuang666")) {
            System.out.println("超级管理员登陆成功");
            httpSession.setAttribute("status","2");
            modelAndView.setViewName("WEB-INF/admin");
            return modelAndView;
        }
        //查看该账号在数据库中是否存在
        if (list.isEmpty() || list.size()==0) {
            try {
                PrintWriter out = response.getWriter();
                out.flush();
                out.println("<script>");
                out.println("alert('账户不存在!');");
                out.println("</script>");
            } catch (IOException e) {
                e.printStackTrace();
            }
            modelAndView.setViewName("login");
        }
        //如果存在就查看密码是否正确
        else {
            Account o = list.get(0);
            System.out.println(o.getAccount());
            System.out.println(o.getPassword());
            System.out.println(o.getStatus());
            if (o.getPassword().equals(s1) ) {
                //0为未激活,1为已激活
                if (o.getStatus() == 1) {

                    //登陆成功,返回index页面,并在session中设定登录状态,使拦截器不再拦截之后的页面
                    httpSession.setAttribute("status","2");
                    modelAndView.setViewName("WEB-INF/temp");
                    return modelAndView;
                }
                //如果密码正确就查看账号是否被激活
                else {
                    try {
                        PrintWriter out = response.getWriter();
                        out.flush();
                        out.println("<script>");
                        out.println("alert('账户未激活!');");
                        out.println("</script>");
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                    modelAndView.setViewName("login");
                }
            } else {
                try {
                    PrintWriter out = response.getWriter();
                    out.flush();
                    out.println("<script>");
                    out.println("alert('密码错误!');");
                    out.println("</script>");
                } catch (IOException e) {
                    e.printStackTrace();
                }
                modelAndView.setViewName("login");
            }
        }
        return modelAndView;
    }

    //注册验证
    @RequestMapping(value = "registerresult", method = {RequestMethod.POST, RequestMethod.GET})
    public ModelAndView register(Account account, HttpServletResponse response) {
        ModelAndView modelAndView = new ModelAndView();
        //account.setStatus(1);
        String s = account.getAccount();
        System.out.println(s);
        List<Account> list = accountService.validate(s);
        for(Account recard:list){
            System.out.println(recard);
        }
        if (list.isEmpty() || list == null) {
            int register = accountService.register(account);
            System.out.println(register);
            if (register > 0) {
                System.out.println("插入成功");
                modelAndView.setViewName("WEB-INF/registerresult");
                return modelAndView;
            }
        } else {
            modelAndView.addObject("result", "账号已被注册");
            response.setContentType("text/html;charset=UTF-8");
            try {
                PrintWriter out = response.getWriter();
                out.flush();
                out.println("<script>");
                out.println("alert('此用户名已存在,请重新输入!');");
                out.println("</script>");
            } catch (IOException e) {
                e.printStackTrace();
            }

            modelAndView.setViewName("WEB-INF/register");

        }
        return modelAndView;
    }
}

总结一下上面代码的思路:

用户登录时,验证用户的账户和密码
生成一个Token保存在数据库(redis)或者缓存中中,将Token写到Cookie或者(Local Storage )中,
将用户数据保存在Session中
请求时都会带上Cookie,检查有没有登录,如果已经登录则放行
如果没看懂的同学,建议回顾Session和Cookie和HTTP

基于 Token 的身份验证方法
使用基于 Token 的身份验证方法,大概的流程是这样的:

客户端使用用户名跟密码请求登录
服务端收到请求,去验证用户名与密码
验证成功后,服务端会签发一个 Token然后保存(缓存或者数据库),再把这个 Token 发送给客户端
客户端收到 Token 以后可以把它存储起来,比如放在 Cookie 里或者 Local Storage 里
客户端每次向服务端请求资源的时候需要带着服务端签发的 Token
服务端收到请求,然后去验证客户端请求里面带着的 Token,如果验证成功,就向客户端返回请求的数据






Token机制相对于Cookie机制又有什么好处呢?

支持跨域访问: Cookie是不允许垮域访问的,这一点对Token机制是不存在的,前提是传输的用户认证信息通过HTTP头传输.
无状态(也称:服务端可扩展行):Token机制在服务端不需要存储session信息,因为Token 自身包含了所有登录用户的信息,只需要在客户端的cookie或本地介质存储状态信息.
更适用CDN: 可以通过内容分发网络请求你服务端的所有资料(如:javascript,HTML,图片等),而你的服务端只要提供API即可.
去耦: 不需要绑定到一个特定的身份验证方案。Token可以在任何地方生成,只要在你的API被调用的时候,你可以进行Token生成调用即可.
更适用于移动应用: 当你的客户端是一个原生平台(iOS, Android,Windows 8等)时,Cookie是不被支持的(你需要通过Cookie容器进行处理),这时采用Token认证机制就会简单得多。
CSRF:因为不再依赖于Cookie,所以你就不需要考虑对CSRF(跨站请求伪造)的防范。
性能: 一次网络往返时间(通过数据库查询session信息)总比做一次HMACSHA256计算 的Token验证和解析要费时得多.
不需要为登录页面做特殊处理: 如果你使用Protractor 做功能测试的时候,不再需要为登录页面做特殊处理.
基于标准化:你的API可以采用标准化的 JSON Web Token (JWT). 这个标准已经存在多个后端库(.NET, Ruby, Java,Python, PHP)和多家公司的支持(如:Firebase,Google, Microsoft).
在HTML5中,新加入了一个localStorage特性,这个特性主要是用来作为本地存储来使用的,解决了cookie存储空间不足的问题(cookie中每条cookie的存储空间为4k),localStorage中一般浏览器支持的是5M大小,这个在不同的浏览器中localStorage会有所不同。

三、多系统登录的问题与解决

3.1 Session多服务器不共享问题

单系统登录功能主要是用Session保存用户信息来实现的,但我们清楚的是:多系统即可能有多个Tomcat,而Session是依赖当前系统的Tomcat,所以系统A的Session和系统B的Session是不共享的。
在这里插入图片描述

session的生命周期
session的生命周期包括三个阶段:创建、活动、销毁
创建:
当客户端第一次访问某个jsp或者servlet的时候,服务器会为当前会话创建一个SessionId,每次客户端向服务器发送请求时,都会将此sessionId携带过去,服务端会对此sessionId进行校验。
活动:
某次会话当中通过超链接打开的新页面属于同义词会话。
只要当前页面没有全部关闭,重新打开新的浏览器窗口访问同一项目资源时属于同一次会话。

本次会话的所有页面都关闭后再重新访问某个Jsp或者Servlet将会创建新的会话。

注意事项:注意原有会话还存在,只是这个旧的SessionID任然存在服务端,只不过再也没有客户端会携带它然后交予服务端校验。
销毁:
Session的销毁只有三种方式:
1.调用了session.invalidate()方法
2.session过期(超时)
3.服务器重新启动

Tomcat默认session超时时间为30分钟。

解决系统之间Session不共享问题有一下几种方案:

1
Tomcat集群Session全局复制(集群内每个tomcat的session完全同步)【会影响集群的性能呢,不建议】

2
根据请求的IP进行Hash映射到对应的机器上(这就相当于请求的IP一直会访问同一个服务器)【如果服务器宕机了,会丢失了一大部分Session的数据,不建议】
3
把Session数据放在Redis中(使用Redis模拟Session)【建议】
如果还不了解Redis的同学,建议移步(Redis合集)

我们可以将登录功能单独抽取出来,做成一个子系统。
在这里插入图片描述
SSO(登录系统)的逻辑如下:

// 登录功能(SSO单独的服务)
@Override
public TaotaoResult login(String username, String password) throws Exception {

    //根据用户名查询用户信息
    TbUserExample example = new TbUserExample();
    Criteria criteria = example.createCriteria();
    criteria.andUsernameEqualTo(username);
    List<TbUser> list = userMapper.selectByExample(example);
    if (null == list || list.isEmpty()) {
        return TaotaoResult.build(400, "用户不存在");
    }
    //核对密码
    TbUser user = list.get(0);
    if (!DigestUtils.md5DigestAsHex(password.getBytes()).equals(user.getPassword())) {
        return TaotaoResult.build(400, "密码错误");
    }
    //登录成功,把用户信息写入redis
    //生成一个用户token
    String token = UUID.randomUUID().toString();
    jedisCluster.set(USER_TOKEN_KEY + ":" + token, JsonUtils.objectToJson(user));
    //设置session过期时间
    jedisCluster.expire(USER_TOKEN_KEY + ":" + token, SESSION_EXPIRE_TIME);
    return TaotaoResult.ok(token);
}

其他子系统登录时,请求SSO(登录系统)进行登录,将返回的token写到Cookie中,下次访问时则把Cookie带上:

public TaotaoResult login(String username, String password, 
        HttpServletRequest request, HttpServletResponse response) {
    //请求参数
    Map<String, String> param = new HashMap<>();
    param.put("username", username);
    param.put("password", password);
    //登录处理
    String stringResult = HttpClientUtil.doPost(REGISTER_USER_URL + USER_LOGIN_URL, param);
    TaotaoResult result = TaotaoResult.format(stringResult);
    //登录出错
    if (result.getStatus() != 200) {
        return result;
    }
    //登录成功后把取token信息,并写入cookie
    String token = (String) result.getData();
    //写入cookie
    CookieUtils.setCookie(request, response, "TT_TOKEN", token);
    //返回成功
    return result;

}

SSO系统生成一个token,并将用户信息存到Redis中,并设置过期时间
其他系统请求SSO系统进行登录,得到SSO返回的token,写到Cookie中
每次请求时,Cookie都会带上,拦截器得到token,判断是否已经登录

到这里,其实我们会发现其实就两个变化:

将登陆功能抽取为一个系统(SSO),其他系统请求SSO进行登录
本来将用户信息存到Session,现在将用户信息存到Redis

3.2 Cookie跨域的问题

在这里插入图片描述
上面我们解决了Session不能共享的问题,但其实还有另一个问题。Cookie是不能跨域的

比如说,我们请求https://www.google.com/时,浏览器会自动把google.com的Cookie带过去给google的服务器,而不会把https://www.baidu.com/的Cookie带过去给google的服务器。

这就意味着,由于域名不同,用户向系统A登录后,系统A返回给浏览器的Cookie,用户再请求系统B的时候不会将系统A的Cookie带过去。

针对Cookie存在跨域问题,有几种解决方案:

1
服务端将Cookie写到客户端后,客户端对Cookie进行解析,将Token解析出来,此后请求都把这个Token带上就行了
2
多个域名共享Cookie,在写到客户端的时候设置Cookie的domain。
3
将Token保存在SessionStroage中(不依赖Cookie就没有跨域的问题了)
到这里,我们已经可以实现单点登录了。

3.2.1Cookie的Domain属性

我们重点说一下这个Domain属性。一般在实现单点登录的时候会经常用到这个属性,通过在父级域设置Cookie,然后在各个子级域拿到存在父级域中的Cookie值。比如刚才设置的username属性,在blog.csdn.net下同样可以访问到,用户不用重新登录就可以拿到第一次登录进来时候的用户信息,因为这些用户信息都是存在父级域".csdn.net"下面,其他页面也可以拿到。
在这里插入图片描述
因此,当在"blog.csdn.net"这个域名下存入一个Cookie;如:

document.cookie = "blogCookie=blog;path=/;domain=.blog.csdn.net";

然后你会发现在mp.csdn.net下看不到blogCookie这个属性。这个就是所谓的Cookie跨域的问题。

总结:domain表示的是cookie所在的域,默认为请求的地址,如网址为www.study.com/study,那么domain默认为www.study.com。而跨域访问,如域A为t1.study.com,域B为t2.study.com,那么在域A生产一个令域A和域B都能访问的cookie就要将该cookie的domain设置为.study.com;如果要在域A生产一个令域A不能访问而域B能访问的cookie就要将该cookie的domain设置为t2.study.com。注意:一般在域名前是需要加一个".“的,如"domain=.study.com”。

四 单点登录的实现

在这里插入图片描述

1、基于Cookie的Session的广播机制(Session复制,多次传送Cookie)

在这里插入图片描述
基于Cookie的单点登录核心原理:

  将用户名密码加密之后存于Cookie中,之后访问网站时在过滤器(filter)中校验用户权限,如果没有权限则从Cookie中取出用户名密码进行登录,让用户从某种意义上觉得只登录了一次。

  该方式缺点就是多次传送用户名密码,增加被盗风险。同时www.qiandu.com与mail.qiandu.com同时拥有登录逻辑的代码,如果涉及到修改操作,则需要修改两处。

2、统一认证中心方案原理(redis)

在生活中我们也有类似的相关生活经验,例如你去食堂吃饭,食堂打饭的阿姨(www.qiandu.com)告诉你,不收现金。并且告诉你,你去门口找换票的(passport.com)换小票。于是你换完票之后,再去找食堂阿姨,食堂阿姨拿着你的票,问门口换票的,这个票是真的吗?换票的说,是真的,于是给你打饭了。
基于上述生活中的场景,我们将基于Cookie的单点登录改良以后的方案如下
在这里插入图片描述
经过分析,Cookie单点登录认证太过于分散,每个网站都持有一份登陆认证代码。于是我们将认证统一化,形成一个独立的服务。当我们需要登录操作时,则重定向到统一认证中心http://passport.com。于是乎整个流程就如上图所示:
第一步:用户访问www.qiandu.com。过滤器判断用户是否登录,没有登录,则重定向(302)到网站http://passport.com。
第二步:重定向到passport.com,输入用户名密码。passport.com将用户登录的信息记录到服务器的session中。
第三步:passport.com给浏览器发送一个特殊的凭证,浏览器将凭证交给www.qiandu.com,www.qiandu.com则拿着浏览器交给他的凭证去passport.com验证凭证是否有效,从而判断用户是否登录成功。
第四步:登录成功,浏览器与网站之间进行正常的访问。

3、Yelu大学研发的CAS(Central Authentication Server)

在这里插入图片描述
部署项目时需要部署一个独立的认证中心(cas.qiandu.com),以及其他N个用户自己的web服务。

认证中心:也就是cas.qiandu.com,即cas-server。用来提供认证服务,由CAS框架提供,用户只需要根据业务实现认证的逻辑即可。

用户web项目:只需要在web.xml中配置几个过滤器,用来保护资源,过滤器也是CAS框架提供了,即cas-client,基本不需要改动可以直接使用。

4、CAS的详细登录流程

在这里插入图片描述

4.1 第一次访问www.qiandu.com

标号1:用户访问http://www.qiandu.com,经过他的第一个过滤器(cas提供,在web.xml中配置)AuthenticationFilter。

  过滤器全称:org.jasig.cas.client.authentication.AuthenticationFilter

  主要作用:判断是否登录,如果没有登录则重定向到认证中心。

标号2:www.qiandu.com发现用户没有登录,则返回浏览器重定向地址。

标号3:浏览器接收到重定向之后发起重定向,请求cas.qiandu.com。

标号4:认证中心cas.qiandu.com接收到登录请求,返回登陆页面。

标号5:用户在cas.qiandu.com的login页面输入用户名密码,提交。

标号6:服务器接收到用户名密码,则验证是否有效,验证逻辑可以使用cas-server提供现成的,也可以自己实现。

标号7:浏览器从cas.qiandu.com哪里拿到ticket之后,就根据指示重定向到www.qiandu.com,请求的url就是上面返回的url。

标号8:www.qiandu.com在过滤器中会取到ticket的值,然后通过http方式调用cas.qiandu.com验证该ticket是否是有效的。

标号9:cas.qiandu.com接收到ticket之后,验证,验证通过返回结果告诉www.qiandu.com该ticket有效。

标号10:www.qiandu.com接收到cas-server的返回,知道了用户合法,展示相关资源到用户浏览器上。

  至此,第一次访问的整个流程结束,其中标号8与标号9的环节是通过代码调用的,并不是浏览器发起,所以没有截取到报文。

4.2、第二次访问www.qiandu.com

上面以及访问过一次了,当第二次访问的时候发生了什么呢?

标号11:用户发起请求,访问www.qiandu.com。会经过cas-client,也就是过滤器,因为第一次访问成功之后www.qiandu.com中会在session中记录用户信息,因此这里直接就通过了,不用验证了。

标号12:用户通过权限验证,浏览器返回正常资源。

4.3、访问mail.qiandu.com

标号13:用户在www.qiandu.com正常上网,突然想访问mail.qiandu.com,于是发起访问mail.qiandu.com的请求。

标号14:mail.qiandu.com接收到请求,发现第一次访问,于是给他一个重定向的地址,让他去找认证中心登录。

标号15:浏览器根据14返回的地址,发起重定向,因为之前访问过一次了,因此这次会携带上次返回的Cookie:TGC到认证中心。

标号16:认证中心收到请求,发现TGC对应了一个TGT,于是用TGT签发一个ST,并且返回给浏览器,让他重定向到mail.qiandu.com

标号17:浏览器根据16返回的网址发起重定向。

标号18:mail.qiandu.com获取ticket去认证中心验证是否有效。

标号19:认证成功,返回在mail.qiandu.com的session中设置登录状态,下次就直接登录。

标号20:认证成功之后就反正用想要访问的资源了。

5、总结

至此,CAS登录的整个过程就完毕了,以后有时间在下一篇文章总结下如何使用CAS,并运用到项目中。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

牵牛刘先生

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值