在验证用户名和密码前,引入辅助验证可有效防范暴力试错,图形验证码就是简单且行有效的一种辅助验证方式。

一、使用过滤器实现

1.SpringSecurity的过滤器

之前的配置:

@Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                //以下资源允许访问
                .antMatchers("/css/**", "/js/**", "/picture/**")
                .permitAll()
                .antMatchers("/admin/**").hasRole("ADMIN")
                .antMatchers("/user/**").hasRole("USER")
                .anyRequest().authenticated()
                .and()
                .formLogin()
                //登录请求
                .loginPage("/login.html")
                .loginProcessingUrl("/login")
                .successHandler(loginSuccessHandler)
                .permitAll()
                .and()
                .csrf().disable()
                .sessionManagement().maximumSessions(1);
    }

HttpSecurity实际就是在配置SpringSecurity的过滤器链,比如formLogin、csrf等,每个配置对应一个过滤器.我们可以通过 HttpSecurity 配置过滤器的行为,甚至可以像CRSF一样直接关闭过滤器。

比如sessionManagement:

public SessionManagementConfigurer<HttpSecurity> sessionManagement() throws Exception {
		return getOrApply(new SessionManagementConfigurer<>());
	}

SpringSecurity通过SessionManagementConfigurer 来配置SessionManagement的行为。与 SessionManagementConfigurer 类似的配置器还有CorsConfigurer、RememberMeConfigurer 等,它们都实 现了SecurityConfigurer的标准接口:

public interface SecurityConfigurer<O, B extends SecurityBuilder<O>> {

	/**
     * 各个配置器的初始化方法
	 */
	void init(B builder) throws Exception;

	/**
     * 各个配置器被统一调用的方法
	 */
	void configure(B builder) throws Exception;

}

因此我们也可以添加自定义的过滤器,利用SpringSecurity提供的方式:

@Override
	public HttpSecurity addFilterAfter(Filter filter, Class<? extends Filter> afterFilter) {
		return addFilterAtOffsetOf(filter, 1, afterFilter);
	}

	@Override
	public HttpSecurity addFilterBefore(Filter filter, Class<? extends Filter> beforeFilter) {
		return addFilterAtOffsetOf(filter, -1, beforeFilter);
	}

	private HttpSecurity addFilterAtOffsetOf(Filter filter, int offset, Class<? extends Filter> registeredFilter) {
		int order = this.filterOrders.getOrder(registeredFilter) + offset;
		this.filters.add(new OrderedFilter(filter, order));
		this.filterOrders.put(filter.getClass(), order);
		return this;
	}

	@Override
	public HttpSecurity addFilter(Filter filter) {
		Integer order = this.filterOrders.getOrder(filter.getClass());
		if (order == null) {
			throw new IllegalArgumentException("The Filter class " + filter.getClass().getName()
					+ " does not have a registered order and cannot be added without a specified order. Consider using addFilterBefore or addFilterAfter instead.");
		}
		this.filters.add(new OrderedFilter(filter, order));
		return this;
	}

 2、选择一个图形验证码组件

git上有很多开源的组件,随便找了个:https://gitee.com/ele-admin/EasyCaptcha

(是一个个人项目,安全性不保证,主要好看)

springsecurity中添加短信验证码参数 spring验证码实现_验证码

 1.maven:

<dependencies>
   <dependency>
      <groupId>com.github.whvcse</groupId>
      <artifactId>easy-captcha</artifactId>
      <version>1.6.2</version>
   </dependency>
</dependencies>

2. 获取验证码的controller

@Controller
public class CaptchaController {

    @GetMapping("/captcha")
    public void captcha(HttpServletRequest request, HttpServletResponse response) throws Exception {
        // 设置请求头为输出图片类型
        response.setContentType("image/png");
        response.setHeader("Pragma", "No-cache");
        response.setHeader("Cache-Control", "no-cache");
        response.setDateHeader("Expires", 0);
        // 三个参数分别为宽、高、位数
        SpecCaptcha specCaptcha = new SpecCaptcha(130, 48, 5);
        // 设置字体
        specCaptcha.setFont(new Font("Verdana", Font.PLAIN, 32));  // 有默认字体,可以不用设置
        // 设置类型,纯数字、纯字母、字母数字混合
        specCaptcha.setCharType(Captcha.TYPE_ONLY_NUMBER);
        // 验证码存入session
        request.getSession().setAttribute("captcha", specCaptcha.text().toLowerCase());
        // 输出图片流
        specCaptcha.out(response.getOutputStream());
    }
}

