当前位置:Java -> Spring OAuth 服务器:使用用户详细信息服务对用户进行认证

Spring OAuth 服务器:使用用户详细信息服务对用户进行认证

在本文中,我们将看到如何自定义身份验证,其中用户详细信息是通过HTTP从另一个组件/服务获取的。将用户详细信息存储为Principal,并在稍后创建令牌时使用它们以自定义JWT中的声明(本文的范围仅涵盖两种流程:client-credentials和code flow)。

代码可在GitHub上找到。

要实现这一点,需要进行以下更改。

  1. 密码编码器
  2. 用于从服务获取用户详细信息的服务/客户端
  3. UserDetails实体
  4. 令牌自定义程序

密码编码器

需要密码编码器来对身份验证/登录时提供的密码进行编码,以验证/验证与数据库中存储的(在注册或更改密码时)编码的密码相匹配。

更多信息请参考D3PasswordEncoder

用于获取用户详细信息的服务/客户端

需要一个bean/服务来提供自定义UserDetails。此服务可以从内存存储中硬编码提供用户详细信息,或者通过调用另一个服务来提供用户详细信息。在本例中,我们将专注于调用另一个服务(user-detail-service)。

在oauth-server中的用户详细信息服务bean实现了spring-security提供的UserDetailsService(因为oauth服务器是建立在spring-security之上的)。

@Service
public class D3UserDetailsService implements UserDetailsService {

  private final WebClient webClient;

  public D3UserDetailsService(@Value("${user.details.service.base.url}") String userServiceBaseUrl) {
    webClient = WebClient.builder().baseUrl(userServiceBaseUrl).build();

  }

  public UserDetails loadUserByUsername(String username) {
    D3User user = webClient.get()
        .uri(uriBuilder -> uriBuilder.path("/users").path("/{username}").build(username))
        .retrieve()
        .onStatus(httpStatusCode -> httpStatusCode.isSameCodeAs(HttpStatus.NOT_FOUND),
            clientResponse -> Mono.error(new D3Exception("Bad credentials")))
        .bodyToMono(D3User.class).block(
            Duration.ofSeconds(2));

    return new D3UserDetails(user.userId(), user.username(), user.password(), getAuthorities(user.roles()), user.ssn(),
        user.email(), user.isPasswordChangeRequired(), user.roles());
  }


  private List<GrantedAuthority> getAuthorities(List<String> roles) {
    List<GrantedAuthority> authorities = new ArrayList<>(roles.size());
    for (String role : roles) {
      authorities.add(new SimpleGrantedAuthority("ROLE_" + role));
    }
    return authorities;
  }

  @JsonIgnoreProperties(ignoreUnknown = true)
  @Builder
  public record D3User(@JsonProperty("id") Integer userId, @JsonProperty("userName") String username,
                       String password, List<String> roles, String ssn, String email,
                       boolean isPasswordChangeRequired) {

  }
  
}


UserDetails实体

UserDetails实体可以(不是必须,除非您想要向经过身份验证的用户上下文添加一些更多细节)定义为:

@Getter
public class D3UserDetails extends User {

  private final Integer userId;
  private final boolean isPasswordChangeRequired;
  private final List<String> roles;
  private final String ssn;
  private final String email;

  public D3UserDetails(Integer userId, String username, String password, List<GrantedAuthority> authorities,
      String ssn, String email, boolean isPasswordChangeRequired, List<String> roles) {
    super(username, password, authorities);
    this.userId = userId;
    this.ssn = ssn;
    this.email = email;
    this.isPasswordChangeRequired = isPasswordChangeRequired;
    this.roles = roles;
  }
}


这个D3UserDetails实体扩展了Spring Security User实体,并提供了额外的属性。

令牌自定义程序

需要令牌自定义程序来为access_token提供额外的属性/声明:

自包含的JWT

如果access_token的格式是self-contained,那么需要实现Auth2TokenCustomizer<JwtEncodingContext>的自定义程序。

public class OAuth2JWTTokenCustomizer implements OAuth2TokenCustomizer<JwtEncodingContext> {

  private static final Consumer<JwtEncodingContext> AUTHORIZE_CODE_FLOW_CUSTOMIZER = (jwtContext) -> {
    if (AUTHORIZATION_CODE.equals(jwtContext.getAuthorizationGrantType()) && ACCESS_TOKEN.equals(
        jwtContext.getTokenType())) {
      UsernamePasswordAuthenticationToken authenticatedUserToken = jwtContext.getPrincipal();
      D3UserDetails userDetails = (D3UserDetails) authenticatedUserToken.getPrincipal();
      Map.of("userId", userDetails.getUserId(),
              "username", userDetails.getUsername(),
              "isPasswordChangeRequired", userDetails.isPasswordChangeRequired(),
              "roles", userDetails.getRoles(),
              "ssn", userDetails.getSsn(),
              "email", userDetails.getEmail())
          .forEach((key, value) -> jwtContext.getClaims().claim(key, value));
    }
  };

