CAS-认证原理及实践

发布于:2022-11-09 ⋅ 阅读:(8) ⋅ 点赞:(0) ⋅ 评论:(0)

CAS-认证原理


cAS,Central Authentication Service–中央认证服务,是Yale大学发起的一个企业级的、开源的项目,旨在为Web应用系统提供一种可靠的SSO解决方案。下面简单介绍一下SSO,并重点介绍CAS认证过程,并在最后尝试使用go语言完成搭建一个简易的Cas Server。


一、单系统登录机制

这部分完全参考 参考中的第一篇。

1. http 无状态

web应用采用browser/server架构,http作为通信协议。http是无状态协议,浏览器的每一次请求,服务器会独立处理,不予之前或之后的请求产生关联,这个过程用下图说明,三次请求/响应对之间没有任何联系

img

但这也同时意味着,任何用户都能通过浏览器访问服务器资源,如果想保护服务器的某些资源,必须限制浏览器请求;要限制浏览器请求,必须鉴别浏览器请求,响应合法请求,忽略非法请求;要鉴别浏览器请求,必须清楚浏览器请求状态。既然http协议无状态,那就让服务器和浏览器共同维护一个状态吧!这就是会话机制。

2. 会话机制

浏览器第一次请求服务器,服务器创建一个会话,并将会话的id作为响应的一部分发送给浏览器,浏览器存储会话id,并在后续的第二次和第三次请求中带上会话id,服务器取得请求中的会话id就指导是不是同一个用户了,这个过程用下图说明,后续请求与第一次请求产生了关联。

img

服务器在内存中保存会话对象,浏览器会怎么保存会话id呢?你可能会想到两种方式

  • 请求参数
  • cookie

将会话id作为每一个请求的参数,服务器接受请求时解析获得该参数,这确实能够判断这是属于哪一次会话,但是很明显这种方案很繁琐而且不靠谱。那就让浏览器自己来维护自己的会话吧,在每次发送http请求时浏览器自动发送会话id,cookie机制正好可以用来做这件事,浏览器发送http请求时自动附带cookie信息。

3. 登录状态

有了会话的机制,登录状态就明白了,登录状态其实就是会话想要保持的其中一种状态。假设我们在刚打开浏览器并着手使用系统时,我们输入用户名与密码去验证身份,服务器难道用户名密码去数据库进行比对,比对成功的化说明当前持有这个会话的用户是合法用户,应该将这个会话标记为“已授权”或者“已登录”等等之类的状态。在之后从浏览器发送的请求中携带该状态的信息才能够被服务器所接受。

登录状态的浏览器请求服务器模型如下图描述:

img

每次请求接受保护资源时都会检查会话对象中的登录状态,只有isLogin=true的会话才能访问,登录机制因此而实现。

二、多系统的复杂性

web系统的发展早已经从简单的单系统发展状态成为了多个不同系统组成的应用集群。这个时候要是再按照上一小结讲的方式进行登录,会话保持,用户难道要不断的登录不同的系统嘛,这无疑会让用户产生不良的使用体验感

d04eb476-c803-4c34-b02b-98463a1f3405

web系统的发展壮大成为一个应用的集群,由于其复杂性的改变。其内部的逻辑架构也应该得到相应的改变。其实无论对于web系统内部多么复杂,对用户而言,都是一个统一的整体,也就是说,用户访问web系统的整个应用集群应该能与访问单个系统一样,登录/注册只要一次就够了。

517472a0-c721-4460-b0d7-efdf090c9076

这里其实就是将整个系统集群抽象为了一个整体,从而共同的登录认证操作可以被独立提取出来。

单系统登录的解决方案很完美,但是对于多系统应用群已经不再适用了,为什么呢?

有的人可能会提出这样一种解决方案:

不是有多个系统嘛,只要子系统1登录成功了,在浏览器端保存一下会话状态,当我们用到子系统2的时候也携带子系统1所保存的会话状态,这样不是也可以避免用户的反复登录嘛。

