1.首先看一下security大概的一个认证流程
- springsecurity在密码登陆时 首先找到了UsernamePasswordAuthenticationFilter类 然后该类根据username和password 构造出一个暂时没有鉴权的 UsernamePasswordAuthenticationToken,并将 UsernamePasswordAuthenticationToken 交给 AuthenticationManager 处理。
- AuthenticationManager 本身并不做验证处理,他遍历找到符合当前登录方式的一个 AuthenticationProvider,并交给它进行验证处理,对于用户名密码登录方式,这个 Provider 就是 DaoAuthenticationProvider。
- 在这个 Provider 中进行一系列的验证处理,如果验证通过,就会重新构造一个添加了鉴权的 UsernamePasswordAuthenticationToken,并将这个 token 传回到 UsernamePasswordAuthenticationFilter 中。
-我们要实现短信验证码登陆时就需要实现自己的验证码过滤器和身份校验提供者类
2. 在此之前 我们先做一下准备工作
- 我们这里为了方便 没有调用云短信功能 只是简单实现一下security的验证码登陆 小伙伴后期如果需要使用 可以自己把云短信<例如 阿里云短信> 集成进去
- 这里的短信set到了session里 并设置其过期时间为60秒
- 验证码相关代码 - 在pom导入一个工具包 方便后期使用
<dependency>
<groupId>commons-lang</groupId>
<artifactId>commons-lang</artifactId>
<version>2.5</version>
</dependency>
- 创建一个验证码实体类 bin提供相对应的构造方法
public class ValidateCode {
private String code;
private LocalDateTime expireTime;
public ValidateCode(String code, int expireIn){
this.code = code;
this.expireTime = LocalDateTime.now().plusSeconds(expireIn);
}
public ValidateCode(String code, LocalDateTime expireTime){
this.code = code;
this.expireTime = expireTime;
}
public boolean isExpried() {
return LocalDateTime.now().isAfter(expireTime);
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public LocalDateTime getExpireTime() {
return expireTime;
}
public void setExpireTime(LocalDateTime expireTime) {
this.expireTime = expireTime;
}
}
- 验证码生成器编写
@Component
public class SmsCodeGenerator {
public ValidateCode generate(ServletWebRequest request) {
String code = RandomStringUtils.randomNumeric(4);
return new ValidateCode(code, 60);
}
}
- 编写一个验证码处理器 进行验证码发送 该处理器路径需要暴露出来 不被security拦截 在这里会把验证码以手机号为键set到session里
- 注意 这个处理器 并不会给手机发送验证码
@RestController
public class ValidateCodeController {
@Autowired
private SmsCodeGenerator smsCodeGenerator;
@Autowired
private DefaultSmsCodeSender defaultSmsCodeSender;
@GetMapping("/code/sms")
public String createSmsCode(HttpServletRequest request, HttpServletResponse response, HttpSession session, @RequestParam String mobile) throws IOException {
//获取验证码
ValidateCode smsCode = smsCodeGenerator.generate(new ServletWebRequest(request));
//把验证码设置到session
session.setAttribute(mobile, smsCode);
return "验证码是 : " + smsCode.getCode();
}
}
- 自定义一个验证码异常类 处理验证码发送失败后的异常请求
public class ValidateCodeException extends AuthenticationException {
private static final long serialVersionUID = -7285211528095468156L;
public ValidateCodeException(String msg) {
super(msg);
}
}
3.接下来我们就开始自定义过滤器 实现security的验证码登陆功能
- 仿照UsernamePasswordAuthenticationFilter类 写一个SmsCodeAuthenticationFilter
public class SmsCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter{
/**
* form表单中手机号码的字段name
*/
public static final String SPRING_SECURITY_FORM_MOBILE_KEY = "mobile";
private String mobileParameter = SPRING_SECURITY_FORM_MOBILE_KEY;
/**
* 是否仅 POST 方式
*/
private boolean postOnly = true;
public SmsCodeAuthenticationFilter(){
// 短信登录的请求 post 方式的 /authentication/mobile
super(new AntPathRequestMatcher("/authentication/mobile", "POST"));
}
/**
* 校验手机号和验证码
* @param request
* @param response
* @return
* @throws AuthenticationException
* @throws IOException
* @throws ServletException
*/
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
if (postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException(
"不支持身份验证方法 需要post请求: " + request.getMethod());
}
String mobile = obtainMobile(request);
if (mobile == null) {
mobile = "";
}
mobile = mobile.trim();
SmsCodeAuthenticationToken authRequest = new SmsCodeAuthenticationToken(mobile);
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
//校验验证码
validateSmsCode(request,request.getSession());
return this.getAuthenticationManager().authenticate(authRequest);
}
//获取手机号
protected String obtainMobile(HttpServletRequest request) {
return request.getParameter(mobileParameter);
}
protected void setDetails(HttpServletRequest request, SmsCodeAuthenticationToken authRequest) {
authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
}
public String getMobileParameter() {
return mobileParameter;
}
public void setMobileParameter(String mobileParameter) {
Assert.hasText(mobileParameter, "Mobile parameter must not be empty or null");
this.mobileParameter = mobileParameter;
}
public void setPostOnly(boolean postOnly) {
this.postOnly = postOnly;
}
//校验手机验证码
private void validateSmsCode(HttpServletRequest request, HttpSession session) throws ServletRequestBindingException {
//请求里的手机号和验证码
String mobileInRequest = request.getParameter("mobile");//获取手机号
String codeInRequest = request.getParameter("smsCode");//获取验证码
//获取session里的验证码
ValidateCode codeInSession = (ValidateCode) session.getAttribute(mobileInRequest);
if (StringUtils.isBlank(codeInRequest)) {
throw new ValidateCodeException("验证码的值不能为空");
}
if(codeInSession == null){
throw new ValidateCodeException("该手机号未发送验证码");
}
//session的验证码是否过期
if(codeInSession.isExpried()){
session.removeAttribute(mobileInRequest);
throw new ValidateCodeException("验证码已过期");
}
//校验输入的验证码和session的验证码是否一致
if(!StringUtils.equals(codeInSession.getCode(), codeInRequest)) {
throw new ValidateCodeException("验证码不匹配");
}
session.removeAttribute(mobileInRequest);
}
}
- 自定义token生成器 类似于 UsernamePasswordAuthenticationToken
public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken {
/**
* UsernamePasswordAuthenticationToken类里代表用户名
* 现在代表手机号
*/
private final Object principal;
/**
*通过手机号构造未鉴权的token
*/
public SmsCodeAuthenticationToken(Object principal) {
super(null);
this.principal = principal;
setAuthenticated(false);
}
/**
* 通过手机号构造已鉴权的token
* @param principal
* @param authorities
*/
public SmsCodeAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
super.setAuthenticated(true); // must use super, as we override
}
@Override
public Object getCredentials() {
return null;
}
@Override
public Object getPrincipal() {
return this.principal;
}
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");
}
super.setAuthenticated(false);
}
}
- 自定义SmsCodeAuthenticationProvider 类似于DaoAuthenticationProvider
public class SmsCodeAuthenticationProvider implements AuthenticationProvider {
private Logger logger = LoggerFactory.getLogger(getClass());
private UserDetailsService userDetailsService;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
logger.info("authentication : " + authentication);
SmsCodeAuthenticationToken smsCodeAuthenticationToken = (SmsCodeAuthenticationToken) authentication;
logger.info("smsCodeAuthenticationToken.getPrincipal() : " + smsCodeAuthenticationToken.getPrincipal());
UserDetails user = userDetailsService.loadUserByUsername((String)smsCodeAuthenticationToken.getPrincipal());
if(user == null){
throw new InternalAuthenticationServiceException("用户不存在");
}
//把未认证token里的信息设置到已认证token里
SmsCodeAuthenticationToken authenticationResult = new SmsCodeAuthenticationToken(user,user.getAuthorities());
authenticationResult.setDetails(smsCodeAuthenticationToken.getDetails());
return authenticationResult;
}
@Override
public boolean supports(Class<?> aClass) {
return SmsCodeAuthenticationToken.class.isAssignableFrom(aClass);
}
public UserDetailsService getUserDetailsService() {
return userDetailsService;
}
public void setUserDetailsService(UserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}
}
- 把我们定义的过滤器 设置到security过滤器链里
@Component
public class SmsSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private LoginSuccessHandler loginSuccessHandler;
@Autowired
private LoginFailureHandler loginFailureHandler;
@Override
public void configure(HttpSecurity http){
//创建短信验证码过滤器
SmsCodeAuthenticationFilter smsCodeAuthenticationFilter = new SmsCodeAuthenticationFilter();
smsCodeAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
smsCodeAuthenticationFilter.setAuthenticationSuccessHandler(loginSuccessHandler);//设置成功处理器
smsCodeAuthenticationFilter.setAuthenticationFailureHandler(loginFailureHandler);//设置失败处理器
//设置SmsCodeAuthenticationProvider的UserDetailsService
SmsCodeAuthenticationProvider smsCodeAuthenticationProvider = new SmsCodeAuthenticationProvider();
smsCodeAuthenticationProvider.setUserDetailsService(userDetailsService);
//把smsCodeAuthenticationFilter过滤器添加在UsernamePasswordAuthenticationFilter之前
http.authenticationProvider(smsCodeAuthenticationProvider);
http.addFilterBefore(smsCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
}
- 在WebSecurityConfig里吧SmsSecurityConfig 设置进去
- 在login.html配置验证码相关代码
<h3>短信登录</h3>
<form action="/authentication/mobile" method="POST">
手机号:<input type="text" name="mobile" value="18434537109"><br>
短信验证码:<input type="text" name="smsCode">
<a href="/code/sms?mobile=18434537109">发送验证码</a><br>
<button type="submit">登录</button><br>
</form>
4.最后我们重新启动服务进行测试
- 点击发送验证码到这里
- 回到登陆页面填写验证码
- 登陆成功后
- 校验失败如下 这里就不一一测试了
- 失败后会根据如下代码配置返回对应信息