SpringBoot 集成 Spring Security(4)——自定义表单登录
|字数总计:3.3k|阅读时长:14分钟|阅读量:
通过前面三篇文章,你应该大致了解了 Spring Security 的流程。你应该发现了,真正的 login 请求是由 Spring Security 帮我们处理的,那么我们如何实现自定义表单登录呢,比如添加一个验证码…
一、添加验证码
1.1 验证码 Servlet
验证码的 Servlet 代码,大家无需关心其内部实现,我也是百度直接捞了一个,直接复制即可。
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 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144
| public class VerifyServlet extends HttpServlet {
private static final long serialVersionUID = -5051097528828603895L;
private int width = 100;
private int height = 30;
private int codeCount = 4;
private int fontHeight;
private int interLine = 16;
private int codeX;
private int codeY;
char[] codeSequence = { 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' };
@Override public void init() throws ServletException { String strWidth = this.getInitParameter("width"); String strHeight = this.getInitParameter("height"); String strCodeCount = this.getInitParameter("codeCount"); try { if (strWidth != null && strWidth.length() != 0) { width = Integer.parseInt(strWidth); } if (strHeight != null && strHeight.length() != 0) { height = Integer.parseInt(strHeight); } if (strCodeCount != null && strCodeCount.length() != 0) { codeCount = Integer.parseInt(strCodeCount); } } catch (NumberFormatException e) { e.printStackTrace(); } codeX = (width-4) / (codeCount+1); fontHeight = height - 10; codeY = height - 7; }
@Override protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, java.io.IOException { BufferedImage buffImg = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); Graphics2D gd = buffImg.createGraphics(); Random random = new Random(); gd.setColor(Color.LIGHT_GRAY); gd.fillRect(0, 0, width, height); Font font = new Font("Times New Roman", Font.PLAIN, fontHeight); gd.setFont(font); gd.setColor(Color.BLACK); gd.drawRect(0, 0, width - 1, height - 1); gd.setColor(Color.gray); for (int i = 0; i < interLine; i++) { int x = random.nextInt(width); int y = random.nextInt(height); int xl = random.nextInt(12); int yl = random.nextInt(12); gd.drawLine(x, y, x + xl, y + yl); } StringBuffer randomCode = new StringBuffer(); int red = 0, green = 0, blue = 0; for (int i = 0; i < codeCount; i++) { String strRand = String.valueOf(codeSequence[random.nextInt(36)]); red = random.nextInt(255); green = random.nextInt(255); blue = random.nextInt(255); gd.setColor(new Color(red,green,blue)); gd.drawString(strRand, (i + 1) * codeX, codeY); randomCode.append(strRand); } HttpSession session = request.getSession(); session.setAttribute("validateCode", randomCode.toString()); response.setHeader("Pragma", "no-cache"); response.setHeader("Cache-Control", "no-cache"); response.setDateHeader("Expires", 0);
response.setContentType("image/jpeg"); ServletOutputStream sos = response.getOutputStream(); ImageIO.write(buffImg, "jpeg", sos); sos.close(); } }
|
然后在 Application 中注入该 Servlet:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| @SpringBootApplication public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); }
@Bean public ServletRegistrationBean indexServletRegistration() { ServletRegistrationBean registration = new ServletRegistrationBean(new VerifyServlet()); registration.addUrlMappings("/getVerifyCode"); return registration; } }
|
1.2 修改 login.html
在原本的 login 页面基础上加上验证码字段:
login.html1 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
| <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>登陆</title> </head> <body> <h1>登陆</h1> <form method="post" action="/login"> <div> 用户名:<input type="text" name="username"> </div> <div> 密码:<input type="password" name="password"> </div> <div> <input type="text" class="form-control" name="verifyCode" required="required" placeholder="验证码"> <img src="getVerifyCode" title="看不清,请点我" onclick="refresh(this)" onmouseover="mouseover(this)" /> </div> <div> <label><input type="checkbox" name="remember-me"/>自动登录</label> <button type="submit">立即登陆</button> </div> </form> <script> function refresh(obj) { obj.src = "getVerifyCode?" + Math.random(); }
function mouseover(obj) { obj.style.cursor = "pointer"; } </script> </body> </html>
|
1.3 添加匿名访问 Url
不要忘记在 WebSecurityConfig 中允许该 Url 的匿名访问,不然没有登录是没有办法访问该 Url 的:
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
| @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/getVerifyCode").permitAll() .anyRequest().authenticated() .and() .formLogin().loginPage("/login") .defaultSuccessUrl("/").permitAll() .failureUrl("/login/error")
.and() .logout().permitAll() .and().rememberMe() .tokenRepository(persistentTokenRepository()) .tokenValiditySeconds(60) .userDetailsService(userDetailsService);
http.csrf().disable(); }
|
这样验证码就加好了,运行下程序:

下面才算是这篇文章真正的部分。我们如何才能实现验证码验证呢,思考一下,应该有以下几种实现方式:
- 登录表单提交前发送 AJAX 验证验证码
- 使用自定义过滤器(Filter),在 Spring security 校验前验证验证码合法性
- 和用户名、密码一起发送到后台,在 Spring security 中进行验证
二、AJAX 验证
使用 AJAX 方式验证和我们 Spring Security 框架就没有任何关系了,其实就是表单提交前先发个 HTTP 请求验证验证码,本篇不再赘述。
三、过滤器验证
使用过滤器的思路是:在Spring Security 处理登录验证请求前,验证验证码,如果正确,放行;如果不正确,调到异常。
3.1 编写验证码过滤器
自定义一个过滤器,实现 OncePerRequestFilter
(该 Filter 保证每次请求一定会过滤),在 isProtectedUrl()
方法中拦截了 POST 方式的 /login 请求。
在逻辑处理中从 request 中取出验证码,并进行验证,如果验证成功,放行;验证失败,手动生成异常。
注:这里的异常设置在上一篇已经说过了:《SpringBoot 集成 Spring Security(3)——异常处理》
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
| public class VerifyFilter extends OncePerRequestFilter { private static final PathMatcher pathMatcher = new AntPathMatcher();
@Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { if(isProtectedUrl(request)) { String verifyCode = request.getParameter("verifyCode"); if(!validateVerify(verifyCode)) { request.getSession().setAttribute("SPRING_SECURITY_LAST_EXCEPTION",new DisabledException("验证码输入错误")); request.getRequestDispatcher("/login/error").forward(request,response); } else { filterChain.doFilter(request,response); } } else { filterChain.doFilter(request,response); }
}
private boolean validateVerify(String inputVerify) { HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest(); String validateCode = ((String) request.getSession().getAttribute("validateCode")).toLowerCase(); inputVerify = inputVerify.toLowerCase();
System.out.println("验证码:" + validateCode + "用户输入:" + inputVerify); return validateCode.equals(inputVerify); }
private boolean isProtectedUrl(HttpServletRequest request) { return "POST".equals(request.getMethod()) && pathMatcher.match("/login", request.getServletPath()); } }
|
3.2 注入过滤器
修改 WebSecurityConfig 的 configure 方法,添加一个 addFilterBefore()
,具有两个参数,作用是在参数二之前执行参数一设置的过滤器。
Spring Security 对于用户名/密码登录方式是通过 UsernamePasswordAuthenticationFilter
处理的,我们在它之前执行验证码过滤器即可。
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
| @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/getVerifyCode").permitAll() .anyRequest().authenticated() .and() .formLogin().loginPage("/login") .defaultSuccessUrl("/").permitAll() .failureUrl("/login/error")
.and() .addFilterBefore(new VerifyFilter(),UsernamePasswordAuthenticationFilter.class) .logout().permitAll() .and().rememberMe() .tokenRepository(persistentTokenRepository()) .tokenValiditySeconds(60) .userDetailsService(userDetailsService);
http.csrf().disable(); }
|
3.3 运行程序
现在来测试下,当验证码错误后:

四、Spring Security 验证
使用过滤器就已经实现了验证码功能,但其实它和 AJAX 验证差别不大。
如果我们要做的需求是用户登录是需要多个验证字段,不单单是用户名和密码,那么使用过滤器会让逻辑变得复杂,这时候可以考虑自定义 Spring Security 的验证逻辑了…
4.1 WebAuthenticationDetails
我们知道 Spring security 默认只会处理用户名和密码信息。这时候就要请出我们的主角——WebAuthenticationDetails
。
WebAuthenticationDetails
: 该类提供了获取用户登录时携带的额外信息的功能,默认提供了 remoteAddress 与 sessionId 信息。
我们需要实现自定义的 WebAuthenticationDetails
,并在其中加入我们的验证码:
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
| import org.springframework.security.web.authentication.WebAuthenticationDetails;
import javax.servlet.http.HttpServletRequest;
public class CustomWebAuthenticationDetails extends WebAuthenticationDetails { private static final long serialVersionUID = 6975601077710753878L; private final String verifyCode;
public CustomWebAuthenticationDetails(HttpServletRequest request) { super(request); verifyCode = request.getParameter("verifyCode"); }
public String getVerifyCode() { return this.verifyCode; }
@Override public String toString() { StringBuilder sb = new StringBuilder(); sb.append(super.toString()).append("; VerifyCode: ").append(this.getVerifyCode()); return sb.toString(); } }
|
在这个方法中,我们将前台 form 表单中的 verifyCode
获取到,并通过 get 方法方便被调用。
4.2 AuthenticationDetailsSource
自定义了WebAuthenticationDetails
,我i们还需要将其放入 AuthenticationDetailsSource
中来替换原本的 WebAuthenticationDetails
,因此还得实现自定义 AuthenticationDetailsSource
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| import org.springframework.security.authentication.AuthenticationDetailsSource; import org.springframework.security.web.authentication.WebAuthenticationDetails; import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
@Component("authenticationDetailsSource") public class CustomAuthenticationDetailsSource implements AuthenticationDetailsSource<HttpServletRequest, WebAuthenticationDetails> { @Override public WebAuthenticationDetails buildDetails(HttpServletRequest request) { return new CustomWebAuthenticationDetails(request); } }
|
该类内容将原本的 WebAuthenticationDetails
替换为了我们的 CustomWebAuthenticationDetails
。
然后我们将 CustomAuthenticationDetailsSource
注入Spring Security中,替换掉默认的 AuthenticationDetailsSource
。
修改 WebSecurityConfig
,将其注入,然后在config()中使用 authenticationDetailsSource(authenticationDetailsSource)
方法来指定它。
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
| @Autowired private AuthenticationDetailsSource<HttpServletRequest, WebAuthenticationDetails> authenticationDetailsSource;
@Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/getVerifyCode").permitAll() .anyRequest().authenticated() .and() .formLogin().loginPage("/login") .defaultSuccessUrl("/").permitAll() .failureUrl("/login/error")
.authenticationDetailsSource(authenticationDetailsSource) .and() .logout().permitAll() .and().rememberMe() .tokenRepository(persistentTokenRepository()) .tokenValiditySeconds(60) .userDetailsService(userDetailsService);
http.csrf().disable(); }
|
4.3 AuthenticationProvider
至此我们通过自定义 WebAuthenticationDetails
和 AuthenticationDetailsSource
将验证码和用户名、密码一起带入了 Spring Security 中,下面我们需要将它取出来。
这里需要我们自定义 AuthenticationProvider,需要注意的是,如果是我们自己实现 AuthenticationProvider
,那么我们就需要自己做密码校验了。
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
| @Component public class CustomAuthenticationProvider implements AuthenticationProvider { @Autowired private CustomUserDetailsService customUserDetailsService;
@Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { String inputName = authentication.getName(); String inputPassword = authentication.getCredentials().toString();
CustomWebAuthenticationDetails details = (CustomWebAuthenticationDetails) authentication.getDetails();
String verifyCode = details.getVerifyCode(); if(!validateVerify(verifyCode)) { throw new DisabledException("验证码输入错误"); }
UserDetails userDetails = customUserDetailsService.loadUserByUsername(inputName);
if(!userDetails.getPassword().equals(inputPassword)) { throw new BadCredentialsException("密码错误"); }
return new UsernamePasswordAuthenticationToken(inputName, inputPassword, userDetails.getAuthorities()); }
private boolean validateVerify(String inputVerify) { HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest(); String validateCode = ((String) request.getSession().getAttribute("validateCode")).toLowerCase(); inputVerify = inputVerify.toLowerCase();
System.out.println("验证码:" + validateCode + "用户输入:" + inputVerify);
return validateCode.equals(inputVerify); }
@Override public boolean supports(Class<?> authentication) { return authentication.equals(UsernamePasswordAuthenticationToken.class); } }
|
最后在 WebSecurityConfig
中将其注入,并在 config 方法中通过 auth.authenticationProvider()
指定使用。
1 2 3 4 5 6 7
| @Autowired private CustomAuthenticationProvider customAuthenticationProvider;
@Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.authenticationProvider(customAuthenticationProvider); }
|
4.4 运行程序
是不是比较复杂,为了实现该需求自定义了 WebAuthenticationDetails
、AuthenticationDetailsSource
、AuthenticationProvider
,让我们运行一下程序,当输入错误验证码时:
