草庐IT

公司产品太多了,怎么实现一次登录产品互通?

Java技术栈 2023-04-17 原文

作者:王老狮
来源:juejin.cn/post/7123787027652280356

最近开发新产品,然后老板说我们现在系统太多了,每次切换系统登录太麻烦了,能不能做个优化,同一账号互通掉。作为一个资深架构狮,老板的要求肯定要满足,安排!

一个公司产品矩阵比较丰富的时候,用户在不同系统之间来回切换,固然对产品用户体验上较差,并且增加用户密码管理成本。也没有很好地利用内部流量进行用户打通,并且每个产品的独立体系会导致产品安全度下降。

因此实现集团产品的单点登录对用户使用体验以及效率提升有很大的帮助。那么如何实现统一认证呢?我们先了解一下传统的身份验证方式。

传统Session机制及身份认证方案

Cookie与服务器的交互

众所周知,http是无状态的协议,因此客户每次通过浏览器访问web页面,请求到服务端时,服务器都会新建线程,打开新的会话,而且服务器也不会自动维护客户的上下文信息。比如我们现在要实现一个电商内的购物车功能,要怎么才能知道哪些购物车请求对应的是来自同一个客户的请求呢?

因此出现了session这个概念,session 就是一种保存上下文信息的机制,他是面向用户的,每一个SessionID 对应着一个用户,并且保存在服务端中。session主要 以 cookie 或 URL 重写为基础的来实现的,默认使用 cookie 来实现,系统会创造一个名为JSESSIONID的变量输出到cookie中。

JSESSIONID 是存储于浏览器内存中的,并不是写到硬盘上的,如果我们把浏览器的cookie 禁止,则 web 服务器会采用 URL 重写的方式传递 Sessionid,我们就可以在地址栏看到 sessionid=KWJHUG6JJM65HS2K6 之类的字符串。

通常 JSESSIONID 是不能跨窗口使用的,当你新开了一个浏览器窗口进入相同页面时,系统会赋予你一个新的sessionid,这样我们信息共享的目的就达不到了。

服务器端的session的机制

当服务端收到客户端的请求时候,首先判断请求里是否包含了JSESSIONID的sessionId,如果存在说明已经创建过了,直接从内存中拿出来使用,如果查询不到,说明是无效的。

如果客户请求不包含sessionid,则为此客户创建一个session并且生成一个与此session相关联的sessionid,这个sessionid将在本次响应中返回给客户端保存。

对每次http请求,都经历以下步骤处理:

  • 服务端首先查找对应的cookie的值(sessionid)。
  • 根据sessionid,从服务器端session存储中获取对应id的session数据,进行返回。
  • 如果找不到sessionid,服务器端就创建session,生成sessionid对应的cookie,写入到响应头中。

session是由服务端生成的,并且以散列表的形式保存在内存中

基于 session 的身份认证流程

基于seesion的身份认证主要流程如下:

因为 http 请求是无状态请求,所以在 Web 领域,大部分都是通过这种方式解决。但是这么做有什么问题呢?我们接着看

集群环境下的 Session 困境及解决方案

随着技术的发展,用户流量增大,单个服务器已经不能满足系统的需要了,分布式架构开始流行。通常都会把系统部署在多台服务器上,通过负载均衡把请求分发到其中的一台服务器上,这样很可能同一个用户的请求被分发到不同的服务器上,因为 session 是保存在服务器上的,那么很有可能第一次请求访问的 A 服务器,创建了 session,但是第二次访问到了 B 服务器,这时就会出现取不到 session 的情况。

我们知道,Session 一般是用来存会话全局的用户信息(不仅仅是登陆方面的问题),用来简化/加速后续的业务请求。
传统的 session 由服务器端生成并存储,当应用进行分布式集群部署的时候,如何保证不同服务器上 session 信息能够共享呢?

Session共享方案

Session共享一般有两种思路

  • session复制
  • session集中存储

session复制

session复制即将不同服务器上 session 数据进行复制,用户登录,修改,注销时,将session信息同时也复制到其他机器上面去。

这种实现的问题就是实现成本高,维护难度大,并且会存在延迟登问题。

session集中存储

集中存储就是将获取session单独放在一个服务中进行存储,所有获取session的统一来这个服务中去取。这样就避免了同步和维护多套session的问题。一般我们都是使用redis进行集中式存储session。