从以下几个方面分析其不适用性:

  • 但系统登录解决方案的核心是cookie,cookie携带会话id在浏览器与服务器之间维护会话状态。但cookie是有限制的,这个限制就是cookie中的域(通常为对应网站的域名),浏览器发送http请求时会自动携带与该域匹配的cookie,而不是所有cookie。既然这样,为什么不将web应用集群汇总所有子系统的域名统一在一个顶级域名下,例如“*.baidu.com”,然后将它们的cookie域设置为"baidu.com",这种做法理论确实可行。但是这种方式存在许多的限制,并不是一种好的解决方案。
  • 耦合度过高 多个子系统仍然需要实现各自的登录功能,对系统集群来说反复实现了同一个功能总体来说其实是高度耦合的。

三、单点登录

什么是单点登录?SSO是指在多系统应用群中登录一个系统,便可在其他所有系统中得到授权而无需再次登录,包括单点登录与单点注销两部分。

登录

相比于但系统登录,sso需要一个独立的认证中心,只有认证中心能接受用户的用户名密码等安全信息,其他系统不提供登录入口,只接受认证中心的间接授权。间接授权通过令牌实现,sso认证中心验证用户的用户名密码没问题,创建授权令牌,在接下来的跳转过程中,授权令牌作为参数发送给各个子系统,子系统拿到令牌,即得到了授权,可以借此创建局部会话,局部会话登录方式与但系统的登录当时相同。这个过程,也就是单点登录的原理,可以见下图:

e417a52c-eba8-42ef-a962-b6e297f01ee3

当用户在其中一个子系统中登录成功时,统一认证系统会给其登录页面在重定向到service的同时设置一个cookie,来作为成功登录的标志。当用户再次使用这个浏览器登录子系统2时,子系统引导至这个统一认证系统中提供的登录页面,页面会直接携带cookie去统一认证该cookie是否有效,有效的话登录页将直接跳转而无需用户再次输入用户名和密码重新登录。

注销

单点登录自然也需要注销,在一个子系统中,注销,所有子系统的绘画都将被销毁,示意图如下。

a0947d1d-d191-48d1-a5ed-af84e561f64b

注销的话相对登录较为简单,其中要注意的点就是:

  • 统一认证系统中全局保会话令牌删除
  • 每个子系统中若存储了 局部会话令牌的删除(可以通过统一认证系统向所有子系统广播然后删除局部令牌)。

四、CAS 原理介绍

体系结构

从结构体系看,CAS包括两部分:CAS Server 和 CAS Client。

CAS Server 负责完成对用户的认证工作,会为用户签发两个重要的票据:登录票据(TGT)和服务票据(ST)来实现认证过程,CAS Server 需要独立部署。

CAS Client 负责处理对客户端保护资源的访问请求,需要对请求方进行身份认证时,重定向到CAS Server进行认证。准确地来说,它以Filter方式保护受保护的资源。对于访问保护资源的每个Web请求,CAS Client 会分析该请求的Http请求中是否包含ServiceTicket(服务票据,由CAS Server发出用户标识目标服务)。CAS Client 与受保护的客户端应用部署在一起。

CAS 基本符合SSO中的角色架构,如下:

  • User (多个)
  • Web 应用 (多个CAS Client 与Web应用部署在一起)
  • SSO 认证中心 (一个CAS Server 独立部署)

其实CAS 就是实现SSO架构的一种方法,其实现思路与SSO一致。具体的实现思路可以参考第五小节中的 与统一认证对接后的登录流程

五、统一认证与传统认证的具体比较

一般前后端分离系统登录流程

首先,我们下图给出了一般系统的用户认证的机制。

image-000

一般的登录流程如下:

  1. 户打开系统首页,前端检测到用户未登录,引导至登录页
  2. 用户输入密码,提交表单到后端验证,后端验证账号密码通过后生成Token并返回给前端,存入 sessionStoreagecookie
  3. 后续所有数据请求都将携带该token,供后端校验身份和权限。

