OAuth2 for Spring RESTful API

1,419 阅读14分钟

深入了解Spring Boot 2.7+无状态资源服务器中的用户身份验证、CORS、CSRF和基于角色的访问控制。

在本文中,我将首先复习基本的OAuth2概念,然后帮助您在桌面上设置完整的测试环境,最后,更深入地了解Spring RESTful API的安全性配置。

Google、Facebook、GitHub、Office365和其他许多人使用OAuth2。为什么它在每个应用程序中都如此受欢迎和优于普通的旧登录名/密码?

  • 单点登录:经过身份验证的用户可以无缝访问您的所有服务,例如在Gmail、Drive和YouTube之间切换一样。
  • 用户凭据在一个地方管理,这减少了攻击面,并减少了用户数据库泄露的可能性。
  • 登录、注销和用户管理仅开发一次,从而节省时间和金钱。
  • SaaS或本地解决方案已经提供了比您的团队合理实现的功能要多得多:多因素身份验证、大多数常见提供商(Google、Facebook、GitHub等)的身份联合、LDAP的连接器、OIDC合规性等。

适用的OAuth2

OAuth2定义了4个参与者。 当您编写Spring配置时,必须清楚地了解谁是谁:

  • 资源所有者:将其视为最终用户。大多数情况下,这是一个自然人,但可以是使用客户端凭据进行身份验证的Web服务(见下文)。在Spring安全中,它在安全上下文中由Authentication表示。
  • 资源服务器:负责服务资源的API(最常见的REST)。在Spring,这是与@RestController(或@Controller@ResponseBody)的应用程序。
  • 客户端:需要访问一个或多个资源服务器上资源的软件;它以自己的名义(使用客户端凭据)或代表拥有访问令牌的最终用户进行访问。
  • 授权服务器:发布和认证身份和身份委托的服务器(最终用户允许客户端代表他做什么)

OAuth2流量

有不少,但2个我们感兴趣:

授权代码

这可能是最有用的一个。它用于验证最终用户(物理人员)。

image.png

在OpenID环境中,资源服务器在启动时或处理第一个请求之前从标准路径获取授权服务器配置。

  1. 未经授权的用户从客户端应用程序重定向到授权服务器,最常见的是使用系统浏览器或Web视图。
  2. 授权服务器处理身份验证(使用表单、cookie、生物识别或任何它喜欢的东西),然后使用一次代码将用户重定向到客户端。
  3. 客户端联系授权服务器,将代码交换为访问令牌(以及可选的刷新和ID令牌)。
  4. 客户端使用Authorization头中的访问令牌向资源服务器发送请求。
  5. 根据资源服务器配置,令牌验证和详细信息检索的两个案例如下:
  • JWT解码器读取令牌,并使用授权服务器公钥(在启动时下载一次)对其进行验证。
  • 请求被发送到授权服务器自检端点(针对每个请求)。

客户凭证

客户端将客户端ID和密钥发送到授权服务器,该服务器返回用于验证客户端本身的访问令牌(没有用户上下文)。这必须仅限于在您信任的服务器上运行的客户端(能够保守实际“秘密”),并排除在浏览器或移动应用程序中运行的所有服务(代码可以反向工程以读取秘密)。

Tokens 令牌

令牌代表资源所有者的身份以及客户可以代表他做什么,就像你可以给别人投票给你的纸质代理一样。它至少包含以下属性:

  • 发行人:发出令牌的授权服务器(认证提供和接收代理的人的身份的警官或其他人)
  • 主题:资源所有者唯一标识符(授予代理的人)
  • 范围:此令牌可用于什么(资源所有者授予投票代理、管理银行账户、在邮局获得包裹等)
  • 到期:直到什么时候可以使用这个令牌

JWT

JWT是JSON Web令牌。它主要用作OAuth2的访问或ID令牌。JWT可以由JWT解码器自行验证 ,JWT解码器只需要授权服务器公共签名密钥。

不透明令牌

可以使用不透明的令牌(任何格式,包括JWT),但它需要自检:客户端和资源服务器必须向授权服务器发送请求,以确保令牌有效并获得令牌“属性”(相当于JWT“索赔”)。与JWT解码相比,这个过程可能会产生严重的性能影响。

访问令牌

这是客户端在向资源服务器发出请求时作为Bearer Authorization标头发送的令牌。访问令牌内容应仅由授权和资源服务器关注(客户端不应尝试读取访问令牌)。

