在验证用户名和密码前,引入辅助验证可有效防范暴力试错,图形验证码就是简单且行有效的一种辅助验证方式。
一、使用过滤器实现
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
(是一个个人项目,安全性不保证,主要好看)
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())));
}
}
修改配置:
4. 前端页面添加
<img src="/captcha" width="130px" height="48px" />
5.启动项目
二、使用自定义认证实现
前面使用过滤器的方式实现了带图形验证码的验证功能,属于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来专门为这类验证服务。
可以看出,是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。
4. 启动验证 我们先把之前给予servlet的验证去除