一文看懂Spring Security,附视频教程!

你知道的越多,不知道的就越多,业余的像一棵小草!

你来,我们一起精进!你不来,我和你的竞争对手一起精进!

编辑:业余草

http://dwz.date/dR2e

推荐:https://www.xttblog.com/?p=5138

权限相关概念

权限管理一般是值根据系统设置的安全规则或者安全策略,用户只能访问被授权的资源。权限管理的前置条件是用户和密码的认证系统。其中认证 是通过用户名和密码成功登陆系统后,让系统得到当前用户的角色身份;授权 则是系统根据当前用户的角色,给其授予可以操作的的权限资源。

主要有三个对象:

  • 用户:用户名,密码和当前用户的角色信息,可以实现认证操作

  • 角色:角色名称,描述和当前角色拥有的权限信息,可以实现授权操作。

  • 权限:权限也可以成为菜单,主要包含当前权限的名称,URL 地址等信息,可以动态展示菜单。

Spring Security 基本概念

Spring Security 是 Spring 采用 AOP 思想,基于 Servlet 过滤器实现的安全框架,其提供了完善的认证机制和方法级授权。其主要是通过过滤器实现认证和授权的,可以简单看一下 Spring Security 中的过滤器。

Spring 主要的 Filters

  • SecurityContextPersistenceFilter

SecurityContextPersistenceFilter 主要是使用 SecurityContextRepository 在 Session 中保存或更新一个 SecurityContext,并将 SecurityContext 给后续的过滤器中使用,来为后续 filter 建立所需的上下文,SecurityContext 中存储了当前用户认证及授权信息。

  • WebAsyncManagerIntegrationFilter

用于集成 SecurityContext 到 Spring 异步执行的 WebAsyncManager

  • HeaderWriterFilter

向请求头 Header 中添加相应的信息。

  • CsrfFilter

Spring Security 会对所有 POST 请求验证是否包含系统生成的 CSRF 的 Token 信息,如果不包含则报错,起到防止 CSRF 共计的效果。

  • LogoutFilter

匹配 URL 为 /logout 的请求,实现用户退出清除认证信息。

  • UsernamePasswordAuthenticationFilter

认证操作,默认匹配 URL 为 /login 且必须为 POST 请求

  • DefaultLoginPageGeneratingFilter

默认的登录页面,该过滤器会生成一个默认的认证页面

  • DefaultLogoutPageGeneratingFilter

默认的登出页面,该过滤器会生成一个默认的登出页面。

  • BasicAuthenticationFilter

过滤器会自动解析 HTTP 请求头中名字为 Authentication,且以 BASIC 开头的头部信息

Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==

  • RequestCacheAwareFilter

通过 HttpSessionRequestCache 内部维护一个 RequestCache,用于缓存 HttpServletRequest

  • SecurityContextHolderAwareRequestFilter

针对于 ServletRequest 进行一次包装,使 Request 具有更多的 API。

  • AnonymousAuthenticationFilter

在 SecurityContextHolder 中认证信息为空的时候,会创建一个匿名的用户存入到 SecurityContextHolder 中,Spring Security 为了兼容未登录的访问,使用匿名的身份走了一套认证的流程。

  • SessionManagementFilter

SecurityContextRepository 限制一个用户开启多个会话的数量。

  • ExceptionTranslationFilter

转换过滤器链中出现的异常,在过滤器链的最后。

  • FilterSecurityInterceptor

获取所配置资源的访问授权信息,根据 SecurityContextHolder 中存储的用户信息决定是否有权限访问。

几个基本概念

其中 FilterSecurityInterceptor 中会根据 config 配置的内容,判断是否可以访问对应的资源,如果不可以则会抛出异常。然后在 ExceptionTranslationFilter 中会捕获异常并作出响应的处理。

Spring Security 几个重要的概念如下:

  • SecurityContextHolder

Spring Security 提供的 SecurityContextHolder 类让程序可以方便访问 SecurityContext。其采用的是 ThreadLocal 方式存储,保证获取到的永远是当前用户的 SecurityContext。

  • SecurityContext

SecurityContext 保存用户的信息,用户是谁,权限有哪些,是否被认证等。可以通过 SecurityContextHolder.getContext() 获取。

  • Authentication

Authentication 包含 sessionId, IP, 用户的 UserDetail 信息,用户的角色。其有很多实现类,每种类都对应着一个认证方式。可以通过 SecurityContextHolder.getContext().getAuthentication(); 获取。

  • AbstractAuthenticationToken

AbstractAuthenticationToken 和 AnonymousAuthenticationToken 及其很多子类都实现了 Authentication 接口,每一个子类代表一种认证方式。

  • GenericFilterBean

用来拦截认证的 filter,可以在这个 filter 注册认证成功和失败的 handler,子类主要有 OncePerRequestFilter, CasAuthenticationFilter, OpenIDAuthenticationFilter, UsernamePasswordAuthenticationFilter, LogoutFilter 等等。

  • AuthenticationManager 和 AuthenticationProvider

Spring Security 将通过 SecurityFilter 来调用 AuthenticationManager 进行验证,默认情况下 AuthenticationManager 将验证工作交给 ProviderManager 去做,而 ProviderManager 会通过一系列的 AuthenticationProvider 完成具体的验证。

  • UserDetails 和 UserDetailsService

UserDetail 包含 Authentication 需要的用户信息,这些信息往往从 JDBC 或其他数据源获取。UserDetailsService 是用来获取指定 username 对应 UserDetail 的接口。

Spring Security 加载及应用 Filter 流程

在 Spring Security 项目启动的时候会走到 WebSecurityConfigurerAdapter 的 getHttp 方法中,该方法中部分代码如下:

http
  .csrf().and()
  .addFilter(new WebAsyncManagerIntegrationFilter())
  .exceptionHandling().and()
  .headers().and()
  .sessionManagement().and()
  .securityContext().and()
  .requestCache().and()
  .anonymous().and()
  .servletApi().and()
  .apply(new DefaultLoginPageConfigurer<>()).and()
  .logout();

会将 SecurityConfigurer 的部分子类放到 AbstractConfiguredSecurityBuilder 的 configurers 中。例如 anonymous 就将 AnonymousConfigurer 放到了 configurers 中。然后

走到 AbstractConfiguredSecurityBuilder 的 configure 方法,简单来说就是调用所有 configurers 的 configure 的方法配置当前的 Builder ,还是拿 AnonymousConfigurer 为例,其就是在调用 AnonymousAuthenticationFilter 的 afterPropertiesSet 之后将其加入到 HttpSecurity 的 filters 中,然后封装成一个 DefaultSecurityFilterChain 过滤器链。然后将 SecurityFilterChain 封装到 FilterChainProxy 中。

在访问的时候首先会访问到 DelegatingFilterProxy 这个 Filter,在其 doFilter 方法中会调用 initDelegate 方法获取 ID 为 springSecurityFilterChain 类型为 FilterChainProxy 的 delegate 然后调用 doFilter 方法,然后调用其 doFilterInternal 方法,其中会获取上面所有的 Filters List<Filter> filters = getFilters(fwRequest);,然后然后将这些 Filters 封装成一个 VirtualFilterChain 逐个调用, 之后就是调用VirtualFilterChain 的 doFilter方法了。

Spring Security 的使用

在 pom 文件中引入相关的配置后就可以直接启动项目:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
</dependency>

默认会有一个密码打印在控制台中,测试的时候也可以直接在代码中定义一个用户:

// 方式 1
@Bean
@Override
public UserDetailsService userDetailsService() {
  UserDetails userDetails = User.withDefaultPasswordEncoder().username("xinyue")
    .password("xinyue").roles("ADMIN").build();
  return new InMemoryUserDetailsManager(userDetails);
}
// 方式 2
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
  auth.inMemoryAuthentication().withUser("xinyue")
    .password("{noop}123")
    .roles("ADMIN")
    .authorities("admin", "demo");
}

然后再次启动就可以使用该用户名和密码登录了。如果要自定义登录页面可以使用在资源文件中添加出来相应的 page,并设置相应的 controller 简单的示例如下:

// Controller
@RequestMapping("/ilogin")
public String ilogin() {
  return "ilogin";
}
// Security 相关配置
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests().anyRequest().authenticated()
                .and().formLogin(form ->
                 form.loginPage("/ilogin").permitAll()) 
                .csrf().disable(); // 禁用 CSRF
    }

    @Override
    public void configure(WebSecurity web) {
        web.ignoring().antMatchers("/static/**");
    }
}

Spring Boot 项目中在 WebSecurityEnablerConfiguration 上的已经添加了 @EnableWebSecurity 所以在自己的配置类上可以不添加。

上面第一个方法自定义了登录页面的地址为 /ilogin 页面,默认情况处理登录请求的地址也是 /iloign 只是 Method 是 POST,当然也可以通过相关的属性配置,例如 loginProcessingUrl 处理登录请求的 URL,successForwardUrl 登录成功之后跳转的地址,defaultSuccessUrl 也登录成功之后跳转的地址,只是其有一个默认为 false 的参数 alwaysUse 用于控制默认会跳转到登录前访问的页面 URL, logoutSuccessUrl 登出跳转地址,failureForwardUrl 登录失败跳转的地址。

在第二个方法表示过滤掉 /static 下所有的请求,用于处理静态资源等。

CSRF

需要提示的一点是如果不想使用 csrf 一定要关闭 csrf 否则会被 Spring Security 认为是非法请求强制跳转到 /error 页面,如果要使用 csrf 需要在登录 form 中添加这样的隐藏字段,这里使用了 Thymeleaf 引擎,获取当前请求已经生成了一个 token 。

<input type="hidden" name="_csrf" th:value="${_csrf.token}"/>

生成这个 Token 也是在每个终端当前 session 中第一次访问的时候,在 CsrfFilter 中的 doFilterInternal 方法中生成的。该方法会首先会从 tokenRepository 中去查找有没有已经生成过 token,如果已经生成则直接使用,如果没有生成过则调用 this.tokenRepository.generateToken(request) 为当前请求的生成一个 token,即生成一个 DefaultCsrfToken 对象,主要使用请求的 Header Name ("X-CSRF-TOKEN"),Parameter Name (_csrf) 和随机数生成。

// CsrfFilter
CsrfToken csrfToken = this.tokenRepository.loadToken(request);
final boolean missingToken = csrfToken == null;
if (missingToken) {
   csrfToken = this.tokenRepository.generateToken(request);
   this.tokenRepository.saveToken(csrfToken, request, response);
}
// HttpSessionCsrfTokenRepository
public CsrfToken generateToken(HttpServletRequest request) {
  return new DefaultCsrfToken(this.headerName, this.parameterName,
                              createNewToken());
}
private String createNewToken() {
  return UUID.randomUUID().toString();
}

因为默认使用的 LazyCsrfTokenRepository ,在生成完 session 紧随其后的 saveToken 方法中并不会真正的去保存 token 到 session 中,而是在 getToken 方法通过调用 saveTokenIfNecessary 方法最终调用 HttpSessionCsrfTokenRepository 的 saveToken 方法存到 session 中,因为如果没有使用 csrf 确实没有必要将这个 Token 放入的 Session 中。

另外就是在 CsrfFilter 中有一个 DEFAULT_CSRF_MATCHER 是 DefaultRequiresCsrfMatcher 其默认仅拦截除 "GET", "HEAD", "TRACE", "OPTIONS" 这些 Method 之外的请求。

if (!this.requireCsrfProtectionMatcher.matches(request)) {
  filterChain.doFilter(request, response);
  return;
}

在非以上的请求中会从请求 Header 中去获取 csrf token 的值,和 session 中的比较确保不是其他页面的非法请求。

String actualToken = request.getHeader(csrfToken.getHeaderName());
if (actualToken == null) {
  actualToken = request.getParameter(csrfToken.getParameterName());
}
if (!csrfToken.getToken().equals(actualToken)) {
  // token 不匹配的时候使用 accessDeniedHandler 跳转到
  if (missingToken) {
    this.accessDeniedHandler.handle(request, response,
                                    new MissingCsrfTokenException(actualToken));
  } else {
    this.accessDeniedHandler.handle(request, response,
                                    new InvalidCsrfTokenException(csrfToken, actualToken));
  }
  return;
}

Spring Security 认证流程

默认的主要认证逻辑在 UsernamePasswordAuthenticationFilter 中完成的,可以看一下该类:

public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";
public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";

private String usernameParameter = SPRING_SECURITY_FORM_USERNAME_KEY;
private String passwordParameter = SPRING_SECURITY_FORM_PASSWORD_KEY;
private boolean postOnly = true;

public UsernamePasswordAuthenticationFilter() {
  super(new AntPathRequestMatcher("/login", "POST"));
}

其定义了默认的用户名和密码参数,登录页面地址等信息。当然这些信息都可以通过上面的 config 方法修改,其实也就是通过 FormLoginConfigurer 进行修改的。其 doFilter 方法默认调用其父类的AbstractAuthenticationProcessingFilter 的方法,在其中完成了认证的操作。方法大概如下:

// AbstractAuthenticationProcessingFilter
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
  throws IOException, ServletException {
  HttpServletRequest request = (HttpServletRequest) req;
  HttpServletResponse response = (HttpServletResponse) res;
  // 首先判断是否需要认证,如果不需要认证则直接进行下一步
  if (!requiresAuthentication(request, response)) {
    chain.doFilter(request, response);
    return;
  }
  Authentication authResult;
  try {
    // 如果需要认证则调用 attemptAuthentication 方法尝试认证
    authResult = attemptAuthentication(request, response);
    if (authResult == null) {
      return;
    }
    sessionStrategy.onAuthentication(authResult, request, response);
  } catch (InternalAuthenticationServiceException failed) {
    unsuccessfulAuthentication(request, response, failed);
    return;
  } catch (AuthenticationException failed) {
    unsuccessfulAuthentication(request, response, failed);
    return;
  }
  
  if (continueChainBeforeSuccessfulAuthentication) {
    chain.doFilter(request, response);
  }
  // 登出成功后续操作
  successfulAuthentication(request, response, chain, authResult);
}

protected void successfulAuthentication(HttpServletRequest request,
                                        HttpServletResponse response, FilterChain chain, Authentication authResult)
  throws IOException, ServletException {
 // 将认证信息放到 SecurityContextHolder 中
  SecurityContextHolder.getContext().setAuthentication(authResult);
 // 记住我设置
  rememberMeServices.loginSuccess(request, response, authResult);

  // 发布成功登陆事件
  if (this.eventPublisher != null) {
    eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(
      authResult, this.getClass()));
  }
 
  successHandler.onAuthenticationSuccess(request, response, authResult);
}

下面看一下 UsernamePasswordAuthenticationFilter 的尝试认证的方法:

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 username = obtainUsername(request);
  String password = obtainPassword(request);
  if (username == null) {
    username = "";
  }
  if (password == null) {
    password = "";
  }
  username = username.trim();
  // 将用户名和密码封装成一个 UsernamePasswordAuthenticationToken 去认证
  UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
    username, password);
  // Allow subclasses to set the "details" property
  setDetails(request, authRequest);
  return this.getAuthenticationManager().authenticate(authRequest);
}

使用 AuthenticationManager 的 authenticate 进行认证。

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;

  // 获取所有 AuthenticationProvider 逐个判断是否 Support 当前的认证
  for (AuthenticationProvider provider : getProviders()) {
    if (!provider.supports(toTest)) {
      continue;
    }

    try {
      // 如果支持则使用 AuthenticationProvider 进行认证
      result = provider.authenticate(authentication);
      if (result != null) {
        copyDetails(authentication, result);
        break;
      }
    } catch (AccountStatusException | InternalAuthenticationServiceException e) {
      prepareException(e, authentication);
      throw e;
    } catch (AuthenticationException e) {
      lastException = e;
    }
  }

  if (result == null && parent != null) {
    // 如果当前 Provider 没有认证成功则使用其 parent 进行认证
    try {
      result = parentResult = parent.authenticate(authentication);
    } catch (ProviderNotFoundException e) { } catch (AuthenticationException e) {
      lastException = parentException = e;
    }
  }

  if (result != null) {
    if (eraseCredentialsAfterAuthentication && (result instanceof CredentialsContainer)) {
      ((CredentialsContainer) result).eraseCredentials(); // 清除认证信息
    }
    if (parentResult == null) {
      eventPublisher.publishAuthenticationSuccess(result); // 发布认证成功的事件
    }
    return result;
  }

  // 如果没有认证成功则抛出异常
  if (lastException == null) {
    lastException = new ProviderNotFoundException(messages.getMessage(
      "ProviderManager.providerNotFound",
      new Object[] { toTest.getName() },
      "No AuthenticationProvider found for {0}"));
  }
  if (parentException == null) {
    prepareException(lastException, authentication);
  }

  throw lastException;
}

默认情况下会使用 DaoAuthenticationProvider 进行认证,首先使用 AbstractUserDetailsAuthenticationProvider 的 authenticate 主要方法如下:

// AbstractUserDetailsAuthenticationProvider
public Authentication authenticate(Authentication authentication)
  throws AuthenticationException {
  // Determine username
  String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED"
    : authentication.getName();

  boolean cacheWasUsed = true;
  // 先尝试从 Cache 中获取用户
  UserDetails user = this.userCache.getUserFromCache(username);
  if (user == null) {
    cacheWasUsed = false;
    try {
      // 如果 Cache 中没有则根据用户名查找用户
      user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
    } catch (UsernameNotFoundException notFound) {
      // 没有找到用户
      if (hideUserNotFoundExceptions) {
        throw new BadCredentialsException(messages.getMessage(
          "AbstractUserDetailsAuthenticationProvider.badCredentials",
          "Bad credentials"));
      } else {
        throw notFound;
      }
    }
  }

  try {
    // 先检查账户是否过期,禁用,被锁等状态
    preAuthenticationChecks.check(user);
    // 后续认证,密码匹配等
    additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
  } catch (AuthenticationException exception) {
    if (cacheWasUsed) {
      // 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);
      preAuthenticationChecks.check(user);
      additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
    } else {
      throw exception;
    }
  }

  postAuthenticationChecks.check(user);
  if (!cacheWasUsed) {
    // 将用户放到 Cache 中
    this.userCache.putUserInCache(user);
  }
  Object principalToReturn = user;
  if (forcePrincipalAsString) {
    principalToReturn = user.getUsername();
  }
  // 生成成功登录的凭证
  return createSuccessAuthentication(principalToReturn, authentication, user);
}

protected Authentication createSuccessAuthentication(Object principal,
                                                     Authentication authentication, UserDetails user) {
  // Ensure we return the original credentials the user supplied,
  // so subsequent attempts are successful even with encoded passwords.
  // Also ensure we return the original getDetails(), so that future
  // authentication events after cache expiry contain the details
  UsernamePasswordAuthenticationToken result = new UsernamePasswordAuthenticationToken(
    principal, authentication.getCredentials(),
    authoritiesMapper.mapAuthorities(user.getAuthorities()));
  result.setDetails(authentication.getDetails());
  return result;
}
// DaoAuthenticationProvider
protected final UserDetails retrieveUser(String username,
                                         UsernamePasswordAuthenticationToken authentication)
  throws AuthenticationException {
  prepareTimingAttackProtection();
  try {
    // 通过 UserDetailsService 根据用户名去查询 User信息
    // 我们在配置中定义了一个 UserDetailsService Bean 并将一个 User 传给了他
    // 所以这里能拿到配置用户的信息。
    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);
  }
}

protected void additionalAuthenticationChecks(UserDetails userDetails,
                                              UsernamePasswordAuthenticationToken authentication)
  throws AuthenticationException {
  // 从 authentication 获取认证信息
  if (authentication.getCredentials() == null) {
    logger.debug("Authentication failed: no credentials provided");
    throw new BadCredentialsException(messages.getMessage(
      "AbstractUserDetailsAuthenticationProvider.badCredentials",
      "Bad credentials"));
  }
  // 获取登录密码
  String presentedPassword = authentication.getCredentials().toString();
 // 使用 passwordEncoder 系统中的密码和请求提交的密码是否一致
  if (!passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
    logger.debug("Authentication failed: password does not match stored value");
    throw new BadCredentialsException(messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
  }
}

以上涉及到类关系图如下:


从数据库中读取数据认证

通过上面的分析知道,如果要实现从数据库中获取认证内容。只要提供一个 UserDetailService 的实现即可,简单如下:

// User 类
public class SysUser implements UserDetails {
    private int id;
    private String password;
    private String username;
// Service 方法
@Service
public class SysUserService implements UserDetailsService {
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        SysUser user = new SysUser(); // retrieve from db
        return user;
    }
}

显然如果明文存储密码是不安全的,所以创建用户的时候需要对用户的密码加密后存储。Spring Security 提供了一个 PasswordEncoder 并提供了很多的实现,这里介绍一下 BCryptPasswordEncoder 这个加密类。其提供了一下默认的构造方法,例如空,加密强度,加密版本,加密随机算法等。例如下面就是向容器中添加一个加密强度为 12 的 BCryptPasswordEncoder。

@Bean
public PasswordEncoder passwordEncoder() {
  return new BCryptPasswordEncoder(12);
}

因为使用的是随机数作为盐,所以同一个字符串每次 encode 出来加密结果并不一致,其提供了 matches 方法,可以判断两个加密过的字符串的原本字符串是否相同。

Remember Me 功能

如何没有配置 rememberMe, 则使用 NullRememberMeServices 表示不进行处理。如果配置了之后为容器增加一个 RememberMeAuthenticationFilter ,第一次登陆成功之后会记录 remeberMe,先是在 AbstractRememberMeServices 中:

public final void loginSuccess(HttpServletRequest request,
                               HttpServletResponse response, Authentication successfulAuthentication) {
 // 在请求中找 rememberme 的标记
  if (!rememberMeRequested(request, parameter)) {
    logger.debug("Remember-me login not requested.");
    return;
  }
  onLoginSuccess(request, response, successfulAuthentication);
}

然后在 TokenBasedRememberMeServices 中处理:

// TokenBasedRememberMeServices
public void onLoginSuccess(HttpServletRequest request, HttpServletResponse response,
                           Authentication successfulAuthentication) {
 // 获取用户名和密码
  String username = retrieveUserName(successfulAuthentication);
  String password = retrievePassword(successfulAuthentication);
 
  //省略部分 Check 逻辑...

  // 计算 Token 声明周期默认为:1209600秒
  int tokenLifetime = calculateLoginLifetime(request, successfulAuthentication);
  // 计算过期日期
  long expiryTime = System.currentTimeMillis();
  expiryTime += 1000L * (tokenLifetime < 0 ? TWO_WEEKS_S : tokenLifetime);

  // 生成数字签名并放到 Token 中
  String signatureValue = makeTokenSignature(expiryTime, username, password);
  setCookie(new String[] { username, Long.toString(expiryTime), signatureValue },
            tokenLifetime, request, response);
}
// 计算数字签名
protected String makeTokenSignature(long tokenExpiryTime, String username,
                                    String password) {
  String data = username + ":" + tokenExpiryTime + ":" + password + ":" + getKey();
  MessageDigest digest;
  try {
    digest = MessageDigest.getInstance("MD5");
  }
  catch (NoSuchAlgorithmException e) {
    throw new IllegalStateException("No MD5 algorithm available!");
  }

  return new String(Hex.encode(digest.digest(data.getBytes())));
}

第二次访问的时候在 RememberMeAuthenticationFilter 的 doFilter 中首先尝试从 SecurityContextHolder 获取认证信息,如果认证信息没有了则尝试自动登录。

// RememberMeAuthenticationFilter
if (SecurityContextHolder.getContext().getAuthentication() == null) {
  // 尝试自动登录
  Authentication rememberMeAuth = rememberMeServices.autoLogin(request, response);
  if (rememberMeAuth != null) {
    try {
      // 使用 authenticationManager 再次认证
      rememberMeAuth = authenticationManager.authenticate(rememberMeAuth);
      // 将认证信息保存的 SecurityContextHolder 中
      SecurityContextHolder.getContext().setAuthentication(rememberMeAuth);
      onSuccessfulAuthentication(request, response, rememberMeAuth);
      // 触发发布事件
      if (this.eventPublisher != null) {
        eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(
            SecurityContextHolder.getContext().getAuthentication(), this.getClass()));
      }
      if (successHandler != null) {
        successHandler.onAuthenticationSuccess(request, response, rememberMeAuth);
        return;
      }
    } catch (AuthenticationException authenticationException) {
      rememberMeServices.loginFail(request, response);
      onUnsuccessfulAuthentication(request, response, authenticationException);
    }
  }
  chain.doFilter(request, response);
}
// AbstractRememberMeServices
public final Authentication autoLogin(HttpServletRequest request,
                                      HttpServletResponse response) {
 // 从请求中获取 Cookie
  String rememberMeCookie = extractRememberMeCookie(request);
  // Cookie 为 null 或者为空
  if (rememberMeCookie == null) {
    return null;
  }
  if (rememberMeCookie.length() == 0) {
    cancelCookie(request, response);
    return null;
  }

  UserDetails user = null;
  try {
    // 解码 cookie 信息
    String[] cookieTokens = decodeCookie(rememberMeCookie);
    // 根据 cookie 自动登录
    user = processAutoLoginCookie(cookieTokens, request, response);
    // 检查用户的信息过期等。
    userDetailsChecker.check(user);
  // 生成登录成功的凭据
    return createSuccessfulAuthentication(request, user);
  }
  //...
 // 删除 cookie
  cancelCookie(request, response);
  return null;
}
// 解码 Cookie
protected String[] decodeCookie(String cookieValue) throws InvalidCookieException {
  for (int j = 0; j < cookieValue.length() % 4; j++) {
    cookieValue = cookieValue + "=";
  }
  try {
    Base64.getDecoder().decode(cookieValue.getBytes());
  } catch (IllegalArgumentException e) {
    throw new InvalidCookieException(
      "Cookie token was not Base64 encoded; value was '" + cookieValue
      + "'");
  }

  String cookieAsPlainText = new String(Base64.getDecoder().decode(cookieValue.getBytes()));
  String[] tokens = StringUtils.delimitedListToStringArray(cookieAsPlainText, DELIMITER);

  for (int i = 0; i < tokens.length; i++) {
    try {
      tokens[i] = URLDecoder.decode(tokens[i], StandardCharsets.UTF_8.toString());
    } catch (UnsupportedEncodingException e) {
      logger.error(e.getMessage(), e);
    }
  }
  return tokens;
}
// TokenBasedRememberMeServices
protected UserDetails processAutoLoginCookie(String[] cookieTokens,
                                             HttpServletRequest request, HttpServletResponse response) {
 // 获取过期时间
  long tokenExpiryTime;
  try {
    tokenExpiryTime = new Long(cookieTokens[1]);
  } catch (NumberFormatException nfe) {
    throw new InvalidCookieException(
      "Cookie token[1] did not contain a valid number (contained '"
      + cookieTokens[1] + "')");
  }
 // 判断 Token 是否过期
  if (isTokenExpired(tokenExpiryTime)) {
    throw new InvalidCookieException("Cookie token[1] has expired (expired on '"
                                     + new Date(tokenExpiryTime) + "'; current time is '" + new Date()
                                     + "')");
  }
 // 根据用户名获取用户信息
  UserDetails userDetails = getUserDetailsService().loadUserByUsername(cookieTokens[0]);
  // 根据查到的用户名和密码构造 token
  String expectedTokenSignature = makeTokenSignature(tokenExpiryTime, userDetails.getUsername(), userDetails.getPassword());
 // 判断请求中 Token 和 实际的需要的 Token 是否相同,不同就抛出异常
  if (!equals(expectedTokenSignature, cookieTokens[2])) {
    throw new InvalidCookieException("Cookie token[2] contained signature '"
                                     + cookieTokens[2] + "' but expected '" + expectedTokenSignature + "'");
  }
 // 相同就返回用户信息
  return userDetails;
}

上面主要是默认的使用 TokenBasedRememberMeServices 也可以指定使用 Spring 提供的另外一种方式 PersistentTokenRepository。

可以通过以下方式启用:

@Bean
public PersistentTokenRepository persistentTokenRepository() {
  JdbcTokenRepositoryImpl persistentTokenRepository = new JdbcTokenRepositoryImpl();
  persistentTokenRepository.setDataSource(dataSource);
  return persistentTokenRepository;
}

// config Method
.rememberMe().tokenRepository(persistentTokenRepository())

其主要方法如下:

// PersistentTokenRepository
protected void onLoginSuccess(HttpServletRequest request,
                              HttpServletResponse response, Authentication successfulAuthentication) {
  String username = successfulAuthentication.getName();

 // 根据用户名等信息, series 加密后的随机值等生成一个 Token
  PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(
    username, generateSeriesData(), generateTokenData(), new Date());
  try {
    // 将 Token 存储起来:默认有两种实现
    // 1. InMemoryTokenRepositoryImpl 默认实现放到内存中
    // 2. JdbcTokenRepositoryImpl 放到数据库中
    tokenRepository.createNewToken(persistentToken);
    // 然后将 Token 添加到 Cookie 中
    addCookie(persistentToken, request, response);
  }
  catch (Exception e) {
    logger.error("Failed to save persistent token ", e);
  }
}
// 添加到 Cookie 中
private void addCookie(PersistentRememberMeToken token, HttpServletRequest request,
   HttpServletResponse response) {
  setCookie(new String[] { token.getSeries(), token.getTokenValue() },
            getTokenValiditySeconds(), request, response);
}
// 基于 Token 自动登录
protected UserDetails processAutoLoginCookie(String[] cookieTokens,
   HttpServletRequest request, HttpServletResponse response) {

  final String presentedSeries = cookieTokens[0];
  final String presentedToken = cookieTokens[1];

  PersistentRememberMeToken token = tokenRepository.getTokenForSeries(presentedSeries);

  // 没有 Token 抛出异常
  if (token == null) {
    throw new RememberMeAuthenticationException(
      "No persistent token found for series id: " + presentedSeries);
  }
 // 比较服务器中 Token 和 请求中的 Token 是否相同,不相同则情况 Token
  if (!presentedToken.equals(token.getTokenValue())) {
    tokenRepository.removeUserTokens(token.getUsername());
    throw new CookieTheftException(messages.getMessage( "PersistentTokenBasedRememberMeServices.cookieStolen",
        "Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack."));
  }
 // Token 是否过期
  if (token.getDate().getTime() + getTokenValiditySeconds() * 1000L < System
      .currentTimeMillis()) {
    throw new RememberMeAuthenticationException("Remember-me login has expired");
  }
 // 生成新的 Token
  PersistentRememberMeToken newToken = new PersistentRememberMeToken(
    token.getUsername(), token.getSeries(), generateTokenData(), new Date());

  try {
    // 更新 Token
    tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(), newToken.getDate());
    addCookie(newToken, request, response);
  } catch (Exception e) {
    logger.error("Failed to update token: ", e);
    throw new RememberMeAuthenticationException( "Autologin failed due to data access problem");
  }
 // 返回用户信息
  return getUserDetailsService().loadUserByUsername(token.getUsername());
}