  private static final Consumer<JwtEncodingContext> CLIENT_CREDENTIALS_FLOW_CUSTOMIZER = (jwtContext) -> {
    if (CLIENT_CREDENTIALS.equals(jwtContext.getAuthorizationGrantType()) && ACCESS_TOKEN.equals(
        jwtContext.getTokenType())) {
      OAuth2ClientCredentialsAuthenticationToken clientCredentialsAuthentication = jwtContext.getAuthorizationGrant();
      Map<String, Object> additionalParameters = clientCredentialsAuthentication.getAdditionalParameters();
      additionalParameters.forEach((key, value) -> jwtContext.getClaims().claim(key, value));
    }
  };

  private final Consumer<JwtEncodingContext> jwtEncodingContextCustomizers = AUTHORIZE_CODE_FLOW_CUSTOMIZER.andThen(
      CLIENT_CREDENTIALS_FLOW_CUSTOMIZER);

  @Override
  public void customize(JwtEncodingContext context) {
    jwtEncodingContextCustomizers.accept(context);
  }
}


由于客户端凭据流程始终是自包含的,因此我们必须在JWTToken中添加对其的支持,以及对代码流的支持。对于代码流,我们会对用户进行身份验证,并在JWT中使用从UserService获取的用户详细信息作为额外的声明。而对于客户端凭据流程,额外的参数将作为请求参数提供。

不透明令牌

如果access_token的格式是reference,则需要实现OAuth2TokenCustomizer<OAuth2TokenClaimsContext>的自定义程序。

@Component
public class OAuth2OpaqueTokenIntrospectionResponseCustomizer implements
    OAuth2TokenCustomizer<OAuth2TokenClaimsContext> {

  private static final Consumer<OAuth2TokenClaimsContext> INTROSPECTION_TOKEN_CLAIMS_CUSTOMIZER = (claimsContext) -> {
    if (AUTHORIZATION_CODE.equals(claimsContext.getAuthorizationGrantType()) && ACCESS_TOKEN.equals(
        claimsContext.getTokenType())) {
      UsernamePasswordAuthenticationToken authenticatedUserToken = claimsContext.getPrincipal();
      D3UserDetails userDetails = (D3UserDetails) authenticatedUserToken.getPrincipal();
      Map.of("userId", userDetails.getUserId(),
              "username", userDetails.getUsername(),
              "isPasswordChangeRequired", userDetails.isPasswordChangeRequired(),
              "roles", userDetails.getRoles(),
              "ssn", userDetails.getSsn(),
              "email", userDetails.getEmail())
          .forEach((key, value) -> claimsContext.getClaims().claim(key, value));
    }
  };

  private final Consumer<OAuth2TokenClaimsContext> claimsContextCustomizer = INTROSPECTION_TOKEN_CLAIMS_CUSTOMIZER;

  @Override
  public void customize(OAuth2TokenClaimsContext jwtContext) {
    claimsContextCustomizer.accept(jwtContext);
  }
}


由于引用令牌与代码流相关,因此在成功身份验证后,当代码被交换为令牌时,授权服务器所颁发的access_token将不是JWT,而是一个引用。这个引用应该通过内省端点来交换,以获得带有用户详细信息声明和其他声明的access_token。工作功能测试可以参考这里

GitHub是一个可用的工作示例这里

在自包含情况下,代码流结束时,access_token将以JWT形式呈现,其中包括通过自定义程序添加的所有额外声明,包括UserDetails。而在不透明令牌(引用)的情况下,需要通过内省调用来获取响应中的UserDetails声明。

响应是什么样的?

您可以通过GitHub添加的测试来验证它:它有两个测试方法,覆盖了两种情景。

自包含的JWT

代码流令牌响应

{
   "access_token":"eyJraWQiOiIxNzdjMzA1MC1lMGY2LTQ4NDctYjJiNy02NTY2ZDVlZGZiMWUiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJkM3VzZXIiLCJyb2xlcyI6WyJhZG1pbiIsInVzZXIiXSwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo2MDYwIiwiaXNQYXNzd29yZENoYW5nZVJlcXVpcmVkIjp0cnVlLCJ1c2VySWQiOjEyMywic3NuIjoiMTk3NjExMTE5ODc3IiwiYXVkIjoic3ByaW5nLXRlc3QiLCJuYmYiOjE2OTkzNDcyODMsInNjb3BlIjpbIm9wZW5pZCIsInByb2ZpbGUiLCJlbWFpbCJdLCJleHAiOjE2OTkzNDc1ODMsImlhdCI6MTY5OTM0NzI4MywiZW1haWwiOiJ0ZXN0LXVzZXJAZDNzb2Z0dGVjaC5jb20iLCJ1c2VybmFtZSI6ImQzdXNlciJ9.RQiLWmGf9_rV4UfKzKomEhuJrncG08a2F34mN-gPDw7vK2csRPGMMDRYh2Gm0Eh-n3JRTaJ9_twdPQG9BgQifKiubPsM_etxpxKLLfQHoTfqzguiP8D53FyXLB9xwhvAgKH0KWLOSRxl-bdZsctpVZpqrMTPZtfdlt7tqcl71tGDY-7Nri76Kod39kyVcKEAuLNNZKt4fhn8tCLUA64jKfmKPM3afmAdvf0PlEwgwqhGhojxtCLnYNtzuO_VQheTaQvZxrzcXw3gNRnO4vppedAyG1gmUV44l4u7cXdhG-vGc1ItU45PSg3EaG7BtHU1axKu3qHB8C7mHAhk3zVuUA",
   "refresh_token":"t9U3CDejVC2k_eNtyvM23RTN3ePpS9x8b8_pVrD-U-ivLij0dWt9NZVO9wn-kIsyr89Yj-fBFpH8BFZoMUIqGI_wZSmKgYqpO0SmNE-C1_hW8DVLqT8zQ7PkhF_Gil7N",
   "scope":"openid profile email",
   "token_type":"Bearer",
   "expires_in":299
}


