Spring Security 入门(二):图形验证码和手机短信验证码
本文在前文 的基础上介绍图形验证码和手机短信验证码登录的实现。
图形验证码
在用户登录时,一般通过表单的方式进行登录都会要求用户输入验证码,Spring Security
默认没有实现图形验证码的功能,所以需要我们自己实现。
实现流程分析
前文中实现的用户名、密码登录是在UsernamePasswordAuthenticationFilter
过滤器进行认证的,而图形验证码一般是在用户名、密码认证之前进行验证的,所以需要在UsernamePasswordAuthenticationFilter
过滤器之前添加一个自定义过滤器 ImageCodeValidateFilter,用来校验用户输入的图形验证码是否正确。自定义过滤器继承 OncePerRequestFilter 类,该类是 Spring 提供的在一次请求中只会调用一次的 filter。
自定义的过滤器 ImageCodeValidateFilter 首先会判断请求是否为 POST 方式的登录表单提交请求,如果是就将其拦截进行图形验证码校验。如果验证错误,会抛出自定义异常类对象 ValidateCodeException,该异常类需要继承 AuthenticationException 类。在自定义过滤器中,我们需要手动捕获自定义异常类对象,并将捕获到自定义异常类对象交给自定义失败处理器进行处理。
kpatcha 使用
Kaptcha 是谷歌提供的生成图形验证码的工具,参考地址为:https://github.com/penggle/kaptcha,依赖如下:
com.github.penggle
kaptcha
2.3.2
? 创建 KaptchaConfig 配置类
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
package com.example.entity;
import java.io.Serializable;
import java.time.LocalDateTime;
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;
}
}
? 在 LoginController 中添加获取图形验证码的 Controller 方法
package com.example.constans;
public class Constants {
// Session 中存储图形验证码的属性名
public static final String KAPTCHA_SESSION_KEY = "KAPTCHA_SESSION_KEY";
}
@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);
// 将验证码文本放进 Session 中
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 中添加验证码功能
登录
表单登录
? 更改安全配置类 SpringSecurityConfig,设置访问/code/image
不需要任何权限
@EnableWebSecurity // 开启 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
//...
/**
* 定制基于 HTTP 请求的用户访问控制
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
//...
// 开启基于 HTTP 请求访问控制
http.authorizeRequests()
// 以下访问不需要任何权限,任何人都可以访问
.antMatchers("/login/page", "/code/image").permitAll()
// 以下访问需要 ROLE_ADMIN 权限
.antMatchers("/admin/**").hasRole("ADMIN")
// 以下访问需要 ROLE_USER 权限
.antMatchers("/user/**").hasAuthority("ROLE_USER")
// 其它任何请求访问都需要先通过认证
.anyRequest().authenticated();
//...
}
//...
}
? 测试
访问localhost:8080/login/page
,出现图形验证的信息
自定义验证码过滤器
?? 创建自定义异常类 ValidateCodeException
package com.example.exception;
import org.springframework.security.core.AuthenticationException;
/**
* 自定义验证码校验错误的异常类,继承 AuthenticationException
*/
public class ValidateCodeException extends AuthenticationException {
public ValidateCodeException(String msg, Throwable t) {
super(msg, t);
}
public ValidateCodeException(String msg) {
super(msg);
}
}
? 自定义图形验证码校验过滤器 ImageCodeValidateFilter
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 {
// 非 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();
// 获取 Session
HttpSession session = request.getSession();
// 获取存储在 Session 里的验证码值
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,将自定义过滤器添加过滤器链中
@EnableWebSecurity // 开启 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
//...
@Autowired
private ImageCodeValidateFilter imageCodeValidateFilter; // 自定义过滤器(图形验证码校验)
//...
/**
* 定制基于 HTTP 请求的用户访问控制
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
//...
// 将自定义过滤器(图形验证码校验)添加到 UsernamePasswordAuthenticationFilter 之前
http.addFilterBefore(imageCodeValidateFilter, UsernamePasswordAuthenticationFilter.class);
}
//...
}
完整的安全配置类 SpringSecurityConfig 如下:
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 // 开启 MVC Security 安全配置
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() {
// 使用 BCryptPasswordEncoder 密码编码器,该编码器会将随机产生的 salt 混入最终生成的密文中
return new BCryptPasswordEncoder();
}
/**
* 定制用户认证管理器来实现用户认证
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 采用内存存储方式,用户认证信息存储在内存中
// auth.inMemoryAuthentication()
// .withUser("admin").password(passwordEncoder()
// .encode("123456")).roles("ROLE_ADMIN");
// 不再使用内存方式存储用户认证信息,而是动态从数据库中获取
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
/**
* 定制基于 HTTP 请求的用户访问控制
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
// 启动 form 表单登录
http.formLogin()
// 设置登录页面的访问路径,默认为 /login,GET 请求;该路径不设限访问
.loginPage("/login/page")
// 设置登录表单提交路径,默认为 loginPage() 设置的路径,POST 请求
.loginProcessingUrl("/login/form")
// 设置登录表单中的用户名参数,默认为 username
.usernameParameter("name")
// 设置登录表单中的密码参数,默认为 password
.passwordParameter("pwd")
// 认证成功处理,如果存在原始访问路径,则重定向到该路径;如果没有,则重定向 /index
//.defaultSuccessUrl("/index")
// 认证失败处理,重定向到指定地址,默认为 loginPage() + ?error;该路径不设限访问
//.failureUrl("/login/page?error");
// 不再使用 defaultSuccessUrl() 和 failureUrl() 方法进行认证成功和失败处理,
// 使用自定义的认证成功和失败处理器
.successHandler(authenticationSuccessHandler)
.failureHandler(authenticationFailureHandler);
// 开启基于 HTTP 请求访问控制
http.authorizeRequests()
// 以下访问不需要任何权限,任何人都可以访问
.antMatchers("/login/page", "/code/image").permitAll()
// 以下访问需要 ROLE_ADMIN 权限
.antMatchers("/admin/**").hasRole("ADMIN")
// 以下访问需要 ROLE_USER 权限
.antMatchers("/user/**").hasAuthority("ROLE_USER")
// 其它任何请求访问都需要先通过认证
.anyRequest().authenticated();
// 关闭 csrf 防护
http.csrf().disable();
// 将自定义过滤器(图形验证码校验)添加到 UsernamePasswordAuthenticationFilter 之前
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 查询用户的方法
public interface UserMapper {
//...
@Select("select * from user where mobile = #{mobile}")
User selectByMobile(String mobile);
}
?? 创建 UserService 类,编写判断指定 mobile 是否存在的方法
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;
/**
* 判断指定 mobile 是否存在
*/
public boolean isExistByMobile(String mobile) {
return userMapper.selectByMobile(mobile) != null;
}
}
?? 创建 MobileCodeSendService 类,模拟手机短信验证码发送服务
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 方法
public class Constants {
//...
// Session 中存储手机短信验证码的属性名
public static final String MOBILE_SESSION_KEY = "MOBILE_SESSION_KEY";
}
@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) {
// 随机生成一个 4 位的验证码
String code = RandomStringUtils.randomNumeric(4);
// 将手机验证码文本存储在 Session 中,设置过期时间为 10 * 60s
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
登录页面
?? 更改安全配置类 SpringSecurityConfig,设置访问/mobile/page
和/code/mobile
不需要任何权限
@EnableWebSecurity // 开启 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
//...
/**
* 定制基于 HTTP 请求的用户访问控制
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
//...
// 开启基于 HTTP 请求访问控制
http.authorizeRequests()
// 以下访问不需要任何权限,任何人都可以访问
.antMatchers("/login/page", "/code/image","/mobile/page", "/code/mobile").permitAll()
// 以下访问需要 ROLE_ADMIN 权限
.antMatchers("/admin/**").hasRole("ADMIN")
// 以下访问需要 ROLE_USER 权限
.antMatchers("/user/**").hasAuthority("ROLE_USER")
// 其它任何请求访问都需要先通过认证
.anyRequest().authenticated();
//...
}
//...
}
?? 测试
访问localhost:8080/mobile/page
,页面显示:
手机号码输入11111111111
,控制台输出:
向手机号 11111111111 发送短信:验证码为 2561,请勿泄露!
浏览器弹出窗口显示”发送成功“。
自定义认证流程配置
?? 自定义短信验证码校验过滤器 MobileValidateFilter
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 {
// 非 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();
// 获取 Session
HttpSession session = request.getSession();
// 获取 Session 中存储的手机短信验证码
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
。
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;
/**
* 继承 SimpleUrlAuthenticationFailureHandler 处理器,该类是 failureUrl() 方法使用的认证失败处理器
*/
@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");
// 判断前端的请求是否为 ajax 请求
if ("XMLHttpRequest".equals(xRequestedWith)) {
// 认证成功,响应 JSON 数据
response.setContentType("application/json;charset=utf-8");
response.getWriter().write(objectMapper.writeValueAsString(new ResultData<>(1, "认证失败!")));
}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);
}
}
}
?? 自定义短信验证码认证过滤器 MobileAuthenticationFilter,仿照 UsernamePasswordAuthenticationFilter 过滤器进行编写
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;
/**
* 手机短信验证码认证过滤器,仿照 UsernamePasswordAuthenticationFilter 过滤器编写
*/
public class MobileAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
private String mobileParamter = "mobile"; // 默认手机号参数名为 mobile
private boolean postOnly = true; // 默认请求方式只能为 POST
protected MobileAuthenticationFilter() {
// 默认登录表单提交路径为 /mobile/form,POST 方式请求
super(new AntPathRequestMatcher("/mobile/form", "POST"));
}
@Override
public 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) 获取请求携带的 mobile
String mobile = request.getParameter(mobileParamter);
if(mobile == null) {
mobile = "";
}
mobile = mobile.trim();
//(3) 使用前端传入的 mobile 构造 Authentication 对象,标记该对象未认证
// MobileAuthenticationToken 是我们自定义的 Authentication 类,后续介绍
MobileAuthenticationToken authRequest = new MobileAuthenticationToken(mobile);
//(4) 将请求中的一些属性信息设置到 Authentication 对象中,如:remoteAddress,sessionId
this.setDetails(request, authRequest);
//(5) 调用 ProviderManager 类的 authenticate() 方法进行身份认证
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 类进行编写
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); // 用户权限为 null
this.principal = principal; // 前端传入的手机号
this.setAuthenticated(false); // 标记为未认证
}
/**
* 认证成功后,使用该构造器封装用户信息
*/
public MobileAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
super(authorities); // 用户权限集合
this.principal = principal; // 封装认证用户信息的 UserDetails 对象,不再是手机号
super.setAuthenticated(true); // 标记认证成功
}
@Override
public Object getCredentials() {
// 由于使用手机短信验证码登录不需要密码,所以直接返回 null
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 处理器进行编写
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 {
//(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) 查询到了用户信息,则认证通过,构建标记认证成功用户信息类对象 AuthenticationToken
MobileAuthenticationToken result = new MobileAuthenticationToken(user, user.getAuthorities());
// 需要把认证前 Authentication 对象中的 details 信息加入认证后的 Authentication
result.setDetails(authentication.getDetails());
return result;
}
/**
* ProviderManager 管理器通过此方法来判断是否采用此 AuthenticationProvider 类
* 来处理由 AuthenticationFilter 过滤器传入的 Authentication 对象
*/
@Override
public boolean supports(Class<?> authentication) {
// isAssignableFrom 返回 true 当且仅当调用者为父类.class,参数为本身或者其子类.class
// ProviderManager 会获取 MobileAuthenticationFilter 过滤器传入的 Authentication 类型
// 所以当且仅当 authentication 的类型为 MobileAuthenticationToken 才返回 true
return 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() {
}
@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 对象的类型需要我们自定义
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 {
//(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;
}
}
?? 自定义短信验证码认证方式配置类 MobileAuthenticationConfig,将上述组件进行管理
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 {
@Autowired
private CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler; // 自定义认证成功处理器
@Autowired
private CustomAuthenticationFailureHandler customAuthenticationFailureHandler; // 自定义认证失败处理器
@Autowired
private MobileCodeValidateFilter mobileCodeValidaterFilter; // 手机短信验证码校验过滤器
@Autowired
private MobileUserDetailsService userDetailsService; // 手机短信验证方式的 UserDetail
@Override
public void configure(HttpSecurity http) throws Exception {
//(1) 将短信验证码认证的自定义过滤器绑定到 HttpSecurity 中
//(1.1) 创建手机短信验证码认证过滤器的实例 filer
MobileAuthenticationFilter 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 实例,并指定所使用的 UserDetailsService
MobileAuthenticationProvider provider = new MobileAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
//(2.2) 将该 AuthenticationProvider 实例绑定到 HttpSecurity 中
http.authenticationProvider(provider);
}
}
?? 将上述自定义配置类 MobileAuthenticationConfig 绑定到最终的安全配置类 SpringSecurityConfig 中
@EnableWebSecurity // 开启 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
//...
@Autowired
private MobileAuthenticationConfig mobileAuthenticationConfig; // 手机短信验证码认证方式的配置类
//...
/**
* 定制基于 HTTP 请求的用户访问控制
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
//...
// 将手机短信验证码认证的配置与当前的配置绑定
http.apply(mobileAuthenticationConfig);
}
//...
}
完整的安全配置类如下:
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 // 开启 MVC Security 安全配置
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() {
// 使用 BCryptPasswordEncoder 密码编码器,该编码器会将随机产生的 salt 混入最终生成的密文中
return new BCryptPasswordEncoder();
}
/**
* 定制用户认证管理器来实现用户认证
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 采用内存存储方式,用户认证信息存储在内存中
// auth.inMemoryAuthentication()
// .withUser("admin").password(passwordEncoder()
// .encode("123456")).roles("ROLE_ADMIN");
// 不再使用内存方式存储用户认证信息,而是动态从数据库中获取
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
/**
* 定制基于 HTTP 请求的用户访问控制
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
// 启动 form 表单登录
http.formLogin()
// 设置登录页面的访问路径,默认为 /login,GET 请求;该路径不设限访问
.loginPage("/login/page")
// 设置登录表单提交路径,默认为 loginPage() 设置的路径,POST 请求
.loginProcessingUrl("/login/form")
// 设置登录表单中的用户名参数,默认为 username
.usernameParameter("name")
// 设置登录表单中的密码参数,默认为 password
.passwordParameter("pwd")
// 认证成功处理,如果存在原始访问路径,则重定向到该路径;如果没有,则重定向 /index
//.defaultSuccessUrl("/index")
// 认证失败处理,重定向到指定地址,默认为 loginPage() + ?error;该路径不设限访问
//.failureUrl("/login/page?error");
// 不再使用 defaultSuccessUrl() 和 failureUrl() 方法进行认证成功和失败处理,
// 使用自定义的认证成功和失败处理器
.successHandler(authenticationSuccessHandler)
.failureHandler(authenticationFailureHandler);
// 开启基于 HTTP 请求访问控制
http.authorizeRequests()
// 以下访问不需要任何权限,任何人都可以访问
.antMatchers("/login/page", "/code/image","/mobile/page", "/code/mobile").permitAll()
// 以下访问需要 ROLE_ADMIN 权限
.antMatchers("/admin/**").hasRole("ADMIN")
// 以下访问需要 ROLE_USER 权限
.antMatchers("/user/**").hasAuthority("ROLE_USER")
// 其它任何请求访问都需要先通过认证
.anyRequest().authenticated();
// 关闭 csrf 防护
http.csrf().disable();
// 将自定义过滤器(图形验证码校验)添加到 UsernamePasswordAuthenticationFilter 之前
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
: