大家好,我是杰哥 互联网系统,几乎都离不开认证 一个系统中的大部分功能只能有认证信息的用户访问,但是总不可能每次请求都要求客户端专门认证一次吧,要是那样的话,用户每点一次鼠标,就得输入一次用户名密码,那可真是太麻烦了 那么,如何解决这种情况呢?一如何实现认证?(一)session机制 一种解决方案是由服务器将session数据保存至数据库(mysql、reids等)中。服务收到请求后,都向持久层请求数据进行认证操作。也就是我们最初学习WEB项目时比较简单直观的CookiesSession机制,相对于其他方案,常被叫做Session机制 Session机制的流程如下: 1、用户输入登录信息(比如用户名密码),由客户端传递至服务端 2、服务端验证无误之后,将Session存储至持久层 3、服务端,返回带有sessionID的Cookie(头部SetCookie) 4、客户端,保存Cookie信息。将JSESSIONID保存至Cookie中 5、客户端请求服务端时,携带着Cookie 6、服务端检查是否存在对应的sessionID 7、若存在,则通过认证,服务端返回响应内容;否则,则返回403禁止访问 这种机制,其实架构还是比较清晰的。但是会存在以下几个缺点:开销大。session保存在服务器,随着注册用户的增加,必然会引起服务器更大的开销扩展能力受限。对于分布式环境,如果采用Session机制进行认证,那么,就需要每台服务器保存或者共享所有用户的session信息。有时候为了解决这种session共享的问题,会采用redis集群存储session的方式来解决。但是这样相对来说复杂度会随之增大CSRF危险。Session是基于Cookie来进行用户识别的,Cookie如果被截获,用户就会很容易受到跨站请求伪造(CSRF)的攻击于是便出现了另一种解决方案 于是,便渐渐衍生出另一种解决方案:JWT机制(二)JWT机制 1认证流程 通俗来讲,JSONWebToken(JWT)是一个含签名并携带用户相关信息的加密串,页面请求校验登录接口时,请求头中携带JWT串到后端服务,后端通过签名加密串匹配校验,保证信息未被篡改。校验通过则认为是可靠的请求,将正常返回数据 相较于Session机制来说,JWT机制,服务器干脆不保存session数据了,所有认证信息只存储在客户端 服务器端只保留一个秘钥,每次进行认证时,只需要通过这个秘钥,重新加密签名之后,与客户端所传递的token信息进行对比即可 因此,其流程与Session机制差不多,只是少了服务器存储token信息的步骤,并且校验token时,是服务端重新加密生成之后与所收到的token值进行对比的,而不是通过查询持久层获得 流程如下: 1)用户输入登录信息(比如用户名密码)来请求服务器 2)服务器验证用户的信息 3)通过验证之后,服务器会返回一个token 4)客户端存储token(可以选择存储在Cookie中,也可以选择存储在localStorage中) 5)客户端在请求时一般会通过请求头,携带这个token值 6)服务端会使用秘钥以及用户的相关信息重新计算生成一个token,然后验证所传递的这个token值 7)若验证成功,则返回数据;否则,返回403禁止访问 2token的样子 了解了JWT的流程,我们来看下JWT生成的token长什么样 杰哥项目中刚才生成的token字符串如下:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9。eyJwYXNzd29yZCI6IjUzYjQzMmU2MTMxNGUwMGJjYzk5Mjg3YWY5NTM3ZGM0IiwiaXNzIjoiamllZ2UiLCJleHAiOjE2NTk0NjM4ODUsImlhdCI6MTY1OTQyNzg4NSwidXNlcm5hbWUiOiJhZG1pbiJ9。z4NFlH92V0fzOxWmxfrsxBb6dIfkMUOdj9slINKVPu8 乍一看,好像是一堆乱码,其实是由三部分组成:header(头部)、payload(载荷)、signature(签名),以。进行分割, 即: header。payload。signature Header header用来声明类型(typ)和算法(alg){typ:JWT,alg:HS256} alg属性表示签名的算法(algorithm),默认是HMACSHA256(写成HS256) typ属性表示这个令牌(token)的类型(type),JWT令牌统一写为JWT。 这个JSON对象使用Base64URL算法转成字符串,就变成上面token字符串的第一部分了 Payload payload一般存放一些不敏感的信息,比如用户名、权限、角色等。{iss:jiege,exp:1659463885,iat:1659427885,username:admin} 除了传递用户信息以外,payload也可以传递JWT所规定的7个官方字段:iss(issuer):签发人exp(expirationtime):过期时间sub(subject):主题aud(audience):受众nbf(NotBefore):生效时间iat(IssuedAt):签发时间jti(JWTID):编号 这个JSON对象使用Base64URL算法转成字符串,就变成上面token字符串的第二部分了 Signature signature则是将header和payload对应的JSON结构进行base64url编码之后得到的两个串用英文句点号拼接起来,然后根据header里面alg指定的签名算法生成出来的 HMACSHA256( base64UrlEncode(header)。 base64UrlEncode(payload), )secretbase64encoded 注意:前面提到,Header和Payload串型化的算法是Base64URL。这个算法跟Base64算法基本类似,但有一些小的不同。 JWT作为一个令牌(token),有些场合可能会放到URL(比如api。example。com?tokenxxx)。Base64有三个字符、和,在URL里面有特殊含义,所以要被替换掉:被省略、替换成,替换成二JWT实战 好了,认识了JWT以后,我们趁热打铁,进入实战环节,通过一个简单的例子,来进一步认识JWT 1引入依赖dependencygroupIdcom。auth0groupIdjavajwtartifactIdversion3。7。0versiondependency 引入javajwt的依赖 2配置文件application。ymlserver:port:8086spring:datasource:url:jdbc:mysql:localhost:3306jwtdemo?serverTimezoneUTCuseUnicodetruecharacterEncodingUTF8autoReconnecttrueuseSSLfalseusername:rootpassword:123456profiles:默认配置为dev,会在开发调试时跳过token的校验,提高调试效率active:prod 分别配置数据库的连接信息和环境信息 这里的环境信息配置为dev,只是为了在项目中的开发环境中,避免进行每次发起请求时token的校验,从而提高调试效率 3访问资源定义 访问资源:UserControllerRestControllerRequestMapping(user)publicclassUserController{GetMapping(common)publicStringcommon(){returnhellocommon;}GetMapping(admin)publicStringadmin(){returnhelloadmin;}} 分别定义usercommon和useradmin两个接口作为后续的测试访问资源 4User类DataBuilderpublicclassUser{privateLongid;privateStringusername;privateStringpassword;privateStringtoken;刷新tokenprivateStringrefreshToken;} 定义User类,分别包含用户名、密码、token以及refreshToken 5JWT的工具类JwtServiceSlf4jServicepublicclassJwtService{加密秘钥privatestaticfinalStringSECRETKEYwangjienihao;签发人privatestaticfinalStringISSUERjiege;token过期时间publicstaticfinalLongTOKENEXSPIRETIME1000606010L;生成tokenparamuserVo用户信息returnpublicStringtoken(UserVouserVo){1确定加密算法AlgorithmalgorithmAlgorithm。HMAC256(SECRETKEY);DatenownewDate();2开始创建和生成tokenreturnJWT。create()。withIssuedAt(now)。withIssuer(ISSUER)。withExpiresAt(newDate(now。getTime()TOKENEXSPIRETIME))token的过期时间。withClaim(username,userVo。getUsername())。withClaim(password,userVo。getPassword())。sign(algorithm);}校验用户名paramtokentokenparamusername用户名returnpublicResponseResultverifyUsername(Stringtoken,Stringusername){log。info(verifyjwtusername{},username);ResponseResultresponseResultnewResponseResult();try{1定义算法AlgorithmalgorithmAlgorithm。HMAC256(SECRETKEY);2进行校验JWTVerifierjwtVerifierJWT。require(algorithm)。withIssuer(ISSUER)。withClaim(username,username)。build();jwtVerifier。verify(token);}catch(Exceptionex){responseResult。setStatus(1);responseResult。setMessage(失败!);log。error(authverifyfail:{},ex。getMessage());}returnresponseResult;}} 分别包括生成token方法和校验用户名的方法 1)生成token生成tokenparamuserVo用户信息returnpublicStringtoken(UserVouserVo){1确定加密算法AlgorithmalgorithmAlgorithm。HMAC256(SECRETKEY);DatenownewDate();2开始创建和生成tokenreturnJWT。create()payload信息开始。withIssuedAt(now)。withIssuer(ISSUER)。withExpiresAt(newDate(now。getTime()TOKENEXSPIRETIME))token的过期时间。withClaim(username,userVo。getUsername())。withClaim(password,userVo。getPassword())payload信息结束签名。sign(algorithm);} 预先定义一个加密秘钥SECRETKEY:wangjienihao,并确定加密算法为HMAC256 采用JWT。create()方法,分别添加了签发时间、签发人、token有效期、用户名、密码这几个信息作为payload,然后采用加密算法进行加密,从而生成token 这里的head定义为了:{typ:JWT,alg:HS256} Payload定义为了:{password:53b432e61314e00bcc99287af9537dc4,iss:jiege,exp:1659463885,iat:1659427885,username:admin} 而其签名Signature则为:HMACSHA256(base64UrlEncode(header)。base64UrlEncode(payload),)secretbase64encoded 2)验证token 一般服务端需要校验客户端请求时所携带的token是否正确或者是否过期,就需要以下的方法进行校验校验用户名paramtokentokenparamusername用户名returnpublicResponseResultverifyUsername(Stringtoken,Stringusername){log。info(verifyjwtusername{},username);ResponseResultresponseResultnewResponseResult();try{1定义算法AlgorithmalgorithmAlgorithm。HMAC256(SECRETKEY);2进行校验JWTVerifierjwtVerifierJWT。require(algorithm)。withIssuer(ISSUER)。withClaim(username,username)。build();jwtVerifier。verify(token);}catch(Exceptionex){responseResult。setStatus(1);responseResult。setMessage(失败!);log。error(authverifyfail:{},ex。getMessage());}returnresponseResult;} 首先,依旧是根据已知的秘钥与加密算法,获得加密算法对象algorithm; 然后采用该算法对象、签发人、用户名等信息生成JWTVerifier对象; 接着,调用JWTVerifier的verify()方法,进行用户名的校验 verify()方法,会根据token,解密出token中的三部分信息,如官网中解析出来的样子 而我们添加了一个当前的用户名:admin,它会将这个用户名与解密出的payload中的用户名进行比较,如果一致,则表示验证成功,否则验证失败 可以顺便来看下JWTVerifier的verify()方法: 1)首先根据token,采用parser生成一个JWTDecoder对象 2)进入JWTDecoder构造方法 分别解析出头部header和payload部分的json字符串 3)再退出来,来到verify(jwt)方法 如上所示,分别校验如下3个部分 a。verifyAlgorithm(jwt,algorithm):校验获取到的jwt的加密方式和发送方的加密方式是否相同 b。algorithm。verify(jwt):通过加密方式的名称,秘钥和头部信息,实体信息等校验获取到的jwt的Signature和发送方的Signature是否一致 c。verifyClaims(jwt,this。claims):校验payload中的具体数据,如username、签发人等 好了,JwtService类就完成了,接下来,我们就需要分别实现在用户登录时生成token,而在接到客户端的请求时,进行token的校验工作了 6登录LoginController 一般来讲,正如Session会存在过期时间,token也是会存在过期时间的,比如过了1个小时,token过期了,就需要重新生成token了RestControllerpublicclassLoginController{ResourceprivateJwtServicejwtService;ResourceprivateUserServiceuserService;ResourceprivateRedisTemplateString,UserVoredisTemplate;PostMapping(jwtlogin)publicResponseResultlogin(Stringusername,Stringpassword)throwsException{1校验用户名密码是否为空if(StrUtil。isBlank(username)StrUtil。isBlank(password)){thrownewException(用户名或密码不能为空!);}2根据用户查询用户是否存在UseruseruserService。findByUsername(username);if(usernull){thrownewException(用户名或密码有误!);}3验证用户名密码passwordMD5Util。md5slat(password);if(!password。equalsIgnoreCase(user。getPassword())){thrownewException(用户名或密码有误!);}4生成tokenUserVouserVoUserVo。builder()。build();userVo。setId(user。getId());userVo。setUsername(username);userVo。setPassword(password);StringtokenjwtService。token(userVo);userVo。setToken(token);userVo。setRefreshToken(UUID。randomUUID()。toString());同时存储用户到redis中redisTemplate。opsForValue()。set(token,userVo,JwtService。TOKENEXSPIRETIME,TimeUnit。SECONDS);returnnewResponseResult(userVo);}PostMapping(refreshToken)publicResponseResultrefreshToken(RequestParam(token)StringoldToken){1获取tokenUserVouserVoredisTemplate。opsForValue()。get(oldToken);if(userVonull){returnnewResponseResult(500,usernotfound!,null);}StringtokenjwtService。token(userVo);userVo。setToken(token);userVo。setRefreshToken(UUID。randomUUID()。toString());同时存储用户到redis中redisTemplate。delete(oldToken);redisTemplate。opsForValue()。set(token,userVo,JwtService。TOKENEXSPIRETIME10000,TimeUnit。SECONDS);returnnewResponseResult(userVo);}} 这里分别定义了登录接口jwtlogin和刷新token接口refreshToken 1)登录接口 分别校验了用户名密码是否为空、用户名是否存在以及用户名密码是否正确之后,便可以生成token操作,并将其存入redis中,同时采用UUID的随机数,生成refreshToken 调用jwtService。token()方法,传递的参数为userVo对象 其实,你可能也发现了,如果考虑token的刷新,将token存入redis中,实际上也类似于Session机制的做法了,因为它也将token存储在了服务器 所以,token的优势,并不在于扩展性,其实主要还是在于在进行token认证时,直接计算生成token,与客户端所携带的token进行对比,而不是从持久层中查询,从而对于大用户量的系统,比较明显地提升了性能 2)刷新token 其实就是获取token,然后重新生成token,并存储在redis中 可以考虑由前端在访问某个功能调用的时候,若检测到token过期,然后调用refreshToken接口进行token的刷新操作 7请求时拦截 1)拦截器Slf4jpublicclassAuthorisationInterceptorimplementsHandlerInterceptor{AutowiredprivateJwtServicejwtService;Value({spring。profiles。active})privateStringprofiles;privatestaticfinalStringAUTHAuthorization;privatestaticfinalStringAUTHUSERNAMEusername;OverridepublicbooleanpreHandle(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler)throwsException{log。info(执行了AuthorisationInterceptor的preHandle方法);1过滤开发环境,开发环境不需要验证tokenif(!StrUtil。isBlank(profiles)dev。equals(profiles)){returntrue;}2ignoreToken,不需要验证tokenif(ignoreToken((HandlerMethod)handler))returntrue;3获取tokenStringtokengetParamValue(request,AUTH);4获取并验证usernameStringusernamegetParamValue(request,AUTHUSERNAME);ResponseResultresponseResultjwtService。verifyUsername(token,username);if(responseResult。getStatus()!1){log。error(用户名校验失败);thrownewValidationException(300,用户名校验失败!);}这里需要注意:1)如果设置为false时,被请求时,拦截器执行到此处将不会继续操作2)如果设置为true时,请求将会继续执行后面的操作returntrue;}OverridepublicvoidpostHandle(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler,ModelAndViewmodelAndView)throwsException{log。info(执行了AuthorisationInterceptor的postHandle方法);}OverridepublicvoidafterCompletion(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler,Exceptionex)throwsException{log。info(执行了AuthorisationInterceptor的afterCompletion方法);}privateStringgetParamValue(HttpServletRequestrequest,Stringfiled)throwsValidationException{StringvaluegetParam(request,filed);if(StrUtil。isEmpty(value)){thrownewValidationException(300,filed不允许为空,请重新登录!);}returnvalue;}获取参数的值若参数中不存在,则从请求头中获取paramrequest请求paramfiledName参数名称returnprivatestaticStringgetParam(HttpServletRequestrequest,StringfiledName){Stringparamrequest。getParameter(filedName);if(StrUtil。isEmpty(param)){paramrequest。getHeader(filedName);}returnparam;}忽略token的处理paramhandlerreturnprivatebooleanignoreToken(HandlerMethodhandler){Methodmethodhandler。getMethod();if(method。isAnnotationPresent(IgnoreToken。class)){IgnoreTokenignoreTokenmethod。getAnnotation(IgnoreToken。class);returnignoreToken。required();}returnfalse;}} 通过实现HandlerInterceptor,定义Spring拦截器类AuthorisationInterceptor来进行token的拦截验证,当然是在调用接口之前进行处理,所以,我们的逻辑主要由其preHandle()方法实现OverridepublicbooleanpreHandle(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler)throwsException{log。info(执行了AuthorisationInterceptor的preHandle方法);1过滤开发环境,开发环境不需要验证tokenif(!StrUtil。isBlank(profiles)dev。equals(profiles)){returntrue;}2ignoreToken,不需要验证tokenif(ignoreToken((HandlerMethod)handler))returntrue;3获取tokenStringtokengetParamValue(request,AUTH);4获取并验证usernameStringusernamegetParamValue(request,AUTHUSERNAME);ResponseResultresponseResultjwtService。verifyUsername(token,username);if(responseResult。getStatus()!1){log。error(用户名校验失败);thrownewValidationException(300,用户名校验失败!);}这里需要注意:1)如果设置为false时,被请求时,拦截器执行到此处将不会继续操作2)如果设置为true时,请求将会继续执行后面的操作returntrue;} 前两步骤,只是为大家提供了一种技巧思路而已 步骤1过滤开发环境,开发环境不需要验证token 就是在开发环境如果需要快速测试一下接口,可以考虑先跳过token的验证操作 步骤2ignoreToken,不需要验证token 则可以考虑对于个别方法可以通过注解的方式,实现忽略token的验证操作 接下来,我们分别获取头信息或者参数中的token和用户名,然后调用jwtService。verifyUsername(token,username)进行校验,如果校验失败,表示用户名校验失败,也就是说token不正确,那么,抛出异常; 否则就会通过这一关,进入下一关的拦截了 2)WebMvcConfig注册拦截器ConfigurationpublicclassWebMvcConfigimplementsWebMvcConfigurer{BeanpublicAuthorisationInterceptorauthorazationIntercepter(){returnnewAuthorisationInterceptor();}OverridepublicvoidaddInterceptors(InterceptorRegistryregistry){添加拦截器registry。addInterceptor(authorazationIntercepter())指定需要拦截的路径。addPathPatterns(user);}} 注册AuthorisationInterceptor拦截器,并指定需要拦截验证的资源路径:user三测试 启动项目,并进行如下测试 1访问登录接口 http:localhost:8086jwtlogin 参数为username和password 如预期,得到了token信息 2资源接口访问测试 1)不带token 若不配置header中的Authorization认证信息,则会返回500的错误(一般可以配置为403禁止访问) 2)带上token访问 useradmin接口,参数为username、头信息Authorization配置为上面获取到的token的值 便实现了接口的成功访问 3刷新token 传递token参数,便可以重新得到一个token,然后下次请求重新带上这个token即可,直至它过期总结 从实战中,我们可以发现,使用JWT,对于大用户量的系统,可以明显降低服务器查询数据库的次数,从而对服务器的性能提升有一定的正向作用 但是对于用户量很少的一些管理平台,其实没有必要采用JWT,这时,采用Session反而更简单,既不会有太大的数据库查询性能影响,也不需要引入Redis进行token的刷新,带来更大的复杂度 JWT本身包含了认证信息,一旦泄露,任何人都可以获得该令牌的所有权限。所以为了减少盗用的情况,JWT的有效期应该设置得比较短。对于一些比较重要的权限,使用时应该再次对用户进行认证 此外,在实际项目中,JWT不应该使用HTTP协议明码传输,要使用HTTPS协议传输 文章演示代码地址:https:github。comhelemileSpringBootNotes参考链接: https:www。ruanyifeng。comblog201807jsonwebtokentutorial。html 嗯,就这样。每天学习一点,时间会见证你的强大 欢迎大家关注我们的公众号,一起持续性学习吧 往期精彩回顾 总结复盘 架构设计读书笔记与感悟总结 带领新人团队的沉淀总结 复盘篇:问题解决经验总结复盘 网络篇 网络篇(四):《图解TCPIP》读书笔记 网络篇(一):《趣谈网络协议》读书笔记(一) 事务篇章 事务篇(四):Spring事务并发问题解决 事务篇(三):分享一个隐性事务失效场景 事务篇(一):毕业三年,你真的学会事务了吗? Docker篇章 Docker篇(六):DockerCompose如何管理多个容器? Docker篇(二):Docker实战,命令解析 Docker篇(一):为什么要用Docker? 。。。。。。。。。。 SpringCloud篇章 SpringCloud(十三):Feign居然这么强大? SpringCloud(十):消息中心篇Kafka经典面试题,你都会吗? SpringCloud(九):注册中心选型篇四种注册中心特点超全总结 SpringCloud(四):公司内部,关于Eureka和zookeeper的一场辩论赛 。。。。。。。。。。 SpringBoot篇章 SpringBoot(十二):陌生又熟悉的OAuth2。0协议,实际上每个人都在用 SpringBoot(七):你不能不知道的Mybatis缓存机制! SpringBoot(六):那些好用的数据库连接池们 SpringBoot(四):让人又爱又恨的JPA SpringBoot(一):特性概览 。。。。。。。。。。 翻译 〔译〕用Mint这门强大的语言来创建一个Web应用 【译】基于50万个浏览器指纹的新发现 使用CSS提升页面渲染速度 WebTransport会在不久的将来取代WebRTC吗? 。。。。。。。。。 职业、生活感悟 你有没有想过,旅行的意义是什么? 程序员的职业规划 灵魂拷问:人生最重要的是什么? 如何高效学习一个新技术? 如何让自己更坦然地度过一天? 。。。。。。。。。。