spring boot 之 security(六) 短信验证登陆
账号密码登陆 和 短信验证登陆 是不一样的 写一起不利于维护,所以我们需要写自己的filter来验证登陆。
原理及流程其实在 SmsAuthenticationFilter 之前还有一层Filter,它是用来检验验证码是否正确的,放在 SmsAuthenticationFilter 之前也是因为可重用,当其他需要短信验证的时候我们则可以直接让该filter替我们校验,无需再写一个。
一、实现自己的 SmsAuthenticationFilter,用于 类似于 UsernamePasswordAuthenticationFilter 的功能
package com.wt.cloud.sms;
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.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public class SmsCodeAuthticationFilter extends AbstractAuthenticationProcessingFilter {
public static final String SPRING_SECURITY_FORM_MOBILE_KEY = "mobile";
private String mobileParameter = SPRING_SECURITY_FORM_MOBILE_KEY;
/**
* 是否只处理 post请求
*/
private boolean postOnly = true;
/**
* 拦截的手机验证码登陆路径
*/
public SmsCodeAuthticationFilter() {
super(new AntPathRequestMatcher("/authentication/mobile", "POST"));
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException {
if (postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException(
"Authentication method not supported: " + request.getMethod());
}
String mobile = obtainMobile(request);
if (mobile == null) {
mobile = "";
}
mobile = mobile.trim();
SmsCodeAuthenticationToken authRequest = new SmsCodeAuthenticationToken(mobile);
setDetails(request, authRequest);
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 void setMobileParameter(String mobileParameter) {
Assert.hasText(mobileParameter, "Username parameter must not be empty or null");
this.mobileParameter = mobileParameter;
}
public void setPostOnly(boolean postOnly) {
this.postOnly = postOnly;
}
public final String getMobileParameter() {
return mobileParameter;
}
}
很简单就是把 UsernamePasswordAuthenticationFilter 的内容copy过来就行,把 UsernamePasswordAuthenticationFilter 里的username 改成我们的 mobile(页面上手机的参数名),把password去掉,因为手机登陆不需要密码。然后登陆路径修改即可。
二、实现 SmsCodeAuthenticationToken
这个是用来 给 provider 准备的,provider 要用这个来区分我得认证类型,并得到传递的数据。实现方式可以查看 UsernamePasswordAuthenticationToken
package com.wt.cloud.sms;
import lombok.Data;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.SpringSecurityCoreVersion;
import java.util.Collection;
@Data
public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
/**
* 没有认证之前是手机号 认证之后是用户信息
*/
private final Object principal;
/**
* 没登陆的时候放的是手机号
*/
public SmsCodeAuthenticationToken(String mobile) {
super(null);
this.principal = mobile;
setAuthenticated(false);
}
public SmsCodeAuthenticationToken(Object principal,Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
// must use super, as we override
super.setAuthenticated(true);
}
@Override
public Object getCredentials() {
return null;
}
@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");
}
super.setAuthenticated(false);
}
@Override
public void eraseCredentials() {
super.eraseCredentials();
}
}
三、实现 SmsCodeAuthenticationProvider
provide 需要先重新定义 校验器,这个校验器是用来判断你的 你所传递的认证的类型。如果是 SmsCodeAuthenticationToken 那就走我得 authenticate方法,authenticate方法就是需获取 UserDetails(UserDetails 是从 userDetailsService.loadUserByUsername获取得到,这里获取了密码,并校验了是否有效等 ),并得到 一个完整的 Authentication。
package com.wt.cloud.sms;
import lombok.Data;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
@Data
public class SmsCodeAuthenticationProvider implements AuthenticationProvider {
private UserDetailsService userDetailsService;
/**
* 获取用户信息 组装Authentication
* @param authentication
* @return
* @throws AuthenticationException
*/
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
SmsCodeAuthenticationToken smsCodeAuthenticationToken = (SmsCodeAuthenticationToken) authentication;
UserDetails user = userDetailsService.loadUserByUsername((String) smsCodeAuthenticationToken.getPrincipal());
if(user == null){
throw new UsernameNotFoundException("无法获取用户信息");
}
SmsCodeAuthenticationToken smsAuthentication = new SmsCodeAuthenticationToken(user,user.getAuthorities());
smsCodeAuthenticationToken.setDetails(smsAuthentication.getDetails());
return smsAuthentication;
}
/**
* 校验器
* @param authentication
* @return
*/
@Override
public boolean supports(Class<?> authentication) {
// 校验类型是否是 SmsCodeAuthenticationToken
return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication);
}
}
四、整合所写的provider 和 filter
package com.wt.cloud.sms;
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.core.userdetails.UserDetailsService;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.stereotype.Component;
@Component
public class SmsCodeAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
@Autowired
private AuthenticationSuccessHandler authenticationSuccessHandler;
@Autowired
private AuthenticationFailureHandler authenticationFailureHandler;
@Autowired
private UserDetailsService userDetailsService;
@Override
public void configure(HttpSecurity builder) throws Exception {
SmsCodeAuthticationFilter smsCodeAuthticationFilter = new SmsCodeAuthticationFilter();
smsCodeAuthticationFilter.setAuthenticationManager(builder.getSharedObject(AuthenticationManager.class));
smsCodeAuthticationFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler);
smsCodeAuthticationFilter.setAuthenticationFailureHandler(authenticationFailureHandler);
SmsCodeAuthenticationProvider smsCodeAuthenticationProvider = new SmsCodeAuthenticationProvider();
smsCodeAuthenticationProvider.setUserDetailsService(userDetailsService);
builder.authenticationProvider(smsCodeAuthenticationProvider)
.addFilterAfter(smsCodeAuthticationFilter, UsernamePasswordAuthenticationFilter.class);
}
}
五、写一个手机验证的过滤校验器
package com.wt.cloud.filter;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.wt.cloud.ValidateException;
import com.wt.cloud.sms.SmsCode;
import com.wt.cloud.validate.ImageCode;
import lombok.Data;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.social.connect.web.HttpSessionSessionStrategy;
import org.springframework.social.connect.web.SessionStrategy;
import org.springframework.web.bind.ServletRequestBindingException;
import org.springframework.web.bind.ServletRequestUtils;
import org.springframework.web.context.request.ServletWebRequest;
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 java.io.IOException;
import java.time.LocalDateTime;
/**
* 功能描述: OncePerRequestFilter spring提供的工具类,保证我们的过滤器始终只会被调一次
* @author : big uncle
* @date : 2019/10/14 15:13
*/
@Data
public class ValidateSmsCodeFilter extends OncePerRequestFilter {
/**
* 失败处理器
*/
private AuthenticationFailureHandler authenticationFailureHandler;
/**
* spring 的session
*/
private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();
@Override
protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
// 拦截登陆uri,不是登陆则全部放过
if(StrUtil.equalsIgnoreCase("/authentication/mobile",httpServletRequest.getRequestURI()) && StrUtil.equalsIgnoreCase(httpServletRequest.getMethod(),"post")){
try{
validate(new ServletWebRequest(httpServletRequest));
}catch(ValidateException v){
authenticationFailureHandler.onAuthenticationFailure(httpServletRequest,httpServletResponse,v);
return;
}
}
filterChain.doFilter(httpServletRequest,httpServletResponse);
}
// 校验
private void validate(ServletWebRequest servletWebRequest) throws ServletRequestBindingException {
String mobile = ServletRequestUtils.getStringParameter(servletWebRequest.getRequest(),"mobile");
SmsCode smsCode = (SmsCode) sessionStrategy.getAttribute(servletWebRequest, mobile);
String code = ServletRequestUtils.getStringParameter(servletWebRequest.getRequest(),"mobileCode");
if(StrUtil.isEmpty(code)){
throw new ValidateException("验证码为空或不存在");
}
if(ObjectUtil.isNull(smsCode)){
throw new ValidateException("验证码错误");
}
if(!StrUtil.equalsIgnoreCase(smsCode.getCode(),code)){
throw new ValidateException("验证码不匹配");
}
if(LocalDateTime.now().isAfter(smsCode.getExpireTime())){
sessionStrategy.removeAttribute(servletWebRequest, mobile);
throw new ValidateException("验证码已过期");
}
sessionStrategy.removeAttribute(servletWebRequest, mobile);
}
}
SmsCode
package com.wt.cloud.sms;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.UUID;
@Data
public class SmsCode {
private String code;
private LocalDateTime expireTime;
public SmsCode(String code,int expireIn){
this.code = code;
this.expireTime = LocalDateTime.now().plusSeconds(expireIn);
}
public static SmsCode createCode(){
String content = UUID.randomUUID().toString().replace("-","").substring(0,4);
//释放资源
return new SmsCode(content,60);
}
}
六、把 SmsCodeAuthenticationSecurityConfig 注册到 WebSecurityConfig
// 校验smsCode的过滤器
ValidateSmsCodeFilter validateSmsCodeFilter = new ValidateSmsCodeFilter(); validateSmsCodeFilter.setAuthenticationFailureHandler(myAuthenticationFailureHandler);
.apply(smsCodeAuthenticationSecurityConfig);
其实就类似于把 SmsCodeAuthenticationSecurityConfig 里的配置,在 WebSecurityConfig 里配置。
package com.wt.cloud.config;
import com.wt.cloud.filter.ValidateCodeFilter;
import com.wt.cloud.filter.ValidateSmsCodeFilter;
import com.wt.cloud.properties.SecurityProperties;
import com.wt.cloud.sms.SmsCodeAuthenticationSecurityConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
import javax.sql.DataSource;
/**
* 功能描述: WebSecurityConfigurerAdapter web安全应用的适配器
* @author : big uncle
* @date : 2019/10/10 10:26
*/
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private SecurityProperties securityProperties;
@Autowired
private MyAuthenticationSuccessHandle myAuthenticationSuccessHandle;
@Autowired
private MyAuthenticationFailureHandler myAuthenticationFailureHandler;
@Autowired
private DataSource dataSource;
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private SmsCodeAuthenticationSecurityConfig smsCodeAuthenticationSecurityConfig;
@Bean
public PersistentTokenRepository persistentTokenRepository(){
JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
// 设置数据源
tokenRepository.setDataSource(dataSource);
// 启动的时候创建存储token的表
// tokenRepository.setCreateTableOnStartup(true);
return tokenRepository;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// 关闭默认的basic认证拦截
// http
// .authorizeRequests()
// .anyRequest()
// .permitAll().and()
// .logout()
// .permitAll();
// 让使用form表单认证
ValidateCodeFilter validateCodeFilter = new ValidateCodeFilter();
validateCodeFilter.setAuthenticationFailureHandler(myAuthenticationFailureHandler);
// 校验smsCode的过滤器
ValidateSmsCodeFilter validateSmsCodeFilter = new ValidateSmsCodeFilter();
validateSmsCodeFilter.setAuthenticationFailureHandler(myAuthenticationFailureHandler);
http
// 在 UsernamePasswordAuthenticationFilter 之前添加手机验证码过滤器
.addFilterBefore(validateSmsCodeFilter,UsernamePasswordAuthenticationFilter.class)
// 在 UsernamePasswordAuthenticationFilter 之前添加图形验证码过滤器
.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class)
.formLogin()
// 自定义登陆页面 或 controller
.loginPage("/web/authentication")
// 覆盖spring security默认登陆地址。默认是login
.loginProcessingUrl("/authentication/login")
// 配置成功处理类
.successHandler(myAuthenticationSuccessHandle)
// 配置失败处理类
.failureHandler(myAuthenticationFailureHandler)
.and()
// 记住我
.rememberMe()
// 设置存储库
.tokenRepository(persistentTokenRepository())
// 设置token 失效时间
.tokenValiditySeconds(3600)
// 获取 userDetailsService 做登陆
.userDetailsService(userDetailsService)
.and()
// 以下都是授权的配置
.authorizeRequests()
// 剔除登陆页面的认证拦截,否则会在进登陆页面一直跳转;permitAll 指任何人都可以访问这个url
.antMatchers(
"/web/authentication",
"/code/*",
"/mobile.html",
securityProperties.getWebProperties().getLoginPage()
).permitAll()
// 任何请求
.anyRequest()
// 都需要身份认证
.authenticated()
.and()
// 关闭跨站请求伪造拦截
.csrf().disable()
// 把 apply 类的配置,加到整体的配置里面
.apply(smsCodeAuthenticationSecurityConfig);
}
}
Controller
package com.wt.cloud.validate;
import com.wt.cloud.sms.SmsCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.social.connect.web.HttpSessionSessionStrategy;
import org.springframework.social.connect.web.SessionStrategy;
import org.springframework.web.bind.ServletRequestBindingException;
import org.springframework.web.bind.ServletRequestUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.ServletWebRequest;
import javax.imageio.ImageIO;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@RestController
@RequestMapping("/code")
@Slf4j
public class ValiDateWeb {
/**
* spring 的session
*/
private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();
@GetMapping("/smsCode")
public void smsCode(HttpServletRequest request, HttpServletResponse response) throws ServletRequestBindingException {
SmsCode smsCode = SmsCode.createCode();
String mobile = ServletRequestUtils.getStringParameter(request,"mobile");
sessionStrategy.setAttribute(new ServletWebRequest(request),mobile,smsCode);
// 调用发送手机的方法
log.info("手机号:{} 验证码:{}",mobile,smsCode.getCode());
}
}
页面
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<form action="/authentication/mobile" method="post">
<input name="mobile" value="15771720555"> 手机号
<br>
<input type="text" name="mobileCode" > 验证码
<br>
<a href="/code/smsCode?mobile=15771720555">发送验证码</a>
<input type="submit" value="登陆" >
</form>
</body>
</html>