当用户访问/captcha的时候,就能获取到一张图片,验证码文本则被存放到session中, 用于后续的校验。

有了图形验证码的API之后,就可以自定义验证码校验过滤器了。

3、实现图形验证码过滤器

虽然Spring Security的过滤器链对过滤器没有特殊要求,只要继承了Filter 即可,但是在 Spring体系中,推荐使用OncePerRequestFilter来实现,它可以确保一次请求只会通过一次该过滤器(Filter实际上并不能保证这 一点)。

//验证码异常类
public class VerificationCodeException extends AuthenticationException {
    public VerificationCodeException(){
        super("验证码校验失败");
    }
}

//过滤器
@Component
public class VerificationCodeFilter extends OncePerRequestFilter {
    @Resource
    private VerificationCodeFailureHandler verificationCodeFailureHandler;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        if (!"/login".equals(request.getRequestURI())) {
            filterChain.doFilter(request, response);
        } else {
            try {
                verificationCode(request);
                filterChain.doFilter(request, response);
            } catch (VerificationCodeException e) {
                verificationCodeFailureHandler.onAuthenticationFailure(request, response, e);
            }
        }
    }

    private void verificationCode(HttpServletRequest request){
        String captcha = request.getParameter("captcha");
        HttpSession session = request.getSession();
        String saveCaptcha = (String) session.getAttribute("captcha");
        session.removeAttribute("captcha");
        if (ObjectUtils.isEmpty(captcha) || ObjectUtils.isEmpty(saveCaptcha) || captcha.equals(saveCaptcha)) {
            throw new VerificationCodeException();
        }
    }
}

//校验不通过的handler
@Component
@Slf4j
public class VerificationCodeFailureHandler implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
            AuthenticationException exception) throws IOException, ServletException {
        log.info("login fail, msg: {}", exception.getMessage());
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(JSON.toJSONString(ResultVO.error(10000, exception.getMessage())));
    }
}

修改配置: 

springsecurity中添加短信验证码参数 spring验证码实现_ide_02

4. 前端页面添加

<img src="/captcha" width="130px" height="48px" />

5.启动项目

springsecurity中添加短信验证码参数 spring验证码实现_ide_03

二、使用自定义认证实现

前面使用过滤器的方式实现了带图形验证码的验证功能,属于Servlet层面,简单、易理解。其 实,Spring Security还提供了一种更优雅的实现图形验证码的方式,即自定义认证。

1.认识AuthenticationProvider

在我们项目中的用户,springsecurity称为主体(principal),主体概念包含了所有能够经过验证而获得系统访问权限的用户、设备或其他系统。主体的概念实际上来自Java Security,SpringSecurity通过一层包装将其定义为一个Authentication。

public interface Authentication extends Principal, Serializable {
   //权限列表
   Collection<? extends GrantedAuthority> getAuthorities();
   //获取凭证 通常为账号密码
   Object getCredentials();
   //获取详细信息
   Object getDetails();
   //获取主体
   Object getPrincipal();
   //是否验证成功
   boolean isAuthenticated();
   
   void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;

}

Authentication中包含主体权限列表、主体凭据、主体详细信息,以及主体是否验证成功等信息。由于大部分场景下身份验证都是基于用户名和密码进行的,所以SpringSecurity提供了一个UsernamePasswordAuthenticationToken来专门为这类验证服务。

springsecurity中添加短信验证码参数 spring验证码实现_安全_04

可以看出,是Authentication的实现类。

在前面的篇幅中使用的表单登录中,每一个用户都被包装成一个UsernamePasswordAuthenticationToken对象,在SpringSecurity的各个AuthenticationProvider中传递。

AuthenticationProvider在SpringSecurity中被称为一个验证过程。

public interface AuthenticationProvider {
   //验证过程,返回一个验证完成的Authentication
   Authentication authenticate(Authentication authentication) throws AuthenticationException;
   //是否支持验证当前的Authentication
   boolean supports(Class<?> authentication);

}

一个完整的验证包含多个AuthenticationProvider,并由ProviderMaanager管理。

public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {
   //。。。只保留了主要代码

   private List<AuthenticationProvider> providers = Collections.emptyList();

   //验证
   @Override
   public Authentication authenticate(Authentication authentication) throws AuthenticationException {
      Class<? extends Authentication> toTest = authentication.getClass();
      AuthenticationException lastException = null;
      AuthenticationException parentException = null;
      Authentication result = null;
      Authentication parentResult = null;
      int currentPosition = 0;
      int size = this.providers.size();
      //循环验证每一个AuthenticationProvider(验证过程)
      for (AuthenticationProvider provider : getProviders()) {
         if (!provider.supports(toTest)) {
            continue;
         }
         if (logger.isTraceEnabled()) {
            logger.trace(LogMessage.format("Authenticating request with %s (%d/%d)",
                  provider.getClass().getSimpleName(), ++currentPosition, size));
         }
         try {
            result = provider.authenticate(authentication);
            if (result != null) {
               copyDetails(authentication, result);
               break;
            }
         }
         catch (AccountStatusException | InternalAuthenticationServiceException ex) {
            prepareException(ex, authentication);
            throw ex;
         }
         catch (AuthenticationException ex) {
            lastException = ex;
         }
      }
      if (result == null && this.parent != null) {
         try {
            parentResult = this.parent.authenticate(authentication);
            result = parentResult;
         }
         catch (ProviderNotFoundException ex) {
         }
         catch (AuthenticationException ex) {
            parentException = ex;
            lastException = ex;
         }
      }
      if (result != null) {
         if (this.eraseCredentialsAfterAuthentication && (result instanceof CredentialsContainer)) {
            ((CredentialsContainer) result).eraseCredentials();
         }
         if (parentResult == null) {
            this.eventPublisher.publishAuthenticationSuccess(result);
         }

         return result;
      }
      if (lastException == null) {
         lastException = new ProviderNotFoundException(this.messages.getMessage("ProviderManager.providerNotFound",
               new Object[] { toTest.getName() }, "No AuthenticationProvider found for {0}"));
      }
      if (parentException == null) {
         prepareException(lastException, authentication);
      }
      throw lastException;
   }

这段代码的主要意思就是主体(Authentication)作为参数,然后通过ProviderManager来循环调用每一个验证过程(AuthenticationProvider)。

2.自定义AuthenticationProvider

为了更好的按需定制,SpringSecurity提供了一个抽象的AuthenticationProvider,代码刚才已经贴出来了。

而且还为我们提供了一些基本的实现---在AbstractUserDetailsAuthenticationProvider中,我们通过继承这个类并实现retrieveUser和additionalAuthenticationChecks两个抽象方法即可自定义核心认证过程,灵活性非常高。

public abstract class AbstractUserDetailsAuthenticationProvider
      implements AuthenticationProvider, InitializingBean, MessageSourceAware {
   //额外的认证过程
   protected abstract void additionalAuthenticationChecks(UserDetails userDetails,
         UsernamePasswordAuthenticationToken authentication) throws AuthenticationException;
   
   //为我们实现的认证过程
   @Override
   public Authentication authenticate(Authentication authentication) throws AuthenticationException {
      Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
            () -> this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports",
                  "Only UsernamePasswordAuthenticationToken is supported"));
      String username = determineUsername(authentication);
      boolean cacheWasUsed = true;
      UserDetails user = this.userCache.getUserFromCache(username);
      if (user == null) {
         cacheWasUsed = false;
         try {
            //查询用户
            user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
         }
         catch (UsernameNotFoundException ex) {
            this.logger.debug("Failed to find user '" + username + "'");
            if (!this.hideUserNotFoundExceptions) {
               throw ex;
            }
            throw new BadCredentialsException(this.messages
                  .getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
         }
         Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract");
      }
      try {
         this.preAuthenticationChecks.check(user);
         //额外的认证
         additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
      }
      catch (AuthenticationException ex) {
         if (!cacheWasUsed) {
            throw ex;
         }
         // There was a problem, so try again after checking
         // we're using latest data (i.e. not from the cache)
         cacheWasUsed = false;
         user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
         this.preAuthenticationChecks.check(user);
         additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
      }
      this.postAuthenticationChecks.check(user);
      if (!cacheWasUsed) {
         this.userCache.putUserInCache(user);
      }
      Object principalToReturn = user;
      if (this.forcePrincipalAsString) {
         principalToReturn = user.getUsername();
      }
      return createSuccessAuthentication(principalToReturn, authentication, user);
   }

   //获取用户
   protected abstract UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)
         throws AuthenticationException;