Spring Security 权限控制

如果要使用注解控制不同请求的权限需要在 config 加上相关的注解开启,例如:

@EnableGlobalMethodSecurity(prePostEnabled = true,  // Spring 指定的权限控制注解开关
                            securedEnabled = true,  // Spring Security 内部权限控制注解开关
                            jsr250Enabled = true)  // 开启 Java 250 注解支持
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// @PreAuthorize("hasAnyAuthority('ROLE_ADMIN', 'ROLE_DEMO') and hasAnyRole('MAINTAIN_PRODUCT')")  // Spring
@Secured({"ROLE_ADMIN", "ROLE_DEMO"}) // Spring Security
// @RolesAllowed({"ROLE_ADMIN", "ROLE_DEMO"})  // JSR 250
@RequestMapping("/maintain")
public String demo1() {
  return "maintain";
}

异常处理

一种是通过实现 HandlerExceptionResolver 的方式实现,示例如下:

@Component
public class HandlerExceptionController implements HandlerExceptionResolver {
    @Override
    public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        ModelAndView mv = new ModelAndView();
        if (ex instanceof AccessDeniedException) {
            mv.setViewName("redirect:/403");
        } else {
            mv.setViewName("redirect:/error");
        }
        return mv;
    }
}

一种是直接通过 @ControllerAdvice 注解,示例如下:

public class HandlerExceptionAdvice {
    @ExceptionHandler(AccessDeniedException.class)
    public String accessDeniedException() {
        return "redirect:/403”";
    }
    @ExceptionHandler(Exception.class)
    public String exceptionHandler() {
        return "redirect:/error”";
    }
}

最后,如果没看懂此文,送给大家一套宇宙最强的 Spring Security 的视频教程:https://www.bilibili.com/video/BV1af4y1i7n1

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值