Spring Security添加到Spring Boot应用程序时,默认情况下会获得基于会话的身份验证系统。Spring Security处理登录和注销请求,并在基础Web服务器(Tomcat,Jetty或Undertow)提供的HTTP会话中存储有关登录用户的信息。

要跟踪哪个会话属于哪个客户端,Web服务器使用随机会话ID设置cookie并将会话对象存储在内存中。每次浏览器向服务器发送请求时,它都会发送会话cookie,服务器将检索与会话ID相关的会话对象。然后,Spring Security从会话中获取身份验证对象,并检查是否允许用户访问某个端点或调用方法。

如果只运行Spring Boot应用程序的一个实例,则此方法可以正常工作。

只要您需要运行同一应用程序的多个实例来处理所有传入流量,您就会遇到问题。

如果用户登录实例1,则Spring Security会将身份验证对象存储在此实例的会话存储中。只要客户端将其请求发送到实例一,一切正常,但如果他向实例二发送HTTP请求,它们将被拒绝,因为该实例不知道实例一中的现有会话。

幸运的是,这个问题有解决方案。当您在这些实例前面运行负载均衡器时,您可以对其进行配置,以便具有会话cookie的HTTP请求始终发送到创建会话的实例。

这样,您不必更改应用程序中的任何内容,并且可以使用存储在内存中的会话。

另一种解决方案是将会话对象存储在数据存储中,或者将它们与多播库一起分发给所有正在运行的应用程序实例。这样,每个应用程序实例都可以访问所有会话信息,如果客户端登录到实例1并且后续请求是否转到实例2则无关紧要。 如果您对此方法感兴趣,请查看Spring Session项目。

无状态

在这篇博文中,我们正在研究一种不同的方法,并使用Spring Security实现无状态解决方案。在此上下文中无状态意味着我们不会在内存或服务器上的数据存储中存储有关登录用户的任何信息。我们仍然需要在某处存储有关登录用户的信息,并将其与客户端关联。在此配置中,我们将使用cookie。但是,与将随机值存储到cookie中然后管理内存中的集合以将随机值关联到会话对象的会话cookie不同,我们将用户的主键直接存储到cookie值中。

这样我们就不需要在服务器上存储任何东西。如果将带有cookie的请求发送到我们的后端,则应用程序将提取cookie值,从数据存储区中使用主键提取用户,然后创建Spring Security身份验证对象。

作为演示应用程序,我创建了一个带有登录页面的Angular / Ionic 4应用程序,用户使用他们的电子邮件和密码登录。客户端架构无关紧要,本博文的重点是Spring Security的配置。此配置适用于任何基于浏览器的客户端框架。

请注意,使用Cookie会使您的应用程序容易受到CSRF攻击。为防止此攻击,以下代码使用Same-Sitecookie属性。此属性可防止现代浏览器上的CSRF攻击,但是当您仍然拥有使用旧版浏览器的用户(如Windows 7上的IE11)时,您需要考虑添加一些额外的CSRF保护。

要查看当前支持该Same-Site属性的浏览器,请访问:https://caniuse.com/#search=same-site

Spring Security配置

在本节中,我们将详细介绍Spring Security配置。此方法取决于存储在某处的User集合,并且每个用户都可以使用主键访问。以下演示应用程序使用JOOQH2数据库,但这不是必需的,您可以使用任何数据存储技术和库。

我在这个演示应用程序中使用的user对象,如下所示。


CREATE TABLE app_user (
    id            BIGINT NOT NULL AUTO_INCREMENT,
    email         VARCHAR(255),
    password_hash VARCHAR(255),
    authority     VARCHAR(255),
    enabled       BOOLEAN,
    PRIMARY KEY(id),
    UNIQUE(email)
);

我们只需要一个用户名和密码。在这个应用程序中,电子邮件地址作为用户名。enabled字段是可选的,但它使我们能够立即锁定用户。

用户详情

首先,我们需要实现UserDetailsUserDetailsService接口

JooqUserDetails.java类实现UserDetails接口,并将数据库中的字段映射到实例变量中,并实现所需的getter方法。

JooqUserDetailsService.java是UserDetailsService接口的实现。