在单系统的场景下这似乎并没有什么问题。但是设想一下若像阿里这样的网站,在网站的背后是成百上千的子系统,用户一次操作或交易可能涉及到几十个子系统的协作,如果每个子系统都需要用户认证,用户会被反复的用户认证登录搞疯掉。

与统一认证对接后的登录流程

针对前面提到的困难点,我们其实换一个视角去看待就可以解决。之前的认证方案,我们过分得将一个系统看作是一个整体,所以每个系统都会有自己的一套认证流程。若我们打开视野,将多个系统看作一个整体,那么这些认证流程是否能够自然地被独立出去为一个独立的功能呢。例如下图,我们独立出去了一个专门用于身份认证的服务器,也是将认证模块独立出去了,这样每个子系统就可以专注于自己的功能实现。

image-001

认证流程简述

  1. 用户打开系统首页,前端检测到用户未登录(无token),引导至CAS登录页(https://××××××/cas/login?service=https://×××××××. com) [其中的service参数是CAS对接的应用系统地址]
  2. 用户输入账号密码(AD),并提交后,CAS进行用户身份验证,通过后生成Service Ticket,并将TAT写入刘安祺Cookie(用户访问其它系统无需再次输入账号密码),引导至上一步service参数所指定的地址并携带ticket参数,如 https://×××××××. com?ticket=××××××
  3. 用户浏览器访问https://×××××××. com?ticket=××××××,[建议此地址映射为前端界面],前端将此ticket,与service发送到后端登录接口(例 https://×××××/api/login)
  4. 后端登录接口获取到Service Ticket后,向CAS Server请求验证,验证通过后可以获得用户名。
  5. 后端根据获得到的用户名进行原先的登录操作,并生成Token,返回给前端,存入sessionStoreagecookie
  6. 后续所有数据请求都将携带该token,供后端校验身份和权限。

解释一下这个的工作流程。第一步就是携带自己系统的地址,然后借用CAS Server提供的登录接口。要是CAS Server登录验证成功了就可以会直接帮你重定向到service所指定的网址中,并且会携带登录成功返回的ticket参数。接下来前端会携带这个ticket去后端请求登录一下,后端则会携带这个ticket以及service再去请求CAS Server进行验证(这样进一步保证了登录的安全性)。此时若后端在CAS Server端验证成功,将生成Token,并返回给前端,这个Token会被存储起来比如以cookie的形式。之后的每次请求都会携带这个token,供后端校验身份。

六、实现讲解

这里的话简单实现了一个CAS Server的全部功能,但是其中的各种票据的生成都做简化处理

登录页面需要做的

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <script src="https://unpkg.com/axios/dist/axios.min.js" ></script>
  <style type="text/css">
    *{
      margin: 0;
      border: 0;
    }
    html, body {
      width: 100vw;
      height: 100vh;
    }
    #root {
      width: 100%;
      height: 100%;
      background-color: lightblue;
      display: flex;
      text-align: center;
      justify-content: center;
      align-items: center;
    }

    #login-table{
      justify-content: center;

    }

    input:focus{
      outline: 2px dashed rgb(183, 214, 99);
    }
    #login-table{
      width: 300px;
      height: 200px;
      border: 1px solid #ccc;
      border-radius: 8px;
      background-color: rgb(44, 84, 122);
      
    }
    #login-form{
      width: 100%;
      height: 100%;
      display: flex;
      flex-direction: column;
    }
    .form-group{
      flex: 1;
      padding: 4px 0;
    }
    button{
      border-radius: 3px;

    }
    button:hover{
      background-color: olive;
    }
  </style>  