刷新令牌

此令牌将由客户端发送到授权服务器,以便在到期时(或最好在到期前)获得新的访问令牌。

ID令牌

ID令牌是OAuth2的OpenID扩展的一部分,是客户端用于获取用户信息的令牌。

范围

范围定义了用户允许客户端以他的名义做什么(而不是允许用户在系统中做什么)。您可能会将其视为客户端访问资源之前应用于资源所有者资源的掩码。

OpenID

这是OAuth2之上的标准,除其他事项外,还有标准声明。

授权服务器要求

要继续进行以下教程,我们需要一个具有几个已声明的客户端和资源所有者的OpenID授权服务器。为了简单起见,我们将使用由Quarkus提供支持的独立Keycloak发行版

服务器配置

如果您还没有主机的SSL证书,请生成一个(仔细阅读到最后)。

解压Keycloak归档后,编辑conf/keycloak.conf文件:

http-port=8442
https-key-store-file=/path/to/self_signed.jks
https-key-store-password=change-me
https-port=8443

您都准备好了:只需从bin/kc.bat start-devbin/kc.sh start-dev开始,然后连接到https://localhost:8443。

客户

我们需要两个不同的客户:

  • spring-addons-confidential将在启用“客户端身份验证”和“服务帐户角色”的情况下创建。我们信任的应用程序将使用此客户端,在我们信任的服务器上运行,使用客户端凭据流与授权服务器联系。

image.png

  • spring-addons-public将在没有客户端身份验证和“标准流程”的情况下创建。Web/移动应用程序和Postman等REST客户端将使用此客户端来验证用户身份。

image.png

您必须为公共客户端定义几个有效的重定向URL(以下示例适用于在dev-server上运行的Angular应用程序):

image.png

添加URL后,别忘了保存。

角色

我们将创建一个名为NICE“管理角色”。

或者,您可以在客户端级别定义角色。如果您正在这样做,请从客户端详细信息->客户端范围->spring-addons-[public|confidential]-dedicated->添加映射器->从预定义映射器中启用“客户端角色”映射器

用户

让我们创建两个用户:

  • brice具有NICE角色(应该被允许获得问候)
  • igor没有角色(应该禁止收到问候)

Spring Boot资源服务器安全配置

我们将看到,在[Spring Boot]的帮助下,我们可以在几分钟内构建一个安全的资源服务器,包括安全规则单元测试。

要求

  • Spring Boot 2.7及更高版本:不建议使用 WebSecurityConfigurerAdapter
  • 用户权限应从realm_access.rolesresource-access.spring-addons-confidential.rolesresource-access.spring-addons-public.roles声明中映射,Keycloak将用户的角色放在这些
  • 具有GET端点的控制器仅在授予用户NICE权限时才会返回问候语(如果身份验证缺失/无效,则返回401,如果缺少NICE角色,则返回403)
  • CORS:“纯”资源服务器需要;UI组件从另一个套接字、主机或域提供服务;需要跨域访问
  • “无状态”会话管理:无servlet会话;客户端状态使用URI和访问令牌进行管理
  • CSRF保护(由于无状态会话管理,使用cookie存储库)
  • OpenAPI规范(来自spring-doc-openapi)以及readiness性和liveness探针应可供匿名访问
  • 所有其他路由仅限于经过身份验证的用户(细粒度的安全规则用@PreAuthorize@Controllers方法上注释)
  • 当请求以缺失或无效的授权标头向到受保护的资源时,401未经授权(而不是302重定向登录)
  • 如果启用了SSL,则强制使用HTTPS

spring-boot-starter-oauth2-resource-server

Spring Boot提供了一个库来简化资源服务器的安全配置:spring-boot-starter-oauth2-resource-server。让我们用它来实现上述要求。

打开Spring初始化器,生成具有以下依赖项的项目:

  • Spring Web
  • OAuth2资源服务器
  • Spring Boot执行器
  • Lombok

image.png

下载并拆包后,添加以下依赖项:

<dependency>
        <groupId>org.springdoc</groupId>
        <artifactId>springdoc-openapi-security</artifactId>
        <version>1.6.9</version>
</dependency>
<dependency>
        <groupId>org.springdoc</groupId>
        <artifactId>springdoc-openapi-ui</artifactId>
        <version>1.6.9</version>
</dependency>

<dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
</dependency>