@Service
public class JooqUserDetailsService implements UserDetailsService {

  private final DSLContext dsl;

  public JooqUserDetailsService(DSLContext dsl) {
    this.dsl = dsl;
  }

  @Override
  public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
    AppUserRecord appUserRecord = this.dsl.selectFrom(APP_USER)
        .where(APP_USER.EMAIL.eq(email)).limit(1).fetchOne();

    if (appUserRecord != null) {
      return new JooqUserDetails(appUserRecord);
    }
    throw new UsernameNotFoundException(email);
  }

}

这个接口只需要我们实现loadByUsername()方法。

每次客户端向/login发送请求时,都会调用此方法.

该方法要么返回自定义JooqUserDetails对象的实例,要么在用户不存在时抛出UsernameNotFoundException异常.

我们必须确保我们的JooqUserDetailsService是一个Spring托管bean。在这种情况下,Spring Boot和Spring Security自动配置登录处理程序来使用这个UserDetailService。

Stateless

现在我们开始Spring Security主要配置

http
        .sessionManagement()
        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

首先,我们将会话创建策略设置为无状态。这不会禁用底层web服务器中的会话管理;相反,它指示Spring Security不再创建或使用HTTP会话来存储身份验证对象。

CSRF


 .csrf().disable()

我们在这里禁用CSRF保护,因为我们将使用同一个站点的cookie。重申一下,这只保护现代浏览器上的用户。如果您还希望针对使用旧浏览器的用户,则应该添加额外的CSRF保护。如果删除csrf().disable(),默认情况下将得到一个基于会话的csrf保护。对于无状态架构,基于cookie的解决方案可能更适合:


.csrf().csrfTokenRepository(new CookieCsrfTokenRepository())

访问官方Spring安全文档了解更多关于CSRF保护的信息: https://docs.spring.io/spring-security/site/docs/current/reference/htmlsingle/#csrf

Login


 .formLogin()
      .successHandler(formLoginSuccessHandler())
      .failureHandler((request, response, exception) ->
                      response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "UNAUTHORIZED"))
      .permitAll()

接下来,我们为登录端点配置成功和失败处理程序。Spring Boot自动注册一个登录端点,侦听URL /login,但是默认处理程序将重定向响应(HTTP状态代码30x)发送回客户端。这对于单页面应用程序是没有用的,我们在这里更改它,以便成功处理程序发送一个HTTP状态代码200和失败处理程序401。

Exception Handler


.exceptionHandling()
          .authenticationEntryPoint((request, response, authException) ->
                           response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "UNAUTHORIZED"))

当客户端试图在没有有效身份验证的情况下调用安全端点时,就会调用Spring安全异常处理程序。在客户端应用程序中,当我们调用/身份验证端点来检查用户是否已登录时,就会使用这个方法。


.authorizeRequests().anyRequest().authenticated()

因为所有的请求(除了/login/logout)都需要有效的身份验证,所以没有有效身份验证cookie的请求/身份验证会抛出异常。异常处理程序捕获此异常并发回HTTP状态码401。

默认行为返回一个HTML登录页面,这对于我们的单页面应用程序来说是没有用的。因此,我们添加了一个发送401状态码的自定义处理程序。

登录成功处理程序还负责设置我们的自定义身份验证cookie。javax.servlet.http.Cookie 类还不支持SameSite属性。幸运的是,cookie只是一个具有特殊语法的HTTP头,我们可以很容易地手工构建它。要在浏览器中设置cookie,我们需要在响应中发送header Set-Cookie,而header的值包含cookie名称、值和属性列表。


private AuthenticationSuccessHandler formLoginSuccessHandler() {
    return (request, response, authentication) -> {
      JooqUserDetails userDetails = (JooqUserDetails) authentication.getPrincipal();

      List<String> headerValues = new ArrayList<>();

      String cookieValue = userDetails.getUserDbId() + ":";
      if (this.appProperties.getCookieMaxAge() != null) {
        cookieValue += Instant.now().plus(this.appProperties.getCookieMaxAge())
            .getEpochSecond();
      }
      else {
        // default max age of 4h
        cookieValue += Instant.now().plus(Duration.ofHours(4)).getEpochSecond();
      }

      String encryptedCookieValue = SecurityConfig.this.cryptoService
          .encrypt(cookieValue);
      headerValues.add(AuthCookieFilter.COOKIE_NAME + "=" + encryptedCookieValue);

      if (this.appProperties.getCookieMaxAge() != null) {
        long maxAgeInSeconds = this.appProperties.getCookieMaxAge().toSeconds();
        if (maxAgeInSeconds > -1) {
          headerValues.add("Max-Age=" + maxAgeInSeconds);

          if (maxAgeInSeconds == 0) {
            headerValues.add("Expires=" + COOKIE_DATE_FORMATTER.format(
                ZonedDateTime.ofInstant(Instant.ofEpochMilli(10000), ZoneOffset.UTC)));
          }
          else {
            headerValues.add("Expires=" + COOKIE_DATE_FORMATTER
                .format(ZonedDateTime.now(ZoneOffset.UTC).plusSeconds(maxAgeInSeconds)));
          }
        }
      }

      headerValues.add("SameSite=Strict");
      headerValues.add("Path=/");
      headerValues.add("HttpOnly");
      if (this.appProperties.isSecureCookie()) {
        headerValues.add("Secure");
      }

      response.addHeader("Set-Cookie",
          headerValues.stream().collect(Collectors.joining("; ")));

      response.getWriter().print(SecurityContextHolder.getContext().getAuthentication()
          .getAuthorities().iterator().next().getAuthority());
    };
  }

我们设置HttpOnly属性以防止JavaScript代码访问cookie。如果生产中的站点可以通过TLS访问(应该是这样),还应该设置Secure属性。这指示浏览器仅通过HTTPS发送此cookie,而不通过不安全的HTTP连接发送。

如果您想了解更多关于cookie及其属性的信息,请访问这个MDN页面:

https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie

另外,如果你想了解更多关于SameSite属性的信息,我推荐你阅读这篇博文:

https://www.netsparker.com/blog/web-security/same-site-cookie-attribute-prevent-cross-site-request-forgery/

在上面的代码中,您可能注意到应用程序加密了cookie值。CryptoService类负责使用AES-GCM加密和解密值。我们这样做有两个原因。原因之一是隐藏关于应用程序内部实现的信息,主要目的是防止对应用程序的简单攻击。即使我们设置了HttpOnly属性(它阻止JavaScript访问cookie),它也不是不可见的。每个用户都可以打开浏览器开发人员控制台并检查cookie

file

如果我们要以明文发送用户的主键,攻击者可以在开发人员工具中分析应用程序后,通过使用类似curl的HTTP客户端发送多个请求来查找有效值。

curl -v --cookie "X-authentication=1" http://localhost:8080/message
   curl -v --cookie "X-authentication=2" http://localhost:8080/message
   curl -v --cookie "X-authentication=3" http://localhost:8080/message
   ...

通过加密cookie,我们试图防止这种攻击。

CryptoService将AES加密密钥存储在文件系统中,这不是最好的解决方案,尤其是在运行应用服务器的多个实例时。所有实例都需要访问相同的AES密钥来加密cookie。在这种情况下,需要将密钥存储在中心位置。一个可能的解决方案是将它存储在HashiCorp’s Vault实例中。Spring提供Spring Vault库,使集成到Spring应用程序非常方便。

在上面的代码中,我们要做的另一件事不仅是设置Max-Age cookie属性,还将相同的过期时间戳和用户的主键一起添加到cookie值中。cookie值中的过期时间戳是为了限制有效期。这个cookie就像用户名和密码的组合,所有知道cookie并将其与请求一起发送到我们的后端的人都可以访问这些服务,而无需进行任何进一步的身份验证。

Max-Age cookie属性指示浏览器在一段时间后删除cookie。但是,如果有人能够访问另一个用户的身份验证cookie,他就可以使用另一个用户的身份验证发送HTTP请求。


curl -v --cookie "X-authentication=nFGbyuFPY+75URsrcenzpdjLzUo7NrjmLqIK61RCsbbSTIQGK/Ccew==" http://localhost:8080/message

如果这个cookie的值中没有有效日期,那么它将永远有效。随着嵌入的过期日期,cookie将在一段时间后失效。所以它不能阻止这样的攻击,但至少cookie会在一段时间后自动过期。

一个关于Max-AgeExpires cookie属性的说明。这些属性是可选的,当您省略它们时,cookie将成为会话cookie。对于属性,会话是一个持久cookie。不同之处在于,持久性cookie在浏览器重启后仍然存在。尽管有些浏览器具有会话恢复功能,还可以恢复会话cookie,如果用户从未关闭过浏览器,会话cookie将在很长一段时间内保持有效。所以,在我看来,最好指定一个Max-Age,这样cookie会在一段时间后自动过期。

Logout



.logout()
      .logoutSuccessHandler((request, response, authentication) ->  response.setStatus(HttpServletResponse.SC_OK))
      .deleteCookies(AuthCookieFilter.COOKIE_NAME)
      .permitAll()

与登录处理程序一样,注销成功处理程序在成功注销后默认发送重定向响应。 我们在这里将其更改为返回状态代码200。 此外,我们指示注销端点删除自定义身份验证cookie。注销功能不需要额外的代码。客户端应用程序只需要向/注销端点发送GET请求并检查HTTP状态代码。

AuthCookieFilter

接下来,我们实现登录成功处理程序的对应项。为每个HTTP请求调用一个过滤器并提取身份验证cookie,它将从数据库中读取用户,并为Spring Security创建一个合适的身份验证对象。


 @Override
  public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
      FilterChain filterChain) throws IOException, ServletException {
    HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;

    Cookie[] cookies = httpServletRequest.getCookies();
    if (cookies != null) {
      for (Cookie cookie : cookies) {
        if (cookie.getName().equals(COOKIE_NAME)) {
          String decryptedCookieValue = this.cryptoService.decrypt(cookie.getValue());
          if (decryptedCookieValue != null) {
            int colonPos = decryptedCookieValue.indexOf(':');
            String appUserIdString = decryptedCookieValue.substring(0, colonPos);
            long expiresAtEpochSeconds = Long
                .valueOf(decryptedCookieValue.substring(colonPos + 1));

            if (Instant.now().getEpochSecond() < expiresAtEpochSeconds) {
              try {
                AppUserRecord appUserRecord = this.dsl.selectFrom(APP_USER)
                    .where(APP_USER.ID.eq(Long.valueOf(appUserIdString))).fetchOne();
                if (appUserRecord != null) {
                  JooqUserDetails userDetails = new JooqUserDetails(appUserRecord);
                  this.userDetailsChecker.check(userDetails);

                  SecurityContextHolder.getContext().setAuthentication(
                      new UsernamePasswordAuthenticationToken(userDetails, null,
                          userDetails.getAuthorities()));
                }
              }
              catch (UsernameNotFoundException | LockedException | DisabledException
                  | AccountExpiredException | CredentialsExpiredException e) {
                // ignore this
              }
            }
          }
        }
      }
    }

    filterChain.doFilter(servletRequest, servletResponse);
  }

该过滤器查找包含自定义身份验证令牌的cookie,并在CryptoService类的帮助下解密该令牌的值。 接下来,使用嵌入的到期日期检查有效性。如果cookie仍然有效,代码将创建一个数据库请求来获取具有主键的用户。

如果用户存在,则创建一个新的JooqUserDetails实例,然后使用AccountStatusUserDetailsChecker进行检查。这将检查帐户是否已锁定、启用、未过期以及凭据是否已过期。这个演示应用程序只实现了启用的标志。

如果检查无误,则实例化UsernamePasswordAuthenticationToken并将其设置到当前securitycontext中。

虽然我们没有在服务器上存储登录状态,但是通过读取每个HTTP请求的用户并通过AccountStatusUserDetailsChecker运行该请求,我们可以通过在数据库中将启用标志设置为false来立即阻止用户。