SpringSecurity提供的代码中已经实现了认证过程,我们需要添加的就是获取用户信息(我们自己决定用户数据来自哪里),添加额外的认证过程。从代码可以看出,自己添加的认证失败需要通过抛出AuthenticationException异常来表示。

这个类需要我们自己实现密码的校验,SpringSecurity还提供了一个该类的子类,功能更加完善---DaoAuthenticationProvider

public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {

   private static final String USER_NOT_FOUND_PASSWORD = "userNotFoundPassword";
   private PasswordEncoder passwordEncoder;
   private volatile String userNotFoundEncodedPassword;
   private UserDetailsService userDetailsService;
   private UserDetailsPasswordService userDetailsPasswordService;
   public DaoAuthenticationProvider() {
      setPasswordEncoder(PasswordEncoderFactories.createDelegatingPasswordEncoder());
   }

   @Override
   @SuppressWarnings("deprecation")
   protected void additionalAuthenticationChecks(UserDetails userDetails,
         UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
      if (authentication.getCredentials() == null) {
         this.logger.debug("Failed to authenticate since no credentials provided");
         throw new BadCredentialsException(this.messages
               .getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
      }
      String presentedPassword = authentication.getCredentials().toString();
      if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
         this.logger.debug("Failed to authenticate since password does not match stored value");
         throw new BadCredentialsException(this.messages
               .getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
      }
   }


   @Override
   protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)
         throws AuthenticationException {
      prepareTimingAttackProtection();
      try {
         UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
         if (loadedUser == null) {
            throw new InternalAuthenticationServiceException(
                  "UserDetailsService returned null, which is an interface contract violation");
         }
         return loadedUser;
      }
      catch (UsernameNotFoundException ex) {
         mitigateAgainstTimingAttack(authentication);
         throw ex;
      }
      catch (InternalAuthenticationServiceException ex) {
         throw ex;
      }
      catch (Exception ex) {
         throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
      }
   }
}

该类帮我们从前面文章中实现的usersvc中获取了用户信息,并且进行了密码的校验。

3.实现图形验证码

所以我们通过继承DaoAuthenticationProvider来实现图形验证码

@Component
public class MyAuthenticationProvider extends DaoAuthenticationProvider {

    //使用构造函数的方式注入我们需要的userDetailsService、passwordEncoder
    public MyAuthenticationProvider(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder){
        this.setUserDetailsService(userDetailsService);
        this.setPasswordEncoder(passwordEncoder);
    }

    @Override
    protected void additionalAuthenticationChecks(UserDetails userDetails,
            UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
        //在父类的基础上校验验证码
        super.additionalAuthenticationChecks(userDetails, authentication);
    }
}

代码这样写就ok,但是我们还需要解决一个问题,就是,如果获取请求中的图形验证码。

前面说过主体(Authencation),认证过程(AuthenticationProvider),循环调用认证过程的ProviderManager。

ProviderManager是由UsernamePasswordAuthenticationFilter调用的,也就是主体参数来自UsernamePasswordAuthenticationFilter。具体如下:

public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

