基于Redis的多步令牌操作防绕过中间步骤
多步操作在日常生活和工作中很常见,比如孩子出生之前先要办理《准生证》,出生以后要办理《出生医学证明》,然后拿着《户口簿》和《出生医学证明》给孩子上户口。软件领域的多步操作事件驱动源于工作和生活,并将工作或生活场景搬到线上。线下操作通过人工核验来确保中间环节不被落下,而在软件领域,我们可以基于状态位、工作流或者工作令牌等防止绕过中间步骤。
我们先简单说说两个实际的软件应用场景:忘记密码和更换手机号码,两个场景中手机号码为登录账号。
忘记密码,忘记密码分为两步操作:
第一步,输入手机号获取短信验证码并对验证码做校验;
第二步,对该账号(手机号)设置新密码和确认密码;
在确认是本人操作后,第二步重置账号密码。逻辑上看似没问题吧?实际上,如果设计不严谨,很容易饶过第一步,直接进入第二步进行密码重置。
更换手机号,更换手机号也分为两步操作(前置条件:已登录):
第一步,获取老手机号短信验证码并校验;
第二步,获取新手机号短信验证码并校验;
两步操作貌似也比较严谨,但是如果第一步和第二步没有强制关联,仍然可以绕过第一步,直接进入第二步成功更换手机号。
试想一下,如果多步操作没有严谨的上下步操作逻辑校验,系统看上去是多麽的不堪一击。
多步操作在软件领域比比皆是,处理方法也多种多样。本文将通过Redis的多步令牌颁发和验证来防绕过中间步骤。
1、添加多步操作token注解
package com.huatech.common.annotation; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * 多步操作token * @author lh@erongdu.com * @since 2019年9月3日 * @version 1.0 * */ @Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface StepToken { /** * 如果是Step.HEAD 等同设置publishKey * 如果是Step.TAIL 等同设置validateKey * @return */ String value() default ""; /** * 当前环节 * @return */ Step step() default Step.HEAD; /** * 发布 token key,除最后一步外其他环节必传 * @return */ String publishKey() default ""; /** * 校验token key,除第一步外其他环节必传 * @return */ String validateKey() default ""; }
package com.huatech.common.annotation; /** * 多步操作环节 * @author lh@erongdu.com * @since 2019年9月3日 * @version 1.0 * */ public enum Step { /** * 第一步 */ HEAD, /** * 中间步骤 */ MIDDLE, /** * 最后一步 */ TAIL }
2、添加多步操作颁发和验证token拦截器
package com.huatech.common.interceptor; import java.util.HashMap; import java.util.Map; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.ModelAndView; import com.alibaba.fastjson.JSONObject; import com.huatech.common.annotation.Step; import com.huatech.common.annotation.StepToken; import com.huatech.common.constant.Constants; /** * 多步操作拦截验证 * @author lh@erongdu.com * @since 2019年9月3日 * @version 1.0 * */ public class StepTokenInterceptor implements HandlerInterceptor { private static final Logger logger = LoggerFactory.getLogger(StepTokenInterceptor.class); @Autowired StringRedisTemplate redisTemplate; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { request.setAttribute("start", System.currentTimeMillis()); if (handler instanceof HandlerMethod) { HandlerMethod method = (HandlerMethod) handler; StepToken stepToken = method.getMethodAnnotation(StepToken.class); if (stepToken == null || Step.HEAD.equals(stepToken.step())) {//不需要校验token return true; } // 校验token Long userId = null;//UserUtil.getSessionUserId(request); String tokenKey = String.format(Constants.KEY_STEP_TOKEN, userId == null ? request.getSession().getId() : userId, StringUtils.isBlank(stepToken.validateKey()) ? stepToken.value() : stepToken.validateKey()); logger.info("validate token, tokenKey:{}", tokenKey); if(!redisTemplate.hasKey(tokenKey)){ Map<String, Object> result = new HashMap<>(); result.put("code", "500"); result.put("msg", "请求超时或重复提交!"); response.setContentType("application/json"); response.setCharacterEncoding("utf-8"); response.getWriter().print(JSONObject.toJSON(result)); return false; } redisTemplate.delete(tokenKey); } return true; } @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { long start = Long.valueOf(request.getAttribute("start").toString()); String url = request.getRequestURI(); if (handler instanceof HandlerMethod) { HandlerMethod method = (HandlerMethod) handler; StepToken stepToken = method.getMethodAnnotation(StepToken.class); if (stepToken == null || Step.TAIL.equals(stepToken.step())) {//不需要添加token return; } // 成功返回 添加token,可以替换成response.getStatus()等做验证 String code = response.getHeader(Constants.HEAD_DATA_CODE); if(StringUtils.isBlank(code) || !"200".equals(code)){// 未成功返回,不添加token return; } // 添加token Long userId = null; //UserUtil.getSessionUserId(request); String tokenKey = String.format(Constants.KEY_STEP_TOKEN, userId == null ? request.getSession().getId() : userId, StringUtils.isBlank(stepToken.publishKey()) ? stepToken.value() : stepToken.publishKey()); logger.info("publish token, tokenKey:{}", tokenKey); redisTemplate.boundValueOps(tokenKey).set("1", 60); } logger.info("当前请求接口:{}, 响应时间:{}ms" , url, (System.currentTimeMillis() - start)); } }
3、spring-mvc配置文件中配置拦截器
<mvc:interceptors> <!-- 多步操作验证,防止跳过中间步骤 --> <mvc:interceptor> <mvc:mapping path="/**"/> <bean class="com.huatech.common.interceptor.StepTokenInterceptor"/> </mvc:interceptor> </mvc:interceptors>
4、在Controller多步操作方法中添加@StepToken
/** * 忘记密码第一步,验证账号和验证码 */ @RequestMapping(value = "/api/userInfo/forgetPwdOne.htm", method = RequestMethod.POST) @StepToken(step = Step.HEAD, value = "forgetPwdOne") public void preForgetPwd(HttpServletRequest request, HttpServletResponse response, @RequestParam(value = "loginName") String loginName, @RequestParam(value = "vCode") String vCode) { Map<String, Object> result = apiUserService.forgetPwdOne(loginName, vCode); ServletUtils.writeToResponse(response, result); } /** * 忘记密码第二步,设置新密码 */ @RequestMapping(value = "/api/userInfo/forgetPwdTwo.htm", method = RequestMethod.POST) @StepToken(step = Step.TAIL, value = "forgetPwdOne") public void forgetPwd(HttpServletRequest request, HttpServletResponse response, @RequestParam(value = "loginName") String loginName, @RequestParam(value = "newPwd") String newPwd, @RequestParam(value = "confirmPwd") String confirmPwd) { Map<String, Object> result = apiUserService.forgetPwdTwo(loginName, newPwd, confirmPwd); ServletUtils.writeToResponse(response, result); }
上一篇: poi excel导入工具类