- JWT 实现登录认证 + Token 自动续期方案
项目中基本都有用户管理模块,而用户管理模块会涉及到加密及认证流程。 今天就来讲讲认证功能的技术选型及实现。技术上没啥难度当然也没啥挑战,但是对一个原先没写过认证功能的人来说也是一种锻炼吧 技术选型要实现认证功能,很容易就会想到JWT或者session,但是两者有啥区别?各自的优缺点?应该Pick谁?夺命三连 区别基于session和基于JWT的方式的主要区别就是用户的状态保存的位置,session是保存在服务端的,而JWT是保存在客户端的。 认证流程基于session的认证流程用户在浏览器中输入用户名和密码,服务器通过密码校验后生成一个session并保存到数据库服务器为用户生成一个sessionId,并将具有sesssionId的cookie放置在用户浏览器中,在后续的请求中都将带有这个cookie信息进行访问服务器获取cookie,通过获取cookie中的sessionId查找数据库判断当前请求是否有效基于JWT的认证流程用户在浏览器中输入用户名和密码,服务器通过密码校验后生成一个token并保存到数据库前端获取到token,存储到cookie或者local storage中,在后续的请求中都将带有这个token信息进行访问服务器获取token值,通过查找数据库判断当前token是否有效优缺点JWT保存在客户端,在分布式环境下不需要做额外工作。而session因为保存在服务端,分布式环境下需要实现多机数据共享session一般需要结合Cookie实现认证,所以需要浏览器支持cookie,因此移动端无法使用session认证方案安全性JWT的payload使用的是base64编码的,因此在JWT中不能存储敏感数据。而session的信息是存在服务端的,相对来说更安全如果在JWT中存储了敏感信息,可以解码出来非常的不安全 性能经过编码之后JWT将非常长,cookie的限制大小一般是4k,cookie很可能放不下,所以JWT一般放在local storage里面。并且用户在系统中的每一次http请求都会把JWT携带在Header里面,HTTP请求的Header可能比Body还要大。而sessionId只是很短的一个字符串,因此使用JWT的HTTP请求比使用session的开销大得多一次性无状态是JWT的特点,但也导致了这个问题,JWT是一次性的。想修改里面的内容,就必须签发一个新的JWT 无法废弃 一旦签发一个JWT,在到期之前就会始终有效,无法中途废弃。若想废弃,一种常用的处理手段是结合redis。续签 如果使用JWT做会话管理,传统的cookie续签方案一般都是框架自带的,session有效期30分钟,30分钟内如果有访问,有效期被刷新至30分钟。一样的道理,要改变JWT的有效时间,就要签发新的JWT。最简单的一种方式是每次请求刷新JWT,即每个HTTP请求都返回一个新的JWT。这个方法不仅暴力不优雅,而且每次请求都要做JWT的加密解密,会带来性能问题。另一种方法是在redis中单独为每个JWT设置过期时间,每次访问时刷新JWT的过期时间选择JWT或session我投JWT一票,JWT有很多缺点,但是在分布式环境下不需要像session一样额外实现多机数据共享,虽然seesion的多机数据共享可以通过粘性session、session共享、session复制、持久化session、terracoa实现seesion复制等多种成熟的方案来解决这个问题。 但是JWT不需要额外的工作,使用JWT不香吗?且JWT一次性的缺点可以结合redis进行弥补。扬长补短,因此在实际项目中选择的是使用JWT来进行认证。 功能实现JWT所需依赖com.auth0java-jwt3.10.3 JWT工具类public class JWTUtil { private static final Logger logger = LoggerFactory.getLogger(JWTUtil.class); //私钥 private static final String TOKEN_SECRET = "123456"; /** * 生成token,自定义过期时间 毫秒 * * @param userTokenDTO * @return */ public static String generateToken(UserTokenDTO userTokenDTO) { try { // 私钥和加密算法 Algorithm algorithm = Algorithm.HMAC256(TOKEN_SECRET); // 设置头部信息 Map header = new HashMap<>(2); header.put("Type", "Jwt"); header.put("alg", "HS256"); return JWT.create() .withHeader(header) .withClaim("token", JSONObject.toJSONString(userTokenDTO)) //.withExpiresAt(date) .sign(algorithm); } catch (Exception e) { logger.error("generate token occur error, error is:{}", e); return null; } } /** * 检验token是否正确 * * @param token * @return */ public static UserTokenDTO parseToken(String token) { Algorithm algorithm = Algorithm.HMAC256(TOKEN_SECRET); JWTVerifier verifier = JWT.require(algorithm).build(); DecodedJWT jwt = verifier.verify(token); String tokenInfo = jwt.getClaim("token").asString(); return JSON.parseObject(tokenInfo, UserTokenDTO.class); } } 说明: 生成的token中不带有过期时间,token的过期时间由redis进行管理UserTokenDTO中不带有敏感信息,如password字段不会出现在token中Redis工具类public final class RedisServiceImpl implements RedisService { /** * 过期时长 */ private final Long DURATION = 1 * 24 * 60 * 60 * 1000L; @Resource private RedisTemplate redisTemplate; private ValueOperations valueOperations; @PostConstruct public void init() { RedisSerializer redisSerializer = new StringRedisSerializer(); redisTemplate.setKeySerializer(redisSerializer); redisTemplate.setValueSerializer(redisSerializer); redisTemplate.setHashKeySerializer(redisSerializer); redisTemplate.setHashValueSerializer(redisSerializer); valueOperations = redisTemplate.opsForValue(); } @Override public void set(String key, String value) { valueOperations.set(key, value, DURATION, TimeUnit.MILLISECONDS); log.info("key={}, value is: {} into redis cache", key, value); } @Override public String get(String key) { String redisValue = valueOperations.get(key); log.info("get from redis, value is: {}", redisValue); return redisValue; } @Override public boolean delete(String key) { boolean result = redisTemplate.delete(key); log.info("delete from redis, key is: {}", key); return result; } @Override public Long getExpireTime(String key) { return valueOperations.getOperations().getExpire(key); } } RedisTemplate简单封装 业务实现登陆功能public String login(LoginUserVO loginUserVO) { //1.判断用户名密码是否正确 UserPO userPO = userMapper.getByUsername(loginUserVO.getUsername()); if (userPO == null) { throw new UserException(ErrorCodeEnum.TNP1001001); } if (!loginUserVO.getPassword().equals(userPO.getPassword())) { throw new UserException(ErrorCodeEnum.TNP1001002); } //2.用户名密码正确生成token UserTokenDTO userTokenDTO = new UserTokenDTO(); PropertiesUtil.copyProperties(userTokenDTO, loginUserVO); userTokenDTO.setId(userPO.getId()); userTokenDTO.setGmtCreate(System.currentTimeMillis()); String token = JWTUtil.generateToken(userTokenDTO); //3.存入token至redis redisService.set(userPO.getId(), token); return token; } 说明: 判断用户名密码是否正确用户名密码正确则生成token将生成的token保存至redis登出功能public boolean loginOut(String id) { boolean result = redisService.delete(id); if (!redisService.delete(id)) { throw new UserException(ErrorCodeEnum.TNP1001003); } return result; } 将对应的key删除即可。 更新密码功能public String updatePassword(UpdatePasswordUserVO updatePasswordUserVO) { //1.修改密码 UserPO userPO = UserPO.builder().password(updatePasswordUserVO.getPassword()) .id(updatePasswordUserVO.getId()) .build(); UserPO user = userMapper.getById(updatePasswordUserVO.getId()); if (user == null) { throw new UserException(ErrorCodeEnum.TNP1001001); } if (userMapper.updatePassword(userPO) != 1) { throw new UserException(ErrorCodeEnum.TNP1001005); } //2.生成新的token UserTokenDTO userTokenDTO = UserTokenDTO.builder() .id(updatePasswordUserVO.getId()) .username(user.getUsername()) .gmtCreate(System.currentTimeMillis()).build(); String token = JWTUtil.generateToken(userTokenDTO); //3.更新token redisService.set(user.getId(), token); return token; } 说明:更新用户密码时需要重新生成新的token,并将新的token返回给前端,由前端更新保存在local storage中的token,同时更新存储在redis中的token,这样实现可以避免用户重新登陆,用户体验感不至于太差。 其他说明在实际项目中,用户分为普通用户和管理员用户,只有管理员用户拥有删除用户的权限,这一块功能也是涉及token操作的,但是我太懒了,demo工程就不写了在实际项目中,密码传输是加密过的拦截器类public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String authToken = request.getHeader("Authorization"); String token = authToken.substring("Bearer".length() + 1).trim(); UserTokenDTO userTokenDTO = JWTUtil.parseToken(token); //1.判断请求是否有效 if (redisService.get(userTokenDTO.getId()) == null || !redisService.get(userTokenDTO.getId()).equals(token)) { return false; } //2.判断是否需要续期 if (redisService.getExpireTime(userTokenDTO.getId()) < 1 * 60 * 30) { redisService.set(userTokenDTO.getId(), token); log.error("update token info, id is:{}, user info is:{}", userTokenDTO.getId(), token); } return true; } 说明:拦截器中主要做两件事,一是对token进行校验,二是判断token是否需要进行续期 token校验: 判断id对应的token是否不存在,不存在则token过期若token存在则比较token是否一致,保证同一时间只有一个用户操作token自动续期: 为了不频繁操作redis,只有当离过期时间只有30分钟时才更新过期时间 拦截器配置类@Configuration public class InterceptorConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authenticateInterceptor()) .excludePathPatterns("/logout/**") .excludePathPatterns("/login/**") .addPathPatterns("/**"); } @Bean public AuthenticateInterceptor authenticateInterceptor() { return new AuthenticateInterceptor(); } } 如果有不懂的地方可以在下方留言,大家一起交流学习
2022-09-28 - 安全课堂|关于小程序session_key泄露漏洞
为进一步提升小程序的安全性和用户体验,目前平台对提审的小程序均需进行安全检测,在检测过程中发现仍有许多小程序存在安全漏洞,其中涉及session_key泄露漏洞,希望通过以下相关的漏洞介绍、案例分析和修复建议,开发者能更加了解如何对该漏洞进行防御。 一、漏洞介绍 为了保证数据安全,微信会对用户数据进行加密传输处理,所以小程序在获取微信侧提供的用户数据(如手机号)时,就需要进行相应的解密,这就会涉及到session_key,具体流程可参考开放数据校验与解密开发文档。 session_key指的是会话密钥,可以简单理解为微信开放数据AES加密的密钥,它是微信服务器给开发者服务器颁发的身份凭证,这个数据正常来说是不能通过任何方式泄露出去的。小程序若存在session_key泄露漏洞的情况,则代表微信侧传递的用户数据有被泄露、篡改等风险,开发者应及时发现该漏洞并快速修复相应问题。 [图片] 二、漏洞案例 某小程序因为session_key泄露,导致该小程序可以使用任意手机号进行登录,造成了极大的安全风险。 我们可以很明显地看到,下列请求中的session_key已经被泄露: [图片] 通过获取该session_key,我们可以结合iv解密出密文: [图片] 只需如下脚本即可进行解密,所以攻击者也可利用同样的信息去篡改用户数据,然后加密后返回给服务器,从而达到使用任意手机号进行登录的目的。 [图片] 三、漏洞修复 通过上述案例,我们了解到session_key泄露会对小程序造成的危害,而导致session_key泄露的原因则可能有以下两种: 1.通过auth.code2Session接口获取用户openid时,返回小程序的数据中包含了session_key字段,以泄露的url:/api/get_openid.php?code=xxxx为例,具体的表现如下图所示: [图片] 查看后端get_openid.php的源码,经排查发现$response 变量包含了session_key字段,开发者应去掉变量中的session_key字段,若需获取openid,应只提取该字段返回小程序即可。 [图片] 2.在解密开放数据时,使用了错误的方式,以获取手机号接口为例,通过事件回调获取微信服务器返回的加密数据(encryptedData和iv)后,将服务端中的session_key传送至小程序前端,直接在前端进行解密: [图片] 这种方式是绝对不可取的,正确的流程应该是将加密数据(encryptedData和iv)传至服务端后,结合服务端中的session_key进行解密获取手机号,然后返回给小程序。另外,目前平台已对获取手机号接口进行了安全升级,建议开发者使用新版本,以增强小程序的安全性。 若小程序存在相应的session_key泄露漏洞问题,请开发者尽快自查并修复漏洞: 请尽快在网络请求中,去除请求和响应中的session_key字段及其对应值,后续也不应该将session_key传到小程序客户端等服务器外的环境,以便消除风险。 其他常见问题 Q1: 如何进行相应的修复,是需要把session_key字段更换个名字就可以了吗? A1: 不是,更换字段名无法从根本上消除风险,session_key这个字段及对应值不应该传到小程序客户端等服务器外的环境,需去除请求和响应中的所有相关信息,才可对该漏洞问题进行修复。 Q2: 解密开放数据的正确方式是什么? A2: 以获取手机号接口为例,通过事件回调获取微信服务器返回的加密数据(encryptedData和iv),将加密数据传至服务端后,结合服务端中的session_key进行解密获取手机号,然后返回给小程序。而不应将服务端中的session_key传送至小程序前端,直接在前端进行解密。 相关文章 安全课堂|关于小程序AppSecret密钥泄露漏洞安全课堂|关于小程序云AK/SK泄露漏洞 如有其他相关疑问,欢迎随时参与官方社区讨论。
2022-09-09 - 【接口相关】聊一聊数据接口的登录态校验以及JWT
最近和群里网友(就是评论区厚颜无耻要冠名的那个子不语啦)聊天,发现他在数据接口中校验登录状态用的还是session,在我及时劝说和科普之后,他最终决定改用JWT。那么接下来我们就聊一聊数据接口应该怎么管理登录状态以及什么是JWT 混合开发的时候是怎么做的 前后端混合开发的时候,用户登录状态的管理一般都是通过session来实现的,原理很简单:用户登录后,服务端将登录用户信息存储到服务器上的特定位置,并生成对应的session id存储到浏览器的cookie中。需要校验的时候先读取cookie中的session id,找到服务器中对应的存储内容,完成校验。 很显然,这个机制是建立在cookie基础上的,cookie又依赖于浏览器,而且有域名限制。是不适合app、小程序、以及前后端时数据接口采用其他域名等情况的。 app、小程序、前后端分离的时候要怎么做 app、小程序、前后端分离时的数据接口一般采用token来做登录信息校验。原理是用户登录后,服务端生成对应用户的一个token(一般都是一段无意义的唯一字符串)后返回,app、小程序、前端(以下统称为前端)拿到token后保存,在需要校验用户登录的接口请求中加入token(可以是get、post参数或者http header的形式),服务端拿到token后校验真实性、有效性等信息后完成登录校验。一般为了防止盗用,还会设置一套签名校验的过程。 其实token和session的原理是差不多的,都是服务端将对应用户的一个key(session的时候是session id,token的时候就是token)交给前端,前端通过token请求服务端,服务端再去反查用户,获取用户登录状态。 现在一般微信、微博等接口都是采用的这种方式。但是这种方式也有弊端,主要是: 服务端必须保存token,以及有效期,校验的时候必须要有数据读取的过程; 校验签名的时候一般需要一个secret做为加密签名的附加字符,前端必须也要同时保存这个secret,这样显然不适合代码会暴露的网页前端。 这时候,就轮到我们这次的主角JWT出场了。 什么是JWT JWT是JSON Web Token的简称,有官网详细介绍,大家可以看一看,这里简单说一下。 JWT其实就是一种特殊的token,原理和使用方法自然和token一样。 JWT是由三部分组成的字符串,结构是:头部+主体内容(官方称之为Payload)+签名,三部分用“.”连接。头部和主体内容都是json格式的字符串再经过base64编码,为了方便放在get请求中,还需要把类似“=”、“/”等特殊字符替换掉。 头部内容是固定的,原始json就是下面这样 [代码]{ "alg": "HS256", "typ": "JWT" } [代码] 主要是说明了最后签名部分的加密算法。 重点是中间的主体内容,原始json一般是类似下面这样的 [代码]{ "user": "John Doe", "exp": "2020-01-01 12:24:30" } [代码] 主体内容一个是当前登录的用户,可以是用户id,也可以是用户名等可以检索定位到用户的信息;还有一个就是过期时间。还可以加入一些其他不私密的信息。 服务端拿到JWT之后可以在不读取数据的情况下,仅通过解码这部分信息就可以完成获取登录用户以及判断是否过期等初期工作。 最后的签名一般是把头部、主体内容再加上secret拼接成字符串再加密,这一步在用户登录生成JWT的时候就完成了。服务端拿到JWT之后只需要把前两部分加上secret再计算一次签名加以比对就可以完成校验签名,前端不需要同时保存secret。 JWT官网提供了各种服务端语言的生成代码,这里我提供一个我自己用PHP写的相对简化的方法,供大家参考 [代码]private function _jwt($payload){ $header['alg']='HS256'; $header['typ']='JWT'; $jwt_header=$this->_base64url($header); $jwt_payload=$this->_base64url($payload); $jwt_sign=hash_hmac('sha256', $jwt_header.'.'.$jwt_payload, $this->secret); $jwt['token']=$jwt_header.'.'.$jwt_payload.'.'.$jwt_sign; $jwt['sign']=$jwt_sign; return $jwt; } private function _base64url($a){ $c=base64_encode(json_encode($a)); $c=str_replace('=', '', $c); $c=str_replace('+', '-', $c); $c=str_replace('/', '_', $c); return $c; } [代码] 我这个方法里需要把主题内容以数组形式的参数传入,最终返回了生成的JWT和签名,方便接收时校验签名。 最后再说一下缺点 JWT在实际使用中也是存在问题的,目前想到以下几点: 安全性:签名包含在token中,一旦token整体被盗用,将没有办法区分,所以有网友称之为“裸奔”; 过期时间放在token中而不是服务端保存处理,一旦token生成并签发出去,将无法灵活的控制有效期; 最后一个是用户体验,其实可以算是token方式的通病。这个问题我也和群友讨论过,大家在访问社区的时候应该会遇到过,还在访问过程中突然变成未登录。我觉得这主要时因为服务端在token过期后就即时判断为用户登录失败,不管你在网页上处于什么状态。这个问题在session方式中是不存在的,前面说过session依赖于cookie,而存储session id的cookie是会保存在整个浏览器进程,就是说只要浏览器不关闭,用户就可以一直保持登录状态。
2019-12-03 - 小程序被反编译抄袭,甚至连服务器接口都被恶意调用,请问怎样防止服务器接口被别人调用?
小程序被反编译抄袭了,这个是前端的问题,基本不太好防。但可恶的抄袭者居然连我的服务器接口都要调用,太无耻了。。。。 网上看了些防止服务器接口被恶意调用的解决方案,但好像都挺麻烦(需要获取openid,再生成token什么的)。请问大家一般怎样来解决这个问题,也就是说大家用什么方法来解决服务器接口验证问题? 我看网上有人说小程序发起网络会带一个 referer header。其格式固定为 https://servicewechat.com/{appid}/{version}/page-frame.html,其中 {appid} 为小程序的 appid,我想在服务器端获得referer header这个字符串,然后在服务器端判断这个字符串是否包含我的appid,包含的话,就是合法访问,不包含就是非法访问。请问这样可以吗?我感觉好像不会有误判,而且处理上很容易,不用更新前端代码(不用重新审核),只是改动下服务器端就可以了。 但还是担心这样做会不会有什么问题。请官方或者其他老师指教,谢谢!
2021-01-20 - 小程序如何反编译,如何与后端实现请求安全
小程序上线一段时间,经常出现莫名用户注册,一开始非常可疑,后面越来越多,确定是黑客攻击。 接口都加了签名验证,但是黑客依然畅通无阻的注册访问,当时也是一脸懵逼。 经过反复确认,签名验证没有问题,唯一有问题的很可能是小程序出问题,其实际上小程序相当于前端页面,非常容易被反编译。 那问题来了,如何反编译?答案是:目前没有办法。微信官方应该要努力解决这个问题。 既然小程序代码被反编译无法改变,那么如何实现请求安全呢?答案是:接口签名验证; 接口签名验证是小程序发起一个请求,携带一个签名(字符串)及参数信息,服务端校验签名,如果一致则通过验证,返回接口数据;这里前后端都会有个共同的密钥,问题就出在小程序必须把密钥写在代码中,这是被攻击的根源; 如何防止黑客拿到密钥呢?目前唯一能解决的是通过云开发,将密钥放到云函数或云数据库中,这样即使黑客反编译了小程序代码,运行时也无法拿到密钥,这样也就无法发起请求。 哈哈...终于摆脱了黑客的骚挠了!
2021-08-09 - 小程序请求数据双向混合加密和防篡改+防重放攻击的实现
前言 大家好,借着中秋放假明日又要上班的这个晚上,平常又没空,趁这个时间点就决定来一篇。 [图片] 我们都知道微信小程序的服务端API上,官方可谓是做足了心思,对用户的数据进行加密,虽然对我们开发者来说似乎是一种麻烦,但是从长远角度来看,是十分有必要的,用户隐私高于一切。 那么在小程序的开发过程中与后端对接接口时是否有想过这样的问题呢? HTTPS真的百分百安全吗? 数据被成功抓包后安全吗? 数据被篡改后还有效吗? 请求被重放后安全吗? 世界上没有绝对安全的系统,但我们可以让它被破解的成本变高。 本篇文章专业性并不高,如果存在错误请大家为我指出来,以免误导别人,谢谢! 以下我们将小程序端称为C端,服务端称为S端,服务端代码是Node.js,仅供参考,但原理都一样,后端可以是其它语言。 思考 围绕着以上的问题,探究一下问题的答案? 本部分只对问题做思考,具体实现请参考下面的实现部分。 HTTPS真的百分百安全吗?只能说是相对安全,当然,在微信小程序的沙箱环境里,HTTPS通信会更加安全,否则官方可能会要求我们对请求加密了对吧。但百密一疏,C端是否存在漏洞,假设C端安全了难道S端就安全嘛?细思恐极,退一万步讲,百分百安全是不存在的,由于篇幅问题相关漏洞大家可以搜索探究。 假设数据被中间人成功抓包,如果数据是明文传输,那么将导致数据泄露,因此对数据进行加密是必要的,但应该如何加密呢?如何做到密钥安全?C端和S端如何进行数据的加解密?RSA和AES加密应该使用哪种加密明文?如何充分发挥RSA和AES两个加密算法的特长? 假设数据被篡改,如果因为请求数据被篡改而导致严重后果,那么很大程度上其实是代码设计有问题,正常设计中安全应被摆在重要的位置,至少不应该出现购买某样商品时是通过C端发送商品价格给S端调起支付那样(只需篡改商品价格即可支付极少的钱购买商品),如果代码设计并不存在严重问题,那么数据被篡改也是不可忽视的,我们需要进行数据签名,让不同的数据拥有唯一的MD5哈希值,如果数据被篡改,通过哈希值即可判断数据是否被篡改,当然也可能会有人问,既然数据被篡改,那攻击者会不会重新生成一个哈希值代替?是的。但是我们有接下来会说到的key,key会作为签名的数据变量之一,由于攻击者并不知道key值因此无法重新签名,key值建议不是固定值而是周期性更换的随机值,例如随着用户的登录态而产生并随之抹除。 假设请求被重放,大部分时候由于数据被加密和防篡改处理过,攻击者并无法直接获得数据或篡改,但如果通过劫持到的登录认证请求的原始数据并重新发起该请求,则攻击者将可能获得重要的认证数据,使得系统将攻击者作为正常用户处理,后续的请求攻击者仍能伪装成正常的用户进行后续攻击。 期望效果 在开始实现之前先看一下完成后的效果,以下是截取了Network中其中一个GET请求发送的数据: [图片] 实际上发送的数据是如下图所示: [图片] 然后是该请求返回的数据: [图片] 实际上返回的数据是如下图所示: [图片] 整个流程: [图片] 实现 由于整个流程在C端的实现上顺序反过来的,因此下面的步骤也将是反向而行。 工具库下载 工欲善其事,必先利其器!这三个库是经过修改压缩的,支持在小程序上使用并且体积可观(总共69.1KB,如果不涉及密码哈希处理只需要前两个库,体积只有65.3KB),接下来的实现操作将会使用到,建议大家可以根据实际情况对功能进行二次封装: CryptoJS.js:点击下载 RSA.js:点击下载 SHA256.js(可选):点击下载 在线的各类加解密工具(可选,可以收藏起来,平时测试挺有用):点击访问 请求数据防重放 要防范请求重放攻击,首先需要了解Unix时间戳 timestamp概念,和时间戳不一样的是它的单位是秒,事实上这个需求也只需要秒级即可。除此之外还将用到另一个值:nonce,它是一个随机产生并只能被使用一次的值,长度自定,请求越频繁长度需要越长(降低同一时间产生相同nonce的几率),C端发送请求时需要将timestamp和生成的nonce加入发送的参数中。那么,如何将两者结合呢?我这为防重放的目标下了一个简单的定义。 同样的请求只能发生一次,且请求必须在规定时间内发出。 何为同样的请求?不是指两次发送的参数一致就是一样的,而是连timestamp和nonce也一样才算是同样的请求。 那么S端如何确认其是同样的请求呢? S端每次接收到一个请求,都会将该请求的nonce存入缓存并保持60秒(这个阈值不一定是60秒,可以根据实际需要定义),时间过后该值将被移除,建议S端采用Redis存储nonce,这样可省去检测和移除nonce的代码。如果S端发现当前请求的nonce存在于已存储的nonce之中,则此请求发生重复,那么timestamp有何用? 如果只使用nonce我们只能保证该请求60秒内不会重复,但60秒后依然任人宰割,这不是要的结果。所以timestamp将用来限制时间,S端时间戳减去C端发送请求的时间戳,得到的差值为N秒,如果N秒大于60秒则此请求过期,那么则可以保证,60秒内因为nonce相同而被判为请求重放,60秒后因为时间差超过而被判为请求已过期,因此确保了请求不会被重放。。 以下展示三种情况: [代码]C端时间戳:1568487720 //2019/9/15 03:02:00 C端NONCE:5rKbMs2Fm3 C端发送请求 -> S端接收请求 S端时间戳:1568487722 //2019/9/15 03:02:02 CS端时间差:1568487722 - 1568487720 = 2 C端NONCE是否存在于缓存:false 【重放校验通过】 [代码] [代码]C端时间戳:1568487722 //2019/9/15 03:05:22 C端NONCE:IzFEs52bAC C端发送请求 -> S端接收请求 S端时间戳:1568487922 //2019/9/15 03:02:00 CS端时间差:1568487922 - 1568487722 = 200 C端NONCE是否存在于缓存:false 【重放校验不通过,请求已过期,因为时间差超过60秒】 [代码] [代码]C端时间戳:1568487720 //2019/9/15 03:02:00 C端NONCE:IxwPHQU0nA C端发送请求 -> S端接收请求 S端时间戳:1568487722 //2019/9/15 03:02:02 CS端时间差:1568487722 - 1568487720 = 2 C端NONCE是否存在于缓存:true 【重放校验不通过,此请求为重放请求,因为nonce已经存在,此请求已经完成,不可重复】 [代码] timestamp和nonce将作为参数参与下面部分的签名。 请求数据防篡改 C端数据签名 首先通过对参数按照参数名进行字典排序(调过一些第三方API的朋友应该明白),假设当前需要传输的参数如下: [代码]{ "c": 123, "b": 456, "a": 789, "timestamp": 1568487720, "nonce": "5rKbMs2Fm3" } [代码] 进行字典排序,参数名顺序应为: [代码]const keys = Object.keys(data); //获得参数名数组 keys.sort(); //字典排序 console.log(key); //["a", "b", "c", "nonce", "timestamp"]; [代码] 参数字典排序后应和参数一起拼接为字符串,至于使用什么拼接符就要与S端商量了,如果参数值是一个数组或一个对象(如c为[1,2,3])那么可以将数据值转为JSON字符串再拼接。以上参数拼接后字符串如下: [代码]a=789&b=456&c=123&nonce=5rKbMs2Fm3×tamp=1568487720 [代码] 下一步是计算拼接字符串的MD5哈希值了嘛? 不是。因为这样拼接的字符串很容易被攻击者伪造签名并篡改数据,这样就失去了签名的意义了。也就是说缺了一个key值。key值又从何而来?上面思考部分有提到建议从登录后发放,并且这个key与该用户的登录态绑定,登录态有效期间,将使用这个key进行请求的签名与验签,至于如何鉴别用户,我们会在最终发送给S端的参数加入一个sessionId作为登录态唯一标识,这里不加是因为这部分数据是需要参与后续的加密的,而sessionId不参与加密。 但是可能又会有一个问题,登录前没有key怎么实现的登录请求?事实上,登录请求并不怕篡改,因为攻击者自己也不知道账号密码,所以无需提供key用于登录请求的签名。 提到登录密码这个需要注意一点,密码不能明文传输,请计算哈希值后传输,S端比对账户密码哈希值即可确认是否正确,同样S端非特殊情况也不能明文存储密码,建议SHA-1或更高级的SHA-256计算后的值,MD5值可能被使用彩虹表(一种为各种常见密码建立的MD5映射表)破解。SHA256计算的库已在上面工具库下载提供。 拼接上我们登录时随机生成的key: [代码]a=789&b=456&c=123&nonce=5rKbMs2Fm3×tamp=1568487720&key=gUelv79KTcFaCkVB [代码] 接下来计算32位MD5哈希值 为什么上面说MD5会被破解而这里却用MD5计算?因为此处计算MD5的目的并不是为了隐藏明文数据,而只是用于数据校验 此处引入了CryptoJS.js [代码]const CryptoJS = require('./CryptoJS'); const signStr = "a=789&b=456&c=123&nonce=5rKbMs2Fm3×tamp=1568487720&key=gUelv79KTcFaCkVB"; const sign = CryptoJS.MD5(signStr).toString(); //a42af0962de99e698d27030c5c9d3b0e [代码] 这么一来我们的数据签名阶段就完成了,然后需要把签名加入参数之中,将和参数一起传输,需要注意的是传输参数无需在意参数名排序。以下是当前的参数处理结果: [代码]{ "c": 123, "b": 456, "a": 789, "timestamp": 1568487720, "nonce": "5rKbMs2Fm3", "sign": "a42af0962de99e698d27030c5c9d3b0e" } [代码] 其实从上面这两部分内容来看,会发现防重放和防篡改是相辅相成的,就像两兄弟一样,少了谁都干不好这件事。 S端验证签名 既然有签名那必定也有验签,验签流程其实就是重复C端的前面流程并比对CS两端得出的签名值是否一致。S端取得请求数据后(假设数据未加密,暂时不讨论解密),将除了sign之外的参数名进行字典排序,sign用一个临时变量存下,然后排好序的参数和C端一样拼接得到字符串。 接下来根据上面部分提到的未加密参数sessionId获得用户登录态并获取到该状态的临时key拼接到字符串末尾,接下来进行MD5计算即可得到S端方获得的签名,此时与请求中携带的sign比较是否一致则可确定签名是否有效,如果不一致返回签名错误。具体流程请参考以下: [代码]C端发送请求 -> S端接收请求 //请求参数为data const _sign = data.sign; //排序并拼接除sign外的参数 let signStr = a=789&b=456&c=123&nonce=5rKbMs2Fm3×tamp=1568487720 const sessionId = ...; //用户的sessionId const sessionData = getUserSessionData(sessionId);//根据sessionId查询用户登录态sessionData并取出key,并拼接到字符串尾部 const key = sessionData.key signStr += key; //MD5计算并比对,代码仅供参考 const sign = crypto.md5(signStr); if(sign !== _sign) { //签名错误! } [代码] 请求数据加解密 有了上面部分的参数处理铺垫后,接下来就该开始本文章最核心的加解密,在这之前我们先了解AES和RSA两种加密算法。了解概念后我们再来思考一下两者的一些特性。 AES加解密需要密钥,除某些模式外还需要提供初始化向量,可使用密钥解出明文,是对称加密算法。 RSA加解密需要一对密钥,分别为公钥和私钥,公钥加密,私钥解密,是非对称加密算法。 两者在加密长文本性能上AES占优势。 根据这些让我们发现,他们可以形成互补关系,RSA加解密安全性高但长文本处理性能不及AES。AES加解密长文本性能优于RSA但需要明文密钥和向量加解密,密钥的安全性成问题。 那么在C端生成随机的AES密钥和向量,使用密钥和向量使用aes-128-cbc加密模式(也可以根据实际需要采用其它的模式)加密真正需要传输的参数(参数则是经过防重放+防篡改处理的参数)得到encryptedData,然后将该密钥使用RSA公钥加密得到encryptedKey,下面我顺便把AES加密的向量也一起加密了得到encryptedIV,这样就完美的互补了对方的缺点,既能够较快的完成数据加密又能保证密钥安全性,两全其美。 整个个加解密流程如下图所示: [图片] 下面的四个小章节将逐一描述流程实现: C端加密数据 C端加密后的数据应如下(并非固定格式,根据自己需要定制): [代码]{ "sessionId": "xxxxxxxx", "encryptedData": "xxxxxx", "encryptedKey": "xxxxxx", "encryptedIV": "xxxxxxx" } [代码] 但RSA公钥又是怎么发放到C端的呢?答案是在登录认证的时候服务器下发的,登录成功时服务器会创建RSA密钥对并把公钥发放给C端,私钥存在服务器上该用户的登录态数据中。流程如下所示: [代码]C端发起登录请求 -> S端接收登录请求 S端登录认证是否通过 true S端生成RSA密钥对 - publicKey , privateKey S端查询相关用户信息 S端生成登录态信息 -> 向登录态信息存入privateKey私钥,登录态信息类似如下 "xxxxxx": { id: "xxxxx", authData: { key: "xxxxxx", privateKey: "xxxxxx" } } S端返回登录态唯一标识sessionId和publicKey公钥以及相关用户信息 -> C端 C端存储登录态信息于本地,后续请求将使用服务器提供的公钥进行加密 [代码] 其中privateKey就是该用户当前登录态所使用的解密私钥,C端通过公钥加密后的AES密钥数据只能用该私钥解密。 如果希望登录阶段的请求也加密,那么可以手动生成一个RSA密钥对,然后客户端放置一个固定的公钥,服务器也使用一个固定的私钥进行登录阶段的加解密。 具体实现如下: 此处引入了CryptoJS.js和RSA.js [代码]const CryptoJS = require('./CryptoJS'); const RSA = require('./RSA.js'); //假设当前已登录成功并获得S端下发的RSA公钥且已存入本地存储 //createRandomStr为生成随机大小写英文和数字的字符串 const aesKey = createRandomStr(16); //生成AES128位密钥 16字节=128位 const aesIV = createRandomStr(16); //生成初始化向量IV const raw = JSON.stringfly({ "c": 123, "b": 456, "a": 789, "timestamp": 1568487720, "nonce": "5rKbMs2Fm3", "sign": "a42af0962de99e698d27030c5c9d3b0e" }); const encryptedData = CryptoJS.AES.encrypt(data: raw, key: aesKey, { iv: aesIV, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); //使用CBC模式和Pkcs7填充加密 const authData = wx.getStorageSync("authData"); //读取本地存的RSA公钥 RSA.setPublicKey(authData.publicKey); //设置RSA公钥 const encryptedKey = RSA.encrypt(aesKey); //RSA加密AES加密密钥 const encryptedIV = RSA.encrypt(aesIV); //RSA加密AES加密初始化向量,是否加密向量可由自己决定 //最后的处理结果 const result = { sessionId: authData.sessionId, encryptedData, encryptedKey, encryptedIV }; [代码] S端解密数据 [代码]const crypto = require('crypto'); const cryptojs = require('crypto-js'); const { sessionId, encryptedData, encryptedKey, encryptedIV } = requestData; const authData = getUserSessionData(sessionId); //获取用户登录态数据 const privateKey = authData.privateKey; const aesKey = crypto.privateDecrypt({ key: privateKey, padding: crypto.constants.RSA_PKCS1_PADDING }, encryptedKey); //使用私钥解密得到AES密钥 const aesIV = crypto.privateDecrypt({ key: privateKey, padding: crypto.constants.RSA_PKCS1_PADDING }, encryptedIV); //使用私钥解密得到AES iv向量 const key = cryptojs.enc.Base64.parse(aesKey); const iv = cryptojs.enc.Utf8.parse(aesIV); const decryptedData = cryptojs.AES.decrypt(encryptedData, key, { iv, mode: cryptojs.mode.CBC, padding: cryptojs.pad.Pkcs7 }); //采用与加密统一的模式和填充进行解密 const { "c": 123, "b": 456, "a": 789, "timestamp": 1568487720, "nonce": "5rKbMs2Fm3", "sign": "a42af0962de99e698d27030c5c9d3b0e" } = decryptedData; //至此解密得到C端传来的数据 [代码] S端加密数据 当S端处理完C端的请求后应加密响应数据,那么加密响应数据应该使用什么密钥呢?既然C端已经将加密的密钥发送过来了,那么干脆将C端使用的AES密钥拿来加密响应数据就可以了。加密的数据传回C端后,C端只需使用该请求所使用的AES加密密钥进行解密即可。响应的加密数据如下: [代码]const cryptojs = require('crypto-js'); const responseData = JSON.stringify(...); //此为S端需要返回给C端的数据 const aesKey = ...; //此为之前C端用来加密数据的AES密钥 const aesIV = createRandomStr(16); //生成初始化向量IV const encryptedData = cryptojs.AES.encrypt(data, aesKey, { iv: aesIV, mode: cryptojs.mode.CBC, padding: cryptojs.pad.Pkcs7 }); //加密响应数据 const encryptedResponse = { "encryptedData": encryptedData, "iv": aesIV }; //得到加密后的响应数据并返回给C端 [代码] C端解密数据 C端接收到S端的响应数据后应对加密的数据进行解密,此次解密就是单纯的AES解密了,使用发起请求时用于加密数据的AES密钥配合响应数据的iv向量对encryptedData进行解密,得到解密后的数据即为S端真正的响应数据。实现过程如下: 此处引入了CryptoJS.js [代码]const CryptoJS = require('./CryptoJS'); const key = ...; //之前用于加密请求参数的AES加密密钥 const { encryptedData, iv } = responseData; const aesIV = CryptoJS.enc.Utf8.parse(iv); const aesKey = CryptoJS.enc.Utf8.parse(key); const decryptedData = CryptoJS.AES.decrypt(encryptedData, aesKey, { iv: aesIV, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); //AES解密响应数据 const { ... } = decryptedData; //得到解密后的响应数据 [代码] 结语 第一次写这么长的文章,可能存在大量纰漏,如果大佬发现问题欢迎指出(`・ω・´)我会马上修改。 也许小程序的运行环境会比我想象中的更安全 也许HTTPS也会比我想象中的更安全 也许Web服务器引擎也比我想象中的更安全 但,安全不总是先行一步的吗?
2019-10-24