为什么需要防范重复提交呢?举个最直接的栗子:你在商城里买了7888元的iphone x,付款后页面卡顿导致你重复点击了付款按钮,这时候如果后端不加重复交易验证的话,相当于付款15766元买了Iphone x手机,划算吧?
不单是互金系统交易时会生产此问题,凡涉及表单提交都会遇到,这里以某互金系统为例说明交易防重的过程设计。下图是交易防重设计的示图:
这个过程相信大家都不陌生,生活中随处可见。开封菜的甜品站,先付款,再给小票,拿着小票到取餐口拿甜品,交易完成后,小票撕毁。这就是一个典型的防止重复取餐的例子。
回到上图,来深入了解一下这个过程:
- 1、在进入到需要防重交易的表单页面之前,请求后端生成token的服务,生成token并存储在后端,与该用户的请求绑定,便于后期在交易验证时与之比对,token返回到交易页面。
- 2、携带token提交表单,在进入真正交易之前,做token验证(比如使用AOP),如果存在,则token正常,比对成功后销毁进入正常的交易功能。如果不存在,则证明token已经被销毁,为重复提交请求。
以上步骤可以看出token的关键性,若token获取失败,那么交易将无法完成,所以需要保证token服务的高可用性。
以上过程针对一个交易是完全没有问题的,但若涉及两个以上的关键交易提交时,就会出现后请求的交易获取的token替换首次交易获取的token,那么在首次交易提交时,会出现token找不到的情况,导致交易失败。由此引出另外两个关键的问题点:
token的数量以及token的销毁机制。 数量决定了能同时发起交易的数量,所以token的数量最好能够覆盖所有关键交易同时发起来的数量。token的销毁决定了使用token的正常顺序。
基于上面流程,我们再改造一下生成token的模块。
关键示例代码:
代码语言:javascript复制//TOKEN对象
@Data
public class TokenVO implements Serializable {
/**
* serialVersionUID
*
* @since JDK 1.7
*/
private static final long serialVersionUID = 1L;
/**
* FORM_TOKEN_ID:表单TokenId.
*
* @since JDK 1.7
*/
public static final String FORM_TOKEN_ID = "_form_token_id";
private String tokenId;
private Date tokenCreateTime;
public TokenVO(String tokenId) {
this.tokenId = tokenId;
this.tokenCreateTime = new Date();
}
public String getTokenId() {
return tokenId;
}
public void setTokenId(String tokenId) {
this.tokenId = tokenId;
}
/**
* getTokenCreateTime:(获得token创建时间). <br/>
*
* @author st-gdq4556
* @return Date
* @since JDK 1.7
*/
public Date getTokenCreateTime() {
if (tokenCreateTime == null) {
return null;
}
return (Date) tokenCreateTime.clone();
}
/**
* setTokenCreateTime:(设置token创建时间). <br/>
*
* @author st-gdq4556
* @param tokenCreateTime
* token创建时间
* @since JDK 1.7
*/
public void setTokenCreateTime(Date tokenCreateTime) {
if (tokenCreateTime == null) {
this.tokenCreateTime = null;
} else {
this.tokenCreateTime = (Date) tokenCreateTime.clone();
}
}
}
生成token方法
代码语言:javascript复制/**
* newFormToken:生成一个新的token,如果目前token个数大于设定的最大token数则先删除最早的一个token. <br/>
* 新token用UUIDUtil.generate16UUID()生成Token.<br/>
*
* @author guooo
* @param request
* 请求对象
* @return TokenVO
* @since JDK 1.7
*/
public TokenVO newFormToken(HttpServletRequest request) {
//每次都新生成一个token,放入队列里
TokenVO TokenVO = new TokenVO(UUIDUtil.generate16UUID());
Map<String, TokenVO> formTokens = getFormTokens(request);
synchronized (formTokens) {
// 如果目前token个数大于等于最大token数,那么删除最老的token,添加新token。
if (formTokens.size() > maxTokenNum) {
removeOldestToken(request);
}
formTokens.put(TokenVO.getTokenId(), TokenVO);
}
return TokenVO;
}
/**
* getTokens:获得目前session中的Token列表. <br/>
*
* @author guooo
* @param request
* 请求对象
* @return 返回的Map中以token的token为键,Form对象为值.
* @since JDK 1.7
*/
@SuppressWarnings("unchecked")
protected Map<String, TokenVO> getFormTokens(HttpServletRequest request) {
Map<String, TokenVO> tokensInSession = null;
HttpSession session = request.getSession();
synchronized (session) {
tokensInSession = (Map<String, TokenVO>) session.getAttribute(SESSION_KEY_OF_TOKENS);
if (tokensInSession == null) {
tokensInSession = new HashMap<String, TokenVO>();
session.setAttribute(SESSION_KEY_OF_TOKENS, tokensInSession);
}
}
return tokensInSession;
}
/**
* removeOldestForm:删除最老的token. <br/>
*
* @author guooo
* @param request
* 请求对象
* @since JDK 1.7
*/
protected void removeOldestToken(HttpServletRequest request) {
List<TokenVO> tokens = new ArrayList<TokenVO>(getFormTokens(request).values());
if (!tokens.isEmpty()) {
TokenVO oldestToken = tokens.get(0);
for (TokenVO TokenVO : tokens) {
if (TokenVO.getTokenCreateTime().before(oldestToken.getTokenCreateTime())) {
oldestToken = TokenVO;
}
}
destroyFormToken(request, oldestToken.getTokenId());
}
}
交易校验,主要由拦截器完成。
代码语言:javascript复制public class TradeTokenInterceptor extends HandlerInterceptorAdapter {
private static Logger logger = LoggerFactory.getLogger(TradeTokenInterceptor.class);
private TradeTokenService tokenMgr;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response, Object handler) throws Exception {
String tokenId = request.getParameter(TokenVO.FORM_TOKEN_ID);
if (tokenId != null) {
if (tokenMgr.hasFormToken(request, tokenId)) {
//token正常,比对后立即销毁
tokenMgr.destroyFormToken(request, tokenId);
} else {
//token多次提交,异常处理
RestAPIResult<Object> result = new RestAPIResult<Object>();
result.setRespCode(500);
result.setRespMsg("请求已受理,请勿重复提交");
response.getWriter().write(JSON.toJSONString(result));
return false;
}
}
return true;
}
@Autowired
public void setFormTokenManage(TradeTokenService formTokenManage) {
this.tokenMgr = formTokenManage;
}
}
一般的解决方案是在前端由JS控制提交表单按钮,提交后置灰,禁止第二次提交。但此方法也只针对小白用户有效,防范机制也不是很彻底,比如直接调用请求而非通过页面表单进行,比如JS校验代码清除等,可以绕过JS的置灰功能进行二次提交。
采用前端JS置灰防止重复提交请求,再加上后端token验证,可以更有效的防止关键交易的重复提交。
扩展阅读:
- 互联网金融产品实战——安全开发篇
- 互联网金融产品实战——设计篇
- 如何从传统软件开发顺利过渡到互联网技术开发
- 学习新技术时你应当掌握的『最少必要知识』
- 从技术到管理——角色转变
- 他山之石,可以攻玉:从别人的项目中汲取经验
- 软技能:代码之外的生存指南
- 程序员,保护你的好奇心和求知欲
- 那些会阻碍程序员成长的细节[7]
- 那些会阻碍程序员成长的细节[6]
- 那些会阻碍程序员成长的细节[5]
- MD4、MD5、SHA1、HMAC、HMAC_SHA1区别
- 做了七年软件开发后反而更迷茫
- 程序员,保护你的好奇心和求知欲