开始介绍使用 redirect 技术防止表单提交,但是 redirect 解决不了后退到表单页面时重复提交表单,为了解决这个问题,加入了 token 的机制,而且同时能够防止 CRSF 攻击。
Token 的生成和校验也有不同的方式,如果每个 form 相关的处理方法中都写一遍 token 的生成和校验代码,在实际项目中是不太能接受的,文章的后面介绍了使用拦截器的方式生成和校验 token,这种方式在实际项目里才有意义。
1. 常规防止表单重复提交
GET
访问表单页面
填写表单
POST
提交表单
Server 端处理表单数据,例如把数据写入数据库
重定向到另一个页面,防止用户刷新页面重复提交表单
1 2 3 4 5 6 7 8 9 10 11 12 13 14 <!DOCTYPE html > <html > <head > <title > Update User</title > </head > <body > <form action ="/user-form" method ="post" > Username: <input type ="text" name ="username" > <br > Password: <input type ="text" name ="password" > <br > <button type ="submit" > Update User</button > </form > </body > </html >
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 package com.xtuer.controller;import org.springframework.stereotype.Controller;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RequestMethod;import org.springframework.web.bind.annotation.RequestParam;import org.springframework.web.servlet.mvc.support.RedirectAttributes;@Controller public class ParameterController { @RequestMapping(value = "/user-form", method= RequestMethod.GET) public String showUserForm () { return "user-form.htm" ; } @RequestMapping(value = "/user-form", method= RequestMethod.POST) public String handleUserForm (@RequestParam String username, @RequestParam String password, final RedirectAttributes redirectAttributes) { System.out.println("Username: " + username + ", Password: " + password); redirectAttributes.addFlashAttribute("result" , "The user is already successfully updated" ); return "redirect:/result" ; } @RequestMapping("/result") public String result () { return "result.htm" ; } }
测试一:
访问 http://localhost/user-form
点击 Update User
,表单成功提交后被重定向到 result 页面
刷新 result 页面,表单没有被重复提交,实现了防止表单重复提交的功能
2. 使用 token 进一步加强防止表单重复提交 但是,如果在浏览器里点击后退按钮后退到表单页面,点击 Update User
,表单被再次提交了。可以使用 token 防止后退的情况下重复提交表单,访问表单页面的时候生成一个 token 在 form 里并且在 Server 端存储这个 token,提交表单的时候先检查 Server 端有没有这个 token,如果有则说明是第一次提交表单,然后把 token 从 Server 端删除,处理表单,redirect 到 result 页面,如果 Server 端没有这个 token,则说明是重复提交的表单,不处理表单的提交。
在 form 里增加一个 input 域存放 token(没有隐藏是为了方便测试):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 <!DOCTYPE html > <html > <head > <title > Update User</title > </head > <body > <form action ="/user-form" method ="post" > <input type ="input" name ="token" value ="${token!}" > <br > Username: <input type ="text" name ="username" > <br > Password: <input type ="text" name ="password" > <br > <button type ="submit" > Update User</button > </form > </body > </html >
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 package com.xtuer.controller;import org.springframework.stereotype.Controller;import org.springframework.ui.ModelMap;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RequestMethod;import org.springframework.web.bind.annotation.RequestParam;import org.springframework.web.servlet.mvc.support.RedirectAttributes;import javax.servlet.http.HttpSession;import java.util.UUID;@Controller public class ParameterController { @RequestMapping(value = "/user-form", method= RequestMethod.GET) public String showUserForm (ModelMap model, HttpSession session) { String token = UUID.randomUUID().toString().toUpperCase().replaceAll("-" , "" ); model.addAttribute("token" , token); session.setAttribute(token, token); return "user-form.htm" ; } @RequestMapping(value = "/user-form", method= RequestMethod.POST) public String handleUserForm (@RequestParam String username, @RequestParam String password, @RequestParam String token, HttpSession session, RedirectAttributes redirectAttributes) { if (token == null || token.isEmpty() || !token.equals(session.getAttribute(token))) { throw new RuntimeException("重复提交表单" ); } session.removeAttribute(token); System.out.println("Username: " + username + ", Password: " + password); redirectAttributes.addFlashAttribute("result" , "The user is already successfully updated" ); return "redirect:result" ; } @RequestMapping("/result") public String result () { return "result.htm" ; } }
测试二:
访问 http://localhost/user-form
点击 Update User
,表单成功提交后被重定向到 result 页面
刷新 result 页面,表单没有被重复提交,实现了防止表单重复提交的功能
点击后退按钮回到表单页面,点击 Update User
,因为 token 不存在了,程序抛出异常,防止了表单的重复提交(显示异常页面不是最好的办法,更友好的做法是显示一个表单重复提交提示页面)
3. 使用 SpringMVC 拦截器生成和验证 token 思考一下,为了给 user-form 增加 token,在处理 user-form 的方法里新加了很多代码,如果有 10 个 form, 100 个 form 都要使用 token 的机制呢?难道要去每个 form 处理的方法里都加上上面的那么多代码吗?上面 token 使用的是 UUID,如果要改成 static 类型的整数,每次生成时都加 1 呢? token 存储在 session 里,项目进行到一定的时候要决定存储在第三方缓存如 Redis 里呢?每次需求的变更都要修改所有 form 的处理方法? 工作量也太大了,谁遇到这样的问题都会抓狂,难怪招聘里着重强调:不许打项目经理!
幸好 SpringMVC 提供了拦截器的机制,能够很简单的给 form 增加 token 的机制:
当访问 user-form 页面时,在拦截器的 postHandle() 里生成 token 存放在 ModelAndView 和 session 里,然后把 token 写到 form 表单中
提交表单时,在拦截器的 preHandle() 里校验 token,如果 token 无效则禁止表单的提交
在拦截器的配置里加上需要使用 token 机制的 form 的路径
如果 form 不需要 token 机制,从拦截器的配置里把它的路径删除即可
不需要修改 Controller 中的代码
什么是 token? 简单的说就是一次操作的标识,可以是数字,字符串,甚至对象等,只要能把不同的表单提交区别开来就可以了。申请表单的时候生成一个 token,表单提交后删除 token。
在 form 里增加一个 input 域存放 token:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 <!DOCTYPE html > <html > <head > <title > Update User</title > </head > <body > <form action ="/user-form" method ="post" > <input type ="input" name ="token" value ="${token!}" > <br > Username: <input type ="text" name ="username" > <br > Password: <input type ="text" name ="password" > <br > <button type ="submit" > Update User</button > </form > </body > </html >
和开始的 Controller 代码一样,没有 token 的相关代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 package com.xtuer.comcontroller;import org.springframework.stereotype.Controller;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RequestMethod;import org.springframework.web.bind.annotation.RequestParam;import org.springframework.web.servlet.mvc.support.RedirectAttributes;@Controller public class ParameterController { @RequestMapping(value = "/user-form", method= RequestMethod.GET) public String showUserForm () { return "user-form.htm" ; } @RequestMapping(value = "/user-form", method= RequestMethod.POST) public String handleUserForm (@RequestParam String username, @RequestParam String password, final RedirectAttributes redirectAttributes) { System.out.println("Username: " + username + ", Password: " + password); redirectAttributes.addFlashAttribute("result" , "The user is already successfully updated" ); return "redirect:result" ; } @RequestMapping("/result") public String result () { return "result.htm" ; } }
拦截器 TokenValidator 用于生成和校验 token:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 package com.xtuer.interceptor;import org.springframework.web.servlet.HandlerInterceptor;import org.springframework.web.servlet.ModelAndView;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import java.util.UUID;public class TokenValidator implements HandlerInterceptor { public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!"GET" .equalsIgnoreCase(request.getMethod())) { String clientToken = request.getParameter("token" ); String serverToken = (String) request.getSession().getAttribute(clientToken); if (clientToken == null || clientToken.isEmpty() || !clientToken.equals(serverToken)) { throw new RuntimeException("重复提交表单" ); } request.getSession().removeAttribute(clientToken); } return true ; } public void postHandle (HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { if (!"GET" .equalsIgnoreCase(request.getMethod())) { return ; } String token = UUID.randomUUID().toString().replaceAll("-" , "" ).toUpperCase(); modelAndView.addObject("token" , token); request.getSession().setAttribute(token, token); } public void afterCompletion (HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { } }
spring-mvc.xml 里配置拦截器
1 2 3 4 5 6 7 8 9 <beans > ... <mvc:interceptors > <mvc:interceptor > <mvc:mapping path ="/user-form" /> <bean class ="com.xtuer.interceptor.TokenValidator" > </bean > </mvc:interceptor > </mvc:interceptors > </beans >
测试三:
访问 http://localhost/user-form
点击 Update User
,表单成功提交后被重定向到 result 页面
刷新 result 页面,表单没有被重复提交,实现了防止表单重复提交的功能
点击后退按钮回到表单页面,点击 Update User
,因为 token 不存在了,程序抛出异常,防止了表单的重复提交(显示异常页面不是最好的办法,更友好的做法是显示一个表单重复提交提示页面)
通过 SpringMVC 拦截器增加 token 的机制,
想改变 token 生成策略? 修改 TokenValidator
想改变 token 的存储策略? 修改 TokenValidator
想给 form 增加 token 校验? 修改 spring-mvc.xml 拦截器的配置
想把 form 的 token 校验删除? 修改 spring-mvc.xml 拦截器的配置
不需要修改任何 form 处理的方法,泰山崩于前而色不变,风波骤起而泰然处之,项目经理好像也没那么可恨了
思考 Token 的存储需要考虑过期时间,否则访问 10 万次 user-form 页面,生成 10 万个 token 而不提交表单,token 一直不会被删除,会造成很大的资源浪费。
Token 应该写入到 form 的隐藏域,为了直观,上面我们写入了普通的 input 中:<input type="hidden" name="token" value="${token!}">
这里使用的是 SpringMVC 的拦截器生成和校验 token,当然也可以使用 Servlet 的 Filter 等技术实现。
重复提交表单时不应该直接把异常显示给用户,可以使用 SpringMVC 的异常处理机制,不同的页面显示不同异常的友好信息。