注意,这个过滤器造成了瓶颈,因为每个传入的HTTP请求都要通过这段代码。如果您期望许多请求具有较高的频率,则应该考虑缓存数据库调用。如果添加缓存,就不应该缓存太长时间,因为这样就失去了立即禁用用户的能力。根据请求的负载和频率,如果缓存只存储用户对象几分钟(例如案例5),那么它可能已经提高了性能。

最后,我们在主安全配置中配置这个过滤器。这里,我们需要确保这个过滤器在UsernamePasswordAuthenticationFilter之前运行。

.apply(securityConfigurerAdapter());
SecurityConfig.java

  private SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> securityConfigurerAdapter() {
    return new SecurityConfigurerAdapter<>() {
      @Override
      public void configure(HttpSecurity builder) throws Exception {
        builder.addFilterBefore(SecurityConfig.this.authCookieFilter,
            UsernamePasswordAuthenticationFilter.class);
      }
    };
  }

这就结束了用于无状态身份验证的服务器端设置。这种设置应该适用于任何客户端JavaScript框架,但它专门针对单个页面应用程序。如果您编写一个多页面的应用程序,您需要查看注销、登录和异常处理程序,并可能发送重定向(HTTP状态代码30x),而不是仅仅发送200和401状态代码。

Client

演示客户端应用程序是用Angular和ion4编写的。 在本节中,我将向您展示客户机应用程序的几个关键部分。

Guard

当用户导航到http://localhost:8100时,应用程序将重定向到http://localhost:8100/home,此路径由CanActivate守卫保护

门卫首先检查用户是否已经登录。isLoggedIn()方法检查客户机是否已经调用了/authenticate/login端口。

@Injectable({
  providedIn: 'root'
})
export class AuthGuard implements CanActivate {

  constructor(private readonly authService: AuthService,
              private readonly router: Router) {
  }

  canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot):
    Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
    if (this.authService.isLoggedIn()) {
      return true;
    }

    return this.authService.isAuthenticated().pipe(map(authenticated => {
        if (authenticated) {
          return true;
        }
        return this.router.parseUrl('/login');
      }
    ));
  }

}

由于客户端无法通过JavaScript访问身份验证cookie (HttpOnly),因此必须向/authenticated端点发送一个请求,以检查cookie的存在性和有效性。



isAuthenticated(): Observable<boolean> {
    return this.httpClient.get(`/authenticate`, {responseType: 'text'})
      .pipe(
        map(response => this.handleAuthResponse(response)),
        catchError(_ => of(false))
      );
  }

此请求由AuthController处理,并返回HTTP状态码401。在这种情况下,AuthGuard重定向到/login并向用户显示登录页面。如果/authenticate返回一个HTTP状态码为200,则响应主体包含用户的权限,该权限随后存储在客户机应用程序中。

@RestController
public class AuthController {

  @GetMapping("/authenticate")
  @PreAuthorize("isFullyAuthenticated()")
  public String authenticate(@AuthenticationPrincipal UserDetails user) {
    return user.getAuthorities().iterator().next().getAuthority();
  }

}

随后对this.authService.isLoggedIn()的调用将返回true

Login

登录页面向用户显示一个表单,当用户单击login按钮时,应用程序向服务器发送一个POST请求,请求体中包含电子邮件和密码。


 login(username: string, password: string): Observable<boolean> {
    const body = new HttpParams().set('username', username).set('password', password);

    return this.httpClient.post(`/login`, body, {responseType: 'text'})
      .pipe(
        map(response => this.handleAuthResponse(response)),
        catchError(_ => of(false))
      );
  }

/authenticate类似,/login端口对于不成功的登录返回HTTP状态码401200,并返回响应主体中的用户权限。

Logout

要注销用户,客户机只需向/Logout发送GET请求。如果调用成功,应用程序将显示登录页面。


logout(): Observable<void> {
    return this.httpClient.get<void>(`/logout`)
      .pipe(
        tap(() => this.authoritySubject.next(null))
      );
  }

注销端口负责删除身份验证cookie

Example application

file file

你可以在这个库中找到GitHub上服务器和客户端的完整源代码: https://github.com/ralscha/blog2019/tree/master/stateless

原文地址:https://golb.hplar.ch/2019/05/stateless.html#exception-handler