AccessToken JWT声明如下:

{
  "sub": "d3user",
  "roles": [
    "admin",
    "user"
  ],
  "iss": "http://localhost:6060",
  "isPasswordChangeRequired": true,
  "userId": 123,
  "ssn": "197611119877",
  "aud": "spring-test",
  "nbf": 1699347283,
  "scope": [
    "openid",
    "profile",
    "email"
  ],
  "exp": 1699347583,
  "iat": 1699347283,
  "email": "test-user@d3softtech.com",
  "username": "d3user"
}


我们可以看到JWT主体包含附加声明,例如:

  1. roles
  2. isPasswordChangeRequired
  3. userId
  4. ssn
  5. email
  6. username

我们在自定义程序中提供了这些内容的标识。同样地,您也可以添加任意数量的声明。

使用access_token的内省响应

{
   "active":true,
   "client_id":"spring-test",
   "iat":1698757155,
   "exp":1698760755
}


/oauth2/introspect的默认响应只会返回access_token的状态。如果需要的话,它也可以自定义。

不透明令牌

代码流:代码交换响应

{
   "access_token":"vbHFMLGQPmqAWWOzjLoYNu_RG1jBHc7oifI9Hl9N1eCyG3jdzTgAoN8YXAAK-GfEy1CUhokTAnM2aC4GsDe07OgPBpI_sAGHP60pQgbTDTyBUJj2jO1inIi0FoCpmPcj",
   "refresh_token":"Rj8CpnQexjtFJzCPFJUmhKGVmgdFAJ6RLMB_h6SwYgDItPLwSu6AR7CZ3WpIEQthm7pGEpis7NlrarvIHX5YjwBX6wGwWpwfnIKVSa0OJYJqhFsZfFvOmn8sypi4DS4b",
   "scope":"openid profile email",
   "token_type":"Bearer",
   "expires_in":299
}


在代码流程结束时,您将获得封装有access_tokenrefresh_tokenscopetoken_typeexpires_in的JSON响应。

要提取经过身份验证的用户的声明,我们必须调用/oauth2/introspect端点来检测 spring-oauth-server。

使用 access_token 进行内省响应,不使用自定义器

{
   "active":true,
   "sub":"d3user",
   "aud":[
      "spring-reference"
   ],
   "nbf":1698755697,
   "scope":"openid profile email",
   "iss":"http://localhost:6060",
   "exp":1698755997,
   "iat":1698755697,
   "jti":"2b4165c0-68f3-4e3d-b67e-d50c3f7b6110",
   "client_id":"spring-reference",
   "token_type":"Bearer"
}


没有自定义器时,它具有默认声明,如状态"active"和代码流程中经过身份验证的用户的主题(sub)。

使用 access_token 进行内省响应,使用自定义器

{
   "active":true,
   "sub":"d3user",
   "roles":[
      "admin",
      "user"
   ],
   "iss":"http://localhost:6060",
   "isPasswordChangeRequired":true,
   "userId":123,
   "ssn":"197611119877",
   "aud":[
      "spring-reference"
   ],
   "nbf":1698755588,
   "scope":"openid profile email",
   "exp":1698755888,
   "iat":1698755588,
   "operatorId":"197611119877",
   "jti":"c0560938-c413-44f7-a01b-9cbc119eae58",
   "email":"test-user@d3softtech.com",
   "username":"d3user",
   "client_id":"spring-reference",
   "token_type":"Bearer"
}


使用自定义器时,access_token将具有额外的声明,例如:

  1. roles
  2. isPasswordChangeRequired
  3. userId
  4. ssn
  5. operatorId
  6. email
  7. username

注意:如果您的服务中使用了Spring Security,则内省将由安全层处理。我将在另一篇文章中详细介绍使用oauth2-resource-server的Spring Security。

推荐阅读: 33. Java中重载和重写的区别

本文链接: Spring OAuth 服务器:使用用户详细信息服务对用户进行认证