# 大厂架构演进实战之手写 CAS 单点登录
# 什么是单点登录
单点登录在大型网站里使用得非常频繁,那么什么是单点登录?一句话解释:一处登录,处处登录。
比如,淘宝和天猫都属于阿里旗下,账号也是通用的,一个账号即可以登录淘宝,又可以登录天猫,这样也是为了方便用户,如果每个子系统都需要单独注册账号的话就太麻烦了,所以是统一账户可以登录同属于一家公司的所有子系统。
那么这些子系统每次都需要登录吗?肯定不是的,比如淘宝登录之后,你在访问天猫就不需要登录,系统会自动识别完成登录的,这就是单点登录。所以单点登录要解决的就是,用户只需要登录一次就可以访问所有相互信任的应用系统。
单点登录(Single Sign On),简称为 SSO,是目前比较流行的企业业务整合的解决方案之一。SSO 并不能算是一种架构,只能说是一个解决方案。SSO 核心意义就一句话:一处登录,处处登录;一处注销,处处注销。
就是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统,即用户只需要记住一组用户名和密码就可以登录所有的子系统。
举个现实生活中的例子:旅游年票大家都应该知道,就是买一张票,所有景点都可以去,旅游年票就是单点登录的思想。大家想想如果你没有这张年票,假如你来西安旅游,你今天去大雁塔需要买票,明天去钟楼还需要买票,后天去兵马俑还得买一次票,也就是说每去一个景点都得买一张票。
你参观西安的每个旅游景点都需要买一次票,是不是就相当于你访问一个大型应用的每个子系统都需要进行登录?这样就比较麻烦,怎么解决呢?你买一张西安的旅游年票不就好了么,有了这张年票,你去哪个任何一个景点都是直接进,不需要再买票了。
这个年票是不是就相当于,你只需要登录一次,之后在一个大型应用的所有子系统中都可以自动登录,不需要再输入账户密码了,这就是单点登录的思想。
# 单点登录实现原理
我们用淘宝和天猫为例给大家讲解单点登录的实现原理,大致如下。
1、当用户第一次访问应用淘宝的时候,因为还没有登录,会被引导到认证系统中进行登录。
2、根据用户提供的登录信息, 认证系统进行身份效验,如果通过效验, 则登录成功,并返回给用户一个认证的凭据 token。
3、当用户访问天猫时, 就会将这个 token 带上,作为自己认证的凭据。
4、应用系统接受到请求之后会把 token 送到认证系统进行校验,检查 token 的合法性。
5、如果通过校验,用户就可以在不用再次登录的情况下访问天猫了。
如果你还不太懂,楠哥给你画了张图,一看就懂的那种。