</head>
<body>
  <div id="root">
    <div id="login-table" >
      <form id="login-form" action="http://ywhabc.com:889/cas/login" >
        <div class="form-group">
          Cas Login
        </div>
        
        <div class="form-group">
          <label for="login-uname" >账号:</label>
          <input type="text" id="login-uname" placeholder="请输入用户账号" name="username"/>
        </div>

        <div class="form-group">
          <label for="login-pwd" >密码:</label>
          <input type="password" id="login-pwd" placeholder="请输入用户账号" name="password" />
        </div>
        <input type="hidden" id="login-service" name="service" />
        <div class="form-group">
          <button type="submit" id="login-btn">登录</button>
        </div>
      </form>
    </div>
  </div>
  <script type="text/javascript">
    
    // 获得请求中的参数
    const getParams = () => {
      const search = location.search;
      var reg = /(\w+)=([^&]+)/g,
        params = {},
        result = null;
      
      while ((result = reg.exec(search))) {
        params[result[1]] = decodeURI(result[2]);
      }
      return params;
    }
    
    // 向表单中添加隐含参数
    const insertHiddenFormParam = () => {
      const casForm = document.querySelector("#login-service");
      const pageParam = getParams();
      if(pageParam && pageParam["service"]){
        casForm.value = pageParam["service"];
        // 提示 service 的域名是否合法
        // if(check(pageParam["service"])) {
        //! do some thing
        // }
      }
    }
    insertHiddenFormParam();

    // 检查登录状态
    const checkLoading = () => {
      const pageParam = getParams();
      const service = pageParam["service"] ? pageParam["service"] : "";
      axios.get("http://ywhabc.com:889/cas/checkLoginState")
            .then((res) => {
              console.dir(res)
              const ticket = res.data["ticket"]
              // 如果登录成功就页面跳转到系统页面
              if (res.status === 200) {
                window.location.href = service + `?ticket=${ticket}`;
              }
            })
            .catch((err) => {
              console.dir(err)
            })
    };
    checkLoading();
    
  </script>
</body>
</html>

大致长下面这个样子,页面样式没有仔细设计

image-20221109145305536

其中代码中的几个要点代码中都有注释,其中几个要做的点:

  • 如何获得页面自带的param参数
  • 表单中隐含添加参数
  • 进入该页面时就去检查登录状态,这里的话用了axios,处理重定向比较麻烦所以,在前端实现这部分的重定向(不过建议这个重定向还是后端来做)。

后端需要做的

后端大致要实现以下几个接口

// 登出接口
func router() {
	r := gin.Default()
	r.LoadHTMLFiles("./html/index.html")
	g := r.Group("/cas")
	// 使用过滤器
	g.Use(pathVerify)

	// 加载登录页面
	g.GET("/loginPage", casLoginPage)
	// 检查登录状态
	g.GET("/checkLoginState", casCheckLoadingState)
	// 用户登录
	g.GET("/login", casLogin)
	// 用户注销
	g.GET("/logout", casLogout)
	// 非法访问跳转到登录页面
	r.GET("/", redirect)
	g.GET("/", redirect)

	r.Run(":889")
}

重点看登录成功部分的操作

// cas 登入
func casLogin(c *gin.Context) {
	params := PraseLoginInfoFromRequest(c)
	isAllowedDomain := checkAllowedDomains(params.Service)
	isAllowedUser := params.Check()

	if isAllowedDomain && isAllowedUser {
		// 这里应该有个生成 ticket 的操作 目前以 uuid 替代
		uidTicket := getUUid()
		InsertMapData(params.Service, uidTicket, params.Username)
		// 登陆成功时向登陆页面写入 cookie
		c.SetCookie(CasLoinTicketName, uidTicket, 100000, "/cas", ".ywhabc.com", false, false)
		c.Redirect(http.StatusMovedPermanently, params.Service+"?ticket="+uidTicket)
	} else if isAllowedUser && !isAllowedDomain {
		c.JSON(http.StatusBadGateway, gin.H{
			"message": "域名不合法",
		})
	} else {
		c.String(http.StatusBadRequest, "登录出错")
	}
}

这里包含了这几点工作:

  • ticket生成
  • 向登录页面写入cookie
  • 重定向到用service指定的系统所在地址(检查是否为合法的域名)

其他的部分就不一一讲解了,可以通过我的代码仓库查看完整代码,。

代码地址

参考