现在,我们可以用Spring Boot 2.7+的方式配置web安全:提供一个SecurityFilterChain bean,而不是扩展已弃用的 WebSecurityConfigurerAdapter。这是相当多的Java代码,但我们稍后将看到如何将其简化为几乎为零。

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig {
    @Bean
    public
            SecurityFilterChain
            filterChain(HttpSecurity http, Converter<Jwt, ? extends AbstractAuthenticationToken> authenticationConverter, ServerProperties serverProperties)
                    throws Exception {

        // Enable OAuth2 with custom authorities mapping
        http.oauth2ResourceServer().jwt().jwtAuthenticationConverter(authenticationConverter);

        // Enable anonymous
        http.anonymous();

        // Enable and configure CORS
        http.cors().configurationSource(corsConfigurationSource());

        // State-less session (state in access-token only)
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        // Enable CSRF with cookie repo because of state-less session-management
        http.csrf().csrfTokenRepository(new CookieCsrfTokenRepository());

        // Return 401 (unauthorized) instead of 302 (redirect to login) when authorization is missing or invalid
        http.exceptionHandling().authenticationEntryPoint((request, response, authException) -> {
            response.addHeader(HttpHeaders.WWW_AUTHENTICATE, "Basic realm=\"Restricted Content\"");
            response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
        });

        // If SSL enabled, disable http (https only)
        if (serverProperties.getSsl() != null && serverProperties.getSsl().isEnabled()) {
            http.requiresChannel().anyRequest().requiresSecure();
        } else {
            http.requiresChannel().anyRequest().requiresInsecure();
        }

        // Route security: authenticated to all routes but actuator and Swagger-UI
        // @formatter:off
        http.authorizeRequests()
            .antMatchers("/actuator/health/readiness", "/actuator/health/liveness", "/v3/api-docs/**").permitAll()
            .anyRequest().authenticated();
        // @formatter:on

        return http.build();
    }

    public interface Jw2tAuthoritiesConverter extends Converter<Jwt, Collection<? extends GrantedAuthority>> {
    }

    public interface Jwt2AuthenticationConverter extends Converter<Jwt, JwtAuthenticationToken> {
    }

    @Bean
    public Jwt2AuthenticationConverter authenticationConverter(Jw2tAuthoritiesConverter authoritiesConverter) {
        return jwt -> new JwtAuthenticationToken(jwt, authoritiesConverter.convert(jwt));
    }

    @SuppressWarnings("unchecked")
    @Bean
    public Jw2tAuthoritiesConverter authoritiesConverter() {
        // This is a converter for roles as embedded in the JWT by a Keycloak server
        // Roles are taken from both realm_access.roles & resource_access.{client}.roles
        return jwt -> {
            final var realmAccess = (Map<String, Object>) jwt.getClaims().getOrDefault("realm_access", Map.of());
            final var realmRoles = (Collection<String>) realmAccess.getOrDefault("roles", List.of());

            final var resourceAccess = (Map<String, Object>) jwt.getClaims().getOrDefault("resource_access", Map.of());
            // We assume here you have "spring-addons-confidential" and "spring-addons-public" clients configured with "client roles" mapper in Keycloak
            final var confidentialClientAccess = (Map<String, Object>) resourceAccess.getOrDefault("spring-addons-confidential", Map.of());
            final var confidentialClientRoles = (Collection<String>) confidentialClientAccess.getOrDefault("roles", List.of());
            final var publicClientAccess = (Map<String, Object>) resourceAccess.getOrDefault("spring-addons-public", Map.of());
            final var publicClientRoles = (Collection<String>) publicClientAccess.getOrDefault("roles", List.of());

            return Stream.concat(realmRoles.stream(), Stream.concat(confidentialClientRoles.stream(), publicClientRoles.stream()))
                    .map(SimpleGrantedAuthority::new).toList();
        };
    }

    private CorsConfigurationSource corsConfigurationSource() {
        // Very permissive CORS config...
        final var configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList("*"));
        configuration.setAllowedMethods(Arrays.asList("*"));
        configuration.setAllowedHeaders(Arrays.asList("*"));
        configuration.setExposedHeaders(Arrays.asList("*"));

        // Limited to API routes (neither actuator nor Swagger-UI)
        final var source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/greet/**", configuration);

        return source;
    }
}

当然,我们还需要一个安全的REST @Controller

@RestController
@RequestMapping("/greet")
@PreAuthorize("isAuthenticated()")
public class GreetingController {

    @GetMapping()
    @PreAuthorize("hasAuthority('NICE')")
    public String getGreeting(JwtAuthenticationToken auth) {
        return "Hi %s! You are granted with: %s.".formatted(
                auth.getToken().getClaimAsString(StandardClaimNames.PREFERRED_USERNAME),
                auth.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.joining(", ", "[", "]")));
    }
}

最后,我们需要application.properties中的一些条目:

spring.security.oauth2.resourceserver.jwt.issuer-uri=https://localhost:8443/realms/master

management.endpoint.health.probes.enabled=true
management.health.readinessstate.enabled=true
management.health.livenessstate.enabled=true
management.endpoints.web.exposure.include=*
spring.lifecycle.timeout-per-shutdown-phase=30s

logging.level.org.springframework.security.web.csrf=DEBUG

该应用程序现在应该在端口8080上运行,并公开仅供brice访问的安全端点。

您可以使用Postman从Keycloak获取访问令牌,然后发送测试请求:

配置削减

我们在网络安全配置中实现的功能列表是我们在大多数资源服务器中需要的非常通用的功能。对于Web MVC与WebFlux和JWT解码器与自检的组合,我们可以使用4种变体中下降的替代spring-addons (github.com/ch4mpy/spri…

当我们用JWT解码器编写servlet应用时,我们将spring-boot-starter-oauth2-resource-server替换为spring-addons-webmvc-jwt-resource-server:

<dependency>
        <groupId>com.c4-soft.springaddons</groupId>
        <artifactId>spring-addons-webmvc-jwt-resource-server</artifactId>
        <version>5.1.3</version>
</dependency>

我们现在可以删除几乎所有的网络安全配置:

@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig {
}

只需提供一些属性:

# 设置你的认证服务器所在地址
com.c4-soft.springaddons.security.issuers[0].location=https://localhost:8443/realms/master

#应该配置此授权服务器将用户角色放入的私人索赔列表
#下面是启用客户端角色映射器的“spring-addons”客户端的默认Keycloak conf
com.c4-soft.springaddons.security.issuers[0].authorities.claims=realm_access.roles,resource-access.spring-addons-confidential.roles,resource_access.spring-addons-public.roles

com.c4-soft.springaddons.security.permit-all=/actuator/health/readiness,/actuator/health/liveness,/v3/api-docs/**

#使用IDE自动完成或查看SpringAddonsSecurityProperties javadoc以获取完整的配置属性列表

management.endpoint.health.probes.enabled=true
management.health.readinessstate.enabled=true
management.health.livenessstate.enabled=true
management.endpoints.web.exposure.include=*
spring.lifecycle.timeout-per-shutdown-phase=30s

这里没有什么魔力:它只是一个Spring Boot模块,有几个@ConditionalOnMissingBean定义,提供了您可以轻松覆盖的合理默认值。

当spring-addons使用OAthentication<OpenidClaimSet>实例(而不是JwtAuthenticationToken)设置安全上下文时,需要对我们的控制器进行轻微修改:

@RestController
@RequestMapping("/greet")
@PreAuthorize("isAuthenticated()")
public class GreetingController {

    @GetMapping()
    @PreAuthorize("hasAuthority('NICE')")
    public String getGreeting(OAuthentication<OpenidClaimSet> auth) {
        return "Hi %s! You are granted with: %s.".formatted(
                auth.getClaims().getPreferredUsername(),
                auth.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.joining(", ", "[", "]")));
    }
}

单元测试

我们看到了如何使用@PreAuthorize(“hasAuthority('NICE')”)这样的表达式向Spring方法添加基于角色的访问控制,该表达式根据JWT访问令牌中包含的标识和角色(或在授权服务器内部化端点上公开)做出断言。

现在让我们看看如何单元测试这些安全规则。 spring-security-test提供了MockMvc后处理器和WebTestClient的mutators来用JwtAuthenticationToken或BearerTokenAuthentication填充测试安全上下文,它们分别是使用JWT解码器或令牌自检的应用的默认身份验证。

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;

@WebMvcTest(controllers = GreetingController.class, properties = "server.ssl.enabled=false")
@Import({ WebSecurityConfig.class })
class GreetingControllerTest {

    @MockBean
    AuthenticationManagerResolver<HttpServletRequest> authenticationManagerResolver;

    @Autowired
    MockMvc mockMvc;

    @Test
    void whenGrantedNiceRoleThenOk() throws Exception {
        mockMvc.perform(get("/greet").with(jwt().jwt(jwt -> {
            jwt.claim("preferred_username", "Tonton Pirate");
        }).authorities(List.of(new SimpleGrantedAuthority("NICE"), new SimpleGrantedAuthority("AUTHOR"))))).andExpect(status().isOk())
                .andExpect(content().string("Hi Tonton Pirate! You are granted with: [NICE, AUTHOR]."));
    }

    @Test
    void whenNotGrantedNiceRoleThenForbidden() throws Exception {
        mockMvc.perform(get("/greet").with(jwt().jwt(jwt -> {
            jwt.claim("preferred_username", "Tonton Pirate");
        }).authorities(List.of(new SimpleGrantedAuthority("AUTHOR"))))).andExpect(status().isForbidden());
    }

    @Test
    void whenAnonymousThenUnauthorized() throws Exception {
        mockMvc.perform(get("/greet")).andExpect(status().isUnauthorized());
    }
}

然而,这有其局限性:

  • 只能测试@Controller安全性(其他@Component单元测试不会在MockMvc或WebTestClient请求的上下文中运行)。
  • 这给测试请求定义带来了相当混乱。

作为替代方案,我们可以添加对spring-addons-webmvc-jwt-test依赖:

<dependency>
        <groupId>com.c4-soft.springaddons</groupId>
        <artifactId>spring-addons-webmvc-jwt-test</artifactId>
        <version>5.1.3</version>
        <scope>test</scope>
</dependency>

它包含类似于@WithMockUser的测试注释,注入其他类型的Authentication

  • @WithMockJwtAuth构建JwtAuthenticationToken(JWT解码器的默认值)。
  • @WithMockBearerTokenAuthentication构建BearerTokenAuthentication(令牌内省的默认值)。
  • @OpenId构建OAuthentication<OpenidClaimSet>(spring-addons启动器的默认值)
@WebMvcTest(controllers = GreetingController.class)
@AutoConfigureAddonsSecurityWebmvcJwt
@Import(WebSecurityConfig.class)
class GreetingControllerTest {

    @Autowired
    MockMvcSupport mockMvc;

    @Test
    @OpenId(authorities = { "NICE", "AUTHOR" }, claims = @OpenIdClaims(preferredUsername = "Tonton Pirate"))
    void whenGrantedWithNiceRoleThenCanGreet() throws Exception {
        mockMvc.get("/greet").andExpect(status().isOk()).andExpect(content().string("Hi Tonton Pirate! You are granted with: [NICE, AUTHOR]."));
    }

    @Test
    @OpenId(authorities = { "AUTHOR" }, claims = @OpenIdClaims(preferredUsername = "Tonton Pirate"))
    void whenNotGrantedWithNiceRoleThenForbidden() throws Exception {
        mockMvc.get("/greet").andExpect(status().isForbidden());
    }

    @Test
    void whenAnonymousThenUnauthorized() throws Exception {
        mockMvc.get("/greet").andExpect(status().isUnauthorized());
    }
}

如果您尚未应用上面的“配置削减”:

  • spring-addons-webmvc-jwt-test依赖项替换为spring-addons-oauth2-test
  • @OpenId@WithMockJwtAuth
  • MockMvcSupportMockMvc
  • get("/greet")替换为 perform(get("/greet"))
  • 删除@AutoConfigureSecurityAddons
  • 添加@MockBean JwtDecoder jwtDecoder;
  • 请务必导入您的所有安全提示。
  • 如果在安全conf中激活,请仔细检查您是否向每个请求构建器添加.secure(true)和.csrf()(MockMvcSupport会自动执行)。

所以是的,Spring插件也可以减轻您的单元测试。

更进一步

我省略了客户端的配置。你应该从认证的实现中选择一个lib。我个人对Angular更有经验,更喜欢angular-auth-oidc-client

要了解如何从spring-addons覆盖默认的@ConditionalOnMissingBean,您可以参考此高级教程,该教程涵盖:

  • 解析您的授权服务器服务的私人索赔
  • 扩展Authentication实现,以实施基于不仅仅是角色的安全规则
  • 丰富Spring安全DSL(@@PreAuthorize@PostFilter表达式)

如果您对令牌内省感兴趣,您可以参考其他教程“如何使用令牌内省配置Spring REST API”。