如何设计一套单点登录系统

开发 前端
昨天介绍了API接口设计token鉴权方案,其实token鉴权最佳的实践场景就是在单点登录系统上。在企业发展初期,使用的后台管理系统还比较少,一个或者两个。

[[401827]]

本文转载自微信公众号「Java极客技术」,作者鸭血粉丝 。转载本文请联系Java极客技术公众号。

一、介绍

昨天介绍了API接口设计token鉴权方案,其实token鉴权最佳的实践场景就是在单点登录系统上。

在企业发展初期,使用的后台管理系统还比较少,一个或者两个。

以电商系统为例,在起步阶段,可能只有一个商城下单系统和一个后端管理产品和库存的系统。

随着业务量越来越大,此时的业务系统会越来越复杂,项目会划分成多个组,每个组负责各自的领域,例如:A组负责商城系统的开发,B组负责支付系统的开发,C组负责库存系统的开发,D组负责物流跟踪系统的开发,E组负责每日业绩报表统计的开发...等等。

规模变大的同时,人员也会逐渐的增多,以研发部来说,大致的人员就有这么几大类:研发人员、测试人员、运维人员、产品经理、技术支持等等。

他们会频繁的登录各自的后端业务系统,然后进行办公。

此时,我们可以设想一下,如果每个组都自己开发一套后端管理系统的登录,假如有10个这样的系统,同时一个新入职的同事需要每个系统都给他开放一个权限,那么我们可能需要给他开通10个账号。

随着业务规模的扩大,大点的公司,可能高达一百多个业务系统,那岂不是要配置一百多个账号,让人去做这种操作,岂不伤天害理。

面对这种繁琐而且又无效的工作,IT大佬们想到一个办法,那就是开发一套登录系统,所有的业务系统都认可这套登录系统,那么就可以实现只需要登录一次,就可以访问其他相互信任的应用系统。

这个登录系统,我们把它称为:单点登录系统。

好了,言归正传,下面我们从两个方面来介绍单点登录系统的实现。

  • 方案设计
  • 项目实践

二、方案设计

2.1、单体后端系统登录

在传统的单体后端系统中,简单点的操作,我们一般都会这么玩,用户使用账号、密码登录之后,服务器会给当前用户创建一个session会话,同时也会生成一个cookie,最后返回给前端。

当用户访问其他后端的服务时,我们只需要检查一下当前用户的session是否有效,如果无效,就再次跳转到登录页面;如果有效,就进入业务处理流程。

但是,如果访问不同的域名系统时,这个cookie是无效的,因此不能跨系统访问,同时也不支持集群环境的共享。

对于单点登录的场景,我们需要重新设计一套新的方案。

2.2、单点登录系统登录

先来一张图!

这个流程图,就是单点登录系统与应用系统之间的交互图。

当用户登录某应用系统时,应用系统会把将客户端传入的token,调用单点登录系统验证token合法性接口,如果不合法就会跳转到单点登录系统的登录页面;如果合法,就直接进入首页。

进入登录页面之后,会让用户输入用户名、密码进行登录验证,如果验证成功之后,会返回一个有效的token,然后客户端会根据服务端返回的参数链接,跳转回之前要访问的应用系统。

接着,应用系统会再次验证token的合法性,如果合法,就进入首页,流程结束。

引入单点登录系统后,接入的应用系统不需要关系用户登录这块,只需要对客户端的token做一下合法性鉴权操作就可以了。

而单点登录系统,只需要做好用户的登录流程和鉴权并返回安全的token给客户端。

有的项目,会将生成的token,存放在客户端的cookie中,这样做的目的,就是避免每次调用接口的时候都在url里面带上token。

但是,浏览器只允许同域名下的cookies可以共享,对于不同的域名系统, cookie 是无法共享的。

对于这种情况,我们可以先将 token 放入到url链接中,类似上面流程图中跳转思路,对于同一个应用系统,我们可以将token放入到 cookie 中,不同的应用系统,我们可以通过 url 链接进行传递,实现token的传输。

