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

再见会议!这个跨域认证方案真是优雅!

时间:2023-03-15 16:43:53 科技观察

大家好,我是二哥!用户登录认证是Web应用中非常常见的业务。大致过程如下:客户端将用户名和密码发送给服务器(session)后保存相关数据,如登录时间、登录IP等,服务器返回一个session_id给客户端,客户端保存在cookie中。当客户端向服务器发起请求时,会将session_id发回给服务器。服务器获取到session_id后,对用户身份进行认证。在单机的情况下,这种模式没有问题,但是对于前后端分离的web应用来说就很痛苦了。所以还有另一种解决方案。服务端不再保存session数据,而是保存在客户端中,客户端每次发起请求时,将这些数据发送给服务端进行校验。JWT(JSONWebToken)就是这种方案的典型代表。一、关于JWTJWT是目前最流行的跨域认证方案:客户端发起用户登录请求,服务端接收并认证成功后,生成一个JSON对象(如下图),然后返回给客户。{"sub":"wanger","created":1645700436900,"exp":1646305236}当客户端再次与服务端通信时,这个JSON对象被作为前后端互信的凭证被搭载。服务端收到请求后,通过JSON对象对用户身份进行认证,无需保存任何会话数据。如果我现在使用用户名wanger和密码123456访问Codingmore的登录界面,实际的JWT是一个字符串,看起来是经过加密的。为了让大家看的更清楚,我复制到了jwt官网。左边的Encoded部分是JWT密文,用“.”分为三部分。中间(右边Decoded部分):Header(头部),描述JWT的元数据,其中alg属性表示签名算法(目前为HS512);Payload(负载),用于存放实际数据需要传递,其中sub属性表示subject(实际值为用户名),created属性表示JWT生成时间,exp属性表示过期时间Signature(签名),对于第一个两部分签名,防止数据篡改;这里,服务端需要指定一个密钥(只有服务端知道),不能泄露给客户端,然后使用Header中指定的签名算法,根据以下公式生成签名:HMACSHA512(base64UrlEncode(header)+"."+base64UrlEncode(payload),your-256-bit-secret)计算完签名后,将Header、Payload、Signature拼接成一个字符串,用"."分隔,返回给客户端。客户端拿到JWT后,可以放在localStorage中,也可以放在cookie中。constTokenKey='1D596CD8-8A20-4CEC-98DD-CDC12282D65C'//createUuid()exportfunctiongetToken(){returnCookies.get(TokenKey)}exportfunctionsetToken(token){returnCookies.set(TokenKey,token)}以后客户端和服务器通信的时候,都会带上这个JWT,这个JWT一般放在HTTP请求的头信息Authorization字段中。Authorization:Bearer服务端收到请求后,对JWT进行校验,校验通过则返回对应的资源。2.实战JWT第一步是在pom.xml文件中添加JWT依赖。io.jsonwebtokenjjwt0.9.0第二步在application.yml中添加JWT配置项。jwt:tokenHeader:Authorization#JWT存储的请求头secret:codingmore-admin-secret#JWT加解密keyexpiration:604800#JWT过期时间(60*60*24*7)tokenHead:'Bearer'#从第三步获取在JWT加载开始,新建一个JwtTokenUtil.java工具类,主要有三个方法:Stringtoken,UserDetailsuserDetails):判断token是否仍然有效publicclassJwtTokenUtil{@Value("${jwt.secret}")privateStringsecret;@Value("${jwt.expiration}")private长到期;@Value("${jwt.tokenHead}")privateStringtokenHead;/***根据用户信息生成token*/publicStringgenerateToken(UserDetailsuserDetails){Mapclaims=newHashMap<>();claims.put(CLAIM_KEY_USERNAME,userDetails.getUsername());claims.put(CLAIM_KEY_CREATED,newDate());返回generateToken(声明);}/***根据用户名和创建时间生成JWTtoken*/privateStringgenerateToken(Mapclaims){returnJwts.builder().setClaims(claims).setExpiration(generateExpirationDate()).signWith(SignatureAlgorithm.HS512,secret).compact();}/***fromtoken从*/publicStringgetUserNameFromToken(Stringtoken){Stringusername=null;获取登录用户名索赔claims=getClaimsFromToken(token);if(claims!=null){username=claims.getSubject();}返回用户名;/***从令牌中获取JWT中的有效载荷*/privateClaimsgetClaimsFromToken(Stringtoken){Claimsclaims=null;try{claims=Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();}catch(Exceptione){LOGGER.info("JWT格式验证失败:{}",token);}退货索赔;}/***验证令牌是否仍然有效**@paramtoken客户端传入的token*@paramuserDetails从数据库中查询到的用户信息*/publicbooleanvalidateToken(Stringtoken,UserDetailsuserDetails){Stringusername=getUserNameFromToken(token);returnusername.equals(userDetails.getUsername())&&!isTokenExpired(token);}/***判断token是否过期*/privatebooleanisTokenExpired(Stringtoken){DateexpiredDate=getExpiredDateFromToken(token);返回expiredDate.before(newDate());}/***从令牌获取到期时间*/privateDategetExpiredDateFromToken(Stringtoken){Claimsclaims=getClaimsFromToken(token);返回claims.getExpiration();}}第四步,在UsersController.javaLogin接口添加登录,接收用户名和密码,返回JWT给客户端@Controller@Api(tags="Users")@RequestMapping("/users")publicclassUsersController{@AutowiredprivateIUsersServiceusersService;@Value("${jwt.tokenHeader}")privateStringtokenHeader;@Value("${jwt.tokenHead}")privateStringtokenHead;@ApiOperation(value="登录后返回令牌")@RequestMapping(value="/login",method=RequestMethod.POST)@ResponseBodypublicResultObjectlogin(@ValidatedUsersLoginParamusers,BindingResultresult){Stringtoken=usersService.login(users.getUserLogin(),users.getUserPass());if(token==null){returnResultObject.validateFailed("用户名或密码错误");}//将JWT传回客户端MaptokenMap=newHashMap<>();tokenMap.put("令牌",令牌);tokenMap.put("tokenHead",tokenHead);返回ResultObject.success(tokenMap);}}第五步,在UsersServiceImpl.java中添加登录方法,根据用户名从数据库中查询用户,密码验证通过后生成JWT。@ServicepublicclassUsersServiceImplextendsServiceImplimplementsIUsersService{@AutowiredprivatePasswordEncoderpasswordEncoder;@AutowiredprivateJwtTokenUtiljwtTokenUtil;publicStringlogin(Stringusername,Stringpassword){Stringtoken=null;//密码需要客户端加密Passtry{//查询用户+用户资源UserDetailsuserDetails=loadUserByUsername(username);//验证密码if(!passwordEncoder.matches(password,userDetails.getPassword())){Asserts.fail("密码不正确");}//返回JWTtoken=jwtTokenUtil.generateToken(userDetails);}catch(AuthenticationExceptione){LOGGER.warn("登录异常:{}",e.getMessage());}返回令牌;}}第六步,添加JwtAuthenticationTokenFilter.java,用于在客户端每次发起请求时验证JWT。publicclassJwtAuthenticationTokenFilterextendsOncePerRequestFilter{privatestaticfinalLoggerLOGGER=LoggerFactory.getLogger(JwtAuthenticationTokenFilter.class);@AutowiredprivateUserDetailsS??erviceuserDetailsS??ervice;@AutowiredprivateJwtTokenUtiljwtTokenUtil;@Value("${jwt.tokenHeader}")privateStringtokenHeader;@Value("${jwt.tokenHead}")privateStringtokenHead;@OverrideprotectedvoiddoFilterInternal(HttpServletRequestrequest,HttpServletResponseresponse,FilterChainchain)throwsServletException,IOException{//从客户端请求中获取JWTStringauthHeader=request.getHeader(this.tokenHeader);//该JWT是我们规定的格式,以tokenHead开头if(authHeader!=null&&authHeader.startsWith(this.tokenHead)){//“Bearer”之后的部分StringauthToken=authHeader.substring(this.tokenHead。长度());//从JWT中获取用户名Stringusername=jwtTokenUtil.getUserNameFromToken(authToken);LOGGER.info("正在检查用户名:{}",username);//SecurityContextHolder是SpringSecurity的一个工具类//保存当前用户在应用中的安全上下文if(username!=null&&SecurityContextHolder.getContext().getAuthentication()==null){//获取登录用户信息根据用户名UserDetailsuserDetails=this.userDetailsS??ervice.loadUserByUsername(username);//验证令牌是否过期if(jwtTokenUtil.validateToken(authToken,userDetails)){//将登录用户保存在安全上下文中authentication.setD(newWebAuthenticationDetailsS??ource().buildDetails(request));秒urityContextHolder.getContext().setAuthentication(认证);LOGGER.info("认证用户:{}",username);}}}chain.doFilter(请求,响应);}}JwtAuthenticationTokenFilter继承了OncePerRequestFilter,保证了一个请求只经过过滤器一次,不需要重复执行。也就是说,客户端每发起一次请求,过滤器就会执行一次。这个过滤器非常关键。基本上,我已经为每一行代码添加了注释。当然,为了保证大家能搞清楚这个类是干什么的,我再画一次流程图,这样一目了然。SpringSecurity是一个可以与SpringBoot应用无缝对接的安全管理框架。SecurityContextHolder是一个非常关键的工具类,它保存着安全上下文信息,里面存储着当前正在操作的用户、用户是否通过认证、用户是否拥有权限等关键信息。SecurityContextHolder默认使用ThreadLocal策略来存储认证信息。ThreadLocal的特点是里面存放的数据只能被存放它的线程访问。也就是说,不同的请求进入服务器后,会被不同的Thread处理。比如线程A在ThreadLocal中保存了请求1的用户信息,线程B在处理请求2的时候无法获取到用户信息。。因此,每次有请求进来时,JwtAuthenticationTokenFilter过滤器都会进行JWT验证,确保客户端的请求是安全的。然后SpringSecurity会发布下一个请求接口。这也是JWT和Session的根本区别:每次请求JWT都需要验证,只要JWT没有过期,即使服务器重启,验证仍然有效。如果session没有过期,则不需要重新认证用户信息。服务器重启后,用户需要重新登录才能获得新的会话。也就是说,在JWT方案下,保存在服务器端的密钥(secret)绝对不能泄露,否则客户端可以根据签名算法伪造用户的认证信息。3、为Swagger添加JWT验证第一步访问登录界面,输入用户名和密码登录,获取服务器返回的JWT。第二步,收集服务器返回的tokenHead和token,填入Authorize(注意tokenHead和token之间有一个空格),完成登录认证。第三步,再次请求其他接口时,Swagger会自动将Authorization作为请求头信息发送给服务器。第四步:服务端收到请求后,会通过JwtAuthenticationTokenFilter过滤器对JWT进行验证。至此,整个流程就完成了,完美!4.总结综上所述,在前后端分离项目中使用JWT解决跨域认证还是很顺利的。这主要得益于JSON的通用性,可以跨语言使用,支持JavaScript和Java;另外JWT的组成非常简单,传输起来非常方便;而且JWT不需要在服务端保存会话信息(Session),非常容易扩展。当然,为了保证JWT的安全,不要在JWT中保存敏感信息,因为一旦私钥泄露,JWT在客户端很容易被解密;如果可能,请使用HTTPS协议。参考链接:阮一峰:https://www.ruanyifeng.com/blog/2018/07/json_web_token-tutorial.html春夏秋冬:https://segmentfault.com/a/1190000012557493江南:https://cloud.tencent.com/developer/article/1612175Dearmadman:https://www.jianshu.com/p/576dbf44b2aemcarozheng:http://www.macrozheng.com/源码路径:https://github.com/itwanger/coding-more