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

教你使用JWT实现单点登录

时间:2023-03-15 23:50:08 科技观察

JSONWebToken(JWT)是目前最流行的跨域认证方案之一。今天我们就一起揭开它的神秘面纱!一、JWT的故事由来,先说说传统的基于session的认证方案和瓶颈。传统的session交互流程如下图所示:当浏览器向服务器发送登录请求,验证通过后,用户信息会存储在seesion中,然后服务器会生成一个sessionId并放入它在cookie中,然后返回给浏览器。当浏览器再次发送请求时,会将sessionId放入请求头的cookie中,将请求数据一起发送给服务器。服务器又可以从seesion中获取到用户信息了,整个过程就完成了!通常seesion的时长是在服务器端设置的,比如30分钟没有活动,seesion中存储的用户信息就会被清除。session.setMaxInactiveInterval(30*60);//30分钟无活动,自动移除。同时,服务端也可以通过seesion判断当前用户是否已经登录,如果为空,表示用户没有登录,会直接跳转到登录页面;如果不为空,则可以从session中获取用户信息,进行后续操作。在单体应用中,这样的交互方式是没有问题的。但是,如果应用服务器的请求量变得非常大,单台服务器所能支持的请求数有限,这时候很容易造成请求变慢或者OOM。解决方案是要么在单台服务器上增加配置,要么增加新的服务器通过负载均衡来满足业务需求。如果在单台服务器上加配置,请求量会不断增加,仍然无法支撑业务处理。显然,增加新的服务器可以实现无限的横向扩展。但是添加新服务器后,不同服务器之间的sessionId是不一样的。有可能你在服务器A上已经成功登录,可以从服务器的session中获取到用户信息,但是在服务器B上却找不到session信息,这时候一定尴尬至极,只好注销后继续登录,结果服务器A中的session超时失败,登录后强制退出,要求重新登录。大佬们一起商量,想出了一个象征性的方案。将各个应用程序与内存数据库redis连接起来,将用户信息通过一定的算法加密后登录成功。生成的ID称为token,token和用户信息存储在redis中;当用户再次发起请求时,将token和请求数据一起发送给服务器。服务器验证token是否存在于redis中。如果存在,则表示验证通过。如果不存在,则告诉浏览器跳转到登录页面,流程结束。token方案保证了服务的无状态性,所有信息都存储在分布式缓存中。基于分布式存储,可横向扩展,支持高并发。当然现在springboot也提供了session共享方案,类似于token方案将session存储在redis中。在集群环境中登录一次后,每台服务器都可以获得用户信息。2.什么是JWT上面我们讲了session和token的解决方案。在集群环境下,它们都依赖于第三方缓存数据库redis来实现数据共享。有没有什么方案可以在不缓存数据库redis的情况下共享用户信息,从而达到一次登录,随处可见的效果?答案肯定是肯定的,那就是我们今天要介绍的JWT!JWT的全称是JSONWebToken。该过程简单的说就是在用户登录成功后,对用户的信息进行加密,然后生成一个token返回给客户端,与传统的session交互没有太大区别。交互过程如下:唯一不同的是:token存储的是用户的基本信息,更直观的一点是将原本放在redis中的用户数据放到token中!这样,客户端和服务端都可以从token中获取到用户的基本信息。既然客户端可以获取到,那么一定不能存储敏感信息,因为浏览器可以直接从token中获取到用户信息。JWT究竟是什么样子的?JWT由三段信息组成,这三段信息的文本链接在一起形成JWT字符串。就像这样:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ第一部分:我们称它为头部(header),用于存放token类型和加密协议,一般都是固定的;第二部分:我们称其为载荷(payload),Theuserdataisstoredinit;thethirdpart:thesignature,whichismainlyusedforserver-sideverification;1.TheheaderofheaderJWTcarriestwopartsofinformation:thestatementtype,hereisJWT;thestatementencryptionalgorithm,usuallyuseddirectlyHMACSHA256;ThecompleteheaderislikethefollowingJSON:{'typ':'JWT','alg':'HS256'}encryptedwithbase64,whichconstitutesthefirstpart.eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ92.Thepayloadiswherevalidinformationisstored.Thevalidinformationconsistsofthreeparts:Statementsregisteredinthestandard;Publicstatements;Privatestatements;Amongthem,thestatementsregisteredinthestandard(recommendedbutnotmandatory)includethefollowingthreeparts:iss:jwtissuer;sub:theuserforwhichjwtisoriented;aud:thepartyreceivingjwt;exp:theexpirationtimeofjwt,whichmustbegreaterthantheissuingtime;nbf:definesbeforewhattime,thejwtTheyareallunavailable;iat:theissuancetimeofjwt;theuniqueidentityofjwt,whichismainlyusedasaone-timetoken,soastoavoidreplayattacks;publicstatementpart:publicstatementcanaddanyinformation,generallyaddingusersRelevantinformationorothernecessaryinformationrequiredbythebusiness,butitisnotrecommendedtoaddsensitiveinformation,becausethispartcanbedecryptedontheclient.Privatedeclarationpart:Privatedeclarationisadeclarationjointlydefinedbytheproviderandtheconsumer.Itisgenerallynotrecommendedtostoresensitiveinformation,becausebase64issymmetricallydecrypted,whichmeansthatthispartofinformationcanbeclassifiedasplaintextinformation.Defineapayload:{"sub":"1234567890","name":"JohnDoe","admin":true}andencryptitwithbase64togetthesecondpartofJwt:eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV93,thethirdpartofasignaturejwtisVisainformation,thisvisainformationconsistsofthreeparts:header(behindbase64);payload(behindbase64);secret(key);thispartrequiresbase64-encryptedheaderandbase64-encryptedpayloadtouse.ConnectionThestringisthenencryptedwiththesaltedsecretcombinationbytheencryptionmethoddeclaredintheheader,andthenconstitutesthethirdpartofjwt.//javascriptvarencodedString=base64UrlEncode(header)+'.'+base64UrlEncode(payload);varsignature=HMACSHA256(encodedString,'key');Afterencryption,thesignaturesignatureinformationisobtained.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ将这三部分用.连接成一个完整的字符串,就构成了最终的jwt://jwt最终格式eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ这个只是通过javascript实现的一个演示,JWT的签发和密钥的保存都Itisdoneontheserverside.Thesecretisusedtoissuejwtandverifyjwt,soitshouldnotberevealedinanyscenario.3、实战中介绍了这么多,如何实现?废话不多说,直接开始!创建springboot项目,添加JWT依赖库com.auth0java-jwt3.4.0然后,创建一个用户信息类,将被加密存储在token@Data@EqualsAndHashCode(callSuper=false)@Accessors(chain=true)publicclassUserTokenimplementsSerializable{privatestaticfinallongserialVersionUID=1L;/***userID*/privateStringuserId;/***用户登录账号*/privateStringuserNo;/***用户中文名*/privateStringuserName;}然后,创建一个JwtTokenUtil工具类,用于创建token和验证tokenpublicclassJwtTokenUtil{//定义token返回头publicstaticfinalStringAUTH_HEADER_KEY="Authorization";//令牌前缀publicstaticfinalStringTOKEN_PREFIX="Bearer";//签名密钥publicstaticfinalStringKEY="q3t6w9z$C)&FJ@NcQfTjWnZr4u7x";//默认有效期为2小时publicstaticfinalLongEXPIRATION_TIME=1000L*60*60*2;/***创建TOKEN*@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("token验证失败!",e);}}}写个配置类,允许跨域,创建权限拦截器@Slf4j@ConfigurationpublicclassGlobalWebMvcConfigimplementsWebMvcConfigurer{/***重写父类提供的跨域请求处理接口*@paramregistry*/@OverridepublicvoidaddCorsMappings(CorsRegistryregistry){//添加映射路径registry.addMapping("/**")//哪个是原来的域被释放。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(“/**”)。ubledepathPatterns(“/static/**”);}}}使用authenticationIntericationInterixationInterpoctor拦截器拦截器拦截器对验证验证@@@slf4jpublicCublicClassautherimplerimpllerimplerimpllerimplersterpsptsppsstersppsppsperspersperperterseprepterrespulterresequere{/从http请求头中获取tokenfinalStringtoken=request.getHeader(JwtTokenUtil.AUTH_HEADER_KEY);//如果不是映射到方法,直接传if(!(handlerinstanceofHandlerMethod)){returntrue;}//如果是方法方法检测,直接通过if(HttpMethod.OPTIONS.equals(request.getMethod())){response.setStatus(HttpServletResponse.SC_OK);returntrue;}//如果方法有JwtIgnore注解,直接传HandlerMethodhandlerMethod=(HandlerMethod)handler;Methodmethod=handlerMethod.getMethod();if(method.isAnnotationPresent(JwtIgnore.class)){JwtIgnorejwtIgnore=ationmethod.get(JwtIgnore.class);if(jwtIgnore.value()){returntrue;}}LocalAssert.isStringEmpty(token,"token为空,认证失败!");//验证获取token内部信息StringuserToken=JwtTokenUtil.verifyToken(token);//将token放入本地缓存WebContextUtil.setUserToken(userToken);returntrue;}@OverridepublicvoidafterCompletion(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler,Exceptionex)throwsUserException{//方法结束后,取出缓存的token.WebContext();}}最后,controller层用户登录后,创建一个token,存放在header中/***login*@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();}}}最后启动项目,我们用postman测试一下,看查看标头中返回的结果。我们提取并处理返回的信息,并使用浏览器的base64解密前两部分。第一部分是header,结果如下:第二部分是payload,结果如下:可以清楚的看到通过base64可以解密出header和payload信息。因此,一定不要在token中存储敏感信息!当我们需要请求其他服务接口时,只需要在请求的header中添加Authorization参数即可。权限拦截器验证通过后,只能通过接口方法中的WebContextUtil工具类获取用户信息。//获取用户token信息UserTokenuserToken=WebContextUtil.getUserToken();4、总结JWT与session方案相比,由于json的通用性,JWT可以支持跨语言,比如JAVA、JavaScript、PHP等多种语言。使用,而会话解决方案仅适用于JAVA。由于负载部分,JWT可以存储其他业务逻辑所需的一些非敏感信息。同时,保护好服务器的保密私钥也很重要,因为私钥可以验证和解密数据!如果可能,请使用https协议!五、参考资料1.简书-什么是JWT——JSONWEBTOKEN2。-基于会话和令牌的认证方案