多服务下的登陆困境及SSO方案

SSO的产生背景

如果企业做大了之后,一般都有很多的业务支持系统为其提供相应的管理和 IT 服务,按照传统的验证方式访问多系统,每个单独的系统都会有自己的安全体系和身份认证系统。进入每个系统都需要进行登录,获取session,再通过session访问对应系统资源。这样的局面不仅给管理上带来了很大的困难,对客户来说也极不友好,那么如何让客户只需登陆一次,就可以进入多个系统,而不需要重新登录呢?

“单点登录”就是专为解决此类问题的。其大致思想流程如下:通过一个 ticket 进行串接各系统间的用户信息。

SSO的底层原理 CAS

CAS实现单点登录流程

我们知道对于完全不同域名的系统,cookie 是无法跨域名共享的,因此 sessionId 在页面端也无法共享,因此需要实现单店登录,就需要启用一个专门用来登录的域名如(ouath.com)来提供所有系统的sessionId。

另外,最近面试整理了 Java 最新、最全的面试题:

https://www.javastack.cn/mst/

当业务系统被打开时,借助中心授权系统进行登录,整体流程如下:

  1. 当b.com打开时,发现自己未登陆,于是跳转到ouath.com去登陆
  2. ouath.com登陆页面被打开,用户输入帐户/密码登陆成功
  3. ouath.com登陆成功,种 cookie 到ouath.com域名下
  4. 把 sessionid 放入后台redis,存放<ticket,sesssionid>数据结构,然后页面重定向到A系统
  5. 当b.com重新被打开,发现仍然是未登陆,但是有了一个 ticket值
  6. 当b.com用ticket 值,到 redis 里查到 sessionid,并做 session 同步,然后种cookie给自己,页面原地重定向
  7. 当b.com打开自己页面,此时有了 cookie,后台校验登陆状态,成功

整个交互流程图如下:

单点登录流程演示

CAS登录服务demo核心代码

用户实体类

public class UserForm implements Serializable{
  private static final long serialVersionUID = 1L;

  private String username;
  private String password;
  private String backurl;

  public String getUsername() {
      return username;
  }

  public void setUsername(String username) {
      this.username = username;
  }

  public String getPassword() {
      return password;
  }

  public void setPassword(String password) {
      this.password = password;
  }

  public String getBackurl() {
      return backurl;
  }

  public void setBackurl(String backurl) {
      this.backurl = backurl;
  }
}

登录控制器

@Controller
public class IndexController {
    @Autowired
    private RedisTemplate redisTemplate;

  @GetMapping("/toLogin")
  public String toLogin(Model model,HttpServletRequest request) {
      Object userInfo = request.getSession().getAttribute(LoginFilter.USER_INFO);
      //不为空,则是已登陆状态
      if (null != userInfo){
          String ticket = UUID.randomUUID().toString();
          redisTemplate.opsForValue().set(ticket,userInfo,2, TimeUnit.SECONDS);
          return "redirect:"+request.getParameter("url")+"?ticket="+ticket;
      }
      UserForm user = new UserForm();
      user.setUsername("laowang");
      user.setPassword("laowang");
      user.setBackurl(request.getParameter("url"));
      model.addAttribute("user", user);

      return "login";
  }

  @PostMapping("/login")
  public void login(@ModelAttribute UserForm user,HttpServletRequest request,HttpServletResponse response) throws IOException, ServletException {
      System.out.println("backurl:"+user.getBackurl());
      request.getSession().setAttribute(LoginFilter.USER_INFO,user);

      //登陆成功,创建用户信息票据
      String ticket = UUID.randomUUID().toString();
      redisTemplate.opsForValue().set(ticket,user,20, TimeUnit.SECONDS);
      //重定向,回原url  ---a.com
      if (null == user.getBackurl() || user.getBackurl().length()==0){
          response.sendRedirect("/index");
      } else {
          response.sendRedirect(user.getBackurl()+"?ticket="+ticket);
      }
  }

  @GetMapping("/index")
  public ModelAndView index(HttpServletRequest request) {
      ModelAndView modelAndView = new ModelAndView();
      Object user = request.getSession().getAttribute(LoginFilter.USER_INFO);
      UserForm userInfo = (UserForm) user;
      modelAndView.setViewName("index");
      modelAndView.addObject("user", userInfo);
      request.getSession().setAttribute("test","123");
      return modelAndView;
  }
}

登录过滤器

public class LoginFilter implements Filter {
    public static final String USER_INFO = "user";
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {}

  @Override
  public void doFilter(ServletRequest servletRequest,
                       ServletResponse servletResponse, FilterChain filterChain)
          throws IOException, ServletException {

      HttpServletRequest request = (HttpServletRequest) servletRequest;
       HttpServletResponse response = (HttpServletResponse)servletResponse;

      Object userInfo = request.getSession().getAttribute(USER_INFO);;

      //如果未登陆,则拒绝请求,转向登陆页面
      String requestUrl = request.getServletPath();
      if (!"/toLogin".equals(requestUrl)//不是登陆页面
              &amp;&amp; !requestUrl.startsWith("/login")//不是去登陆
              &amp;&amp; null == userInfo) {//不是登陆状态

          request.getRequestDispatcher("/toLogin").forward(request,response);
          return ;
      }

      filterChain.doFilter(request,servletResponse);
  }

  @Override
  public void destroy() {

  }
}

配置过滤器

@Configuration
public class LoginConfig {

//配置filter生效
@Bean
public FilterRegistrationBean sessionFilterRegistration() {

    FilterRegistrationBean registration = new FilterRegistrationBean();
    registration.setFilter(new LoginFilter());
    registration.addUrlPatterns("/*");
    registration.addInitParameter("paramName", "paramValue");
    registration.setName("sessionFilter");
    registration.setOrder(1);
    return registration;
}
}

登录页面

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>enjoy login</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<div text-align="center">
    <h1>请登陆</h1>
    <form action="#" th:action="@{/login}" th:object="${user}" method="post">
        <p>用户名: <input type="text" th:field="*{username}" /></p>
        <p>密  码: <input type="text" th:field="*{password}" /></p>
        <p><input type="submit" value="Submit" /> <input type="reset" value="Reset" /></p>
        <input type="text" th:field="*{backurl}" hidden="hidden" />
    </form>
</div>
</body>
</html>

推荐一个开源免费的 Spring Boot 最全教程:

https://github.com/javastacks/spring-boot-best-practice

web系统demo核心代码

过滤器

public class SSOFilter implements Filter {
    private RedisTemplate redisTemplate;

    public static final String USER_INFO = "user";

    public SSOFilter(RedisTemplate redisTemplate){
        this.redisTemplate = redisTemplate;
    }
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {

    }

    @Override
    public void doFilter(ServletRequest servletRequest,
                         ServletResponse servletResponse, FilterChain filterChain)
            throws IOException, ServletException {

        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse)servletResponse;

        Object userInfo = request.getSession().getAttribute(USER_INFO);;

        //如果未登陆,则拒绝请求,转向登陆页面
        String requestUrl = request.getServletPath();
        if (!"/toLogin".equals(requestUrl)//不是登陆页面
                &amp;&amp; !requestUrl.startsWith("/login")//不是去登陆
                &amp;&amp; null == userInfo) {//不是登陆状态

            String ticket = request.getParameter("ticket");
            //有票据,则使用票据去尝试拿取用户信息
            if (null != ticket){
                userInfo = redisTemplate.opsForValue().get(ticket);
            }
            //无法得到用户信息,则去登陆页面
            if (null == userInfo){
                response.sendRedirect("http://127.0.0.1:8080/toLogin?url="+request.getRequestURL().toString());
                return ;
            }

            /**
             * 将用户信息,加载进session中
             */
            UserForm user = (UserForm) userInfo;
            request.getSession().setAttribute(SSOFilter.USER_INFO,user);
            redisTemplate.delete(ticket);
        }

        filterChain.doFilter(request,servletResponse);
    }

    @Override
    public void destroy() {

    }
}

控制器

@Controller
public class IndexController {
    @Autowired
    private RedisTemplate redisTemplate;

    @GetMapping("/index")
    public ModelAndView index(HttpServletRequest request) {
        ModelAndView modelAndView = new ModelAndView();
        Object userInfo = request.getSession().getAttribute(SSOFilter.USER_INFO);
        UserForm user = (UserForm) userInfo;
        modelAndView.setViewName("index");
        modelAndView.addObject("user", user);

        request.getSession().setAttribute("test","123");
        return modelAndView;
    }
}

