Сервер 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).