本文在前文 Spring Security 入门(一):认证和原理分析 的基础上介绍图形验证码和手机短信验证码登录的实现。
图形验证码
在用户登录时,一般通过表单的方式进行登录都会要求用户输入验证码,Spring Security
默认没有实现图形验证码的功能,所以需要我们自己实现。
实现流程分析
前文中实现的用户名、密码登录是在UsernamePasswordAuthenticationFilter
过滤器进行认证的,而图形验证码一般是在用户名、密码认证之前进行验证的,所以需要在UsernamePasswordAuthenticationFilter
过滤器之前添加一个自定义过滤器 ImageCodeValidateFilter,用来校验用户输入的图形验证码是否正确。自定义过滤器继承 OncePerRequestFilter 类,该类是 Spring 提供的在一次请求中只会调用一次的 filter。
自定义的过滤器 ImageCodeValidateFilter 首先会判断请求是否为 POST 方式的登录表单提交请求,如果是就将其拦截进行图形验证码校验。如果验证错误,会抛出自定义异常类对象 ValidateCodeException,该异常类需要继承 AuthenticationException 类。在自定义过滤器中,我们需要手动捕获自定义异常类对象,并将捕获到自定义异常类对象交给自定义失败处理器进行处理。
kpatcha 使用
Kaptcha 是谷歌提供的生成图形验证码的工具,参考地址为:https://github.com/penggle/kaptcha ,依赖如下:
1 2 3 4 5 6 <dependency > <groupId > com.github.penggle</groupId > <artifactId > kaptcha</artifactId > <version > 2.3.2</version > </dependency >
☕ 创建 KaptchaConfig 配置类
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 package com.example.config;import com.google.code.kaptcha.Constants;import com.google.code.kaptcha.impl.DefaultKaptcha;import com.google.code.kaptcha.util.Config;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import java.util.Properties;@Configuration public class KaptchaConfig { @Bean public DefaultKaptcha captchaProducer () { DefaultKaptcha defaultKaptcha = new DefaultKaptcha(); Properties properties = new Properties(); properties.setProperty(Constants.KAPTCHA_BORDER, "yes" ); properties.setProperty(Constants.KAPTCHA_BORDER_COLOR, "192,192,192" ); properties.setProperty(Constants.KAPTCHA_IMAGE_WIDTH, "110" ); properties.setProperty(Constants.KAPTCHA_IMAGE_HEIGHT, "40" ); properties.setProperty(Constants.KAPTCHA_TEXTPRODUCER_FONT_COLOR, "0,0,0" ); properties.setProperty(Constants.KAPTCHA_TEXTPRODUCER_FONT_SIZE, "32" ); properties.setProperty(Constants.KAPTCHA_TEXTPRODUCER_CHAR_LENGTH, "4" ); properties.setProperty(Constants.KAPTCHA_TEXTPRODUCER_CHAR_STRING, "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYAZ" ); properties.setProperty(Constants.KAPTCHA_NOISE_IMPL, "com.google.code.kaptcha.impl.NoNoise" ); Config config = new Config(properties); defaultKaptcha.setConfig(config); return defaultKaptcha; } }
kaptcha 配置的参数说明(定义在 Constants 常量类中):
kaptcha.border
:是否有图片边框,合法值:yes,no;默认值为 yes。
kaptcha.border.color
:边框颜色,合法值:rgb 或者 white,black,blue;默认值为 black。
kaptcha.border.thickness
边框厚度,合法值:>0;默认为 1。
kaptcha.image.width
:图片宽,默认值为 200px。
kaptcha.image.height
:图片高,默认值为 50px。
kaptcha.producer.impl
:图片实现类,默认值为com.google.code.kaptcha.impl.DefaultKaptcha
。
kaptcha.textproducer.impl
:文本实现类,默认值为com.google.code.kaptcha.text.impl.DefaultTextCreator
。
kaptcha.textproducer.char.string
:文本集合,验证码值从此集合中获取,默认值为 abcde2345678gfynmnpwx
。
kaptcha.textproducer.char.length
:验证码长度,默认值为 5。
kaptcha.textproducer.font.names
:字体,默认值为 Arial, Courier。
kaptcha.textproducer.font.size
:字体大小,默认值为 40px。
kaptcha.textproducer.font.color
:字体颜色,合法值:rgb 或者 white,black,blue;默认值为black。
kaptcha.textproducer.char.space
:文字间隔,默认值为 2px。
kaptcha.noise.impl
:干扰实现类,com.google.code.kaptcha.impl.NoNoise
为没有干扰。默认值为 com.google.code.kaptcha.impl.DefaultNoise
。
kaptcha.noise.color
:干扰线颜色,合法值:rgb 或者 white,black,blue;默认值为 black。
kaptcha.obscurificator.impl
:图片样式,合法值:水纹 com.google.code.kaptcha.impl.WaterRipple
,鱼眼 com.google.code.kaptcha.impl.FishEyeGimpy
, 阴影 com.google.code.kaptcha.impl.ShadowGimpy
;默认值为 com.google.code.kaptcha.impl.WaterRipple
。
kaptcha.background.impl
:背景实现类,默认值为com.google.code.kaptcha.impl.DefaultBackground
。
kaptcha.background.clear.from
:背景颜色渐变,开始颜色,默认值为light grey
。
kaptcha.background.clear.to
:背景颜色渐变, 结束颜色,默认值为 white。
kaptcha.word.impl
:文字渲染器 实现类,默认值为 com.google.code.kaptcha.text.impl.DefaultWordRenderer
。
kaptcha.session.key
:session key,默认值为KAPTCHA_SESSION_KEY
。
kaptcha.session.date
:session date,默认值为KAPTCHA_SESSION_DATE
。
☕ 创建验证码的实体类 CheckCode
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 package com.example.entity;import java.io.Serializable;import java.time.LocalDateTime;public class CheckCode implements Serializable { private String code; private LocalDateTime expireTime; public CheckCode (String code, int expireTime) { this .code = code; this .expireTime = LocalDateTime.now().plusSeconds(expireTime); } public CheckCode (String code) { this (code, 60 ); } public boolean isExpried () { return this .expireTime.isBefore(LocalDateTime.now()); } public String getCode () { return this .code; } }
☕ 在 LoginController 中添加获取图形验证码的 Controller 方法
1 2 3 4 5 6 7 package com.example.constans;public class Constants { public static final String KAPTCHA_SESSION_KEY = "KAPTCHA_SESSION_KEY" ; }
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 @Controller public class LoginController { @Autowired private DefaultKaptcha defaultKaptcha; @GetMapping("/code/image") public void imageCode (HttpServletRequest request, HttpServletResponse response) throws IOException { String capText = defaultKaptcha.createText(); BufferedImage image = defaultKaptcha.createImage(capText); CheckCode code = new CheckCode(capText); request.getSession().setAttribute(Constants.KAPTCHA_SESSION_KEY, code); response.setHeader("Cache-Control" , "no-store" ); response.setHeader("Pragma" , "no-cache" ); response.setDateHeader("Expires" , 0 ); response.setContentType("image/jpeg" ); ImageIO.write(image, "jpg" , response.getOutputStream()); } }
☕ 在 login.html 中添加验证码功能
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 <!DOCTYPE html > <html lang ="en" xmlns:th ="http://www.thymeleaf.org" > <head > <meta charset ="UTF-8" > <title > 登录</title > </head > <body > <h3 > 表单登录</h3 > <form method ="post" th:action ="@{/login/form}" > <input type ="text" name ="name" placeholder ="用户名" > <br > <input type ="password" name ="pwd" placeholder ="密码" > <br > <input name ="imageCode" type ="text" placeholder ="验证码" > <br > <img th:onclick ="this.src='/code/image?'+Math.random()" th:src ="@{/code/image}" alt ="验证码" /> <br > <div th:if ="${param.error}" > <span th:text ="${session.SPRING_SECURITY_LAST_EXCEPTION.message}" style ="color:red" > 用户名或密码错误</span > </div > <button type ="submit" > 登录</button > </form > </body > </html >
☕ 更改安全配置类 SpringSecurityConfig,设置访问/code/image
不需要任何权限
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @EnableWebSecurity public class SpringSecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure (HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/login/page" , "/code/image" ).permitAll() .antMatchers("/admin/**" ).hasRole("ADMIN" ) .antMatchers("/user/**" ).hasAuthority("ROLE_USER" ) .anyRequest().authenticated(); } }
☕ 测试
访问localhost:8080/login/page
,出现图形验证的信息
自定义验证码过滤器
⭐️ 创建自定义异常类 ValidateCodeException
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 package com.example.exception;import org.springframework.security.core.AuthenticationException;public class ValidateCodeException extends AuthenticationException { public ValidateCodeException (String msg, Throwable t) { super (msg, t); } public ValidateCodeException (String msg) { super (msg); } }
⭐ 自定义图形验证码校验过滤器 ImageCodeValidateFilter
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 package com.example.config.security;import com.example.constans.Constants;import com.example.entity.CheckCode;import com.example.exception.ValidateCodeException;import org.apache.commons.lang3.StringUtils;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.stereotype.Component;import org.springframework.web.filter.OncePerRequestFilter;import javax.servlet.FilterChain;import javax.servlet.ServletException;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import javax.servlet.http.HttpSession;import java.io.IOException;@Component public class ImageCodeValidateFilter extends OncePerRequestFilter { private String codeParamter = "imageCode" ; @Autowired private CustomAuthenticationFailureHandler authenticationFailureHandler; @Override protected void doFilterInternal (HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { if ("/login/form" .equals(request.getRequestURI()) && "POST" .equals(request.getMethod())) { try { validate(request); } catch (ValidateCodeException e) { authenticationFailureHandler.onAuthenticationFailure(request, response, e); return ; } } filterChain.doFilter(request, response); } private void validate (HttpServletRequest request) { String requestCode = request.getParameter(this .codeParamter); if (requestCode == null ) { requestCode = "" ; } requestCode = requestCode.trim(); HttpSession session = request.getSession(); CheckCode savedCode = (CheckCode) session.getAttribute(Constants.KAPTCHA_SESSION_KEY); if (savedCode != null ) { session.removeAttribute(Constants.KAPTCHA_SESSION_KEY); } if (StringUtils.isBlank(requestCode)) { throw new ValidateCodeException("验证码的值不能为空" ); } if (savedCode == null ) { throw new ValidateCodeException("验证码不存在" ); } if (savedCode.isExpried()) { throw new ValidateCodeException("验证码过期" ); } if (!requestCode.equalsIgnoreCase(savedCode.getCode())) { throw new ValidateCodeException("验证码输入错误" ); } } }
⭐️ 更改安全配置类 SpringSecurityConfig,将自定义过滤器添加过滤器链中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @EnableWebSecurity public class SpringSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private ImageCodeValidateFilter imageCodeValidateFilter; @Override protected void configure (HttpSecurity http) throws Exception { http.addFilterBefore(imageCodeValidateFilter, UsernamePasswordAuthenticationFilter.class); } }
完整的安全配置类 SpringSecurityConfig 如下:
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 package com.example.config;import com.example.config.security.CustomAuthenticationFailureHandler;import com.example.config.security.CustomAuthenticationSuccessHandler;import com.example.config.security.ImageCodeValidateFilter;import com.example.service.CustomUserDetailsService;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.context.annotation.Bean;import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;import org.springframework.security.config.annotation.web.builders.HttpSecurity;import org.springframework.security.config.annotation.web.builders.WebSecurity;import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;@EnableWebSecurity public class SpringSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private CustomUserDetailsService userDetailsService; @Autowired private CustomAuthenticationSuccessHandler authenticationSuccessHandler; @Autowired private CustomAuthenticationFailureHandler authenticationFailureHandler; @Autowired private ImageCodeValidateFilter imageCodeValidateFilter; @Bean public BCryptPasswordEncoder passwordEncoder () { return new BCryptPasswordEncoder(); } @Override protected void configure (AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder()); } @Override protected void configure (HttpSecurity http) throws Exception { http.formLogin() .loginPage("/login/page" ) .loginProcessingUrl("/login/form" ) .usernameParameter("name" ) .passwordParameter("pwd" ) .successHandler(authenticationSuccessHandler) .failureHandler(authenticationFailureHandler); http.authorizeRequests() .antMatchers("/login/page" , "/code/image" ).permitAll() .antMatchers("/admin/**" ).hasRole("ADMIN" ) .antMatchers("/user/**" ).hasAuthority("ROLE_USER" ) .anyRequest().authenticated(); http.csrf().disable(); http.addFilterBefore(imageCodeValidateFilter, UsernamePasswordAuthenticationFilter.class); } @Override public void configure (WebSecurity web) throws Exception { web.ignoring().antMatchers("/**/*.css" , "/**/*.js" , "/**/*.png" , "/**/*.jpg" , "/**/*.jpeg" ); } }
⭐ 测试
访问localhost:8080/login/page
,等待 60 秒后,输入正确的用户名、密码和验证码:
验证码过期,重定向到localhost:8080/login/page?error
,显示错误信息:
手机短信验证码
一般登录除了用户名、密码登录,还可以使用手机短信验证码登录,Spring Security
默认没有实现手机短信验证码的功能,所以需要我们自己实现。
实现流程分析
手机短信验证码登录和前面的带有图形验证码的用户名、密码登录流程类似,红色标记的部分是需要我们自定义实现的类。
我们首先分析下带有图形验证码的用户名、密码登录流程:
在 ImageCodeValidateFilter 过滤器中校验用户输入的图形验证码是否正确。
在 UsernamePasswordAuthenticationFilter 过滤器中将 username 和 password 生成一个用于认证的 Token(UsernamePasswordAuthenticationToken),并将其传递给 ProviderManager 接口的实现类 AuthenticationManager。
AuthenticationManager 管理器寻找到一个合适的处理器 DaoAuthenticationProvider 来处理 UsernamePasswordAuthenticationToken。
DaoAuthenticationProvider 通过 UserDetailsService 接口的实现类 CustomUserDetailsService 从数据库中获取指定 username 的相关信息,并校验用户输入的 password。如果校验成功,那就认证通过,用户信息类对象 Authentication 标记为已认证。
认证通过后,将已认证的用户信息对象 Authentication 存储到 SecurityContextHolder 中,最终存储到 Session 中。
仿照上述流程,我们分析手机短信验证码登录流程:
仿照 ImageCodeValidateFilter 过滤器设计 MobileVablidateFilter 过滤器,该过滤器用来校验用户输入手机短信验证码。
仿照 UsernamePasswordAuthenticationFilter 过滤器设计 MobileAuthenticationFilter 过滤器,该过滤器将用户输入的手机号生成一个 Token(MobileAuthenticationToken),并将其传递给 ProviderManager 接口的实现类 AuthenticationManager。
AuthenticationManager 管理器寻找到一个合适的处理器 MobileAuthenticationProvider 来处理 MobileAuthenticationToken,该处理器是仿照 DaoAuthenticationProvider 进行设计的。
MobileAuthenticationProvider 通过 UserDetailsService 接口的实现类 MobileUserDetailsService 从数据库中获取指定手机号对应的用户信息,此处不需要进行任何校验,直接将用户信息类对象 Authentication 标记为已认证。
认证通过后,将已认证的用户信息对象 Authentication 存储到 SecurityContextHolder 中,最终存储到 Session 中,此处的操作不需要我们编写。
最后通过自定义配置类 MobileAuthenticationConfig 组合上述组件,并添加到安全配置类 SpringSecurityConfig 中。
模拟发送短信验证码
✏️ 在 UserMapper 接口中添加根据 mobile 查询用户的方法
1 2 3 4 5 public interface UserMapper { @Select("select * from user where mobile = #{mobile}") User selectByMobile (String mobile) ; }
✏️ 创建 UserService 类,编写判断指定 mobile 是否存在的方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 package com.example.service;import com.example.mapper.UserMapper;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.stereotype.Service;@Service public class UserService { @Autowired private UserMapper userMapper; public boolean isExistByMobile (String mobile) { return userMapper.selectByMobile(mobile) != null ; } }
✏️ 创建 MobileCodeSendService 类,模拟手机短信验证码发送服务
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 package com.example.service;import lombok.extern.slf4j.Slf4j;import org.springframework.stereotype.Service;@Service @Slf4j public class MobileCodeSendService { public void send (String mobile, String code) { String sendContent = String.format("验证码为 %s,请勿泄露!" , code); log.info("向手机号 " + mobile + " 发送短信:" + sendContent); } }
✏️ 在 LoginController 中添加手机短信验证码相关的 Controller 方法
1 2 3 4 5 public class Constants { public static final String MOBILE_SESSION_KEY = "MOBILE_SESSION_KEY" ; }
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 @Controller public class LoginController { @Autowired private MobileCodeSendService mobileCodeSendService; @Autowired private UserService userService; @GetMapping("/mobile/page") public String mobileLoginPage () { return "login-mobile" ; } @GetMapping("/code/mobile") @ResponseBody public Object sendMoblieCode (String mobile, HttpServletRequest request) { String code = RandomStringUtils.randomNumeric(4 ); CheckCode mobileCode = new CheckCode(code, 10 * 60 ); request.getSession().setAttribute(Constants.MOBILE_SESSION_KEY, mobileCode); if (!userService.isExistByMobile(mobile)) { return new ResultData<>(1 , "该手机号不存在!" ); } mobileCodeSendService.send(mobile, code); return new ResultData<>(0 , "发送成功!" ); } }
✏️ 编写手机短信验证码登录页面 login-mobile.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 <!DOCTYPE html > <html lang ="en" xmlns:th ="http://www.thymeleaf.org" > <head > <meta charset ="UTF-8" > <title > 登录页面</title > <script src ="https://s3.pstatp.com/cdn/expire-1-M/jquery/3.3.1/jquery.min.js" > </script > </head > <body > <form method ="post" th:action ="@{/mobile/form}" > <input id ="mobile" name ="mobile" type ="text" placeholder ="手机号码" > <br > <div > <input name ="mobileCode" type ="text" placeholder ="验证码" > <button type ="button" id ="sendCode" > 获取验证码</button > </div > <div th:if ="${param.error}" > <span th:text ="${session.SPRING_SECURITY_LAST_EXCEPTION.message}" style ="color:red" > 用户名或密码错误</span > </div > <button type ="submit" > 登录</button > </form > <script > $("#sendCode" ).click(function ( ) { var mobile = $('#mobile' ).val().trim(); if (mobile == '' ) { alert("手机号不能为空" ); return ; } var url = "/code/mobile?mobile=" + mobile; $.get(url, function (data ) { alert(data.msg); }); }); </script > </body > </html >
✏️ 更改安全配置类 SpringSecurityConfig,设置访问/mobile/page
和/code/mobile
不需要任何权限
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @EnableWebSecurity public class SpringSecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure (HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/login/page" , "/code/image" ,"/mobile/page" , "/code/mobile" ).permitAll() .antMatchers("/admin/**" ).hasRole("ADMIN" ) .antMatchers("/user/**" ).hasAuthority("ROLE_USER" ) .anyRequest().authenticated(); } }
✏️ 测试
访问localhost:8080/mobile/page
,页面显示:
手机号码输入11111111111
,控制台输出:
1 向手机号 11111111111 发送短信:验证码为 2561 ,请勿泄露!
浏览器弹出窗口显示”发送成功“。
自定义认证流程配置
☕ 自定义短信验证码校验过滤器 MobileValidateFilter
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 package com.example.config.security.mobile;import com.example.config.security.CustomAuthenticationFailureHandler;import com.example.constans.Constants;import com.example.entity.CheckCode;import com.example.exception.ValidateCodeException;import org.apache.commons.lang3.StringUtils;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.stereotype.Component;import org.springframework.web.filter.OncePerRequestFilter;import javax.servlet.FilterChain;import javax.servlet.ServletException;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import javax.servlet.http.HttpSession;import java.io.IOException;@Component public class MobileCodeValidateFilter extends OncePerRequestFilter { private String codeParamter = "mobileCode" ; @Autowired private CustomAuthenticationFailureHandler authenticationFailureHandler; @Override protected void doFilterInternal (HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { if ("/mobile/form" .equals(request.getRequestURI()) && "POST" .equals(request.getMethod())) { try { validate(request); } catch (ValidateCodeException e) { authenticationFailureHandler.onAuthenticationFailure(request, response, e); return ; } } filterChain.doFilter(request, response); } private void validate (HttpServletRequest request) { String requestCode = request.getParameter(this .codeParamter); if (requestCode == null ) { requestCode = "" ; } requestCode = requestCode.trim(); HttpSession session = request.getSession(); CheckCode savedCode = (CheckCode) session.getAttribute(Constants.MOBILE_SESSION_KEY); if (savedCode != null ) { session.removeAttribute(Constants.MOBILE_SESSION_KEY); } if (StringUtils.isBlank(requestCode)) { throw new ValidateCodeException("验证码的值不能为空" ); } if (savedCode == null ) { throw new ValidateCodeException("验证码不存在" ); } if (savedCode.isExpried()) { throw new ValidateCodeException("验证码过期" ); } if (!requestCode.equalsIgnoreCase(savedCode.getCode())) { throw new ValidateCodeException("验证码输入错误" ); } } }
☕ 更改自定义失败处理器 CustomAuthenticationFailureHandler,原先的处理器在认证失败时,会直接重定向到/login/page?error
显示认证异常信息。现在我们有两种登录方式,应该进行以下处理:
带图形验证码的用户名、密码方式登录方式出现认证异常,重定向到/login/page?error
。
手机短信验证码方式登录出现认证异常,重定向到/mobile/page?error
。
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 package com.example.config.security;import com.example.entity.ResultData;import com.fasterxml.jackson.databind.ObjectMapper;import org.apache.commons.lang3.StringUtils;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.security.core.AuthenticationException;import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;import org.springframework.stereotype.Component;import javax.servlet.ServletException;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import java.io.IOException;@Component public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler { @Autowired private ObjectMapper objectMapper; @Override public void onAuthenticationFailure (HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException { String xRequestedWith = request.getHeader("x-requested-with" ); if ("XMLHttpRequest" .equals(xRequestedWith)) { response.setContentType("application/json;charset=utf-8" ); response.getWriter().write(objectMapper.writeValueAsString(new ResultData<>(1 , "认证失败!" ))); }else { String refer = request.getHeader("Referer" ); String lastUrl = StringUtils.substringBefore(refer, "?" ); super .setDefaultFailureUrl(lastUrl + "?error" ); super .onAuthenticationFailure(request, response, e); } } }
☕ 自定义短信验证码认证过滤器 MobileAuthenticationFilter,仿照 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 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 package com.example.config.security.mobile;import org.springframework.lang.Nullable;import org.springframework.security.authentication.AuthenticationServiceException;import org.springframework.security.core.Authentication;import org.springframework.security.core.AuthenticationException;import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;import org.springframework.security.web.util.matcher.AntPathRequestMatcher;import org.springframework.util.Assert;import javax.servlet.ServletException;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import java.io.IOException;public class MobileAuthenticationFilter extends AbstractAuthenticationProcessingFilter { private String mobileParamter = "mobile" ; private boolean postOnly = true ; protected MobileAuthenticationFilter () { super (new AntPathRequestMatcher("/mobile/form" , "POST" )); } @Override public Authentication attemptAuthentication (HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException { if (postOnly && !request.getMethod().equals("POST" )) { throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod()); }else { String mobile = request.getParameter(mobileParamter); if (mobile == null ) { mobile = "" ; } mobile = mobile.trim(); MobileAuthenticationToken authRequest = new MobileAuthenticationToken(mobile); this .setDetails(request, authRequest); return this .getAuthenticationManager().authenticate(authRequest); } } @Nullable protected String obtainMobile (HttpServletRequest request) { return request.getParameter(this .mobileParamter); } protected void setDetails (HttpServletRequest request, MobileAuthenticationToken authRequest) { authRequest.setDetails(this .authenticationDetailsSource.buildDetails(request)); } public void setMobileParameter (String mobileParamter) { Assert.hasText(mobileParamter, "Mobile par ameter must not be empty or null" ); this .mobileParamter = mobileParamter; } public void setPostOnly (boolean postOnly) { this .postOnly = postOnly; } public String getMobileParameter () { return mobileParamter; } }
☕ 自定义用户信息封装类 MobileAuthenticationToken,仿照 UsernamePasswordAuthenticationToken 类进行编写
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 package com.example.config.security.mobile;import org.springframework.security.authentication.AbstractAuthenticationToken;import org.springframework.security.core.GrantedAuthority;import java.util.Collection;public class MobileAuthenticationToken extends AbstractAuthenticationToken { private static final long serialVersionUID = 520L ; private final Object principal; public MobileAuthenticationToken (Object principal) { super ((Collection) null ); this .principal = principal; this .setAuthenticated(false ); } public MobileAuthenticationToken (Object principal, Collection<? extends GrantedAuthority> authorities) { super (authorities); this .principal = principal; super .setAuthenticated(true ); } @Override public Object getCredentials () { return null ; } @Override public Object getPrincipal () { return this .principal; } @Override public void setAuthenticated (boolean isAuthenticated) throws IllegalArgumentException { if (isAuthenticated) { throw new IllegalArgumentException("Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead" ); } else { super .setAuthenticated(false ); } } @Override public void eraseCredentials () { super .eraseCredentials(); } }
☕ 自定义短信验证码认证的处理器 MobileAuthenticationProvider,仿照 DaoAuthenticationProvider 处理器进行编写
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 package com.example.config.security.mobile;import org.springframework.context.support.MessageSourceAccessor;import org.springframework.security.authentication.*;import org.springframework.security.core.Authentication;import org.springframework.security.core.AuthenticationException;import org.springframework.security.core.SpringSecurityMessageSource;import org.springframework.security.core.userdetails.UserDetails;import org.springframework.security.core.userdetails.UserDetailsChecker;import org.springframework.security.core.userdetails.UserDetailsService;import org.springframework.util.Assert;public class MobileAuthenticationProvider implements AuthenticationProvider { private UserDetailsService userDetailsService; protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor(); private UserDetailsChecker authenticationChecks = new MobileAuthenticationProvider.DefaultAuthenticationChecks(); @Override public Authentication authenticate (Authentication authentication) throws AuthenticationException { Assert.isInstanceOf(MobileAuthenticationToken.class, authentication, () -> { return this .messages.getMessage("MobileAuthenticationProvider.onlySupports" , "Only MobileAuthenticationToken is supported" ); }); String mobile = authentication.getPrincipal() == null ? "NONE_PROVIDED" : authentication.getName(); UserDetails user = this .userDetailsService.loadUserByUsername(mobile); if (user == null ) { throw new AuthenticationServiceException("该手机号未注册" ); } this .authenticationChecks.check(user); MobileAuthenticationToken result = new MobileAuthenticationToken(user, user.getAuthorities()); result.setDetails(authentication.getDetails()); return result; } @Override public boolean supports (Class<?> authentication) { return MobileAuthenticationToken.class.isAssignableFrom(authentication); } public void setUserDetailsService (UserDetailsService userDetailsService) { this .userDetailsService = userDetailsService; } public UserDetailsService getUserDetailsService () { return userDetailsService; } private class DefaultAuthenticationChecks implements UserDetailsChecker { private DefaultAuthenticationChecks () { } @Override public void check (UserDetails user) { if (!user.isAccountNonLocked()) { throw new LockedException(MobileAuthenticationProvider.this .messages.getMessage("AbstractUserDetailsAuthenticationProvider.locked" , "User account is locked" )); } else if (!user.isEnabled()) { throw new DisabledException(MobileAuthenticationProvider.this .messages.getMessage("AbstractUserDetailsAuthenticationProvider.disabled" , "User is disabled" )); } else if (!user.isAccountNonExpired()) { throw new AccountExpiredException(MobileAuthenticationProvider.this .messages.getMessage("AbstractUserDetailsAuthenticationProvider.expired" , "User account has expired" )); } else if (!user.isCredentialsNonExpired()) { throw new CredentialsExpiredException(MobileAuthenticationProvider.this .messages.getMessage("AbstractUserDetailsAuthenticationProvider.credentialsExpired" , "User credentials have expired" )); } } } }
☕ 自定义 MobileUserDetailsService 类,MobileAuthenticationProvider 处理器传入的 UserDetailsService 对象的类型需要我们自定义
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 package com.example.service;import com.example.entity.User;import com.example.mapper.UserMapper;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.security.core.authority.AuthorityUtils;import org.springframework.security.core.userdetails.UserDetails;import org.springframework.security.core.userdetails.UserDetailsService;import org.springframework.security.core.userdetails.UsernameNotFoundException;import org.springframework.stereotype.Service;@Service public class MobileUserDetailsService implements UserDetailsService { @Autowired private UserMapper userMapper; @Override public UserDetails loadUserByUsername (String mobile) throws UsernameNotFoundException { User user = userMapper.selectByMobile(mobile); if (user == null ) { throw new UsernameNotFoundException("用户不存在" ); } user.setAuthorities(AuthorityUtils.commaSeparatedStringToAuthorityList(user.getRoles())); return user; } }
☕ 自定义短信验证码认证方式配置类 MobileAuthenticationConfig,将上述组件进行管理
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 package com.example.config.security.mobile;import com.example.config.security.CustomAuthenticationFailureHandler;import com.example.config.security.CustomAuthenticationSuccessHandler;import com.example.service.MobileUserDetailsService;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.security.authentication.AuthenticationManager;import org.springframework.security.config.annotation.SecurityConfigurerAdapter;import org.springframework.security.config.annotation.web.builders.HttpSecurity;import org.springframework.security.web.DefaultSecurityFilterChain;import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;import org.springframework.stereotype.Component;@Component public class MobileAuthenticationConfig extends SecurityConfigurerAdapter < ;DefaultSecurityFilterChain, HttpSecurity> { @Autowired private CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler; @Autowired private CustomAuthenticationFailureHandler customAuthenticationFailureHandler; @Autowired private MobileCodeValidateFilter mobileCodeValidaterFilter; @Autowired private MobileUserDetailsService userDetailsService; @Override public void configure (HttpSecurity http) throws Exception { MobileAuthenticationFilter filter = new MobileAuthenticationFilter(); AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class); filter.setAuthenticationManager(authenticationManager); filter.setAuthenticationSuccessHandler(customAuthenticationSuccessHandler); filter.setAuthenticationFailureHandler(customAuthenticationFailureHandler); SessionAuthenticationStrategy sessionAuthenticationStrategy = http.getSharedObject(SessionAuthenticationStrategy.class); filter.setSessionAuthenticationStrategy(sessionAuthenticationStrategy); http.addFilterBefore(mobileCodeValidaterFilter, UsernamePasswordAuthenticationFilter.class); http.addFilterAfter(filter, UsernamePasswordAuthenticationFilter.class); MobileAuthenticationProvider provider = new MobileAuthenticationProvider(); provider.setUserDetailsService(userDetailsService); http.authenticationProvider(provider); } }
☕ 将上述自定义配置类 MobileAuthenticationConfig 绑定到最终的安全配置类 SpringSecurityConfig 中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @EnableWebSecurity public class SpringSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private MobileAuthenticationConfig mobileAuthenticationConfig; @Override protected void configure (HttpSecurity http) throws Exception { http.apply(mobileAuthenticationConfig); } }
完整的安全配置类如下:
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 package com.example.config;import com.example.config.security.CustomAuthenticationFailureHandler;import com.example.config.security.CustomAuthenticationSuccessHandler;import com.example.config.security.ImageCodeValidateFilter;import com.example.config.security.mobile.MobileAuthenticationConfig;import com.example.service.CustomUserDetailsService;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.context.annotation.Bean;import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;import org.springframework.security.config.annotation.web.builders.HttpSecurity;import org.springframework.security.config.annotation.web.builders.WebSecurity;import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;@EnableWebSecurity public class SpringSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private CustomUserDetailsService userDetailsService; @Autowired private CustomAuthenticationSuccessHandler authenticationSuccessHandler; @Autowired private CustomAuthenticationFailureHandler authenticationFailureHandler; @Autowired private ImageCodeValidateFilter imageCodeValidateFilter; @Autowired private MobileAuthenticationConfig mobileAuthenticationConfig; @Bean public BCryptPasswordEncoder passwordEncoder () { return new BCryptPasswordEncoder(); } @Override protected void configure (AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder()); } @Override protected void configure (HttpSecurity http) throws Exception { http.formLogin() .loginPage("/login/page" ) .loginProcessingUrl("/login/form" ) .usernameParameter("name" ) .passwordParameter("pwd" ) .successHandler(authenticationSuccessHandler) .failureHandler(authenticationFailureHandler); http.authorizeRequests() .antMatchers("/login/page" , "/code/image" ,"/mobile/page" , "/code/mobile" ).permitAll() .antMatchers("/admin/**" ).hasRole("ADMIN" ) .antMatchers("/user/**" ).hasAuthority("ROLE_USER" ) .anyRequest().authenticated(); http.csrf().disable(); http.addFilterBefore(imageCodeValidateFilter, UsernamePasswordAuthenticationFilter.class); http.apply(mobileAuthenticationConfig); } @Override public void configure (WebSecurity web) throws Exception { web.ignoring().antMatchers("/**/*.css" , "/**/*.js" , "/**/*.png" , "/**/*.jpg" , "/**/*.jpeg" ); } }
☕ 测试
访问localhost:8080/mobile/page
:
手机号码输入11111111111
,控制台输出:
向手机号 11111111111 发送短信:验证码为 6979,请勿泄露!
浏览器弹出窗口显示”发送成功“,输入正确短信验证码进行认证之后,浏览器重定向到/index
:
本文转载自:[呵呵233 ]《Spring Security 入门(二):图形验证码和手机短信验证码 》