首页

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>enjoy index</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<div th:object="${user}">
    <h1>cas-website:欢迎你"></h1>
</div>
</body>
</html>

CAS的单点登录和OAuth2的区别

  • OAuth2: 三方授权协议,允许用户在不提供账号密码的情况下,通过信任的应用进行授权,使其客户端可以访问权限范围内的资源。
  • CAS: 中央认证服务(Central Authentication Service),一个基于Kerberos票据方式实现SSO单点登录的框架,为Web 应用系统提供一种可靠的单点登录解决方法(属于 Web SSO )。
  1. CAS的单点登录时保障客户端的用户资源的安全 ;OAuth2则是保障服务端的用户资源的安全 。
  2. CAS客户端要获取的最终信息是,这个用户到底有没有权限访问我(CAS客户端)的资源;OAuth2获取的最终信息是,我(oauth2服务提供方)的用户的资源到底能不能让你(oauth2的客户端)访问。

因此,需要统一的账号密码进行身份认证,用CAS;需要授权第三方服务使用我方资源,使用OAuth2。

关于 OAuth2、JWT 的最新应用,可以报名栈长的《Spring Cloud 微服务实战课》,一次订阅,永久免费学习。

近期热文推荐:

1.1,000+ 道 Java面试题及答案整理(2022最新版)

2.劲爆!Java 协程要来了。。。

3.Spring Boot 2.x 教程,太全了!

4.别再写满屏的爆爆爆炸类了,试试装饰器模式,这才是优雅的方式!!

5.《Java开发手册(嵩山版)》最新发布,速速下载!

觉得不错,别忘了随手点赞+转发哦!

有关公司产品太多了,怎么实现一次登录产品互通?的更多相关文章

  1. ruby - 使用 Vim Rails,您可以创建一个新的迁移文件并一次性打开它吗? - 2

    使用带有Rails插件的vim,您可以创建一个迁移文件,然后一次性打开该文件吗?textmate也可以这样吗? 最佳答案 你可以使用rails.vim然后做类似的事情::Rgeneratemigratonadd_foo_to_bar插件将打开迁移生成的文件,这正是您想要的。我不能代表textmate。 关于ruby-使用VimRails,您可以创建一个新的迁移文件并一次性打开它吗?,我们在StackOverflow上找到一个类似的问题: https://sta

  2. ruby - 如何每月在 Heroku 运行一次 Scheduler 插件? - 2

    在选择我想要运行操作的频率时,唯一的选项是“每天”、“每小时”和“每10分钟”。谢谢!我想为我的Rails3.1应用程序运行调度程序。 最佳答案 这不是一个优雅的解决方案,但您可以安排它每天运行,并在实际开始工作之前检查日期是否为当月的第一天。 关于ruby-如何每月在Heroku运行一次Scheduler插件?,我们在StackOverflow上找到一个类似的问题: https://stackoverflow.com/questions/8692687/

  3. ruby - 如何根据特征实现 FactoryGirl 的条件行为 - 2

    我有一个用户工厂。我希望默认情况下确认用户。但是鉴于unconfirmed特征,我不希望它们被确认。虽然我有一个基于实现细节而不是抽象的工作实现,但我想知道如何正确地做到这一点。factory:userdoafter(:create)do|user,evaluator|#unwantedimplementationdetailshereunlessFactoryGirl.factories[:user].defined_traits.map(&:name).include?(:unconfirmed)user.confirm!endendtrait:unconfirmeddoenden

  4. ruby - Ruby 中的隐式返回值是怎么回事? - 2

    所以我开始关注ruby​​,很多东西看起来不错,但我对隐式return语句很反感。我理解默认情况下让所有内容返回self或nil但不是语句的最后一个值。对我来说,它看起来非常脆弱(尤其是)如果你正在使用一个不打算返回某些东西的方法(尤其是一个改变状态/破坏性方法的函数!),其他人可能最终依赖于一个返回对方法的目的并不重要,并且有很大的改变机会。隐式返回有什么意义?有没有办法让事情变得更简单?总是有返回以防止隐含返回被认为是好的做法吗?我是不是太担心这个了?附言当人们想要从方法中返回特定的东西时,他们是否经常使用隐式返回,这不是让你组中的其他人更容易破坏彼此的代码吗?当然,记录一切并给出

  5. ruby - 怎么来的(a_method || :other) returns :other only when assigning to a var called a_method? - 2

    给定以下方法:defsome_method:valueend以下语句按我的预期工作:some_method||:other#=>:valuex=some_method||:other#=>:value但是下面语句的行为让我感到困惑:some_method=some_method||:other#=>:other它按预期创建了一个名为some_method的局部变量,随后对some_method的调用返回该局部变量的值。但为什么它分配:other而不是:value呢?我知道这可能不是一件明智的事情,并且可以看出它可能有多么模棱两可,但我认为应该在考虑作业之前评估作业的右侧...我已经在R

  6. ruby-on-rails - 我该怎么办 :remote location validation with CarrierWave? - 2

    我在我的Rails3示例应用程序上使用CarrierWave。我想验证远程位置上传,因此当用户提交无效URL(空白或非图像)时,我不会收到标准错误异常:CarrierWave::DownloadErrorinImageController#createtryingtodownloadafilewhichisnotservedoverHTTP这是我的模型:classPaintingtrue,:length=>{:minimum=>5,:maximum=>100}validates:image,:presence=>trueend这是我的Controller:classPaintingsC

  7. 电脑0x0000001A蓝屏错误怎么U盘重装系统教学 - 2

      电脑0x0000001A蓝屏错误怎么U盘重装系统教学分享。有用户电脑开机之后遇到了系统蓝屏的情况。系统蓝屏问题很多时候都是系统bug,只有通过重装系统来进行解决。那么蓝屏问题如何通过U盘重装新系统来解决呢?来看看以下的详细操作方法教学吧。  准备工作:  1、U盘一个(尽量使用8G以上的U盘)。  2、一台正常联网可使用的电脑。  3、ghost或ISO系统镜像文件(Win10系统下载_Win10专业版_windows10正式版下载-系统之家)。  4、在本页面下载U盘启动盘制作工具:系统之家U盘启动工具。  U盘启动盘制作步骤:  注意:制作期间,U盘会被格式化,因此U盘中的重要文件请注

  8. 华为OD机试用Python实现 -【明明的随机数】 2023Q1A - 2

    华为OD机试题本篇题目:明明的随机数题目输入描述输出描述:示例1输入输出说明代码编写思路最近更新的博客华为od2023|什么是华为od,od薪资待遇,od机试题清单华为OD机试真题大全,用Python解华为机试题|机试宝典【华为OD机试】全流程解析+经验分享,题型分享,防作弊指南华为o

  9. 基于C#实现简易绘图工具【100010177】 - 2

    C#实现简易绘图工具一.引言实验目的:通过制作窗体应用程序(C#画图软件),熟悉基本的窗体设计过程以及控件设计,事件处理等,熟悉使用C#的winform窗体进行绘图的基本步骤,对于面向对象编程有更加深刻的体会.Tutorial任务设计一个具有基本功能的画图软件**·包括简单的新建文件,保存,重新绘图等功能**·实现一些基本图形的绘制,包括铅笔和基本形状等,学习橡皮工具的创建**·设计一个合理舒适的UI界面**注明:你可能需要先了解一些关于winform窗体应用程序绘图的基本知识,以及关于GDI+类和结构的知识二.实验环境Windows系统下的visualstudio2017C#窗体应用程序三.

  10. MIMO-OFDM无线通信技术及MATLAB实现(1)无线信道:传播和衰落 - 2

     MIMO技术的优缺点优点通过下面三个增益来总体概括:阵列增益。阵列增益是指由于接收机通过对接收信号的相干合并而活得的平均SNR的提高。在发射机不知道信道信息的情况下,MIMO系统可以获得的阵列增益与接收天线数成正比复用增益。在采用空间复用方案的MIMO系统中,可以获得复用增益,即信道容量成倍增加。信道容量的增加与min(Nt,Nr)成正比分集增益。在采用空间分集方案的MIMO系统中,可以获得分集增益,即可靠性性能的改善。分集增益用独立衰落支路数来描述,即分集指数。在使用了空时编码的MIMO系统中,由于接收天线或发射天线之间的间距较远,可认为它们各自的大尺度衰落是相互独立的,因此分布式MIMO

随机推荐