protected AuthenticationDetailsSource<HttpServletRequest, ?> authenticationDetailsSource = new WebAuthenticationDetailsSource();

@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
      throws AuthenticationException {
   if (this.postOnly && !request.getMethod().equals("POST")) {
      throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
   }
   String username = obtainUsername(request);
   username = (username != null) ? username : "";
   username = username.trim();
   String password = obtainPassword(request);
   password = (password != null) ? password : "";
   UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
   // Allow subclasses to set the "details" property
   setDetails(request, authRequest);
   return this.getAuthenticationManager().authenticate(authRequest);
}

protected void setDetails(HttpServletRequest request, UsernamePasswordAuthenticationToken authRequest) {
   authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
}
public class WebAuthenticationDetailsSource
      implements AuthenticationDetailsSource<HttpServletRequest, WebAuthenticationDetails> {

   /**
    * @param context the {@code HttpServletRequest} object.
    * @return the {@code WebAuthenticationDetails} containing information about the
    * current request
    */
   @Override
   public WebAuthenticationDetails buildDetails(HttpServletRequest context) {
      return new WebAuthenticationDetails(context);
   }

}
public class WebAuthenticationDetails implements Serializable {

   private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

   private final String remoteAddress;

   private final String sessionId;

   /**
    * Records the remote address and will also set the session Id if a session already
    * exists (it won't create one).
    * @param request that the authentication request was received from
    */
   public WebAuthenticationDetails(HttpServletRequest request) {
      this.remoteAddress = request.getRemoteAddr();
      HttpSession session = request.getSession(false);
      this.sessionId = (session != null) ? session.getId() : null;
   }

这些代码的意思是,SpringSecurity在将主体传递前,调用了WebAuthenticationDetailsSource的buildDetails,获取HttpServletRequest中的session等信息封装到了WebAuthenticationDetails中,然后放到了主体的details属性中。

因此我们可以实现自己的AuthenticationDetails以及AuthenticationDetailsSource,将验证码放入其中,然后就解决了之前的问题。

public class MyAuthenticationDetails extends WebAuthenticationDetails {

    private boolean captchaIsRight;

    public boolean getCaptchaIsRight(){
        return this.captchaIsRight;
    }

    public MyAuthenticationDetails(HttpServletRequest request) {
        super(request);
        String captcha = request.getParameter("captcha");
        HttpSession session = request.getSession();
        String saveCaptcha = (String) session.getAttribute("captcha");
        if(!ObjectUtils.isEmpty(saveCaptcha)){
            session.removeAttribute("captcha");
            if(!ObjectUtils.isEmpty(captcha) && captcha.equals(saveCaptcha)){
                this.captchaIsRight = true;
            }
        }
    }
}
@Component
public class MyAuthenticationDetailsSource implements AuthenticationDetailsSource<HttpServletRequest, WebAuthenticationDetails> {

    @Override
    public WebAuthenticationDetails buildDetails(HttpServletRequest context) {
        return new MyAuthenticationDetails(context);
    }
}

接下来将我们刚才的MyAuthenticationProvider补充完成:

@Component
public class MyAuthenticationProvider extends DaoAuthenticationProvider {

    //使用构造函数的方式注入我们需要的userDetailsService、passwordEncoder
    public MyAuthenticationProvider(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder){
        this.setUserDetailsService(userDetailsService);
        this.setPasswordEncoder(passwordEncoder);
    }

    @Override
    protected void additionalAuthenticationChecks(UserDetails userDetails,
            UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
        MyAuthenticationDetails myAuthenticationDetails = (MyAuthenticationDetails)authentication.getCredentials();
        if(!myAuthenticationDetails.getCaptchaIsRight()){
            throw new VerificationCodeException();
        }
        //在父类的基础上校验验证码
        super.additionalAuthenticationChecks(userDetails, authentication);
    }
}

最后一步,需要将我们实现的AuthenticationDetailsSource暴露给SpringSecurity。

springsecurity中添加短信验证码参数 spring验证码实现_安全_05

4. 启动验证 我们先把之前给予servlet的验证去除