图形验证码
SpringSecurity 实现的用户名、密码登录是在 UsernamePasswordAuthenticationFilter 过滤器进行认证的,而图形验证码一般是在用户名、密码认证之前进行验证的,所以需要在 UsernamePasswordAuthenticationFilter 过滤器之前添加一个自定义过滤器 ImageCodeValidateFilter,用来校验用户输入的图形验证码是否正确。
实现逻辑
自定义过滤器继承 OncePerRequestFilter 类,该类是 Spring 提供的在一次请求中只会调用一次的 filter,确保每个请求只会进入过滤器一次,避免了多次执行的情况
自定义的过滤器 ImageCodeValidateFilter 首先会判断请求是否为 POST 方式的登录表单提交请求,如果是就将其拦截进行图形验证码校验。如果验证错误,会抛出自定义异常类对象 ValidateCodeException,该异常类需要继承 AuthenticationException 类。在自定义过滤器中,我们需要手动捕获自定义异常类对象,并将捕获到自定义异常类对象交给自定义失败处理器进行处理
添加验证码配置
<!-- 图形验证码工具 kaptcha -->
<dependency><groupId>com.github.penggle</groupId><artifactId>kaptcha</artifactId><version>2.3.2</version>
</dependency>
/*** 图形验证码的配置类*/
@Configuration
public class KaptchaConfig {@Beanpublic 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;}
}
提供验证码接口
public class CheckCode implements Serializable {private String code; // 验证码字符private LocalDateTime expireTime; // 过期时间/*** @param code 验证码字符* @param expireTime 过期时间,单位秒*/public CheckCode(String code, int expireTime) {this.code = code;this.expireTime = LocalDateTime.now().plusSeconds(expireTime);}public CheckCode(String code) {// 默认验证码 60 秒后过期this(code, 60);}// 是否过期public boolean isExpried() {return this.expireTime.isBefore(LocalDateTime.now());}public String getCode() {return this.code;}
}
@Controller
public class LoginController {// Session 中存储图形验证码的属性名public static final String KAPTCHA_SESSION_KEY = "KAPTCHA_SESSION_KEY";@Autowiredprivate DefaultKaptcha defaultKaptcha;@GetMapping("/login/page")public String login() {return "login";}@GetMapping("/code/image")public void imageCode(HttpServletRequest request, HttpServletResponse response) throws IOException {// 创建验证码文本String capText = defaultKaptcha.createText();// 创建验证码图片BufferedImage image = defaultKaptcha.createImage(capText);// 将验证码文本放进 Session 中CheckCode code = new CheckCode(capText);request.getSession().setAttribute(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());}
}
<!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="username" placeholder="用户名"><br><input type="password" name="password" 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>
自定义验证码过滤器
/*** 自定义验证码校验错误的异常类,继承 AuthenticationException*/
public class ValidateCodeException extends AuthenticationException {public ValidateCodeException(String msg, Throwable t) {super(msg, t);}public ValidateCodeException(String msg) {super(msg);}
}
@Component
public class ImageCodeValidateFilter extends OncePerRequestFilter {private String codeParamter = "imageCode"; // 前端输入的图形验证码参数名@Autowiredprivate AuthenticationFailureHandlerImpl authenticationFailureHandler; // 自定义认证失败处理器@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {// 非 POST 方式的表单提交请求不校验图形验证码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();// 获取 SessionHttpSession session = request.getSession();// 获取存储在 Session 里的验证码值CheckCode savedCode = (CheckCode) session.getAttribute(LoginController.KAPTCHA_SESSION_KEY);if (savedCode != null) {// 随手清除验证码,无论是失败,还是成功。客户端应在登录失败时刷新验证码session.removeAttribute(LoginController.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("验证码输入错误");}}
}
@Component
public class UserDetailServiceImpl implements UserDetailsService {@Autowiredprivate PasswordEncoder passwordEncoder;@Overridepublic UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {if ("root".equals(username)) {return new User(username, passwordEncoder.encode("123"), AuthorityUtils.createAuthorityList("admin"));} else {throw new UsernameNotFoundException("用户名不存在");}}
}
设置过滤器顺序
@Configuration
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {@Autowiredprivate AuthenticationSuccessHandlerImpl authenticationSuccessHandler;@Autowiredprivate AuthenticationFailureHandlerImpl authenticationFailureHandler;@Autowiredprivate ImageCodeValidateFilter imageCodeValidateFilter; // 自定义过滤器(图形验证码校验)@Autowiredprivate UserDetailServiceImpl userDetailService;/*** 密码编码器,密码不能明文存储*/@Beanpublic BCryptPasswordEncoder passwordEncoder() {// 使用 BCryptPasswordEncoder 密码编码器,该编码器会将随机产生的 salt 混入最终生成的密文中return new BCryptPasswordEncoder();}@Overrideprotected void configure(AuthenticationManagerBuilder auth) throws Exception {auth.userDetailsService(userDetailService).passwordEncoder(passwordEncoder());}/*** 定制基于 HTTP 请求的用户访问控制*/@Overrideprotected void configure(HttpSecurity http) throws Exception {// 启动 form 表单登录http.formLogin()// 设置登录页面的访问路径,默认为 /login,GET 请求;该路径不设限访问.loginPage("/login/page")// 设置登录表单提交路径,默认为 loginPage() 设置的路径,POST 请求.loginProcessingUrl("/login/form")// 使用自定义的认证成功和失败处理器.successHandler(authenticationSuccessHandler).failureHandler(authenticationFailureHandler);// 开启基于 HTTP 请求访问控制http.authorizeRequests()// 以下访问不需要任何权限,任何人都可以访问.antMatchers("/login/page", "/code/image").permitAll()// 其它任何请求访问都需要先通过认证.anyRequest().authenticated();// 关闭 csrf 防护http.csrf().disable();// 将自定义过滤器(图形验证码校验)添加到 UsernamePasswordAuthenticationFilter 之前http.addFilterBefore(imageCodeValidateFilter, UsernamePasswordAuthenticationFilter.class); }/*** 定制一些全局性的安全配置,例如:不拦截静态资源的访问*/@Overridepublic void configure(WebSecurity web) throws Exception {// 静态资源的访问不需要拦截,直接放行web.ignoring().antMatchers("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg");}
}
手机短信验证码
验证流程
带有图形验证码的用户名、密码登录流程:
- 在 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 中。
提供短信发送接口
@Controller
public class LoginController {// Session 中存储手机短信验证码的属性名public static final String MOBILE_SESSION_KEY = "MOBILE_SESSION_KEY";@GetMapping("/mobile/page")public String mobileLoginPage() { // 跳转到手机短信验证码登录页面return "login-mobile";}@GetMapping("/code/mobile")@ResponseBodypublic Object sendMoblieCode(HttpServletRequest request) { // 随机生成一个 4 位的验证码String code = RandomStringUtils.randomNumeric(4);// 将手机验证码文本存储在 Session 中,设置过期时间为 10 * 60sCheckCode mobileCode = new CheckCode(code, 10 * 60);request.getSession().setAttribute(MOBILE_SESSION_KEY, mobileCode);return mobileCode;}
}
<!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;}// /code/mobile?mobile=123123123var url = "/code/mobile?mobile=" + mobile;$.get(url, function(data){alert(data);});});</script>
</body>
</html>
自定义短信验证码校验过滤器
更改自定义失败处理器 CustomAuthenticationFailureHandler,原先的处理器在认证失败时,会直接重定向到/login/page?error 显示认证异常信息。现在我们有两种登录方式,应该进行以下处理:
- 带图形验证码的用户名、密码方式登录方式出现认证异常,重定向到/login/page?error
- 手机短信验证码方式登录出现认证异常,重定向到/mobile/page?error
/**
* 继承 SimpleUrlAuthenticationFailureHandler 处理器,该类是 failureUrl() 方法使用的认证失败处理器
*/
@Component
public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {@Overridepublic void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {String xRequestedWith = request.getHeader("x-requested-with");// 判断前端的请求是否为 ajax 请求if ("XMLHttpRequest".equals(xRequestedWith)) {// 认证成功,响应 JSON 数据response.setContentType("application/json;charset=utf-8");response.getWriter().write("认证失败");}else {// 用户名、密码方式登录出现认证异常,需要重定向到 /login/page?error// 手机短信验证码方式登录出现认证异常,需要重定向到 /mobile/page?error// 使用 Referer 获取当前登录表单提交请求是从哪个登录页面(/login/page 或 /mobile/page)链接过来的String refer = request.getHeader("Referer");String lastUrl = StringUtils.substringBefore(refer, "?");// 设置默认的重定向路径super.setDefaultFailureUrl(lastUrl + "?error");// 调用父类的 onAuthenticationFailure() 方法super.onAuthenticationFailure(request, response, e);}}
}
/*** 手机短信验证码校验*/
@Component
public class MobileCodeValidateFilter extends OncePerRequestFilter {private String codeParamter = "mobileCode"; // 前端输入的手机短信验证码参数名@Autowiredprivate CustomAuthenticationFailureHandler authenticationFailureHandler; // 自定义认证失败处理器@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {// 非 POST 方式的手机短信验证码提交请求不进行校验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();// 获取 SessionHttpSession session = request.getSession();// 获取 Session 中存储的手机短信验证码CheckCode savedCode = (CheckCode) session.getAttribute(LoginController.MOBILE_SESSION_KEY);if (savedCode != null) {// 随手清除验证码,无论是失败,还是成功。客户端应在登录失败时刷新验证码session.removeAttribute(LoginController.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("验证码输入错误");}}
}
自定义短信验证码认证过滤器
- 仿照 UsernamePasswordAuthenticationToken 类进行编写
- 仿照 UsernamePasswordAuthenticationFilter 过滤器进行编写
public class MobileAuthenticationToken extends AbstractAuthenticationToken {private static final long serialVersionUID = 520L;private final Object principal;/*** 认证前,使用该构造器进行封装信息*/public MobileAuthenticationToken(Object principal) {super(null); // 用户权限为 nullthis.principal = principal; // 前端传入的手机号this.setAuthenticated(false); // 标记为未认证}/*** 认证成功后,使用该构造器封装用户信息*/public MobileAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {super(authorities); // 用户权限集合this.principal = principal; // 封装认证用户信息的 UserDetails 对象,不再是手机号super.setAuthenticated(true); // 标记认证成功}@Overridepublic Object getCredentials() {// 由于使用手机短信验证码登录不需要密码,所以直接返回 nullreturn null;}@Overridepublic Object getPrincipal() {return this.principal;}@Overridepublic 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);}}@Overridepublic void eraseCredentials() {// 手机短信验证码认证方式不必去除额外的敏感信息,所以直接调用父类方法super.eraseCredentials();}
}
/*** 手机短信验证码认证过滤器,仿照 UsernamePasswordAuthenticationFilter 过滤器编写*/
public class MobileAuthenticationFilter extends AbstractAuthenticationProcessingFilter {private String mobileParamter = "mobile"; // 默认手机号参数名为 mobileprivate boolean postOnly = true; // 默认请求方式只能为 POSTprotected MobileAuthenticationFilter() {// 默认登录表单提交路径为 /mobile/form,POST 方式请求super(new AntPathRequestMatcher("/mobile/form", "POST"));}@Overridepublic Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {//(1) 默认情况下,如果请求方式不是 POST,会抛出异常if(postOnly && !request.getMethod().equals("POST")) {throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());}else {//(2) 获取请求携带的 mobileString mobile = request.getParameter(mobileParamter);if(mobile == null) {mobile = "";}mobile = mobile.trim();//(3) 使用前端传入的 mobile 构造 Authentication 对象,标记该对象未认证// MobileAuthenticationToken 是我们自定义的 Authentication 类,后续介绍MobileAuthenticationToken authRequest = new MobileAuthenticationToken(mobile);//(4) 将请求中的一些属性信息设置到 Authentication 对象中,如:remoteAddress,sessionIdthis.setDetails(request, authRequest);//(5) 调用 ProviderManager 类的 authenticate() 方法进行身份认证return this.getAuthenticationManager().authenticate(authRequest);}}@Nullableprotected 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;}
}
自定义短信验证码认证处理器
- 仿照 DaoAuthenticationProvider 处理器进行编写
- MobileAuthenticationProvider 处理器传入的 UserDetailsService 对象的类型需要我们自定义
public class MobileAuthenticationProvider implements AuthenticationProvider {private UserDetailsService userDetailsService;protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();private UserDetailsChecker authenticationChecks = new MobileAuthenticationProvider.DefaultAuthenticationChecks();/*** 处理认证*/@Overridepublic Authentication authenticate(Authentication authentication) throws AuthenticationException {//(1) 如果入参的 Authentication 类型不是 MobileAuthenticationToken,抛出异常Assert.isInstanceOf(MobileAuthenticationToken.class, authentication, () -> {return this.messages.getMessage("MobileAuthenticationProvider.onlySupports", "Only MobileAuthenticationToken is supported");});// 获取手机号String mobile = authentication.getPrincipal() == null ? "NONE_PROVIDED" : authentication.getName();//(2) 根据手机号从数据库中查询用户信息UserDetails user = this.userDetailsService.loadUserByUsername(mobile);if (user == null) {//(3) 未查询到用户信息,抛出异常throw new AuthenticationServiceException("该手机号未注册");}//(4) 检查账号是否锁定、账号是否可用、账号是否过期、密码是否过期this.authenticationChecks.check(user);//(5) 查询到了用户信息,则认证通过,构建标记认证成功用户信息类对象 AuthenticationTokenMobileAuthenticationToken result = new MobileAuthenticationToken(user, user.getAuthorities());// 需要把认证前 Authentication 对象中的 details 信息加入认证后的 Authenticationresult.setDetails(authentication.getDetails());return result;}/*** ProviderManager 管理器通过此方法来判断是否采用此 AuthenticationProvider 类* 来处理由 AuthenticationFilter 过滤器传入的 Authentication 对象*/@Overridepublic boolean supports(Class<?> authentication) {// isAssignableFrom 返回 true 当且仅当调用者为父类.class,参数为本身或者其子类.class// ProviderManager 会获取 MobileAuthenticationFilter 过滤器传入的 Authentication 类型// 所以当且仅当 authentication 的类型为 MobileAuthenticationToken 才返回 truereturn MobileAuthenticationToken.class.isAssignableFrom(authentication);}/*** 此处传入自定义的 MobileUserDetailsSevice 对象*/public void setUserDetailsService(UserDetailsService userDetailsService) {this.userDetailsService = userDetailsService;}public UserDetailsService getUserDetailsService() {return userDetailsService;}/*** 检查账号是否锁定、账号是否可用、账号是否过期、密码是否过期*/private class DefaultAuthenticationChecks implements UserDetailsChecker {private DefaultAuthenticationChecks() {}@Overridepublic 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"));}}}
}
@Service
public class MobileUserDetailsService implements UserDetailsService {@Autowiredprivate UserMapper userMapper;@Overridepublic UserDetails loadUserByUsername(String mobile) throws UsernameNotFoundException {//(1) 从数据库尝试读取该用户User user = userMapper.selectByMobile(mobile);// 用户不存在,抛出异常if (user == null) {throw new UsernameNotFoundException("用户不存在");}//(2) 将数据库形式的 roles 解析为 UserDetails 的权限集合// AuthorityUtils.commaSeparatedStringToAuthorityList() 是 Spring Security 提供的方法,用于将逗号隔开的权限集字符串切割为可用权限对象列表user.setAuthorities(AuthorityUtils.commaSeparatedStringToAuthorityList(user.getRoles()));//(3) 返回 UserDetails 对象return user;}
}
自定义短信验证码认证方式配置类
- 将上述组件进行管理,仿照 SecurityConfigurerAdapter类进行编写
- 绑定到最终的安全配置类 SpringSecurityConfig 中
@Component
public class MobileAuthenticationConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {@Autowiredprivate CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler; // 自定义认证成功处理器@Autowiredprivate CustomAuthenticationFailureHandler customAuthenticationFailureHandler; // 自定义认证失败处理器@Autowiredprivate MobileCodeValidateFilter mobileCodeValidaterFilter; // 手机短信验证码校验过滤器@Autowiredprivate MobileUserDetailsService userDetailsService; // 手机短信验证方式的 UserDetail@Overridepublic void configure(HttpSecurity http) throws Exception {//(1) 将短信验证码认证的自定义过滤器绑定到 HttpSecurity 中//(1.1) 创建手机短信验证码认证过滤器的实例 filerMobileAuthenticationFilter filter = new MobileAuthenticationFilter();//(1.2) 设置 filter 使用 AuthenticationManager(ProviderManager 接口实现类) 认证管理器// 多种登录方式应该使用同一个认证管理器实例,所以获取 Spring 容器中已经存在的 AuthenticationManager 实例AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);filter.setAuthenticationManager(authenticationManager);//(1.3) 设置 filter 使用自定义成功和失败处理器filter.setAuthenticationSuccessHandler(customAuthenticationSuccessHandler);filter.setAuthenticationFailureHandler(customAuthenticationFailureHandler);//(1.4) 设置 filter 使用 SessionAuthenticationStrategy 会话管理器// 多种登录方式应该使用同一个会话管理器实例,获取 Spring 容器已经存在的 SessionAuthenticationStrategy 实例SessionAuthenticationStrategy sessionAuthenticationStrategy = http.getSharedObject(SessionAuthenticationStrategy.class);filter.setSessionAuthenticationStrategy(sessionAuthenticationStrategy);//(1.5) 在 UsernamePasswordAuthenticationFilter 过滤器之前添加 MobileCodeValidateFilter 过滤器// 在 UsernamePasswordAuthenticationFilter 过滤器之后添加 MobileAuthenticationFilter 过滤器http.addFilterBefore(mobileCodeValidaterFilter, UsernamePasswordAuthenticationFilter.class);http.addFilterAfter(filter, UsernamePasswordAuthenticationFilter.class);//(2) 将自定义的 MobileAuthenticationProvider 处理器绑定到 HttpSecurity 中//(2.1) 创建手机短信验证码认证过滤器的 AuthenticationProvider 实例,并指定所使用的 UserDetailsServiceMobileAuthenticationProvider provider = new MobileAuthenticationProvider();provider.setUserDetailsService(userDetailsService);//(2.2) 将该 AuthenticationProvider 实例绑定到 HttpSecurity 中http.authenticationProvider(provider);}
}
@Configuration
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {@Autowiredprivate UserDetailServiceImpl userDetailsService;@Autowiredprivate CustomAuthenticationSuccessHandler authenticationSuccessHandler; // 自定义认证成功处理器@Autowiredprivate CustomAuthenticationFailureHandler authenticationFailureHandler; // 自定义认证失败处理器@Autowiredprivate ImageCodeValidateFilter imageCodeValidateFilter; // 自定义过滤器(图形验证码校验)@Autowiredprivate MobileAuthenticationConfig mobileAuthenticationConfig; // 手机短信验证码认证方式的配置类/*** 密码编码器,密码不能明文存储*/@Beanpublic BCryptPasswordEncoder passwordEncoder() {// 使用 BCryptPasswordEncoder 密码编码器,该编码器会将随机产生的 salt 混入最终生成的密文中return new BCryptPasswordEncoder();}/*** 定制用户认证管理器来实现用户认证*/@Overrideprotected void configure(AuthenticationManagerBuilder auth) throws Exception { // 不再使用内存方式存储用户认证信息,而是动态从数据库中获取auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());}/*** 定制基于 HTTP 请求的用户访问控制*/@Overrideprotected void configure(HttpSecurity http) throws Exception {// 启动 form 表单登录http.formLogin()// 设置登录页面的访问路径,默认为 /login,GET 请求;该路径不设限访问.loginPage("/login/page")// 设置登录表单提交路径,默认为 loginPage() 设置的路径,POST 请求.loginProcessingUrl("/login/form")// 使用自定义的认证成功和失败处理器.successHandler(authenticationSuccessHandler).failureHandler(authenticationFailureHandler);// 开启基于 HTTP 请求访问控制http.authorizeRequests()// 以下访问不需要任何权限,任何人都可以访问.antMatchers("/login/page", "/code/image","/mobile/page", "/code/mobile").permitAll()// 其它任何请求访问都需要先通过认证.anyRequest().authenticated();// 关闭 csrf 防护http.csrf().disable();// 将自定义过滤器(图形验证码校验)添加到 UsernamePasswordAuthenticationFilter 之前http.addFilterBefore(imageCodeValidateFilter, UsernamePasswordAuthenticationFilter.class);// 将手机短信验证码认证的配置与当前的配置绑定http.apply(mobileAuthenticationConfig);}/*** 定制一些全局性的安全配置,例如:不拦截静态资源的访问*/@Overridepublic void configure(WebSecurity web) throws Exception {// 静态资源的访问不需要拦截,直接放行web.ignoring().antMatchers("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg");}
}