本篇文章我们讲解Spring Security开发基于表单的登录的最后一块内容,基于短信验证码的接口开发。 前面的文章我们讲解了基于图片验证码的校验,有兴趣的同学可以翻看一下。这篇文章除了讲解短信验证码的开发之外,我们还会对之前的代码做一次重构,我们希望短信验证码和图片验证码的逻辑的公共部分可以抽离出来。
1.实现短信验证码登录步骤
- 开发短信验证码获取接口
- 校验短信验证码并登录
2.开发短信验证码获取接口
2.1ValidateCodeController
@RestController
public class ValidateCodeController {
@Autowired
private ValidateCodeProcessorHolder validateCodeProcessorHolder;
/**
* 创建验证码,根据验证码类型不同,调用不同的 {@link ValidateCodeProcessor}接口实现
*
* @param request
* @param response
* @param type
* @throws Exception
*/
@GetMapping(SecurityConstants.DEFAULT_VALIDATE_CODE_URL_PREFIX + "/{type}")
public void createCode(HttpServletRequest request, HttpServletResponse response, @PathVariable String type)
throws Exception {
validateCodeProcessorHolder.findValidateCodeProcessor(type).create(new ServletWebRequest(request, response));
}
}
这个controller中只有一个方法,就是createCode创建验证码。我们可以看到我们的GetMapping里面有一个type变量,这个就是用于区分到底是创建图片验证码还是短信验证码。为什么我们只用一个方法来搞定呢?这是因为我们觉得图片验证码和短信验证码的处理逻辑有两个
1.创建校验码
- 生成随机数
- 随机数存储
- 发送随机数给前台用户
2.校验验证码
我们可以创建一个ValidateCodeProcessor接口来声明这两个方法。使用模板模式创建一个抽象类AbstractValidateCodeProcessor,将生成随机数和随机数存储实现,至于发送随机数功能交给这个抽象类的两个子类ImageCodeProcessor和SmsCodeProcessor,同时为了让用户能够实现自己的随机数生成逻辑,我们在抽象类中引入了ValidateCodeGenerator,这样就可以让用户替换默认的随机数生成逻辑。如下图所示:
2.2ValidateCodeProcessor
public interface ValidateCodeProcessor {
/**
* 验证码放入session时的前缀
*/
String SESSION_KEY_PREFIX = "SESSION_KEY_FOR_CODE_";
/**
* 创建校验码
*
* @param request
* @throws Exception
*/
void create(ServletWebRequest request) throws Exception;
/**
* 校验验证码
*
* @param servletWebRequest
* @throws Exception
*/
void validate(ServletWebRequest servletWebRequest);
}
2.3AbstractValidateCodeProcessor
public abstract class AbstractValidateCodeProcessor implements ValidateCodeProcessor {
/**
* 操作session的工具类
*/
private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();
/**
* 收集系统中所有的 {@link ValidateCodeGenerator} 接口的实现。
*/
@Autowired
private Map validateCodeGenerators;
/*
* (non-Javadoc)
*
* @see
* com.imooc.security.core.validate.code.ValidateCodeProcessor#create(org.
* springframework.web.context.request.ServletWebRequest)
*/
@Override
public void create(ServletWebRequest request) throws Exception {
C validateCode = generate(request);
save(request, validateCode);
send(request, validateCode);
}
/**
* 生成校验码
*
* @param request
* @return
*/
@SuppressWarnings("unchecked")
private C generate(ServletWebRequest request) {
String type = getValidateCodeType(request).toString().toLowerCase();
String generatorName = type + ValidateCodeGenerator.class.getSimpleName();
ValidateCodeGenerator validateCodeGenerator = validateCodeGenerators.get(generatorName);
if (validateCodeGenerator == null) {
throw new ValidateCodeException("验证码生成器" + generatorName + "不存在");
}
return (C) validateCodeGenerator.generate(request);
}
/**
* 保存校验码
*
* @param request
* @param validateCode
*/
private void save(ServletWebRequest request, C validateCode) {
sessionStrategy.setAttribute(request, getSessionKey(request), validateCode);
}
/**
* 构建验证码放入session时的key
*
* @param request
* @return
*/
private String getSessionKey(ServletWebRequest request) {
return SESSION_KEY_PREFIX + getValidateCodeType(request).toString().toUpperCase();
}
/**
* 发送校验码,由子类实现
*
* @param request
* @param validateCode
* @throws Exception
*/
protected abstract void send(ServletWebRequest request, C validateCode) throws Exception;
/**
* 根据请求的url获取校验码的类型
*
* @param request
* @return
*/
private ValidateCodeType getValidateCodeType(ServletWebRequest request) {
String type = StringUtils.substringBefore(getClass().getSimpleName(), "CodeProcessor");
return ValidateCodeType.valueOf(type.toUpperCase());
}
@SuppressWarnings("unchecked")
@Override
public void validate(ServletWebRequest request) {
ValidateCodeType processorType = getValidateCodeType(request);
String sessionKey = getSessionKey(request);
C codeInSession = (C) sessionStrategy.getAttribute(request, sessionKey);
String codeInRequest;
try {
codeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(),
processorType.getParamNameOnValidate());
} catch (ServletRequestBindingException e) {
throw new ValidateCodeException("获取验证码的值失败");
}
if (StringUtils.isBlank(codeInRequest)) {
throw new ValidateCodeException(processorType + "验证码的值不能为空");
}
if (codeInSession == null) {
throw new ValidateCodeException(processorType + "验证码不存在");
}
if (codeInSession.isExpried()) {
sessionStrategy.removeAttribute(request, sessionKey);
throw new ValidateCodeException(processorType + "验证码已过期");
}
if (!StringUtils.equals(codeInSession.getCode(), codeInRequest)) {
throw new ValidateCodeException(processorType + "验证码不匹配");
}
sessionStrategy.removeAttribute(request, sessionKey);
}
}
2.4ValidateCodeGenerator
public interface ValidateCodeGenerator {
ValidateCode generate(ServletWebRequest request);
}
这个接口在Processor中被使用
2.5SmsCodeGenerator
短信验证码的验证码生成器实现类
@Component("smsValidateCodeGenerator")
public class SmsCodeGenerator implements ValidateCodeGenerator {
@Autowired
private SecurityProperties securityProperties;
/*
* (non-Javadoc)
*
* @see
* com.imooc.security.core.validate.code.ValidateCodeGenerator#generate(org.
* springframework.web.context.request.ServletWebRequest)
*/
@Override
public ValidateCode generate(ServletWebRequest request) {
String code = RandomStringUtils.randomNumeric(securityProperties.getCode().getSms().getLength());
return new ValidateCode(code, securityProperties.getCode().getSms().getExpireIn());
}
public SecurityProperties getSecurityProperties() {
return securityProperties;
}
public void setSecurityProperties(SecurityProperties securityProperties) {
this.securityProperties = securityProperties;
}
}
2.6前台demo页面
登录
短信登录
短信验证码的获取接口就写到这里,代码中的注释也比较详细,大家可以自行研究
3.验证码校验逻辑
因为短信登录和密码登录请求不一样,所以我们要仿照UsernamePasswordAuthenticationFilter写一套自己的逻辑,如果大家对密码登录请求的处理流程不熟悉的地方,可以参考我的这篇文章SpringSecurity认证流程源码进行了讲解。我们需要开发一个SmsAuthenticationFilter来专门处理短信登录请求,这个Filter会调用AuthenticationManager来进行验证码的验证,AuthenticationManager是管理Provider的地方,所以我们还要专门写一个SmsAuthenticationProvider来获取用户信息,这个Provider会调用UserDetailsService来获取用户信息,最后来完成认证。注意我们这里的SmsAuthenticationFilter不是校验短信验证码的,它主要是用于获取用户信息的,真正的校验短信验证码的逻辑,我们会写到一个专门的Filter中,这样做可以做到让这个Filter被其他模块共用。具体流程如下:
最终模块之间的逻辑如下:
3.1SmsAuthenticationFilter
public class SmsCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
// ~ Static fields/initializers
// =====================================================================================
private String mobileParameter = SecurityConstants.DEFAULT_PARAMETER_NAME_MOBILE;
private boolean postOnly = true;
// ~ Constructors
// ===================================================================================================
public SmsCodeAuthenticationFilter() {
super(new AntPathRequestMatcher(SecurityConstants.DEFAULT_LOGIN_PROCESSING_URL_MOBILE, "POST"));
}
// ~ Methods
// ========================================================================================================
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));
}
/**
* 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 setMobileParameter(String usernameParameter) {
Assert.hasText(usernameParameter, "Username parameter must not be empty or null");
this.mobileParameter = usernameParameter;
}
/**
* 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 unsuccessfulAuthentication() method
* will be called as if handling a failed authentication.
*
* Defaults to true but may be overridden by subclasses.
*/
public void setPostOnly(boolean postOnly) {
this.postOnly = postOnly;
}
public final String getMobileParameter() {
return mobileParameter;
}
}
3.2 SmsAuthenticationProvider
public class SmsCodeAuthenticationProvider implements AuthenticationProvider {
private UserDetailsService userDetailsService;
/*
* (non-Javadoc)
*
* @see org.springframework.security.authentication.AuthenticationProvider#
* authenticate(org.springframework.security.core.Authentication)
*/
@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;
}
/*
* (non-Javadoc)
*
* @see org.springframework.security.authentication.AuthenticationProvider#
* supports(java.lang.Class)
*/
@Override
public boolean supports(Class> authentication) {
return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication);
}
public UserDetailsService getUserDetailsService() {
return userDetailsService;
}
public void setUserDetailsService(UserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}
}
3.3SmsCodeAuthenticationSecurityConfig
@Component
public class SmsCodeAuthenticationSecurityConfig extends SecurityConfigurerAdapter {
@Autowired
private AuthenticationSuccessHandler imoocAuthenticationSuccessHandler;
@Autowired
private AuthenticationFailureHandler imoocAuthenticationFailureHandler;
@Autowired
private UserDetailsService userDetailsService;
@Override
public void configure(HttpSecurity http) throws Exception {
SmsCodeAuthenticationFilter smsCodeAuthenticationFilter = new SmsCodeAuthenticationFilter();
smsCodeAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
smsCodeAuthenticationFilter.setAuthenticationSuccessHandler(imoocAuthenticationSuccessHandler);
smsCodeAuthenticationFilter.setAuthenticationFailureHandler(imoocAuthenticationFailureHandler);
SmsCodeAuthenticationProvider smsCodeAuthenticationProvider = new SmsCodeAuthenticationProvider();
smsCodeAuthenticationProvider.setUserDetailsService(userDetailsService);
http.authenticationProvider(smsCodeAuthenticationProvider)
.addFilterAfter(smsCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
}
3.4SmsCodeAuthenticationToken
我们现在不能再使用UsernamePasswordAuthenticationToken了,因为这个token里面有面,我们现在的短信验证码登录是不需要输入密码的,只需要输入短信验证码就可以了。所以要自定义一个Token
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
* UsernamePasswordAuthenticationToken
, as the {@link #isAuthenticated()}
* will return false
.
*
*/
public SmsCodeAuthenticationToken(String mobile) {
super(null);
this.principal = mobile;
setAuthenticated(false);
}
/**
* This constructor should only be used by AuthenticationManager
or
* AuthenticationProvider
implementations that are satisfied with
* producing a trusted (i.e. {@link #isAuthenticated()} = true
)
* authentication token.
*
* @param principal
* @param credentials
* @param authorities
*/
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();
}
}
3.5AbstractChannelSecurityConfig
无论是APP还是页面,都需要设置默认登录页面,和默认登录页面的登录处理逻辑,所以我们封装到一个AbstractChannelSecurityConfig中,这样就可以让两个模块共用了
public class AbstractChannelSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
protected AuthenticationSuccessHandler imoocAuthenticationSuccessHandler;
@Autowired
protected AuthenticationFailureHandler imoocAuthenticationFailureHandler;
protected void applyPasswordAuthenticationConfig(HttpSecurity http) throws Exception {
http.formLogin()
.loginPage(SecurityConstants.DEFAULT_UNAUTHENTICATION_URL)
.loginProcessingUrl(SecurityConstants.DEFAULT_LOGIN_PROCESSING_URL_FORM)
.successHandler(imoocAuthenticationSuccessHandler)
.failureHandler(imoocAuthenticationFailureHandler);
}
}
3.6ValidateCodeFilter
真正校验验证码的逻辑Filter,这个Filter不仅校验了短信验证码还校验了图片验证码
@Component("validateCodeFilter")
public class ValidateCodeFilter extends OncePerRequestFilter implements InitializingBean {
/**
* 验证码校验失败处理器
*/
@Autowired
private AuthenticationFailureHandler authenticationFailureHandler;
/**
* 系统配置信息
*/
@Autowired
private SecurityProperties securityProperties;
/**
* 系统中的校验码处理器
*/
@Autowired
private ValidateCodeProcessorHolder validateCodeProcessorHolder;
/**
* 存放所有需要校验验证码的url
*/
private Map urlMap = new HashMap<>();
/**
* 验证请求url与配置的url是否匹配的工具类
*/
private AntPathMatcher pathMatcher = new AntPathMatcher();
/**
* 初始化要拦截的url配置信息
*/
@Override
public void afterPropertiesSet() throws ServletException {
super.afterPropertiesSet();
urlMap.put(SecurityConstants.DEFAULT_LOGIN_PROCESSING_URL_FORM, ValidateCodeType.IMAGE);
addUrlToMap(securityProperties.getCode().getImage().getUrl(), ValidateCodeType.IMAGE);
urlMap.put(SecurityConstants.DEFAULT_LOGIN_PROCESSING_URL_MOBILE, ValidateCodeType.SMS);
addUrlToMap(securityProperties.getCode().getSms().getUrl(), ValidateCodeType.SMS);
}
/**
* 讲系统中配置的需要校验验证码的URL根据校验的类型放入map
*
* @param urlString
* @param type
*/
protected void addUrlToMap(String urlString, ValidateCodeType type) {
if (StringUtils.isNotBlank(urlString)) {
String[] urls = StringUtils.splitByWholeSeparatorPreserveAllTokens(urlString, ",");
for (String url : urls) {
urlMap.put(url, type);
}
}
}
/*
* (non-Javadoc)
*
* @see
* org.springframework.web.filter.OncePerRequestFilter#doFilterInternal(
* javax.servlet.http.HttpServletRequest,
* javax.servlet.http.HttpServletResponse, javax.servlet.FilterChain)
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
ValidateCodeType type = getValidateCodeType(request);
if (type != null) {
logger.info("校验请求(" + request.getRequestURI() + ")中的验证码,验证码类型" + type);
try {
validateCodeProcessorHolder.findValidateCodeProcessor(type)
.validate(new ServletWebRequest(request, response));
logger.info("验证码校验通过");
} catch (ValidateCodeException exception) {
authenticationFailureHandler.onAuthenticationFailure(request, response, exception);
return;
}
}
chain.doFilter(request, response);
}
/**
* 获取校验码的类型,如果当前请求不需要校验,则返回null
*
* @param request
* @return
*/
private ValidateCodeType getValidateCodeType(HttpServletRequest request) {
ValidateCodeType result = null;
if (!StringUtils.equalsIgnoreCase(request.getMethod(), "get")) {
Set urls = urlMap.keySet();
for (String url : urls) {
if (pathMatcher.match(url, request.getRequestURI())) {
result = urlMap.get(url);
}
}
}
return result;
}
}
3.7browser模块中BrowserSecurityConfig的配置
@Configuration
public class BrowserSecurityConfig extends AbstractChannelSecurityConfig {
@Autowired
private SecurityProperties securityProperties;
@Autowired
private DataSource dataSource;
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private SmsCodeAuthenticationSecurityConfig smsCodeAuthenticationSecurityConfig;
@Autowired
private ValidateCodeSecurityConfig validateCodeSecurityConfig;
@Override
protected void configure(HttpSecurity http) throws Exception {
applyPasswordAuthenticationConfig(http);
http.apply(validateCodeSecurityConfig)
.and()
.apply(smsCodeAuthenticationSecurityConfig)
.and()
.rememberMe()
.tokenRepository(persistentTokenRepository())
.tokenValiditySeconds(securityProperties.getBrowser().getRememberMeSeconds())
.userDetailsService(userDetailsService)
.and()
.authorizeRequests()
.antMatchers(
SecurityConstants.DEFAULT_UNAUTHENTICATION_URL,
SecurityConstants.DEFAULT_LOGIN_PROCESSING_URL_MOBILE,
securityProperties.getBrowser().getLoginPage(),
SecurityConstants.DEFAULT_VALIDATE_CODE_URL_PREFIX+"/*")
.permitAll()
.anyRequest()
.authenticated()
.and()
.csrf().disable();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public PersistentTokenRepository persistentTokenRepository() {
JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
tokenRepository.setDataSource(dataSource);
// tokenRepository.setCreateTableOnStartup(true);
return tokenRepository;
}
}