先来看下 Spring Security密码登录大概流程,模拟这个流程,开发短信登录流程
1,密码登录请求发送给过滤器 UsernamePasswordAuthenticationFilter
2,过滤器拿出用户名密码组装成 UsernamePasswordAuthenticationToken 对象传给AuthenticationManager
3,AuthenticationManager 会从一堆 AuthenticationProvider 里选出一个Provider 处理认证请求。挑选的依据是AuthenticationProvider 里有个
boolean supports(Class<?> authentication);方法,判断当前的provider是否支持传进的token,如果支持就用这个provider认证这个token,并调用authenticate() 方法 进行认证
4,认证过程会调用UserDetailsService获取用户信息,跟传进来的登录信息做比对。认证通过会把UsernamePasswordAuthenticationToken做一个标识 标记已认证,放进session。
做短信登录,不能在这个流程上改,这是两种不同的登录方式,混在一起代码质量不好,需要仿照这个流程写一套自己的流程:
SmsAuthenticationFilter:拦截短信登录请求,从请求中获取手机号,封装成 SmsAuthenticationToken 也会传给AuthenticationManager,AuthenticationManager会找适合的provider,自定义SmsAuthenticationProvider校验SmsAuthenticationToken 里手机号信息。也会调UserDetailsService 看是否能登录,能的话标记为已登录。
其中SmsAuthenticationFilter 参考UsernamePasswordAuthenticationFilter写,SmsCodeAuthenticationToken参考UsernamePasswordAuthenticationToken写,其实就是就是复制粘贴改改
从上图可知,需要写三个类:
1,SmsAuthenticationToken:复制UsernamePasswordAuthenticationToken,把没用的去掉
package com.imooc.security.core.authentication.mobile; import java.util.Collection; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.SpringSecurityCoreVersion; /** * 模仿UsernamePasswordAuthenticationToken写的短信登录token * ClassName: SmsCodeAuthenticationToken * @Description: TODO * @author lihaoyang * @date 2018年3月7日 */ public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken { private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID; //没登陆,放手机号,登录成功,放用户信息 private final Object principal; /** * 没登录放手机号 * <p>Description: </p> * @param mobile */ public SmsCodeAuthenticationToken(String mobile) { super(null); this.principal = mobile;//没登录放手机号 setAuthenticated(false);//没登录 } public SmsCodeAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) { super(authorities); this.principal = principal; super.setAuthenticated(true); // must use super, as we override } // ~ Methods // ======================================================================================================== 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(); } @Override public Object getCredentials() { return null; } }
2,SmsCodeAuthenticationFilter,参考UsernamePasswordAuthenticationFilter
package com.imooc.security.core.authentication.mobile; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.springframework.security.authentication.AuthenticationServiceException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; 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; /** * 模仿UsernamePasswordAuthenticationFilter 写的短信验证码过滤器 * ClassName: SmsCodeAuthenticationFilter * @Description: TODO * @author lihaoyang * @date 2018年3月8日 */ public class SmsCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter{ public static final String IMOOC_FORM_MOBILE_KEY = "mobile"; private String mobilePatameter = IMOOC_FORM_MOBILE_KEY; private boolean postOnly = true; // ~ Constructors // =================================================================================================== public SmsCodeAuthenticationFilter() { //过滤的请求url,登录表单的url super(new AntPathRequestMatcher("/authentication/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); //在这里把SmsCodeAuthenticationToken交给AuthenticationManager return this.getAuthenticationManager().authenticate(authRequest); } /** * 获取手机号 * @Description: TODO * @param @param request * @param @return * @return String * @throws * @author lihaoyang * @date 2018年3月7日 */ private String obtainMobile(HttpServletRequest request) { return request.getParameter(mobilePatameter); } protected void setDetails(HttpServletRequest request, SmsCodeAuthenticationToken authRequest) { authRequest.setDetails(authenticationDetailsSource.buildDetails(request)); } public void setPostOnly(boolean postOnly) { this.postOnly = postOnly; } }
3,SmsCodeAuthenticationProvider:
在 SmsCodeAuthenticationFilter 里 attemptAuthentication方法的最后, return this.getAuthenticationManager().authenticate(authRequest);这句话就是进到 SmsCodeAuthenticationProvider 先调用 supports() 方法,通过后,再调用 authenticate()方法进行认证
package com.imooc.security.core.authentication.mobile; 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; /** * AuthenticationManager 认证时候需要用的一个Provider * ClassName: SmsCodeAuthenticationProvider * @Description: TODO * @author lihaoyang * @date 2018年3月8日 */ public class SmsCodeAuthenticationProvider implements AuthenticationProvider { private UserDetailsService userDetailsService; /** * 认证 */ @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { //能进到这说明authentication是SmsCodeAuthenticationToken,转一下 SmsCodeAuthenticationToken authenticationToken = (SmsCodeAuthenticationToken)authentication; //token.getPrincipal()就是手机号 mobile UserDetails user = userDetailsService.loadUserByUsername((String) authenticationToken.getPrincipal()); //认证没通过 if(user == null){ throw new InternalAuthenticationServiceException("无法获取用户信息"); } //认证通过 SmsCodeAuthenticationToken authenticationResult = new SmsCodeAuthenticationToken(user, user.getAuthorities()); //把认证之前得token里存的用户信息赋值给认证后的token对象 authenticationResult.setDetails(authenticationToken.getDetails()); return authenticationResult; } /** * 告诉AuthenticationManager,如果是SmsCodeAuthenticationToken的话用这个类处理 */ @Override public boolean supports(Class<?> authentication) { //判断传进来的authentication是不是SmsCodeAuthenticationToken类型的 return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication); } public UserDetailsService getUserDetailsService() { return userDetailsService; } public void setUserDetailsService(UserDetailsService userDetailsService) { this.userDetailsService = userDetailsService; } }
短信 验证码过滤器,照着图片验证码过滤器写,其实可以重构,不会弄:
package com.imooc.security.core.validate.code; import java.io.IOException; import java.util.HashSet; import java.util.Set; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.apache.commons.lang.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; 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 com.imooc.security.core.properties.SecurityConstants; import com.imooc.security.core.properties.SecurityProperties; /** * 短信验证码过滤器 * ClassName: ValidateCodeFilter * @Description: * 继承OncePerRequestFilter:spring提供的工具,保证过滤器每次只会被调用一次 * 实现 InitializingBean接口的目的: * 在其他参数都组装完毕的时候,初始化需要拦截的urls的值 * @author lihaoyang * @date 2018年3月2日 */ public class SmsCodeFilter extends OncePerRequestFilter implements InitializingBean{ private Logger logger = LoggerFactory.getLogger(getClass()); //认证失败处理器 private AuthenticationFailureHandler authenticationFailureHandler; //获取session工具类 private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy(); //需要拦截的url集合 private Set<String> urls = new HashSet<>(); //读取配置 private SecurityProperties securityProperties; //spring工具类 private AntPathMatcher antPathMatcher = new AntPathMatcher(); /** * 重写InitializingBean的方法,设置需要拦截的urls */ @Override public void afterPropertiesSet() throws ServletException { super.afterPropertiesSet(); //读取配置的拦截的urls String[] configUrls = StringUtils.splitByWholeSeparatorPreserveAllTokens(securityProperties.getCode().getSms().getUrl(), ","); //如果配置了需要验证码拦截的url,不判断,如果没有配置会空指针 if(configUrls != null && configUrls.length > 0){ for (String configUrl : configUrls) { logger.info("ValidateCodeFilter.afterPropertiesSet()--->配置了验证码拦截接口:"+configUrl); urls.add(configUrl); } }else{ logger.info("----->没有配置拦验证码拦截接口<-------"); } //短信验证码登录一定拦截 urls.add(SecurityConstants.DEFAULT_LOGIN_PROCESSING_URL_MOBILE); } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { //如果是 登录请求 则执行 // if(StringUtils.equals("/authentication/form", request.getRequestURI()) // &&StringUtils.equalsIgnoreCase(request.getMethod(), "post")){ // try { // validate(new ServletWebRequest(request)); // } catch (ValidateCodeException e) { // //调用错误处理器,最终调用自己的 // authenticationFailureHandler.onAuthenticationFailure(request, response, e); // return ;//结束方法,不再调用过滤器链 // } // } /** * 可配置的验证码校验 * 判断请求的url和配置的是否有匹配的,匹配上了就过滤 */ boolean action = false; for(String url:urls){ if(antPathMatcher.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); } /** * 校验验证码 * @Description: 校验验证码 * @param @param request * @param @throws ServletRequestBindingException * @return void * @throws ValidateCodeException * @author lihaoyang * @date 2018年3月2日 */ private void validate(ServletWebRequest request) throws ServletRequestBindingException { //拿出session中的ImageCode对象 ValidateCode smsCodeInSession = (ValidateCode) sessionStrategy.getAttribute(request, ValidateCodeController.SESSION_KEY_SMS); //拿出请求中的验证码 String imageCodeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(), "smsCode"); //校验 if(StringUtils.isBlank(imageCodeInRequest)){ throw new ValidateCodeException("验证码不能为空"); } if(smsCodeInSession == null){ throw new ValidateCodeException("验证码不存在,请刷新验证码"); } if(smsCodeInSession.isExpired()){ //从session移除过期的验证码 sessionStrategy.removeAttribute(request, ValidateCodeController.SESSION_KEY_SMS); throw new ValidateCodeException("验证码已过期,请刷新验证码"); } if(!StringUtils.equalsIgnoreCase(smsCodeInSession.getCode(), imageCodeInRequest)){ throw new ValidateCodeException("验证码错误"); } //验证通过,移除session中验证码 sessionStrategy.removeAttribute(request, ValidateCodeController.SESSION_KEY_SMS); } public AuthenticationFailureHandler getAuthenticationFailureHandler() { return authenticationFailureHandler; } public void setAuthenticationFailureHandler(AuthenticationFailureHandler authenticationFailureHandler) { this.authenticationFailureHandler = authenticationFailureHandler; } public SecurityProperties getSecurityProperties() { return securityProperties; } public void setSecurityProperties(SecurityProperties securityProperties) { this.securityProperties = securityProperties; } }
把新建的这三个类做下配置,让spring security知道
SmsCodeAuthenticationSecurityConfig:
package com.imooc.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; /** * 短信验证码配置 * ClassName: SmsCodeAuthenticationSecurityConfig * @Description: TODO * @author lihaoyang * @date 2018年3月8日 */ @Component public class SmsCodeAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> { @Autowired private AuthenticationFailureHandler imoocAuthenticationFailureHandler; @Autowired private AuthenticationSuccessHandler imoocAuthenticationSuccessHandler; @Autowired private UserDetailsService userDetailsService; @Override public void configure(HttpSecurity http) throws Exception { //1,配置短信验证码过滤器 SmsCodeAuthenticationFilter smsCodeAuthenticationFilter = new SmsCodeAuthenticationFilter(); smsCodeAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class)); //设置认证失败成功处理器 smsCodeAuthenticationFilter.setAuthenticationSuccessHandler(imoocAuthenticationSuccessHandler); smsCodeAuthenticationFilter.setAuthenticationFailureHandler(imoocAuthenticationFailureHandler); //配置pprovider SmsCodeAuthenticationProvider smsCodeAuthenticationProvider = new SmsCodeAuthenticationProvider(); smsCodeAuthenticationProvider.setUserDetailsService(userDetailsService); http.authenticationProvider(smsCodeAuthenticationProvider) .addFilterAfter(smsCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); } }
最后在BrowserSecurityConfig里配置短信验证码
@Configuration //这是一个配置 public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter{ //读取用户配置的登录页配置 @Autowired private SecurityProperties securityProperties; //自定义的登录成功后的处理器 @Autowired private AuthenticationSuccessHandler imoocAuthenticationSuccessHandler; //自定义的认证失败后的处理器 @Autowired private AuthenticationFailureHandler imoocAuthenticationFailureHandler; //数据源 @Autowired private DataSource dataSource; @Autowired private UserDetailsService userDetailsService; @Autowired private SmsCodeAuthenticationSecurityConfig smsCodeAuthenticationSecurityConfig; @Autowired private SpringSocialConfigurer imoocSocialSecurityConfig; //注意是org.springframework.security.crypto.password.PasswordEncoder @Bean public PasswordEncoder passwordencoder(){ //BCryptPasswordEncoder implements PasswordEncoder return new BCryptPasswordEncoder(); } /** * 记住我TokenRepository配置,在登录成功后执行 * 登录成功后往数据库存token的 * @Description: 记住我TokenRepository配置 * @param @return JdbcTokenRepositoryImpl * @return PersistentTokenRepository * @throws * @author lihaoyang * @date 2018年3月5日 */ @Bean public PersistentTokenRepository persistentTokenRepository(){ JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl(); jdbcTokenRepository.setDataSource(dataSource); //启动时自动生成相应表,可以在JdbcTokenRepositoryImpl里自己执行CREATE_TABLE_SQL脚本生成表 //第二次启动表已存在,需要注释 // jdbcTokenRepository.setCreateTableOnStartup(true); return jdbcTokenRepository; } //版本二:可配置的登录页 @Override protected void configure(HttpSecurity http) throws Exception { //~~~-------------> 图片验证码过滤器 <------------------ ValidateCodeFilter validateCodeFilter = new ValidateCodeFilter(); //验证码过滤器中使用自己的错误处理 validateCodeFilter.setAuthenticationFailureHandler(imoocAuthenticationFailureHandler); //配置的验证码过滤url validateCodeFilter.setSecurityProperties(securityProperties); validateCodeFilter.afterPropertiesSet(); //~~~-------------> 短信验证码过滤器 <------------------ SmsCodeFilter smsCodeFilter = new SmsCodeFilter(); //验证码过滤器中使用自己的错误处理 smsCodeFilter.setAuthenticationFailureHandler(imoocAuthenticationFailureHandler); //配置的验证码过滤url smsCodeFilter.setSecurityProperties(securityProperties); smsCodeFilter.afterPropertiesSet(); //实现需要认证的接口跳转表单登录,安全=认证+授权 //http.httpBasic() //这个就是默认的弹框认证 // http .addFilterBefore(smsCodeFilter, UsernamePasswordAuthenticationFilter.class) // .apply(imoocSocialSecurityConfig)//社交登录 // .and() //把验证码过滤器加载登录过滤器前边 .addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class) //表单认证相关配置 .formLogin() .loginPage(SecurityConstants.DEFAULT_UNAUTHENTICATION_URL) //处理用户认证BrowserSecurityController //登录过滤器UsernamePasswordAuthenticationFilter默认登录的url是"/login",在这能改 .loginProcessingUrl(SecurityConstants.DEFAULT_LOGIN_PROCESSING_URL_FORM) .successHandler(imoocAuthenticationSuccessHandler)//自定义的认证后处理器 .failureHandler(imoocAuthenticationFailureHandler) //登录失败后的处理 .and() //记住我相关配置 .rememberMe() .tokenRepository(persistentTokenRepository())//TokenRepository,登录成功后往数据库存token的 .tokenValiditySeconds(securityProperties.getBrowser().getRememberMeSeconds())//记住我秒数 .userDetailsService(userDetailsService) //记住我成功后,调用userDetailsService查询用户信息 .and() //授权相关的配置 .authorizeRequests() // /authentication/require:处理登录,securityProperties.getBrowser().getLoginPage():用户配置的登录页 .antMatchers(SecurityConstants.DEFAULT_UNAUTHENTICATION_URL, securityProperties.getBrowser().getLoginPage(),//放过登录页不过滤,否则报错 SecurityConstants.DEFAULT_VALIDATE_CODE_URL_PREFIX+"/*").permitAll() //验证码 .anyRequest() //任何请求 .authenticated() //都需要身份认证 .and() .csrf().disable() //关闭csrf防护 .apply(smsCodeAuthenticationSecurityConfig);//把短信验证码配置应用上 } }
访问登陆页,点击发送验证码模拟发送验证码
输入后台打印的验证码
登录成功:
完整代码在github:https://github.com/lhy1234/spring-security
欢迎关注个人公众号一起交流学习: