Spring Security OAuth 2 教程 - 8:资源服务器

上一篇文章 中,我们创建了 messages-webapp,并使用 “授权码模式” 通过 Spring Security OAuth 2.0 对其进行了访问控制。在本文中,我们将创建 messages-service(Spring Boot 资源服务器),并使用 Spring Security OAuth 2.0 进行访问控制。

你可以在 Github 仓库 找到该项目完整的源码。

创建 messages-service

点击此 链接 可使用 Spring Initializr 生成 messages-service。我们选择了 WebValidationSecurityOAuth2 Resource Server Starter。应用生成后,在 IDE 打开它。

配置 OAuth 2.0 资源服务器属性

messages-servicebearer-only 类型的资源服务器。这意味着如果有人使用有效的 access_token 作为 Authorization 头发送请求到受保护的 API 端点,该服务将返回响应。否则,它将只会返回 401 或 403 的 HTTP 状态码,而不会启动 OAuth 2.0 的授权流程。

bearer-only 类型的资源服务器无需向授权服务器(Keycloak)注册。我们只需在 application.properties 文件中配置 issuer-uri 如下:

spring.application.name=messages-service
server.port=8181
spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:9191/realms/sivalabs

实现 API 端点

实现第一个 API 端点 /api/messages,它返回一个消息列表。

创建 Message 类,如下:

package com.sivalabs.messages.domain;

import jakarta.validation.constraints.NotEmpty;

import java.time.Instant;

public class Message {
    private Long id;
    @NotEmpty
    private String content;
    @NotEmpty
    private String createdBy;
    private Instant createdAt;

    // 忽略构造函数和 get/set 方法
}

创建 MessageRepository 类,如下:

package com.sivalabs.messages.repository;

import com.sivalabs.messages.model.Message;

import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;

import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Repository;

@Repository
public class MessageRepository {
    private static final AtomicLong ID = new AtomicLong(0L);
    private static final List<Message> MESSAGES = new ArrayList<>();

    @PostConstruct
    void init() {
        getDefaultMessages().forEach( p -> {
            p.setId(ID.incrementAndGet());
            MESSAGES.add(p);
        });
    }

    public List<Message> getMessages() {
        return MESSAGES;
    }

    public Message createMessage(Message message) {
        message.setId(ID.incrementAndGet());
        message.setCreatedAt(Instant.now());
        MESSAGES.add(message);
        return message;
    }

    private List<Message> getDefaultMessages() {
        List<Message> messages = new ArrayList<>();
        messages.add(new Message(null, "Test Message 1", "admin", Instant.now()));
        messages.add(new Message(null, "Test Message 2", "admin", Instant.now()));
        return messages;
    }
}

我们的重点是使用 OAuth 2 确保 API 端点的安全,因此我们不使用任何数据库来存储消息。我们仅使用一个简单的内存 List 来存储消息。

创建 MessageController 类,如下:

package com.sivalabs.messages.api;

import com.sivalabs.messages.domain.Message;
import com.sivalabs.messages.domain.MessageRepository;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequestMapping("/api/messages")
class MessageController {
    private final MessageRepository messageRepository;

    MessageController(MessageRepository messageRepository) {
        this.messageRepository = messageRepository;
    }

    @GetMapping
    List<Message> getMessages() {
        return messageRepository.getMessages();
    }

    @PostMapping
    Message createMessage(@RequestBody @Valid Message message) {
        return messageRepository.createMessage(message);
    }
}

我们实现了两个 API 端点,GET /api/messages 用于获取所有消息,POST /api/messages 用于创建新消息。

现在,如果我们尝试访问 http://localhost:8181/api/messages,会得到 401 HTTP 状态码,因为我们没有发送任何 access_token。默认情况下,Spring Security 会确保所有端点的安全。

使用 Postman 访问受保护的 API 端点

我们在 前面的文章 中已经介绍了如何使用 Postman 获取 access_token

让我们使用 Postman 获取 access_token 并调用 GET /api/messages API 端点。

  • 在 Postman 中打开新请求选项卡
  • 选择 HTTP MethodGET,然后输入 URL:http://localhost:8181/api/messages
  • 转到 Authorization 选项, 选择 TypeOAuth 2.0
  • Configure New Token 部分:
    • Grant Type: Authorization Code
    • Callback URL: http://localhost:8080/login/oauth2/code/messages-webapp
    • Auth URL: http://localhost:9191/realms/sivalabs/protocol/openid-connect/auth
    • Access Token URL: http://localhost:9191/realms/sivalabs/protocol/openid-connect/token
    • Client ID: messages-webapp
    • Client Secret: qVcg0foCUNyYbgF0Sg52zeIhLYyOwXpQ
    • Scope: openid profile
    • State: randomstring
    • Client Authentication: Send as Basic Auth header
  • 点击 Get New Access Token 按钮
  • Postman 会弹出 Keycloak 登录页面
  • 使用用户凭证 siva/siva1234 登录
  • 现在你应该可以看到带有 Token 详细信息的响应了
  • 点击 Use Token 按钮
  • 点击 Send 按钮调用 API 端点

你应该可以看到包含消息列表的响应。

如上,我们使用 Postman 首先获取了 access_token,然后通过将 access_token 作为 Authorization 头调用了 API 端点。

自定义 Security 配置

默认情况下,Spring Security OAuth 2.0 资源服务器实现将确保所有端点的安全。但是,我们希望允许未经身份认证的匿名用户访问 GET /api/messages API 端点。

自定义 Security 配置类 SecurityConfig,如下:

package com.sivalabs.messages.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.CorsConfigurer;
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(c ->
                c.requestMatchers(HttpMethod.GET, "/api/messages").permitAll()
                 .anyRequest().authenticated()
            )
            .sessionManagement(c -> c.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .cors(CorsConfigurer::disable)
            .csrf(CsrfConfigurer::disable)
            .oauth2ResourceServer(oauth2 ->
                oauth2.jwt(Customizer.withDefaults())
            );
        return http.build();
    }
}

如你所见,我们已经允许匿名用户访问 GET /api/messages API 端点,并对其余所有端点进行了访问控制。此外,我们配置了 OAuth2 资源服务器以使用基于 JWT 令牌的访问控制,并使用默认配置。

现在,如果你重启应用并访问 http://localhost:8181/api/messages,就可以在没有任何身份认证的情况下看到响应。但是,在调用 POST /api/messages API 端点时,你需要按照上一节所述配置身份认证(Authentication)。

它是如何运行的?

我们在 application.properties 文件中配置了 issuer-uri, http://localhost:9191/realms/sivalabs。启动应用时,Spring Security OAuth 2.0 会使用发现端点 (http://localhost:9191/realms/sivalabs/.well-known/openid-configuration)获取 jwks_uri,并用它下载用于验证 JWT token 的公钥。

因此,我们可以配置某些 API 端点为公开访问,无需进行身份认证,并对其余端点进行安全保护。但是,基于角色的访问呢?

记住,我们只允许 ROLE_ADMIN 用户调用 POST /api/messages/archive API 端点。

在了解这一点之前,我们先来看看如何获取当前用户的详细信息。

获取当前用户的详细信息

我们可以从 SecurityContextHolder 获取当前用户的详细信息。

实现一个 API 端点 GET /api/me,以获取当前用户的详细信息,如下:

package com.sivalabs.messages.api;

import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

@RestController
class UserInfoController {

    @GetMapping("/api/me")
    Map<String, Object> currentUserDetails() {
        return getLoginUserDetails();
    }

    Map<String, Object> getLoginUserDetails() {
        Map<String, Object> map = new HashMap<>();
        JwtAuthenticationToken authentication =
                (JwtAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
        Jwt jwt = (Jwt) authentication.getPrincipal();

        map.put("username", jwt.getClaimAsString("preferred_username"));
        map.put("email", jwt.getClaimAsString("email"));
        map.put("name", jwt.getClaimAsString("name"));
        map.put("token", jwt.getTokenValue());
        map.put("authorities", authentication.getAuthorities());
        map.put("roles", getRoles(jwt));

        return map;
    }

    List<String> getRoles(Jwt jwt) {
        Map<String,Object> realm_access = (Map<String, Object>) jwt.getClaims().get("realm_access");
        if(realm_access != null && !realm_access.isEmpty()) {
            return  (List<String>) realm_access.get("roles");
        }
        return List.of();
    }
}

现在,如果我们在 Postman 中调用 GET /api/me API 端点,并像之前一样配置授权(Authorization),我们将获得类似以下的当前用户详细信息:

{
  
  "name": "Siva Katamreddy",
  "email": "siva@gmail.com",
  "username": "siva",
  "token": "eyJhbGciOiJSUzI1NiIsInR5c.....qgGIu8iF86azw",
  "roles": [
    "default-roles-sivalabs",
    "offline_access",
    "uma_authorization"
  ],
  "authorities": [
    {
      "authority": "SCOPE_openid"
    },
    {
      "authority": "SCOPE_email"
    },
    {
      "authority": "SCOPE_profile"
    }
  ]
}

如果我们访问 jwt.io 并粘贴 token 值,就可以看到解码后的响应如下:

{
  "exp": 1695919675,
  "iat": 1695919375,
  "auth_time": 1695914182,
  "jti": "64128bc7-8f4d-48ff-978f-93b0764f39cd",
  "iss": "http://localhost:9191/realms/sivalabs",
  "aud": "account",
  "sub": "ca1a2f34-1614-45dd-86c1-5eafff085d8a",
  "typ": "Bearer",
  "azp": "messages-webapp",
  "session_state": "3e5865f1-0f0e-4ada-b2a1-97e7b118af4d",
  "acr": "0",
  "allowed-origins": [
    "http://localhost:8080"
  ],
  "realm_access": {
    "roles": [
      "default-roles-sivalabs",
      "offline_access",
      "uma_authorization"
    ]
  },
  "resource_access": {
    "account": {
      "roles": [
        "manage-account",
        "manage-account-links",
        "view-profile"
      ]
    }
  },
  "scope": "openid email profile",
  "sid": "3e5865f1-0f0e-4ada-b2a1-97e7b118af4d",
  "email_verified": true,
  "name": "Siva Katamreddy",
  "preferred_username": "siva",
  "given_name": "Siva",
  "family_name": "Katamreddy",
  "email": "siva@gmail.com"
}

如果我们将 /api/me API 响应与解码后的 token 数据进行比较,就会发现:

  • roles 源自 JWT 令牌中的 realm_access claim。
  • authorities 来自 JWT token 中的 scope claim。

默认的 JwtAuthenticationConverter 实现会将 scope claim 转换为添加 SCOPE_ 前缀的 authorities

Keycloak 会在 JWT token 中发送 realm_access claim 中的 roles。我们从 realm_access claim 明中提取了 roles,并将其添加到了响应中。

现在,让我们在 Keycloak 中创建 ROLE_USERROLE_ADMIN 角色,并将它们分配给用户 siva

  • 进入 Keycloak 管理控制台,选择 sivalabs Realm
  • 单击 Realm roles,创建 ROLE_USERROLE_ADMIN 角色
  • 点击 Users,选择用户 siva
  • 点击 Role Mappings,为用户 siva 分配 ROLE_USERROLE_ADMIN 角色

现在,如果我们从 Postman 中调用 GET /api/me API 端点,就会得到如下更新后的响应:

{

  "name": "Siva Katamreddy",
  "email": "siva@gmail.com",
  "username": "siva",
  "token": "eyJhbGciOiJSUzI1NiIsInR5c.....qgGIu8iF86azw",
  "roles": [
    "default-roles-sivalabs",
    "offline_access",
    "uma_authorization",
    "ROLE_USER",
    "ROLE_ADMIN"
  ],
  "authorities": [
    {
      "authority": "SCOPE_openid"
    },
    {
      "authority": "SCOPE_email"
    },
    {
      "authority": "SCOPE_profile"
    }
  ]
}

我们刚才所做的是从 JWT token 中找出当前用户的详细信息并提取角色。但是,要让 Spring Security 将角色(roles)视为授权(authorities),我们需要以某种方式将角色转换为授权。

为此,我们可以通过实现 Converter<Jwt, AbstractAuthenticationToken> 创建一个自定义的 JwtAuthenticationConverter,如下:

package com.sivalabs.messages.config;

import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;

import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

class KeycloakJwtAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> {
    private final Converter<Jwt, Collection<GrantedAuthority>> delegate = new JwtGrantedAuthoritiesConverter();

    @Override
    public AbstractAuthenticationToken convert(Jwt jwt) {
        List<GrantedAuthority> authorityList = extractRoles(jwt);
        Collection<GrantedAuthority> authorities = delegate.convert(jwt);
        if (authorities != null) {
            authorityList.addAll(authorities);
        }
        return new JwtAuthenticationToken(jwt, authorityList);
    }

