一、前言
我这几天看到查看这篇博文的人比较多,特意更新了两种解决第二个问题办法。
这两天看隔壁组项目,由于我自己项目和他们项目一样使用的Spring boot基础框架,想看看有什么值得学习的地方,结果就看到人家的登录表单可以正常分GET和POST提交,也没做什么特别的处理,唯一的区别就是人是用Ajax中并submit方法提交的。当时我的项目在登录模块也分GET和POST两种请求方式的控制层方法。但我的POST方法直接通过表单形式提交的话会有本文标题的问题。
二、问题分析与解决
先了解一下web请求链,由于项目采用Spring security做权限控制,系统的访问流程如下(英文原版文档见9.4Authentication in a Web Application):
- 您访问主页,然后单击链接。
- 请求转到服务器,服务器确定您已请求受保护的资源。
- 由于您目前尚未通过身份验证,因此服务器会发回一个响应,指示您必须进行身份验证。响应将是HTTP响应代码,或重定向到特定网页。
- 根据身份验证机制,您的浏览器将重定向到特定的网页,以便您可以填写表单,或者浏览器将以某种方式检索您的身份(通过BASIC身份验证对话框,cookie,X.509证书等) )。
- 浏览器将向服务器发回响应。这将是包含您填写的表单内容的HTTP POST,或者包含您的身份验证详细信息的HTTP标头。
- 接下来,服务器将决定所呈现的凭证是否有效。如果它们有效,则下一步将会发生。如果它们无效,通常会要求您的浏览器再次尝试(因此您将返回上面的第二步)。
- 将重试您进行身份验证过程的原始请求。希望您已通过足够授权的权限进行身份验证以访问受保护资源。如果您有足够的访问权限,请求将成功。否则,您将收到HTTP错误代码403,这意味着“禁止”。
这里说的很清楚是可以POST请求方式传到后台的。结合文档中关于CSRF的介绍,基本可以确定是CRSF机制转发后POST变成了GET(这句没错,但是有坑)。
处理这种CSRF问题(此处可以解决POST请求报403的错误)有多种解决方案,如下:
第一种方法,也是官方推荐使用的。form 表单使用 th:action 属性, thymeleaf 会自动在 form 表单中生成 _csrf 隐藏域
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
...
<form class="form-signin" th:action="@{login}" action="login" method="post">
...
</form>
...
第二种方法,手动添加隐藏域。
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
第三种方法,加在请求头部分
<meta name="_csrf" th:content="${_csrf.token}"/>
<meta name="_csrf_header" th:content="${_csrf.headerName}"/>
第四种办法,直接禁掉CSRF.。这个方法太极端。禁用方式不放出来了,总之强烈不推荐。
第五种办法,增加例外,让CSRF直接通过。
http.csrf().ignoringAntMatchers("/login")
POST请求403的问题通过设置以上参数就可以解决了
下面解决POST登录表单直接提交后台接收时变成GET的问题
还记得上面讲到CRSF时说的坑吗?上面我们怀疑是POST表单提交后经由CRSF机制转发后最终提交给后台的是GET请求方式,由于不能正确提交登录信息,导致不管怎样反复会跳到登录页面。
在说这个问题前,先列举两个它的野路子解法。最后再分析官方解法。
A、.do应用解决
原代码如下:
页面
<form class="form-signin" th:action="@{login}" action="login" method="post">
<div class="form-group">
<label for="username">账号</label>
<input type="text" class="form-control" name="username" value="" placeholder="账号"/>
</div>
<div class="form-group">
<label for="password">密码</label>
<input type="password" class="form-control" name="password" placeholder="密码"/>
</div>
<input type="submit" id="login" value="Login" class="btn btn-primary"/>
</form>
控制层
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import com.music.league.service.SignManager;
@Controller
public class SignController {
@Resource
SignManager signManager;
@RequestMapping(value="/login",method = RequestMethod.GET)
public String sign(){
System.out.println("Judge!!");
return "login";
}
@RequestMapping(value="/login",method = RequestMethod.POST)
public String sign(HttpServletRequest request){
System.out.println("登录方法入参:"+request.getParameter("userName")+":"+request.getParameter("password"));
return "welcome";
}
}
SpringMVC配置
@Configuration
public class WebMvcConfig extends WebMvcConfigurerAdapter {
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/login").setViewName("login");
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/static/**").addResourceLocations("classpath:/static/");
}
}
Spring security配置
http.authorizeRequests()
.anyRequest().authenticated()
.and().formLogin()
.loginPage("/login")
//设置默认登录成功跳转页面
.defaultSuccessUrl("/welcome").failureUrl("/login?error").permitAll()
把上述几处login的请求,除了控制层的POST方式登录方法和 th:action="@{login},其他的login都改成login.do。这样做是为了通过除请求Method属性外的第二种办法去区分开get和post请求的不同。
B、参数差异
这种方法是基于上面web请求链第二步请求本身无参的性质硬搞。当login方法无参时,自动处理走GET,大家和和美美。当有参时,手动写逻辑去掉POST。不过可能会有安全问题,所以不太推荐。
听说用ModelAndView也可以解决,我简单试了一下,好像不行哦。anyway,野路子解法到此为止。
正经的问题本质原因,如下:
在翻看Spring security5.0官方文档的时候,发现文档中提到Spring security特别为大家提供了一个登录验证表单(具体哪句找不到了,文档连接点我),倾力奉献撒!继续读文档,通过前后文的联系,官方的表单页面代码大概是这样的(这段代码在文档5.3节末尾):
<c:url value="/login" var="loginUrl"/>
<form action="${loginUrl}" method="post"> 1
<c:if test="${param.error != null}"> 2
<p>
Invalid username and password.
</p>
</c:if>
<c:if test="${param.logout != null}"> 3
<p>
You have been logged out.
</p>
</c:if>
<p>
<label for="username">Username</label>
<input type="text" id="username" name="username"/> 4
</p>
<p>
<label for="password">Password</label>
<input type="password" id="password" name="password"/> 5
</p>
<input type="hidden" 6
name="${_csrf.parameterName}"
value="${_csrf.token}"/>
<button type="submit" class="btn">Log in</button>
</form>
看到官方这么贴心,然后我的表单整体样式和它基本一致,就再次仔细看了一下。发现除了我的页面写的是loginName其他没区别。于是抱着试一试的态度改成username。MMP,完美的POST请求进入控制层POST请求登录方法。MMP...要不要限制这么死?CRSF底层实现我找不到,但问题就是在这个特殊的登录,官方给登录做了特别处理。
经过进一步的验证发现即使实体中写的是userName或者loginName,只要你想在登录模块直接通过表单方式提交的话,就必须是username。
如果改了名字还是无效,那么还有两个解决方法。就是改为小写的username后,执行下面两个方法之一。推荐第二个。
第一个办法是手动给指定一下登录请求的处理。就是在loginPage后面加一个loginProcessingUrl,内容是你登陆方法的控制层RequestMapping中的value和登录方法。如果你没有写RequestMapping的话,那就是控制层的Spring自动转换值,一般是去掉Contrller的驼峰命名。比如SignController,这里写sign就行。
第二个办法是把defaultSuccessUrl改为successForwardUrl,这个办法的原理就是把直接跳转页面改为跳转后台方法。defaultSuccessUrl("/login")改为successForwardUrl("/sign/login")。建议用这个,因为这个依旧会照常按security过滤器链自动加载权限,第一个需要手动添加权限,否则一直是匿名。
.loginPage("/login").loginProcessingUrl("/sign/login")
三、注意事项
.do的应用解决也要把登录名改成小写的username,官方的3.x版本文档写的是must,否则无法通过表达提交