当前位置: 首页 > 科技观察

如何设计单点登录系统

时间:2023-03-19 18:32:13 科技观察

本文转载自微信公众号《Java极客技术》,作者鸭血范。转载本文请联系Java极客技术公众号。1.介绍昨天介绍了API接口设计token认证方案。事实上,令牌认证的最佳实践场景是在单点登录系统上。在企业发展初期,后台管理系统还比较少,一两个。以电子商务系统为例。在初期,可能只有一套商城订单系统和一套后台系统用于产品和库存管理。随着业务量的增加,此时的业务系统会越来越复杂,项目会分成多个组,每个组负责自己的领域,例如:A组负责开发商城系统,B组负责支付系统开发,C组负责库存系统开发,D组负责物流追踪系统开发,E组负责每日业绩报表统计开发。。ETC。随着规模变大,人员数量会逐渐增加。对于研发部门来说,人员大致分为几类:研发人员、测试人员、运维人员、产品经理、技术支持等,他们会经常登录各自的后台业务系统,然后进行工作。至此,我们可以想象一下,如果每个组自己开发一套后台管理系统的登录,如果有10个这样的系统,一个新同事需要为每个系统开通一个权限,那么我们可能需要为他开10个账户。随着业务规模的扩大,较大的公司可能有100多个业务系统。配置100多个账号,让人去做这种操作,岂不是很不合理?面对这种繁琐低效的工作,IT大佬们想出了一个解决方案,那就是开发一个登录系统,所有业务系统都认可,然后就可以实现只需要一次登录就可以访问其他相互信任的应用系统。这个登录系统,我们称之为:单点登录系统。好了,废话不多说,下面从两个方面来介绍一下单点登录系统的实现。方案设计项目实践2.方案设计2.1.单体后台系统登录在传统的单体后台系统中,我们通常都是这样玩的,为了简单的操作。用户使用账号和密码登录后,服务器会给当前用户创建一个会话session,同时也会生成一个cookie,最后返回给前端。当用户访问其他后端服务时,我们只需要检查当前用户的session是否有效,如果不有效,则重新跳转到登录页面;如果有效,则进入业务流程。但是如果访问不同的域名系统,这个cookie是无效的,所以不能跨系统访问,也不支持在集群环境下共享。针对单点登录的场景,我们需要重新设计一个新的方案。2.2.单点登录系统登录首先来张图吧!该流程图是单点登录系统与应用系统的交互图。当用户登录应用系统时,应用系统会调用单点登录系统的接口,用客户端传入的token验证token的有效性。如果不合法,则跳转到单点登录系统的登录页面;如果合法,直接进入首页。进入登录页面后,会要求用户输入用户名和密码进行登录验证。如果验证成功,则返回一个有效的token,然后客户端根据服务端返回的参数链接,跳回需要访问的应用系统。.然后,应用系统将再次验证令牌的合法性。如果合法,则进入首页,流程结束。引入单点登录系统后,接入的应用系统无需与用户登录相关,只需要对客户端的token进行合法性认证即可。单点登录系统只需要完成用户的登录过程和身份验证,并返回一个安全令牌给客户端。在一些项目中,生成的token会保存在客户端的cookie中。这样做的目的是为了避免每次调用接口时都在url中带上token。但浏览器只允许共享同一域名下的cookies。对于不同的域名系统,cookie不能共享。这种情况下,我们可以先将token放入url链接中,类似于上面流程图中跳转的思路,对于同一个应用系统,我们可以将token放入cookie中,对于不同的应用系统,我们可以通过传递url链接来实现token传输。3.项目实践在实践中,token的存储有两种选择:存储在服务器,如果是分布式环境,一般会存储在redis和存储在客户端,由服务器做校验,自然supportsdistributed3.1andstoredinRedis存储在redis中,这是一种比较常见的处理方式,也是一开始的处理方式。当用户登录成功后,会以用户的信息为value,uuid为key,存储在redis中,各个服务集群共享用户信息。代码练习也很简单。用户登录后,将用户信息存储在redis中,并返回一个有效的token给客户端。@RequestMapping(value="/login",method=RequestMethod.POST,produces={"application/json;charset=UTF-8"})publicTokenVOlogin(@RequestBodyLoginDTOloginDTO){//...参数合法性验证//来自数据库获取用户信息UserdbUser=userService.selectByUserNo(loginDTO.getUserNo);//...用户,密码验证//创建tokenStringtoken=UUID.randomUUID();//将token和用户信息存储到redis,并设置有效期2小时redisUtil.save(token,dbUser,2*60*60);//定义返回结果TokenVOresult=newTokenVO();//封装tokenresult.setToken(token);//封装应用系统访问地址result.setRedirectURL(loginDTO.getRedirectURL());returnresult;}客户端收到登录成功后,会根据参数组合跳转到对应的应用系统。跳转示例如下:http://xxx.com/page.html?token=xxxxxx对于每个应用系统,只需要写一个过滤器TokenFilter来验证拦截token参数,就可以实现连接.Thecodeisasfollows:@OverridepublicvoiddoFilter(ServletRequestservletRequest,ServletResponseservletResponse,FilterChainfilterChain)throwsServletException,IOException,SecurityException{HttpServletRequestrequest=(HttpServletRequest)servletRequest;HttpServletResponseresponse=(HttpServletResponse)servletResponse;StringrequestUri=request.getRequestURI();StringcontextPath=request.getContextPath();StringserviceName=request.getServerName();//加入白名单的URL被释放String[]excludeUrls={"(?:/images/|/css/|/js/|/template/|/static/|/web/|/constant/).+$","/user/login","/user/createImage"};for(Stringurl:excludeUrls){if(requestUri.matches(contextPath+url)||(serviceName.matches(url))){filterChain.doFilter(request,response);return;}}//运行跨域检测if(RequestMethod.OPTIONS.name().equals(request.getMethod())){filterChain.doFilter(request,response);return;}//检查token是否有效finalStringtoken=request.getHeader("token");if(StringUtils.isEmpty(token)||!redisUtil.exist(token)){ResultMsgresultMsg=newResultMsg<>(4000,"tokenhasexpired");//封装跳转地址resultMsg.setRedirectURL("http://sso.xxx.com?redirectURL="+request.getRequestURL());WebUtil.buildPrintWriter(response,resultMsg);return;}//将用户信息存入请求,方便获取Useruser=redisUtil.get(token);request.setAttribute("user",user);filterChain.doFilter(请求,响应);return;}以上返回的是json数据给前端,当然你也可以直接在服务端使用重定向跳转,根据自己的情况选择。由于各个应用系统可能都需要连接,我们可以将上述方法打包成一个公共jar包,应用系统只需要依赖该包即可。对接即可完成!3.2.客户端存储token还有一种方案,就是将token存储在客户端。这种方案是服务端根据规则对数据进行加密,生成签名串。这个签名串就是我们所说的token。最后回到前端。因为加密操作都是在服务器端完成的,所以密钥管理很重要,不能泄露,否则很容易被黑客解密。最典型的应用就是JWT!JWT由三段信息组成,这三段信息文本通过.形成JWT字符串。像这样:com.auth0java-jwt3.4.0然后,创建一个用户信息类将被加密存储在令牌中。@Data@EqualsAndHashCode(callSuper=false)@Accessors(chain=true)publicclassUserTokenimplementsSerializable{privatestaticfinallongserialVersionUID=1L;/***用户ID*/privateStringuserId;/***用户登录账号*/privateStringuserNo;/***用户中文名*/privateStringuserName;}接下来,创建一个JwtTokenUtil工具类,用于创建令牌和验证令牌。publinClassJwtTokenutil{//tokenlupplyStaticFinalStringAuth_header_key=“授权”;//token前缀publicStaticfinalStringtoken_prefix=“bearer”;//////////////*60*60*2;/***创建令牌*@paramcontent*@return*/publicstaticStringcreateToken(Stringcontent){returnTOKEN_PREFIX+JWT.create().withSubject(content).withExpiresAt(newDate(System.currentTimeMillis()+EXPIRATION_TIME)).sign(Algorithm.HMAC512(KEY));}/***验证令牌*@paramtoken*/publicstaticStringverifyToken(Stringtoken)throwsException{try{returnJWT.require(Algorithm.HMAC512(KEY)).build().verify(token.replace(TOKEN_PREFIX,"")).getSubject();}catch(TokenExpiredExceptione){thrownewException("令牌已过期,请重新登录",e);}catch(JWTVerificationExceptione){thrownewException("令牌验证失败!",e);}}}同时写配置类,允许跨域,创建权限拦截器。@Slf4j@ConfigurationpublicclassGlobalWebMvcConfigimplementsWebMvcConfigurer{/***重写父类提供的跨域请求处理接口*@paramregistry*/@OverridepublicvoidaddCorsMappings(CorsRegistryregistry){//添加映射路径registry.addMapping("/**")//Release释放哪些原始域.allowedOrigins("*")//是否发送Cookie信息.allowCredentials(true)//释放哪些原始域(请求方式).allowedMethods("GET","POST","DELETE","PUT","OPTIONS","HEAD")//释放哪些原始域(头部信息)allowedHeaders("*")//暴露哪些头部信息(因为跨域访问默认无法获取所有头部信息).exposedHeaders("Server","Content-Length","Authorization","Access-Token","Access-Control-Allow-Origin","Access-Control-Allow-Credentials");}/***添加拦截器*@paramregistry*/@OverridepublicvoidaddInterceptors(InterceptorRegistryregistry){//添加权限拦截器registry.addInterceptor(newAuthenticationInterceptor()).addPathPatterns("/**").excludePathPatterns("/static/**");}}使用AuthenticationInterceptor拦截器验证接口参数@Slf4jpublicclassAuthenticationInterceptorimplementsHandlerInterceptor{@OverridepublicbooleanpreHandle(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler)throwsException{//从http请求头中取出tokenfinalStringtoken=request.getHeader(JwtTokenUtil.AUTH_HEADER_KEY);//如果不是映射到方法,直接通过if(!(handlerinstanceofHandlerMethod)){returntrue;}//如果是method检测,直接pass,直接传HandlerMethodhandlerMethod=(HandlerMethod)handler;Methodmethod=handlerMethod.getMethod();if(method.isAnnotationPresent(JwtIgnore.class)){JwtIgnorejwtIgnore=method.getAnnotation(JwtIgnore.class);if(jwtIgnore.value()){返回真;}}LocalAssert.isStringEmpty(token,"token为空,认证失败!");//验证并获取token内部信息StringuserToken=JwtTokenUtil.verifyToken(token);//将token放入本地缓存WebContextUtil。setUserToken(userToken);returntrue;}@OverridepublicvoidafterCompletion(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler,Exceptionex)throwsException{//方法结束后,取出缓存的tokenWebContextUtil.removeUserToken();}}最后在controller层用户登录后,创建一个token存放在header中/***登录*@paramuserDto*@return*/@JwtIgnore@RequestMapping(value="/login",method=RequestMethod.POST,produces={"application/json;charset=UTF-8"})publicUserVologin(@RequestBodyUserDtouserDto,HttpServletResponseresponse){//...参数合法性验证//从数据库中获取用户信息UserdbUser=userService.selectByUserNo(userDto.getUserNo);//....用户、密码验证//创建token并将token放入响应头UserTokenuserToken=newUserToken();BeanUtils.copyProperties(dbUser,userToken);Stringtoken=JwtTokenUtil.createToken(JSONObject.toJSONString(userToken));response.setHeader(JwtTokenUtil.AUTH_HEADER_KEY,token);//定义返回结果UserVoresult=newUserVo();BeanUtils.copyProperties(dbUser,result);returnresult;}到这里基本就完成了!AuthenticationInterceptor中使用的JwtIgnore是对不需要验证token的方法的注解,比如获取验证码等。@Target({ElementType.METHOD,ElementType.TYPE})@Retention(RetentionPolicy.RUNTIME)public@interfaceJwtIgnore{booleanvalue()defaulttrue;}而WebContextUtil是线程缓存工具类,其他接口可以通过该方法获取token用户信息。publicclassWebContextUtil{//本地线程缓存tokenprivatestaticThreadLocallocal=newThreadLocal<>();/***设置token信息*@paramcontent*/publicstaticvoidsetUserToken(Stringcontent){removeUserToken();local.set(content);}/***获取令牌信息*@return*/publicstaticUserTokengetUserToken(){if(local.get()!=null){UserTokenuserToken=JSONObject.parseObject(local.get(),UserToken.class);returnuserToken;}returnnull;}/***移除token信息*@return*/publicstaticvoidremoveUserToken(){if(local.get()!=null){local.remove();}}}对于应用系统,重点在于token校验,可以将拦截器方法封装成公共jar包,然后引用到各个应用系统!类似于上面介绍的token存储到redis的方案,不同的是:一种是将用户数据存储在redis中,另一种是使用加密算法存储到客户端。4.总结在实际使用过程中,我个人比较喜欢采用jwt方案,直接在服务端使用签名加密算法生成token,然后在客户端传递,自然支持分布式,但是要注意加密使用的密钥得到安全管理。使用redis方案进行存储时,需要搭建高可用的集群环境,同时还要保证缓存的数据不会失效等,维护成本高!实际实现中,每个公司玩法不一样,有的对安全性要求高,后台也会加一个关键环节进行安全验证,基本思路类似。