    private List<GrantedAuthority> extractRoles(Jwt jwt) {
        Map<String,Object> realm_access = (Map<String, Object>) jwt.getClaims().get("realm_access");
        if(realm_access == null || realm_access.isEmpty()) {
            return List.of();
        }
        List<String> roles = (List<String>) realm_access.get("roles");
        if (roles == null || roles.isEmpty()) {
            roles = List.of("ROLE_USER");
        }
        return roles.stream()
                        .filter(role -> role.startsWith("ROLE_"))
                        .map(SimpleGrantedAuthority::new).collect(Collectors.toList());
    }
}

如上:

  • 我们正在从 realm_access claim 中提取 roles,并将其转换为 authorities。注意,我们只考虑具有 ROLE_ 前缀的角色。
  • 我们使用 Spring Security 的 JwtGrantedAuthoritiesConverterscope claim 转换为 authorities
  • 我们将 scope claim 中的 authoritiesrealm_access claim 中的 roles 结合起来,创建一个新的 JwtAuthenticationToken

现在,让我们在 SecurityConfig 中注册这个自定义的 JwtAuthenticationConverter,如下:

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
        ...
        ...
        .oauth2ResourceServer(oauth2 ->
                oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(new KeycloakJwtAuthenticationConverter()))
        );
        return http.build();
    }
}

现在,如果我们从 Postman 中调用 GET /api/me API 端点,就会得到如下更新后的响应:

{

  "name": "Siva Katamreddy",
  "email": "siva@gmail.com",
  "username": "siva",
  "token": "eyJhbGciOiJSUzI1NiIsInR5c.....qgGIu8iF86azw",
  "roles": [
    "default-roles-sivalabs",
    "offline_access",
    "uma_authorization",
    "ROLE_USER",
    "ROLE_ADMIN"
  ],
  "authorities": [
    {
      "authority": "ROLE_USER"
    },
    {
      "authority": "ROLE_ADMIN"
    },
    {
      "authority": "SCOPE_openid"
    },
    {
      "authority": "SCOPE_email"
    },
    {
      "authority": "SCOPE_profile"
    }
  ]
}

实际上,我们并不需要 GET /api/me API 端点,但我们实现它只是为了了解如何获取当前用户的详细信息,以及如何使用自定义 JwtAuthenticationConverter 将 Keycloak 角色(roles)映射为授权(authorities)。

验证基于角色的访问控制

通过实现 POST /api/messages/archive API 端点来验证基于角色的访问控制。

MessageController 类中添加如下端点:

@RestController
@RequestMapping("/api/messages")
class MessageController {
    private static final Logger log = LoggerFactory.getLogger(MessageController.class);
    ...
    ...

   @PostMapping("/archive")
   Map<String,String> archiveMessages() {
      log.info("Archiving all messages");
      return Map.of("status", "success");
   }
}

更新 SecurityConfig 类如下:

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
          .authorizeHttpRequests(c ->
            c.requestMatchers(HttpMethod.GET, "/api/messages").permitAll()
             .requestMatchers(HttpMethod.POST, "/api/messages/archive").hasAnyRole("ADMIN")
             .anyRequest().authenticated()
          )
        ...
        ...
      
        return http.build();
    }
}

现在,如果我们在 Postman 中调用 POST /api/messages/archive API 端点,并像之前那样配置授权(Authorization),就会得到以下 HTTP 状态码为 200 的响应。

{
  "status": "success"
}

现在,进入 Keycloak 管理控制台,删除用户 sivaROLE_ADMIN 角色。现在尝试获取新的 access_token,并从 Postman 调用 POST /api/messages/archive API 端点。由于用户 siva 没有 ROLE_ADMIN 角色,因此会收到 Forbidden 403 HTTP 状态码。

总结

在本文中,我们创建了 messages-service 资源服务器,并使用 Spring Security OAuth 2.0 对其进行了访问控制,我们还学习了如何实现基于角色的访问控制。

在下一篇文章中,我们将整合 messages-webappmessages-service 资源服务器。


参考:https://www.sivalabs.in/spring-security-oauth2-tutorial-securing-resource-server/