现在大多数平台都是通过手机号+验证码的形式进行登录,但是SpringSecurity本身并没有直接提供我们这样的封装,所以我们需要根据自己的流程,自定义我们的操作,来满足我们的需求。
首先我们需要定义创建声明手机验证码的流程,这其实和生成图片验证码的流程相似,这里不详细说明,详细说明可以看这篇博客图片验证码登录,这里我直接上代码:
首先定义一个用来接收验证码的类来存放验证码:
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);
}
//省去get/set方法
}
然后就是生成验证码的流程:
@GetMapping("/code/sms")
public void createSmsCode(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletRequestBindingException {
ValidateCode smsCode = createSmsCode(new ServletWebRequest(request));
sessionStrategy.setAttribute(new ServletWebRequest(request),SESSION_key_SMS,smsCode);
String mobile = ServletRequestUtils.getRequiredStringParameter(request,"mobile");
smsCodeSender.send(mobile, smsCode.getCode());
}
//具体生成验证码类的方法
private ValidateCode createSmsCode(ServletWebRequest servletWebRequest) {
String code = RandomStringUtils.randomNumeric(4);
return new ValidateCode(code,60);
}
其中短信验证码和图片验证码的流程都三个步骤,都是第一步生成验证码实体,第二步存入session,第三步发送验证码,只是第三步的具体实现不同,这样我们就可以通过模版模式对我们的代码进行封装,不知道模版模式的小伙伴可以看一下这篇博客模版方法模式,最后我会想重构前后的代码发到git上,有兴趣的可以对比一下,这里不具体讲怎么进行代码重构,只实现功能。
发送验证码的具体步骤不展开,根据每个人的需求,具体是一样,这里直接写一个简单方法:
//发送验证码的接口
public interface SmsCodeSender {
void send(String mobile, String code);
}
@Component
//具体发送验证码的实现,并注入到SPringBean中
public class DefaultSmsCodeSender implements SmsCodeSender {
@Override
public void send(String mobile, String code) {
System.out.println("向手机"+mobile+"发送验证码"+code);
}
}
验证码的生成的步骤就完成了!
下面就是如何去校验验证码完成登录的操作,首先我们来看一下SpringSecurity账号密码的登录流程:
从上图可以看出,密码登录首先通过Filter拦截器从登录请求中拿到用户名密码,生成AuthenticationToken对象,然后传给Manager,然后Manager会从一堆Provider去找到一个Provider来处理我们的AuthenticationToken对象,在处理过程中就会调用DetailsService来获取用户的信息,去和Token中信息进行比对来判断是否可以认证成功,认证通过后就把Authentication设置成已认证,然后放进session。
因为我们就仿照上述来定义我们的手机号短信认证流程,那么首先我们来定义一个SmsFilter拦截器来处理我们的请求:
public class SmsCodeAuthenticationFilter extends
AbstractAuthenticationProcessingFilter {
// ~ Static fields/initializers
// =====================================================================================
public static final String SPRING_SECURITY_FORM_MOBILE_KEY = "mobile";
private String mobileParameter = SPRING_SECURITY_FORM_MOBILE_KEY;
//表示只支持post请求
private boolean postOnly = true;
// ~ Constructors
public SmsCodeAuthenticationFilter() {
//过滤的请求是什么
super(new AntPathRequestMatcher("/authentication/mobile", "POST"));
}
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);
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
//调用Manager
return this.getAuthenticationManager().authenticate(authRequest);
}
/**
* 获得手机号
*/
protected String obtainMobile(HttpServletRequest request) {
return request.getParameter(mobileParameter);
}
/**
* Provided so that subclasses may configure what is put into the authentication
* request's details property.
*
*
*/
protected void setDetails(HttpServletRequest request,
SmsCodeAuthenticationToken authRequest) {
authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
}
public void setMobileParameter(String usernameParameter) {
Assert.hasText(usernameParameter, "Username parameter must not be empty or null");
this.mobileParameter = usernameParameter;
}
public void setPostOnly(boolean postOnly) {
this.postOnly = postOnly;
}
public final String getMobileParameter() {
return mobileParameter;
}
}
然后定义我们用来Filter拿到的手机号等信息,来封装一个SmsToken,代码如下:
public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
//放我们的认证信息,验证起放手机号,验证后放用户信息
private final Object principal;
/**
* This constructor can be safely used by any code that wishes to create a
* <code>UsernamePasswordAuthenticationToken</code>, as the {@link #isAuthenticated()}
* will return <code>false</code>.
*
*/
public SmsCodeAuthenticationToken(String mobile) {
super(null);
this.principal = mobile;
setAuthenticated(false);
}
public SmsCodeAuthenticationToken(Object principal,
Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
super.setAuthenticated(true); // must use super, as we override
}
// ~ Methods
// ========================================================================================================
public Object getCredentials() {
return null;
}
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);
}
@Override
public void eraseCredentials() {
super.eraseCredentials();
}
}
Manager整个系统只有一个,所以我们直接使用就可以,我们只需要定义处理手机验证的Provider类,代码如下:
public class SmsCodeAuthenticationProvider implements AuthenticationProvider {
private UserDetailsService userDetailsService;
//进行身份认证的逻辑
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
SmsCodeAuthenticationToken authenticationToken = (SmsCodeAuthenticationToken) authentication;
UserDetails user = userDetailsService.loadUserByUsername((String) authenticationToken.getPrincipal());
if (user == null) {
throw new InternalAuthenticationServiceException("无法获得用户信息");
}
SmsCodeAuthenticationToken authenticationResult = new SmsCodeAuthenticationToken(user,user.getAuthorities());
authenticationResult.setDetails(authenticationToken.getDetails());
return authenticationResult;
}
@Override
//根据support方法使Manager调用那个provider来处理
public boolean supports(Class<?> authentication) {
return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication);
}
public UserDetailsService getUserDetailsService() {
return userDetailsService;
}
public void setUserDetailsService(UserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}
}
上面这个流程只是根据手机号进行用户的认证,具体判断验证码是否正确,只在用户认证之前,再加一个过滤器,来验证我们的验证码是否匹配,过滤器代码如下:
/**
* 定义一个验证码的拦截器
* @author hdd
*/
public class ValidateCodeFilter extends OncePerRequestFilter {
private DemoAuthenticationFailureHandler demoAuthenticationFailureHandler;
private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
if (StringUtils.equals("/authentication/form", request.getRequestURI()) &&
StringUtils.endsWithIgnoreCase(request.getMethod(), "post")) {
try {
validate(new ServletWebRequest(request));
} catch (ValidateCodeException e) {
demoAuthenticationFailureHandler.onAuthenticationFailure(request,response,e);
return;
}
}
filterChain.doFilter(request,response);
}
//具体的验证流程
private void validate(ServletWebRequest request) throws ServletRequestBindingException {
ImageCode codeInSession = (ImageCode) sessionStrategy.getAttribute(request, ValidateCodeController.SESSION_KEY);
String codeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(), "imageCode");
if (StringUtils.isBlank(codeInRequest)) {
throw new ValidateCodeException("验证码的值不能为空");
}
if (codeInSession == null) {
throw new ValidateCodeException("验证码不存在");
}
if (codeInSession.isExpried()) {
sessionStrategy.removeAttribute(request, ValidateCodeController.SESSION_KEY);
throw new ValidateCodeException("验证码已过期");
}
if (!StringUtils.equals(codeInSession.getCode(), codeInRequest)) {
throw new ValidateCodeException("验证码不匹配");
}
sessionStrategy.removeAttribute(request, ValidateCodeController.SESSION_KEY);
}
public DemoAuthenticationFailureHandler getDemoAuthenticationFailureHandler() {
return demoAuthenticationFailureHandler;
}
public void setDemoAuthenticationFailureHandler(DemoAuthenticationFailureHandler demoAuthenticationFailureHandler) {
this.demoAuthenticationFailureHandler = demoAuthenticationFailureHandler;
}
}
那么手机号+验证码登录请求就完成了,最后我们来手机号配置验证码验证流程:
/**
*手机验证码配置
*
*/
@Configuration
public class SmsCodeAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain,HttpSecurity> {
@Autowired
private DemoAuthenticationSuccessHandler demoAuthenticationSuccessHandler;
@Autowired
private DemoAuthenticationFailureHandler demoAuthenticationFailureHandler;
@Autowired
private UserDetailsService userDetailsService;
@Override
public void configure(HttpSecurity http) throws Exception {
SmsCodeAuthenticationFilter smsCodeAuthenticationFilter = new SmsCodeAuthenticationFilter();
smsCodeAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
smsCodeAuthenticationFilter.setAuthenticationFailureHandler(demoAuthenticationFailureHandler);
smsCodeAuthenticationFilter.setAuthenticationSuccessHandler(demoAuthenticationSuccessHandler);
SmsCodeAuthenticationProvider smsCodeAuthenticationProvider = new SmsCodeAuthenticationProvider();
smsCodeAuthenticationProvider.setUserDetailsService(userDetailsService);
http.authenticationProvider(smsCodeAuthenticationProvider)
.addFilterAfter(smsCodeAuthenticationFilter,UsernamePasswordAuthenticationFilter.class);
}
}
最后讲配置放到我们的主配置上:
@Override
protected void configure(HttpSecurity http) throws Exception {
ValidateCodeFilter validateCodeFilter = new ValidateCodeFilter();
validateCodeFilter.setDemoAuthenticationFailureHandler(demoAuthenticationFailureHandler);
SmsCodeFilter smsCodeFilter = new SmsCodeFilter();
smsCodeFilter.setDemoAuthenticationFailureHandler(demoAuthenticationFailureHandler);
http.addFilterBefore(smsCodeFilter,UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(validateCodeFilter,UsernamePasswordAuthenticationFilter.class)//在UsernamePasswordAuthenticationFilter添加新添加的拦截器
.formLogin()//表示使用form表单提交
.loginPage("/login.html")//我们定义的登录页
.loginProcessingUrl("/authentication/form")//因为SpringSecurity默认是login请求为登录请求,所以需要配置自己的请求路径
.successHandler(demoAuthenticationSuccessHandler)//登录成功的操作
.failureHandler(demoAuthenticationFailureHandler)//登录失败的操作
.and()
.rememberMe()
.tokenRepository(persistentTokenRepository())
.and()
.authorizeRequests()//对请求进行授权
.antMatchers("/login.html","/code/*").permitAll()//表示login.html路径不会被拦截
.anyRequest()//表示所有请求
.authenticated()//需要权限认证
.and()
.csrf().disable()
.apply(smsCodeAuthenticationSecurityConfig);//添加手机号验证码校验
}
最后写一下测试页面:
<h3>表单登录</h3>
<form action="/authentication/mobile" method="post">
<table>
<tr>
<td>手机号:</td>
<td><input type="text" name="mobile" value="13012345678"></td>
</tr>
<tr>
<td>短信验证码:</td>
<td>
<input type="text" name="smsCode">
<a href="/code/sms?mobile=13012345678">发送验证码</a>
</td>
</tr>
<tr>
<td colspan="2"><button type="submit">登录</button></td>
</tr>
</table>
</form>
最后测试也就就是如此:
完整项目代码请从git上拉取git地址