项目源码地址 https://github.com/nieandsun/security
1. 理论
前面的文章说过:
spring-security并没有实现一套关于短信验证码的校验逻辑,但是我们可以仿照用户名+密码的校验方式来自己写Filter,然后将自己写的Filter加入到spring-security的过滤器链中来实现利用短信进行认证的功能。
具体的工作内容如下图:
2. 具体代码 – 建议直接从github上下载下来代码查看commit记录
2.1 短信验证码校验Filter
package com.nrsc.security.core.validate.code;
import com.nrsc.security.core.properties.SecurityProperties;
import lombok.Data;
import org.apache.commons.lang.ArrayUtils;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.social.connect.web.HttpSessionSessionStrategy;
import org.springframework.social.connect.web.SessionStrategy;
import org.springframework.util.AntPathMatcher;
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.util.HashSet;
import java.util.Set;
/**
* @author: Sun Chuan
* @date: 2019/7/15 19:43
* Description: 直接复制的图形验证码的校验逻辑----会进行优化
*/
@Data
public class SmsCodeFilter extends OncePerRequestFilter implements InitializingBean {
private AuthenticationFailureHandler authenticationFailureHandler;
private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();
private Set<String> urls = new HashSet<>();
private SecurityProperties securityProperties;
private AntPathMatcher pathMatcher = new AntPathMatcher();
@Override
public void afterPropertiesSet() throws ServletException {
super.afterPropertiesSet();
String[] configUrls = StringUtils.splitByWholeSeparatorPreserveAllTokens(securityProperties.getCode().getSms().getUrls(), ",");
if (ArrayUtils.isNotEmpty(configUrls)) {
for (String configUrl : configUrls) {
urls.add(configUrl);
}
}
urls.add("/authentication/mobile");
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
boolean action = false;
for (String url : urls) {
if (pathMatcher.match(url, request.getRequestURI())) {
action = true;
}
}
if (action) {
try {
validate(new ServletWebRequest(request));
} catch (ValidateCodeException e) {
authenticationFailureHandler.onAuthenticationFailure(request, response, e);
return;
}
}
filterChain.doFilter(request, response);
}
private void validate(ServletWebRequest request) throws ServletRequestBindingException {
ValidateCode codeInSession = (ValidateCode) sessionStrategy.getAttribute(request,
ValidateCodeProcessor.SESSION_KEY_PREFIX + "SMS");
String codeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(), "smsCode");
if (StringUtils.isBlank(codeInRequest)) {
throw new ValidateCodeException("验证码的值不能为空");
}
if (codeInSession == null) {
throw new ValidateCodeException("验证码不存在");
}
if (codeInSession.isExpried()) {
sessionStrategy.removeAttribute(request, ValidateCodeProcessor.SESSION_KEY_PREFIX + "SMS");
throw new ValidateCodeException("验证码已过期");
}
if (!StringUtils.equals(codeInSession.getCode(), codeInRequest)) {
throw new ValidateCodeException("验证码不匹配");
}
sessionStrategy.removeAttribute(request, ValidateCodeProcessor.SESSION_KEY_PREFIX + "SMS");
}
}
2.2 SmsCodeAuthenticationFilter
package com.nrsc.security.core.authentication.mobile;
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 javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* @author: Sun Chuan
* @date: 2019/7/15 19:30
* Description:
*/
public class SmsCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
// ~ Static fields/initializers
// =====================================================================================
public static final String NRSC_FORM_MOBILE_KEY = "mobile";
private String mobileParameter = NRSC_FORM_MOBILE_KEY;
private boolean postOnly = true;
// ~ Constructors
// ===================================================================================================
public SmsCodeAuthenticationFilter() {
super(new AntPathRequestMatcher("/authentication/mobile", "POST"));
}
// ~ Methods
// ========================================================================================================
@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);
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
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.
*
* @param request that an authentication request is being created for
* @param authRequest the authentication request object that should have its details
* set
*/
protected void setDetails(HttpServletRequest request, SmsCodeAuthenticationToken authRequest) {
authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
}
//---------下面这一块是用户登陆个性化配置有关的内容,比如说spring-security默认的登陆名字段为username,密码为password---------
//---------但可以在配置类中通过.usernameParameter("XXX")和.passwordParameter("YYY")修改其默认值----------------------
// /**
// * Sets the parameter name which will be used to obtain the username from the login
// * request.
// *
// * @param usernameParameter the parameter name. Defaults to "username".
// */
// public void setUsernameParameter(String usernameParameter) {
// Assert.hasText(usernameParameter, "Username parameter must not be empty or null");
// this.usernameParameter = usernameParameter;
// }
//
// /**
// * Sets the parameter name which will be used to obtain the password from the login
// * request..
// *
// * @param passwordParameter the parameter name. Defaults to "password".
// */
// public void setPasswordParameter(String passwordParameter) {
// Assert.hasText(passwordParameter, "Password parameter must not be empty or null");
// this.passwordParameter = passwordParameter;
// }
//
// /**
// * Defines whether only HTTP POST requests will be allowed by this filter. If set to
// * true, and an authentication request is received which is not a POST request, an
// * exception will be raised immediately and authentication will not be attempted. The
// * <tt>unsuccessfulAuthentication()</tt> method will be called as if handling a failed
// * authentication.
// * <p>
// * Defaults to <tt>true</tt> but may be overridden by subclasses.
// */
// public void setPostOnly(boolean postOnly) {
// this.postOnly = postOnly;
// }
//
// public final String getUsernameParameter() {
// return usernameParameter;
// }
//
// public final String getPasswordParameter() {
// return passwordParameter;
// }
}
2.3 SmsCodeAuthenticationProvider和相应的token – SmsCodeAuthenticationToken
package com.nrsc.security.core.authentication.mobile;
import lombok.Data;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.InternalAuthenticationServiceException;
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;
/**
* @author: Sun Chuan
* @date: 2019/7/15 19:40
* Description: 相比于用户名密码使用的AbstractUserDetailsAuthenticationProvider这里简化了很多
*/
@Data
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
public boolean supports(Class<?> authentication) {
return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication);
}
}
package com.nrsc.security.core.authentication.mobile;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.SpringSecurityCoreVersion;
import java.util.Collection;
/**
* @author: Sun Chuan
* @date: 2019/7/15 19:23
* Description: 模仿UsernamePasswordAuthenticationToken
*/
public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
// ~ Instance fields
// ================================================================================================
private final Object principal;
// ~ Constructors
// ===================================================================================================
/**
* 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;
//this.credentials = credentials;
setAuthenticated(false);
}
/**
* This constructor should only be used by <code>AuthenticationManager</code> or
* <code>AuthenticationProvider</code> implementations that are satisfied with
* producing a trusted (i.e. {@link #isAuthenticated()} = <code>true</code>)
* authentication token.
*
* @param principal //@param credentials
* @param authorities
*/
public SmsCodeAuthenticationToken(Object principal,
Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
//this.credentials = credentials;
super.setAuthenticated(true); // must use super, as we override
}
// ~ Methods
// ========================================================================================================
@Override
public Object getCredentials() {
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");
}
super.setAuthenticated(false);
}
@Override
public void eraseCredentials() {
super.eraseCredentials();
//credentials = null;
}
}
2.4 将写好的Filter加入到spring-security的过滤器链
2.4.1 电话验证Filter — 单独一个配置文件
package com.nrsc.security.core.authentication.mobile;
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;
/**
* @author: Sun Chuan
* @date: 2019/7/15 19:53
* Description: 将smsCodeAuthenticationProvider 和 smsCodeAuthenticationFilter 放到spring-security过滤器链
*/
@Component
public class SmsCodeAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
@Autowired
private AuthenticationSuccessHandler NRSCAuthenticationSuccessHandler;
@Autowired
private AuthenticationFailureHandler NRSCAuthenticationFailureHandler;
@Autowired
private UserDetailsService NRSCDetailsService;
@Override
public void configure(HttpSecurity http) throws Exception {
SmsCodeAuthenticationFilter smsCodeAuthenticationFilter = new SmsCodeAuthenticationFilter();
//可以查看AbstractAuthenticationProcessingFilter源码,AuthenticationManager需要进行set进来
//UsernamePasswordAuthenticationFilter之所以没有进行set,是因为配置文件里默认给UsernamePasswordAuthenticationFilter进行了set
smsCodeAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
//可以查看AbstractAuthenticationProcessingFilter源码,该Filter给了一个默认的成功处理器和失败处理器
//为了使用我们自己写的,这里也需要重新set一下
smsCodeAuthenticationFilter.setAuthenticationSuccessHandler(NRSCAuthenticationSuccessHandler);
smsCodeAuthenticationFilter.setAuthenticationFailureHandler(NRSCAuthenticationFailureHandler);
SmsCodeAuthenticationProvider smsCodeAuthenticationProvider = new SmsCodeAuthenticationProvider();
//注意这里直接将NRSCDetailsService给set进去了,实际业务中要在NRSCDetailsService里判断我们的数据库有没有该电话号码
smsCodeAuthenticationProvider.setUserDetailsService(NRSCDetailsService);
http.authenticationProvider(smsCodeAuthenticationProvider)
.addFilterAfter(smsCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
}
2.4.2 将短信验证码校验Filter和2.4.1中的配置引入到继承了WebSecurityConfigurerAdapter配置文件中,使其真正在spring-security过滤器链中生效
package com.nrsc.security.browser.config;
import com.nrsc.security.core.authentication.mobile.SmsCodeAuthenticationSecurityConfig;
import com.nrsc.security.core.properties.SecurityProperties;
import com.nrsc.security.core.validate.code.SmsCodeFilter;
import com.nrsc.security.core.validate.code.ValidateCodeFilter;
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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
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;
@Configuration
public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Autowired
private AuthenticationSuccessHandler NRSCAuthenticationSuccessHandler;
@Autowired
private AuthenticationFailureHandler NRSCAuthenticationFailureHandler;
@Autowired
private SecurityProperties securityProperties;
@Autowired
private UserDetailsService NRSCDetailsService;
@Autowired
//springboot会根据yml文件中的spring:datasource将数据源注入到spring容器
//所以这里直接通过 @Autowired就可以拿到数据源
private DataSource dataSource;
@Autowired
private SmsCodeAuthenticationSecurityConfig smsCodeAuthenticationSecurityConfig;
@Bean
public PersistentTokenRepository persistentTokenRepository() {
JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
tokenRepository.setDataSource(dataSource);
// 第一次启动的时候自动建表(建议不用这句话,因为第二次启动会报错)
// 建表语句可在JdbcTokenRepositoryImpl源码中找到
// tokenRepository.setCreateTableOnStartup(true);
return tokenRepository;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
ValidateCodeFilter validateCodeFilter = new ValidateCodeFilter();
validateCodeFilter.setAuthenticationFailureHandler(NRSCAuthenticationFailureHandler);
validateCodeFilter.setSecurityProperties(securityProperties);
//调用afterPropertiesSet,初始化urls
validateCodeFilter.afterPropertiesSet();
//直接复制的上面的代码,之后会进行优化
SmsCodeFilter smsCodeFilter = new SmsCodeFilter();
smsCodeFilter.setAuthenticationFailureHandler(NRSCAuthenticationFailureHandler);
smsCodeFilter.setSecurityProperties(securityProperties);
//调用afterPropertiesSet,初始化urls
smsCodeFilter.afterPropertiesSet();
//将图形验证码的校验逻辑放在用户名和密码校验逻辑之前
http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(smsCodeFilter,UsernamePasswordAuthenticationFilter.class)
.formLogin()
.loginPage("/authentication/require")//登陆时进入的url-->相当于进入登陆页面
.loginProcessingUrl("/authentication/form")//告诉spring-security点击登陆时访问的url为/authentication/form
// ---->当spring-security接收到此url的请求后,会自动调用
//com.nrsc.security.browser.action.NRSCDetailsService中的loadUserByUsername
//.usernameParameter("user")-->与UsernamePasswordAuthenticationFilter中的usernameParameter对应,可修改其默认值
//.passwordParameter("code")-->与UsernamePasswordAuthenticationFilter中的passwordParameter对应,可修改其默认值
//进行登陆校验
.successHandler(NRSCAuthenticationSuccessHandler)//指定使用NRSCAuthenticationSuccessHandler处理登陆成功后的行为
.failureHandler(NRSCAuthenticationFailureHandler)//指定使用NNRSCAuthenticationFailureHandler处理登陆失败后的行为
.and()
//Remember相关配置
.rememberMe()
.tokenRepository(persistentTokenRepository())//指定使用的tokenRepository
.tokenValiditySeconds(securityProperties.getBrowser().getRememberMeSeconds())//指定记住我的时间(秒)
.userDetailsService(NRSCDetailsService)//指定进行登陆认证的UserDetailsService
.and()
.authorizeRequests()
//不进认证的url
.antMatchers("/code/*", "/authentication/require", securityProperties.getBrowser().getLoginPage())//指定不校验的url
.permitAll()
//除了不进行认证的url其他请求都需要认证
.anyRequest()
.authenticated()
.and()
.csrf().disable() //关闭csrf
//将smsCodeAuthenticationSecurityConfig配置文件加到该配置文件里
.apply(smsCodeAuthenticationSecurityConfig);
}
}
3. 测试
不具体展示,应该达到的效果
- 原来的用户名/密码登陆+图形验证码功能+记住我功能都能正常运行
- 利用短信验证码可以完成登陆,并且登陆成功后如果配置的成功处理器为返回校验成功的json对象,则应可以看到下面的校验结果
4. 代码优化 – 已上传github
基本本文的代码优化已经提交到github上:https://github.com/nieandsun/security
主要优化点有三个
- 将项目里用到的所有常量封装到一起 —> 便于管理
- 短信验证码+图形验证码优化 —> 消除重复代码
- 配置类优化 —> 将不同类型的配置分开,去耦合