三、项目实践

在实践上,token的存储,有两种方案:

  • 存放在服务器,如果是分布式环境,一般都会存储在 redis 中
  • 存储在客户端,服务器做验证,天然支持分布式

3.1、存放在redis

存放在redis中,是一种比较常见的处理办法,最开始的时候也是这种处理办法。

当用户登录成功之后,会将用户的信息作为value,用uuid作为key,存储到redis中,各个服务集群共享用户信息。

代码实践也非常简单。

用户登录之后,将用户信息存在到redis,同时返回一个有效的token给客户端。

  1. @RequestMapping(value = "/login", method = RequestMethod.POST, produces = {"application/json;charset=UTF-8"}) 
  2. public TokenVO login(@RequestBody LoginDTO loginDTO){ 
  3.     //...参数合法性验证 
  4.     //从数据库获取用户信息 
  5.     User dbUser = userService.selectByUserNo(loginDTO.getUserNo); 
  6.     //....用户、密码验证 
  7.  
  8.     //创建token 
  9.     String token = UUID.randomUUID(); 
  10.     //将token和用户信息存储到redis,并设置有效期2个小时 
  11.     redisUtil.save(token, dbUser, 2*60*60); 
  12.     //定义返回结果 
  13.     TokenVO result = new TokenVO(); 
  14.     //封装token 
  15.     result.setToken(token); 
  16.     //封装应用系统访问地址 
  17.     result.setRedirectURL(loginDTO.getRedirectURL()); 
  18.     return result; 

客户端收到登录成功之后,根据参数组合进行跳转到对应的应用系统。

跳转示例如下:http://xxx.com/page.html?token=xxxxxx

各个应用系统,只需要编写一个过滤器TokenFilter对token参数进行验证拦截,即可实现对接,代码如下:

  1. @Override 
  2. public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws ServletException, IOException, SecurityException { 
  3.     HttpServletRequest request = (HttpServletRequest) servletRequest; 
  4.     HttpServletResponse response = (HttpServletResponse) servletResponse; 
  5.  
  6.     String requestUri = request.getRequestURI(); 
  7.     String contextPath = request.getContextPath(); 
  8.     String serviceName = request.getServerName(); 
  9.  
  10.     //添加到白名单的URL放行 
  11.     String[] excludeUrls = { 
  12.             "(?:/images/|/css/|/js/|/template/|/static/|/web/|/constant/).+$"
  13.             "/user/login"
  14.             "/user/createImage" 
  15.     }; 
  16.     for (String url : excludeUrls) { 
  17.         if (requestUri.matches(contextPath + url) || (serviceName.matches(url))) { 
  18.             filterChain.doFilter(request, response); 
  19.             return
  20.         } 
  21.     } 
  22.     //运行跨域探测 
  23.     if(RequestMethod.OPTIONS.name().equals(request.getMethod())){ 
  24.         filterChain.doFilter(request, response); 
  25.         return
  26.     } 
  27.  
  28.     //检查token是否有效 
  29.     final String token = request.getHeader("token"); 
  30.     if(StringUtils.isEmpty(token) || !redisUtil.exist(token)){ 
  31.         ResultMsg<Object> resultMsg = new ResultMsg<>(4000, "token已失效"); 
  32.         //封装跳转地址 
  33.         resultMsg.setRedirectURL("http://sso.xxx.com?redirectURL=" + request.getRequestURL()); 
  34.         WebUtil.buildPrintWriter(response, resultMsg); 
  35.         return
  36.     } 
  37.     //将用户信息,存入request中,方便后续获取 
  38.     User user =  redisUtil.get(token); 
  39.     request.setAttribute("user"user); 
  40.     filterChain.doFilter(request, response); 
  41.     return

上面返回的是json数据给前端,当然你还可以直接在服务器采用重定向进行跳转,具体根据自己的情况进行选择。

由于每个应用系统都可能需要进行对接,因此我们可以将上面的方法封装成一个公共jar包,应用系统只需要依赖包即可完成对接!

3.2、token存放客户端

还有一种方案,是将token存放客户端,这种方案就是服务端根据规则对数据进行加密生成一个签名串,这个签名串就是我们所说的token,最后返回给前端。

因为加密的操作都是在服务端完成的,因此密钥的管理非常重要,不能泄露出去,不然很容易被黑客解密出来。

最典型的应用就是JWT!

JWT 是由三段信息构成的,将这三段信息文本用.链接一起就构成了JWT字符串。就像这样:

  1. eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ 

如何实现呢?首先我们需要添加一个jwt依赖包。

  1. <!-- jwt支持 --> 
  2. <dependency> 
  3.     <groupId>com.auth0</groupId> 
  4.     <artifactId>java-jwt</artifactId> 
  5.     <version>3.4.0</version> 
  6. </dependency> 

然后,创建一个用户信息类,将会通过加密存放在token中。

  1. @Data 
  2. @EqualsAndHashCode(callSuper = false
  3. @Accessors(chain = true
  4. public class UserToken implements Serializable { 
  5.  
  6.     private static final long serialVersionUID = 1L; 
  7.  
  8.     /** 
  9.      * 用户ID 
  10.      */ 
  11.     private String userId; 
  12.  
  13.     /** 
  14.      * 用户登录账户 
  15.      */ 
  16.     private String userNo; 
  17.  
  18.     /** 
  19.      * 用户中文名 
  20.      */ 
  21.     private String userName; 

接着,创建一个JwtTokenUtil工具类,用于创建token、验证token。

  1. public class JwtTokenUtil { 
  2.  
  3.  //定义token返回头部 
  4.     public static final String AUTH_HEADER_KEY = "Authorization"
  5.  
  6.  //token前缀 
  7.     public static final String TOKEN_PREFIX = "Bearer "
  8.  
  9.  //签名密钥 
  10.     public static final String KEY = "q3t6w9z$C&F)J@NcQfTjWnZr4u7x"
  11.   
  12.  //有效期默认为 2hour 
  13.     public static final Long EXPIRATION_TIME = 1000L*60*60*2; 
  14.  
  15.  
  16.     /** 
  17.      * 创建TOKEN 
  18.      * @param content 
  19.      * @return 
  20.      */ 
  21.     public static String createToken(String content){ 
  22.         return TOKEN_PREFIX + JWT.create() 
  23.                 .withSubject(content) 
  24.                 .withExpiresAt(new Date(System.currentTimeMillis() + EXPIRATION_TIME)) 
  25.                 .sign(Algorithm.HMAC512(KEY)); 
  26.     } 
  27.  
  28.     /** 
  29.      * 验证token 
  30.      * @param token 
  31.      */ 
  32.     public static String verifyToken(String token) throws Exception { 
  33.         try { 
  34.             return JWT.require(Algorithm.HMAC512(KEY)) 
  35.                     .build() 
  36.                     .verify(token.replace(TOKEN_PREFIX, "")) 
  37.                     .getSubject(); 
  38.         } catch (TokenExpiredException e){ 
  39.             throw new Exception("token已失效,请重新登录",e); 
  40.         } catch (JWTVerificationException e) { 
  41.             throw new Exception("token验证失败!",e); 
  42.         } 
  43.     } 

同时编写配置类,允许跨域,并且创建一个权限拦截器。

  1. @Slf4j 
  2. @Configuration 
  3. public class GlobalWebMvcConfig implements WebMvcConfigurer { 
  4.     /** 
  5.      * 重写父类提供的跨域请求处理的接口 
  6.      * @param registry 
  7.      */ 
  8.     @Override 
  9.     public void addCorsMappings(CorsRegistry registry) { 
  10.         // 添加映射路径 
  11.         registry.addMapping("/**"
  12.                 // 放行哪些原始域 
  13.                 .allowedOrigins("*"
  14.                 // 是否发送Cookie信息 
  15.                 .allowCredentials(true
  16.                 // 放行哪些原始域(请求方式) 
  17.                 .allowedMethods("GET""POST""DELETE""PUT""OPTIONS""HEAD"
  18.                 // 放行哪些原始域(头部信息) 
  19.                 .allowedHeaders("*"
  20.                 // 暴露哪些头部信息(因为跨域访问默认不能获取全部头部信息) 
  21.                 .exposedHeaders("Server","Content-Length""Authorization""Access-Token""Access-Control-Allow-Origin","Access-Control-Allow-Credentials"); 
  22.     } 
  23.  
  24.     /** 
  25.      * 添加拦截器 
  26.      * @param registry 
  27.      */ 
  28.     @Override 
  29.     public void addInterceptors(InterceptorRegistry registry) { 
  30.         //添加权限拦截器 
  31.         registry.addInterceptor(new AuthenticationInterceptor()).addPathPatterns("/**").excludePathPatterns("/static/**"); 
  32.     } 

使用AuthenticationInterceptor拦截器对接口参数进行验证。

  1. @Slf4j 
  2. public class AuthenticationInterceptor implements HandlerInterceptor { 
  3.  
  4.     @Override 
  5.     public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { 
  6.         // 从http请求头中取出token 
  7.         final String token = request.getHeader(JwtTokenUtil.AUTH_HEADER_KEY); 
  8.         //如果不是映射到方法,直接通过 
  9.         if(!(handler instanceof HandlerMethod)){ 
  10.             return true
  11.         } 
  12.         //如果是方法探测,直接通过 
  13.         if (HttpMethod.OPTIONS.equals(request.getMethod())) { 
  14.             response.setStatus(HttpServletResponse.SC_OK); 
  15.             return true
  16.         } 
  17.         //如果方法有JwtIgnore注解,直接通过 
  18.         HandlerMethod handlerMethod = (HandlerMethod) handler; 
  19.         Method method=handlerMethod.getMethod(); 
  20.         if (method.isAnnotationPresent(JwtIgnore.class)) { 
  21.             JwtIgnore jwtIgnore = method.getAnnotation(JwtIgnore.class); 
  22.             if(jwtIgnore.value()){ 
  23.                 return true
  24.             } 
  25.         } 
  26.         LocalAssert.isStringEmpty(token, "token为空,鉴权失败!"); 
  27.         //验证,并获取token内部信息 
  28.         String userToken = JwtTokenUtil.verifyToken(token); 
  29.    
  30.         //将token放入本地缓存 
  31.         WebContextUtil.setUserToken(userToken); 
  32.         return true
  33.     } 
  34.  
  35.     @Override 
  36.     public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { 
  37.         //方法结束后,移除缓存的token 
  38.         WebContextUtil.removeUserToken(); 
  39.     } 

最后,在controller层用户登录之后,创建一个token,存放在头部即可。

  1. /** 
  2.  * 登录 
  3.  * @param userDto 
  4.  * @return 
  5.  */ 
  6. @JwtIgnore 
  7. @RequestMapping(value = "/login", method = RequestMethod.POST, produces = {"application/json;charset=UTF-8"}) 
  8. public UserVo login(@RequestBody UserDto userDto, HttpServletResponse response){ 
  9.     //...参数合法性验证 
  10.  
  11.     //从数据库获取用户信息 
  12.     User dbUser = userService.selectByUserNo(userDto.getUserNo); 
  13.  
  14.     //....用户、密码验证 
  15.  
  16.     //创建token,并将token放在响应头 
  17.     UserToken userToken = new UserToken(); 
  18.     BeanUtils.copyProperties(dbUser,userToken); 
  19.  
  20.     String token = JwtTokenUtil.createToken(JSONObject.toJSONString(userToken)); 
  21.     response.setHeader(JwtTokenUtil.AUTH_HEADER_KEY, token); 
  22.  
  23.  
  24.     //定义返回结果 
  25.     UserVo result = new UserVo(); 
  26.     BeanUtils.copyProperties(dbUser,result); 
  27.     return result; 

到这里基本就完成了!

其中AuthenticationInterceptor中用到的JwtIgnore是一个注解,用于不需要验证token的方法上,例如验证码的获取等等。

  1. @Target({ElementType.METHOD, ElementType.TYPE}) 
  2. @Retention(RetentionPolicy.RUNTIME) 
  3. public @interface JwtIgnore { 
  4.  
  5.     boolean value() default true

而WebContextUtil是一个线程缓存工具类,其他接口通过这个方法即可从token中获取用户信息。

  1. public class WebContextUtil { 
  2.  
  3.     //本地线程缓存token 
  4.     private static ThreadLocal<String> local = new ThreadLocal<>(); 
  5.  
  6.     /** 
  7.      * 设置token信息 
  8.      * @param content 
  9.      */ 
  10.     public static void setUserToken(String content){ 
  11.         removeUserToken(); 
  12.         local.set(content); 
  13.     } 
  14.  
  15.     /** 
  16.      * 获取token信息 
  17.      * @return 
  18.      */ 
  19.     public static UserToken getUserToken(){ 
  20.         if(local.get() != null){ 
  21.             UserToken userToken = JSONObject.parseObject(local.get() , UserToken.class); 
  22.             return userToken; 
  23.         } 
  24.         return null
  25.     } 
  26.  
  27.     /** 
  28.      * 移除token信息 
  29.      * @return 
  30.      */ 
  31.     public static void removeUserToken(){ 
  32.         if(local.get() != null){ 
  33.             local.remove(); 
  34.         } 
  35.     } 

对应用系统而言,重点在于token的验证,可以将拦截器方法封装成一个公共的jar包,然后各个应用系统引用即可!

和上面介绍的token存储到redis方案类似,不同点在于:一个将用户数据存储到redis,另一个是采用加密算法存储到客户端进行传输。

四、小结

在实际的使用过程中,我个人更加倾向于采用jwt方案,直接在服务端使用签名加密算法生成一个token,然后在客户端进行流转,天然支持分布式,但是要注意加密时用的密钥要安全管理。

而采用redis方案存储的时候,你需要搭建高可用的集群环境,同时保证缓存数据不会失效等等,维护成本高! 

在实际的实现上,每个公司玩法不一样,有的安全性要求高,后端还会加上密钥环节进行安全验证,基本思路大同小异。

 

责任编辑:武晓燕 来源: Java极客技术
相关推荐

2021-05-06 11:06:52

人工智能语音识别声闻检索

2016-11-28 10:22:52

物联网设备系统

2022-11-12 17:50:02

Web服务器微服务

2022-08-04 00:05:11

系统分布式流量

2022-02-25 09:00:00

数据科学工具架构

2020-10-19 10:35:43

iOS设备尺寸

2019-10-11 15:58:25

戴尔

2016-10-12 17:42:04

云服务云计算云迁移

2009-03-03 13:00:00

虚拟化技术vmwarexen

2020-05-12 14:20:47

GitHub 系统微软

2009-06-23 18:01:45

Ajax框架源代码

2018-08-31 08:42:48

LinuxUnix实用程序

2010-06-09 17:00:43

UML试题

2014-12-02 10:02:21

Android异步任务

2023-03-03 17:00:00

部署Linux内核

2021-09-28 10:48:07

开源双因素认证单点登录

2022-05-17 07:35:13

安全Session

2010-12-24 11:27:13

华为HCNE认证

2016-11-29 18:39:05

移动·开发技术周刊
点赞
收藏

51CTO技术栈公众号