Сервер Spring Auth не авторизует HTTP-запросы к защищенным конечным точкам с помощью переданного токена носителя.

Ключевой момент

Я разрабатываю приложение в Spring Boot 3.1.0 , используя сервер авторизации Spring для реализации сервера OAuth 2.1 для потока кода аутентификации с помощью PKCE.

OAuth работает отлично, но как только я продолжил работу над частью сервисного API и защитил ее, мое приложение отказалось авторизовать входящие http-запросы к конечным точкам API с токеном Bearer, переданным в заголовке.

Главный вопрос

Как защитить конечные точки REST API с помощью аутентификации токена носителя на этом одномодульном веб-сервере?
Является ли это возможным? Что мне следует делать?

Тестовый пример

Журналы приложений Spring:

      # I try to exchange OAuth code for an access token
23:16:44.991 [nio-8080-exec-4] o.s.security.web.FilterChainProxy        : Securing POST /oauth2/token
23:16:45.059 [nio-8080-exec-4] o.s.a.w.OAuth2ClientAuthenticationFilter : Set SecurityContextHolder authentication to OAuth2ClientAuthenticationToken

# And then I try to use this token for default OAuth provided endpoints (result: 200 OK)
23:17:16.212 [nio-8080-exec-6] o.s.security.web.FilterChainProxy        : Securing GET /userinfo
23:17:16.217 [nio-8080-exec-6] o.s.web.client.RestTemplate              : HTTP POST http://localhost:8080/oauth2/introspect
23:17:16.221 [nio-8080-exec-6] o.s.web.client.RestTemplate              : Accept=[application/json, application/*+json]
23:17:16.222 [nio-8080-exec-6] o.s.web.client.RestTemplate              : Writing [{token=[2Jd2M-Pq3Cx8We9gKVpfosvAnNGjprCJoyA6-gHOH3t2_cbpVaGsmGkgJ1n9wzam_kvvL4cthCUwSCNRWrfm_uGZJUtFWJjL_jaaKla0p37MDwkPbrGGhoJOGLeGDSrC]}] with org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter

# Also for opaque token introspection (result: 200 OK)
23:17:16.227 [nio-8080-exec-5] o.s.security.web.FilterChainProxy        : Securing POST /oauth2/introspect
23:17:16.294 [nio-8080-exec-5] o.s.a.w.OAuth2ClientAuthenticationFilter : Set SecurityContextHolder authentication to OAuth2ClientAuthenticationToken
23:17:16.307 [nio-8080-exec-6] o.s.web.client.RestTemplate              : Response 200 OK
23:17:16.308 [nio-8080-exec-6] o.s.web.client.RestTemplate              : Reading to [java.util.Map<java.lang.String, java.lang.Object>]
23:17:16.320 [nio-8080-exec-6] .s.r.a.OpaqueTokenAuthenticationProvider : Authenticated token
23:17:16.320 [nio-8080-exec-6] .s.r.w.a.BearerTokenAuthenticationFilter : Set SecurityContextHolder to BearerTokenAuthentication [Principal=org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionAuthenticatedPrincipal@49bc387c, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=null], Granted Authorities=[SCOPE_openid, SCOPE_user:read, SCOPE_user:write]]

# After that I try to send request to my REST API controller (result: 401 + redirect)
23:17:43.504 [nio-8080-exec-8] o.s.security.web.FilterChainProxy        : Securing GET /api/user/get
23:17:43.504 [nio-8080-exec-8] o.s.s.w.a.AnonymousAuthenticationFilter  : Set SecurityContextHolder to anonymous SecurityContext
23:17:43.504 [nio-8080-exec-8] o.s.s.w.session.SessionManagementFilter  : Request requested invalid session id 9FC445DE02E5AA86CF6C7D898290112F
23:17:43.505 [nio-8080-exec-8] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to team.flow.server.api.controller.UserApiController#getUser(Long, Authentication)
23:17:43.505 [nio-8080-exec-8] o.s.s.w.s.HttpSessionRequestCache        : Saved request http://localhost:8080/api/user/get?continue to session
23:17:43.505 [nio-8080-exec-8] o.s.s.web.DefaultRedirectStrategy        : Redirecting to http://localhost:8080/auth/sign-in
23:17:43.512 [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Securing GET /auth/sign-in
23:17:43.512 [nio-8080-exec-9] o.s.s.w.a.AnonymousAuthenticationFilter  : Set SecurityContextHolder to anonymous SecurityContext
23:17:43.512 [nio-8080-exec-9] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to team.flow.server.auth.controller.AuthFrontController#handleDefaultRequest()
23:17:43.512 [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Secured GET /auth/sign-in
23:17:43.512 [nio-8080-exec-9] o.s.web.servlet.DispatcherServlet        : GET "/auth/sign-in", parameters={}
23:17:43.512 [nio-8080-exec-9] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to team.flow.server.auth.controller.AuthFrontController#handleDefaultRequest()
23:17:43.513 [nio-8080-exec-9] o.s.web.servlet.DispatcherServlet        : Completed 200 OK

Скриншот консоли Postman для последнего запроса:

Исходный код

Конфигурация безопасности:

      @Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private static final List<String> FLOW_OAUTH2_SCOPES = List.of(
            "openid",
            "user:read", "user:write"
    );

    private final UserRepository userRepository;
    private final FlowAuthenticationHandler authenticationHandler;

    @Bean
    @Order(1)
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);

        http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
                .oidc(Customizer.withDefaults());

        return http
                .exceptionHandling((exceptions) -> exceptions
                        .defaultAuthenticationEntryPointFor(
                                new LoginUrlAuthenticationEntryPoint("/auth"),
                                new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
                        ))
                .oauth2ResourceServer((resourceServer) -> resourceServer
                        .opaqueToken(Customizer.withDefaults()))
                .build();
    }

    @Bean
    @Order(2)
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        return http
                .authorizeHttpRequests(authorizeRequests -> authorizeRequests
                        .requestMatchers("/error", "/flow-web/**", "/favicon.ico").permitAll()
                        .requestMatchers("/auth/complete").authenticated()
                        .requestMatchers("/auth/**", "/logout").permitAll()
                        .requestMatchers("/oauth2/code").permitAll()
                        .requestMatchers(HttpMethod.GET, "/api/user/**").hasAuthority("SCOPE_user:read")
                        .requestMatchers(HttpMethod.POST, "/api/user/**").hasAuthority("SCOPE_user:write")
                        .anyRequest().authenticated())
                .sessionManagement(sessionManagement -> sessionManagement
                        .maximumSessions(1))
                .formLogin(formLogin -> formLogin
                        .loginPage("/auth/sign-in")
                        .loginProcessingUrl("/auth/sign-in")
                        .successHandler(authenticationHandler)
                        .failureHandler(authenticationHandler)
                        .usernameParameter("email")
                        .passwordParameter("password"))
                .logout(logout -> logout
                        .deleteCookies("JSESSIONID")
                        .logoutUrl("/logout")
                        .logoutSuccessUrl("/auth"))
                .build();
    }

    @Bean
    public UserDetailsService userDetailsService() {
        return new FlowUserDetailsService(userRepository);
    }

    @Bean
    public RegisteredClientRepository registeredClientRepository() {
        RegisteredClient flowClient = RegisteredClient.withId(UUID.randomUUID().toString())
                .clientId("")
                .clientName("")
                .clientSecret("")
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                .redirectUri("http://localhost:8080/oauth2/code")
                .postLogoutRedirectUri("http://localhost:8080/auth")
                .scopes(scopes -> scopes.addAll(FLOW_OAUTH2_SCOPES))
                .clientSettings(ClientSettings.builder()
                        .requireAuthorizationConsent(false)
                        .requireProofKey(true)
                        .build())
                .tokenSettings(TokenSettings.builder()
                        .accessTokenFormat(OAuth2TokenFormat.REFERENCE) // yes, I use opaque tokens here
                        .authorizationCodeTimeToLive(Duration.ofSeconds(30))
                        .accessTokenTimeToLive(Duration.ofDays(3))
                        .refreshTokenTimeToLive(Duration.ofDays(14))
                        .reuseRefreshTokens(false)
                        .build())
                .build();

        return new InMemoryRegisteredClientRepository(flowClient);
    }

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

    @Bean
    public SecurityContextRepository securityContextRepository() {
        return new HttpSessionSecurityContextRepository();
    }

Контроллер REST API:

      @RestController
@RequestMapping("/api/user")
@RequiredArgsConstructor
public class UserApiController {

    private final UserApiService userApiService;

    @GetMapping("/get")
    public ResponseEntity<?> getUser(@RequestParam(name = "id", required = false) Long userId) throws ApiException {
        if (userId < 1) {
            return ResponseEntity.badRequest().build();
        }

        User user = userApiService.getUser(userId);
        return ResponseEntity.ok(UserModel.constructFrom(user));
    }

    @GetMapping("/meta/get")
    public ResponseEntity<?> getUserMeta(@RequestParam(name = "id", required = false) Long userId) throws ApiException {
        if (userId < 1) {
            return ResponseEntity.badRequest().build();
        }

        UserMeta userMeta = userApiService.getUserMeta(userId);
        return ResponseEntity.ok(UserMetaModel.constructFrom(userMeta));
    }

}

Конфигурация приложения:

      spring:
  datasource:
    driver-class-name: org.postgresql.Driver
    url: jdbc:postgresql://127.0.0.1/${DATABASE}
    username: ${USERNAME}
    password: ${PASSWORD}

  jpa:
    properties:
      hibernate:
        dialect: org.hibernate.dialect.PostgreSQLDialect

    hibernate:
      ddl-auto: update

    open-in-view: false

  security:
    oauth2:
      resourceserver:
        opaquetoken:
          introspection-uri: http://localhost:8080/oauth2/introspect
          client-id: my_client_id
          client-secret: my_client_secret

logging:
  level:
    root: INFO
    '[org.springframework.web]': DEBUG
    '[org.springframework.security]': DEBUG
    '[org.springframework.security.oauth2]': DEBUG
    org.springframework.security.web.FilterChainProxy: DEBUG

server:
  servlet:
    session:
      cookie:
        same-site: lax

  error:
    whitelabel:
      enabled: false
    path: /error

Часть конфигурации проекта maven:

          <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.1.0</version>
        <relativePath/>
    </parent>

    <dependencies>
        <!-- Spring Boot Starter -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-mail</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
        </dependency>

        <!-- Thymeleaf Extras: Spring Security 5 -->
        <dependency>
            <groupId>org.thymeleaf.extras</groupId>
            <artifactId>thymeleaf-extras-springsecurity5</artifactId>
            <version>3.1.1.RELEASE</version>
            <scope>compile</scope>
        </dependency>

        <!-- PostgreSQL JDBC Driver -->
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
        </dependency>

        <!-- Lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
    </dependencies>

2 ответа

Следует заметить, что в подходе PKCE секрет клиента не используется, поскольку цель PKCE — не хранить секрет клиента во внешнем приложении из соображений безопасности. При использовании PKCE каждый раз, когда клиент отправляет запрос на сервер авторизации для получения кода авторизации, генерируется новый хэш-код для проверки клиента вместо использования секрета клиента. вы можете выполнить поиск кода вызова и средства проверки кода в Интернете, чтобы получить дополнительную информацию о хэш-кодах, которые генерируются для проверки клиента вместо секрета клиента.

Как защитить конечные точки REST API с помощью аутентификации токена на предъявителя?

С конфигурацией сервера ресурсов

Как это сделать в приложении, уже содержащем конфигурацию клиента?

Как конечные точки сервера авторизации, так и те, которые предоставляют доступ к страницам Thymeleaf, требуют конфигурации клиента OAuth2, в которой запросы защищаются с помощью сеансов (и требуют защиты CSRF).

Чтобы конечные точки REST API были защищены токенами доступа (и без сеанса, защиты CSRF, входа в систему или выхода из системы), определите третью цепочку фильтров безопасности, предназначенную для конечных точек сервера ресурсов.

Чтобы сохранить конфигурацию клиента OAuth2 по умолчанию, измените ее порядок на@Order(3)и вставьтеresourceServerFilterChainс @Order(2)и средство сопоставления безопасности, например http.securityMatcher("/api/**")так что он соответствует только маршрутам REST API и позволяетdefaultSecurityFilterChainобрабатывать все запросы, которые не были перехвачены ни одной из цепочек фильтров безопасности с более низким@Order.

Вы можете обратиться к моим руководствам по настройке сервера ресурсов и приложениям с конфигурацией как клиента OAuth2, так и сервера ресурсов OAuth2 (но ничего о сервере авторизации Spring, поскольку я использую другие решения в качестве поставщиков OpenID).

Другие вопросы по тегам