# 单点登录解决方案
SSO 是一种思想,具体的解决方案有很多,目前业内比较常用的有以下 3 种:
1、OAuth2
主要用来做第三方登录授权,第三方系统访问主系统资源,用户无需将主系统的账号告知第三方,只需通过主系统的授权,第三方就可使用主系统的资源。
最常见的就是 APP 需使用微信支付,当你在京东买东西使用微信支付时,会自动提示用户是否授权,用户授权后,京东就可使用微信支付功能了,京东和微信是有合作关系的。
所以 OAuth2 是用来允许用户授权的第三方应用,访问用户已经登录过的另外一个服务器上的资源,的一种协议,它不是用来做单点登录的,但我们可以利用它来实现单点登录。
2、JWT
Json web token (JWT),是为了在网络应用之间传递信,息而执行的一种基于 JSON 的开放标准,难度较大,需要了解很多协议,所以它是一种偏向底层的东西,需要你基于 JWT 认证协议,自己开发 SSO 服务和权限控制。
3、CAS
Central Authentication Service(中央认证服务),CAS 是耶鲁大学发起的一个开源项目,为 Web 应用系统提供的单点登录解决方案,实现多个系统只需登录一次,无需重复登录,支持 Java、PHP、.NET 等语言。
CAS 包含两个部分:CAS Server 和 CAS Client
CAS Server 和 CAS Client 分别独立部署,CAS Server 主要负责认证工作。
CAS Client 负责处理对客户端资源的访问请求,需要登录时,重定向到 CAS Server 进行认证。
我们以 CAS 为例,自己写一套代码的实现,以此来彻底搞清楚 SSO,下面开始撸代码啦。
# 手写 CAS
正式开撸之前,我们先来分析 CAS 流程。
1、授权服务器保存一个全局 session,多个客户端各自保存自己的 session。
2、客户端登录时判断自己的 session 是否已登录,若未登录,则(告诉浏览器)重定向到授权服务器(参数带上自己的地址,用于回调)。
3、授权服务器判断全局的 session 是否已登录,若未登录则定向到登录页面,提示用户登录,登录成功后,授权服务器重定向到客户端(参数带上 token)。
4、客户端收到 Token 后,请求服务器获取用户信息。
5、服务器同意客户端授权后,服务端保存用户信息至全局 session,客户端将用户保存至本地 session。
代码实现
1、创建客户端 ssoclienttaobao ,完成 Controller 跳转 index.html 的逻辑。
@Controller
public class TaobaoController {
@GetMapping("/taobao")
public String index(){
return "index";
}
}
2、添加拦截器 TaobaoInterceptor。
public class TaobaoInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//判断会话是否存在
HttpSession session = request.getSession();
Boolean isLogin = (Boolean) session.getAttribute("isLogin");
if(isLogin!=null && isLogin){
return true;
}
//判断token
String token = request.getParameter("token");
if(!StringUtils.isEmpty(token)){
}
//token为空,登录认证
SSOClientUtil.redirectToCheckToken(request, response);
return false;
}
}
3、创建配置类,使拦截器生效。
@Configuration
public class InterceptorConfiguration implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
String[] addPathPattrens = {"/taobao"};
registry.addInterceptor(new TaobaoInterceptor()).addPathPatterns(addPathPattrens);
}
}
4、创建认证中心 ssoserver,实现 checkToken 方法。
@Controller
public class SSOServerController {
@GetMapping("/checkToken")
public String checkToken(String redirectUrl, HttpServletRequest request,HttpSession session, Model model){
String token = (String) session.getServletContext().getAttribute("token");
if(StringUtils.isEmpty(token)){
model.addAttribute("redirectUrl", redirectUrl);
return "login";
}else{
return "";
}
}
}
5、实现 login 方法,登录成功,重新回到 ssoclienttaobao 客户端。
@PostMapping("/login")
public String login(String username,String password,String redirectUrl,HttpSession session,Model model){
if("admin".equals(username) && "123123".equals(password)){
String token = UUID.randomUUID().toString();
session.getServletContext().setAttribute("token", token);
MockDB.tokenSet.add(token);
return "redirect:"+redirectUrl+"?token="+token;
}else{
return "login";
}
}
6、再次进入 ssoclienttaobao 客户端拦截器,此时有 token,进行验证。
//判断token
String token = request.getParameter("token");
if(!StringUtils.isEmpty(token)){
//token存在,进行验证
String httpUrl = SSOClientUtil.SERVER_HOST_URL+"/verify";
HashMap<String,String> params = new HashMap<>();
params.put("token", token);
String isVerify = HttpUtil.sendHttpRequest(httpUrl, params);
if("true".equals(isVerify)){
//验证通过
Cookie cookie = new Cookie("token", token);
response.addCookie(cookie);
session.setAttribute("isLogin", true);
return true;
}
}
7、实现 ssoserver 的 verify 方法。
@PostMapping("/verify")
@ResponseBody
public String verifyToken(String token){
if(MockDB.tokenSet.contains(token)){
return "true";
}
return "false";
}
8、ssoclienttaobao 登录逻辑完成,创建另外一个客户端 ssoclienttmall,完成 Controller 跳转 index.html 的逻辑。
@Controller
public class TmallController {
@GetMapping("/tmall")
public String index(){
return "index";
}
}
9、创建拦截器 TmallInterceptor。
public class TmallInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
HttpSession session = request.getSession();
Boolean isLogin = (Boolean) session.getAttribute("isLogin");
if(isLogin != null && isLogin){
return true;
}
String token = request.getParameter("token");
if(!StringUtils.isEmpty(token)){
String httpUrl = SSOClientUtil.SERVER_HOST_URL+"/verify";
HashMap<String,String> params = new HashMap<>();
params.put("token", token);
String isVerify = HttpUtil.sendHttpRequest(httpUrl, params);
if("true".equals(isVerify)){
Cookie cookie = new Cookie("token", token);
response.addCookie(cookie);
session.setAttribute("isLogin", true);
return true;
}
}
SSOClientUtil.redirectToCheckToken(request, response);
return false;
}
}
10、创建拦截器配置类 InterceptorConfiguration。
@Configuration
public class InterceptorConfiguration implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
String[] addPathPattrens = {"/tmall"};
registry.addInterceptor(new TmallInterceptor()).addPathPatterns(addPathPattrens);
}
}
11、我们的逻辑是先让 taobao 登录,然后 tmall 登录的时候 ssoserver 直接检测全局 token 即可(taobao 登录成功会存入全局 token),所以接下来完善 ssoserver checkToken 的方法。
@GetMapping("/checkToken")
public String checkToken(String redirectUrl, HttpServletRequest request, HttpSession session, Model model){
String token = (String) session.getServletContext().getAttribute("token");
if(StringUtils.isEmpty(token)){
model.addAttribute("redirectUrl", redirectUrl);
return "login";
}else{
Cookie[] cookies = request.getCookies();
for (Cookie cookie : cookies) {
if(cookie.getValue().equals(token)){
return "redirect:"+redirectUrl+"?token="+token;
}
}
model.addAttribute("redirectUrl", redirectUrl);
return "login";
}
}
到目前为止,单点登录的逻辑全部实现了,楠哥再给大家梳理一下思路。
(1)taobao 首先访问,被拦截,检测到没有 token,进入 ssoserver 的 checktoken 方法,此时全局 token 为空,进入登录页面,完成登录逻辑,生成 token 存入全局 token,并且将 token 存入数据库,再带着这个 token 返回 taobao。
(2)进入 taobao 拦截器,有 token,进行验证,进入 ssoserver 的 verify 方法,从数据库中查询,token 存在,则返回 true。
(3)回到 taobao 拦截器,结果为 true,将 token 存入 Cookie(给 tmall 检测使用),并将 isLogin = true 存入本地 session,返回 true,通过拦截器,进入页面,taobao 登录逻辑完成。
(4)tmall 访问,被拦截,检测到没有 token,进入 ssoserver 的 checktoken 方法,此时全局 token 存在,则对比 Cookie,如果 Cookie 中没有相等的 token,则登录,如果有相等的 token,则表示其他子项目(taobao)已登录过,tmall 不需要再次登录,带着这个 token 返回 tmall。
(5)进入 tmall 拦截器,有 token,进行验证,进入 ssoserver 的 verify 方法,从数据库中查询,token 存在,则返回 true。
(6)回到 tmall 拦截器,结果为 true,将 token 存入 Cookie,并将 isLogin = true 存入本地 session,返回 true,通过拦截器,进入页面,tmall 登录逻辑完成。
实现了单点登录,接下来我们实现单点登出。
思路很简单:ssoserver 销毁 session,并且触发监听器,删除全局会话中的 token,删除数据库中的 token,通知所有客户端销毁 session,删除数据库中客户端登出 URL 集合。
1、taobao 和 tmall 两个客户端进入首页的方法需要将 ssoserver 的 logoutUrl 存入 model,给页面退出使用。
@Controller
public class TaobaoController {
@GetMapping("/taobao")
public String index(Model model){
model.addAttribute("serverLogoutUrl", SSOClientUtil.getServerLogoutUrl());
return "index";
}
}
@Controller
public class TmallController {
@GetMapping("/tmall")
public String index(Model model){
model.addAttribute("serverLogoutUrl", SSOClientUtil.getServerLogoutUrl());
return "index";
}
}
2、实现 ssoserver 的 logout 方法。
@GetMapping("/logout")
public String logout(HttpSession session){
session.invalidate();
return "login";
}
3、创建监听器,监听 session 销毁的行为,分别让 taobao 和 tmall 客户端执行登出逻辑。
@WebListener
public class SessionListener implements HttpSessionListener {
@Override
public void sessionDestroyed(HttpSessionEvent se) {
String token = (String) se.getSession().getServletContext().getAttribute("token");
se.getSession().getServletContext().removeAttribute("token");
MockDB.tokenSet.remove(token);
Set<String> set = MockDB.clientLogoutUrlMap.get(token);
Iterator<String> iterator = set.iterator();
while (iterator.hasNext()){
HttpUtil.sendHttpRequest(iterator.next(), null);
}
MockDB.clientLogoutUrlMap.remove(token);
}
}
4、创建监听器配置类,让监听器生效。
@Configuration
public class ListenerConfiguration {
@Bean
public ServletListenerRegistrationBean listenerRegistrationBean(){
ServletListenerRegistrationBean servletListenerRegistrationBean = new ServletListenerRegistrationBean();
servletListenerRegistrationBean.setListener(new SessionListener());
return servletListenerRegistrationBean;
}
}
5、基本逻辑实现完成,现在还差一步,给 MockDB 存入客户端的登出 URL,这一步在 token 验证过程中完成。
@PostMapping("/verify")
@ResponseBody
public String verifyToken(String token,String clientLogoutUrl){
if(MockDB.tokenSet.contains(token)){
Set<String> set = MockDB.clientLogoutUrlMap.get(token);
if(set == null){
set = new HashSet<>();
MockDB.clientLogoutUrlMap.put(token, set);
}
set.add(clientLogoutUrl);
return "true";
}
return "false";
}
6、客户端在验证的时候需要将各自的登出 URL 传给 ssoserver。
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
HttpSession session = request.getSession();
Boolean isLogin = (Boolean) session.getAttribute("isLogin");
if(isLogin != null && isLogin){
return true;
}
String token = request.getParameter("token");
if(!StringUtils.isEmpty(token)){
String httpUrl = SSOClientUtil.SERVER_HOST_URL+"/verify";
HashMap<String,String> params = new HashMap<>();
params.put("token", token);
params.put("clientLogoutUrl", SSOClientUtil.getClientLogoutUrl());
String isVerify = HttpUtil.sendHttpRequest(httpUrl, params);
if("true".equals(isVerify)){
Cookie cookie = new Cookie("token", token);
response.addCookie(cookie);
session.setAttribute("isLogin", true);
return true;
}
}
SSOClientUtil.redirectToCheckToken(request, response);
return false;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
HttpSession session = request.getSession();
Boolean isLogin = (Boolean) session.getAttribute("isLogin");
if(isLogin != null && isLogin){
return true;
}
String token = request.getParameter("token");
if(!StringUtils.isEmpty(token)){
String httpUrl = SSOClientUtil.SERVER_HOST_URL+"/verify";
HashMap<String,String> params = new HashMap<>();
params.put("token", token);
params.put("clientLogoutUrl", SSOClientUtil.getClientLogoutUrl());
String isVerify = HttpUtil.sendHttpRequest(httpUrl, params);
if("true".equals(isVerify)){
Cookie cookie = new Cookie("token", token);
response.addCookie(cookie);
session.setAttribute("isLogin", true);
return true;
}
}
SSOClientUtil.redirectToCheckToken(request, response);
return false;
}
7、taobao、tmall 各自的 Controller 添加 logout 方法。
@PostMapping("/logout")
public String logout(HttpSession session){
session.invalidate();
return "redirect:/taobao";
}
@PostMapping("/logout")
public String logout(HttpSession session){
session.invalidate();
return "redirect:/tmall";
}
代码全部完成,以上就是手写 CAS 单点登录的整个过程,能看到这的小伙伴请一定要跟着楠哥的思路自己手撸一遍,彻底搞清楚单点登录的原理,源码链接
https://github.com/southwind9801/sso (opens new window)
如果看文章没有完全理解,B 站搜索:楠哥教你学Java,或点击转载链接,关注楠哥的号,后期会在 B 站给小伙伴们直播讲解。如果楠哥的文章对你有帮助,请帮忙转发,分享给你身边的小伙伴,谢谢~