参考《<font style=color:rgb(18, 18, 18);>升级 spring-security-oauth2 到 spring-boot-starter-oauth2-resource-server - Jeff Tian的文章 - 知乎 》和 《<font style=color:rgb(18, 18, 18);>通过 Bean 的方式扩展 Spring 应用,使其同时支持多个授权服务颁发的令牌。 - Jeff Tian的文章 - 知乎 》,我们可以使用 <font style=color:rgb(18, 18, 18);>spring-boot-starter-oauth2-resource-server<font style=color:rgb(18, 18, 18);> 来对接 OIDC Server。

<font style=color:rgb(18, 18, 18);>

<font style=color:rgb(18, 18, 18);>今天再介绍一下另一种方式,不依赖已有的包,而是自己写代码来完成同样的事情。

<font style=color:rgb(18, 18, 18);>

<font style=color:rgb(18, 18, 18);>示意图

重述问题,我们使用了 OIDC Server 来保护我们的 API。 API 的调用者可以是人类用户,也可以是机器(另一个 API)。即 OIDC Server 起到了一个认证中心的作用,API 消费方通过认证中心获取令牌,并在请求 API 提供方时携带令牌。API 提供方需要依赖认证中心来核实令牌的有效性。

无效就拒绝服务:

前提

API 消费方在认证中心已经注册为一个有效的客户端。对于 API 消费方是机器的场景,这种客户端的许可类型是 client_credentials;如果 API 消费方是代替人类用户来请求 API 提供方,那么最终的客户端的许可类型很可能是 authorize_code。

我们现在的 API 提供方,假设是使用 springboot 开发,只是去掉了对 <font style=color:rgb(18, 18, 18);>spring-boot-starter-oauth2-resource-server 的引用。

配置

由于需要和认证中心做远程调用沟通,不妨这样配置:

yaml rpc: authServer: url: ${AUTH_SERVER_URL:https://id6.azurewebsites.net}

要发远程调用,需要一个 HTTP 客户端,为了排查问题的方便,我们增加一个客户端配置,用来将请求以 cURL 的形式打印到日志里(参考《<font style=color:rgb(18, 18, 18);>将 FeignClient 的请求记录成 cURL 格式 - Jeff Tian的文章 - 知乎 》)。

java @Slf4j @Configuration @EnableFeignClients(basePackages = com.hardway.infrastructure.rpc) public class AuthClientInterceptor {

public static final String CURL_PATTERN = curl --location --request %s %s %s --data-raw %s;

@Bean
public RequestInterceptor requestInterceptor() {
    return template -> {
        template.header(TRACE_NO, MDC.get(TRACE_NO));
        log.info(cURL to replay  + toCurl(template));
    };
}

public String toCurl(feign.RequestTemplate template) {
    try {
        val headers = Arrays.stream(template.headers().entrySet().toArray())
                .map(header -> header.toString()
                        .replace(=, :)
                        .replace([,  )
                        .replace(],  ))
                .map(h -> String.format( --header %s , h))
                .collect(Collectors.joining());
        val httpMethod = template.method().toUpperCase(Locale.ROOT);
        val url = template.feignTarget().url() + template.url();
        final byte[] bytes = template.body();
        val body = bytes == null ?  : new String(bytes, StandardCharsets.UTF_8);

        return String.format(CURL_PATTERN, httpMethod, url, headers, body);
    } catch (Exception ex) {
        log.error(ex.getMessage(), ex);
        return ex.getMessage();
    }
}

@Bean
Logger.Level feignLoggerLevel() {
    return Logger.Level.FULL;
}

}

接口

然后,我们来定义 HTTP 客户端的接口。分析一下需求,在 API 提供方,依赖认证中心的部分有两点,第一是需要校验令牌,第二是在令牌有效的情况下,需要基于令牌识别调用者的身份。对于结构化令牌 JWT 来说,要校验令牌是否为认证中心颁发,只需要验证该令牌的第三部分,即签名。认证中心会使用自己的私钥对令牌进行签名,而验证该签名,需要其公钥信息。因此我们需要调用认证中心的公钥信息获取接口,我们将这个方法命名为 getPublicKey。在签名验证通过后,我们从 JWT 的载荷里解析出过期时间,如果令牌未过期,就可以从中获取调用者的身份信息。如果出于各种原因(比如防止 PII 泄露),载荷中没有身份信息,或者不够,就需要再次调用认证中心以获取身份信息,我们将这个方法命名为 getPublicKey。

java @FeignClient(name = auth-client, url = ${rpc.authServer.url:}, configuration = AuthClientInterceptor.class) public interface IAuthClient {

@GetMapping(value = /connect/userinfo)
UserInfoResponse getUserInfo(@RequestHeader(authorization) String token);

@GetMapping(value = /.well-known/openid-configuration/jwks)
PublicKeyResponse getPublicKey();

}

一个典型的 OIDC Server 认证中心,其公钥接口如下:

1689678921515 45f98dab 232d 4cec bd46 9a6ff322f274

以上是 HTTP 客户端的 rpc 接口,但是以上两个接口返回的信息,在短时间内都不会发生变化,我们可以将其缓存起来。为些我们再定义一个服务接口,为代码中的其他部分提供服务:

java import org.springframework.cache.annotation.Cacheable;

public interface IAuthService {

@Cacheable(cacheNames = user-info, key = #userId, unless = #result == null)
String getAccount(String userId, String token);

@Cacheable(cacheNames = public-key, key = #kid, unless = #result == null)
String getPublicKey(String kid);

}

实现接口

实现服务接口以对外提供服务,底层调用 HTTP 客户端。

java

@Slf4j @Service @AllArgsConstructor public class AuthService implements IAuthService {

private final IAuthClient authClient;
private final ObjectMapper objectMapper;

@Override
public String getAccount(String userId, String token) {
    UserInfoResponse response = authClient.getUserInfo(token);
    String email = Objects.isNull(response) ? null : response.getEmail();

    return email;
}

@SneakyThrows
@Override
public String getPublicKey(String kid) {
    PublicKeyResponse response = authClient.getPublicKey();
    return Objects.isNull(response) ? null : objectMapper.writeValueAsString(response.getKey(kid));
}

}

对接口进行保护

我们希望以一种方便的途径来标记要保护的接口,比如这样:

java @Slf4j @RequiredArgsConstructor @RestController @RequestMapping(/protected/api) public class ProtectedApiController { @RequireAuth() @PostMapping(api1) public ApiResponse<?> doSomething() { ... return ApiResponse.success(); } }

为此,我们可以通过切面编程实现以上标记。首先实现一个接口:

java @Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface RequireAuth { boolean moreFields() default true; }

然后定义一个切面类。

切面类和切入点用于在应用程序中定义横切关注点的行为。通过 @Aspect 注解将类标记为切面类,然后使用 @Pointcut 注解定义一个切入点。切入点指定了在何处应该应用切面的逻辑。

doAround() 方法是一个环绕通知,在目标方法执行之前和之后执行额外的逻辑。它在 @RequireAuth 注解标记的方法执行之前验证和处理身份验证和授权逻辑,并在之后执行目标方法。

doAfterThrowing() 方法是一个异常通知,在目标方法抛出异常时执行额外的逻辑。它用于记录和处理目标方法抛出的异常。

java @Aspect @Component @Slf4j @AllArgsConstructor public class AuthPointcut { private final IAuthService authService;

@Pointcut(@annotation(RequireAuth))
public void authPointcut() {}

@Around(authPointcut())
public Object doAround(ProceedingJoinPoint pjp) throws Throwable {
    ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();

    if(attributes == null) {
        throw new RuntimeException(缺少 HTTP 必要元信息);
    }

    String token = attributes.getRequest().getHeader(authorization);

    if(StringUtils.isBlank(token)) {
        throw new RuntimeException(token 缺失);
    }

    String finalToken = StringUtils.removeStart(token, Bearer );

    DecodedJWT decodedJWT = JWT.decode(finalToken);
    String kid = decodedJWT.getKeyId();
    String key = authService.getPublicKey(kid);

    Security.addProvider(new BouncyCastleProvider());

    JWK jwk = JWK.parse(key);
    RSAPublicKey rsaPublicKey = (RSAPublicKey) jwk.toRSAKey().toPublicKey();

    byte[] encoded = rsaPublicKey.getEncoded();
    X509EncodedKeySpec keySpec = new X509EncodedKeySpec(encoded);
    PublicKey publicKey = KeyFactory.getInstance(RSA).generatePublic(keySpec);

    Claims body = Jwts.parserBuilder()
        .setSigningKey(publicKey)
        .require(client_id, 前提中在认证中心提前注册好的 client_id)
        .build()
        .parseClaimsJws(token)
        .getBody();

    String userId = body.get(sub, String.class);
    String email = authService.getAccount(userId, token);

    if(StringUtils.isBlank(email)) {
        throw new RuntimeException(身份信息缺失);
    }

    Class<?> clazz = pjp.getTarget().getClass();
    MethodSignature signature = (MethodSignature) pjp.getSignature();
    Method targetMethod = clazz.getDeclaredMethod(signature.getName(), signature.getParameterTypes());
    RequireAuth requireAuth = targetMethod.getAnnotation(RequireAuth.class);

    if(!auth.moreFields()) {
        // 更多自定义逻辑
    }

    return pjp.proceed();
}

@AfterThrowing(value=authPointcut(), throwing = throwable)
public void doAfterThrowing(Throwable throwable) {
    log.error(异常: {}, throwable.getMessage());
}

}

以上 Security.addProvider(new BouncyCastleProvider()) 这行代码的作用是向 Java 的安全提供者列表中添加 Bouncy Castle 提供的安全提供者。

Java 的安全架构中使用了安全提供者(Security Provider)的概念,安全提供者是一个实现了 Java Security API 的具体实现,它提供了一系列的加密、解密、签名、验证等安全功能。不同的安全提供者可能支持不同的加密算法、密钥管理方式等。

Bouncy Castle 是一个开源的密码学库,提供了丰富的密码学算法和安全服务,如加密、签名、密钥交换等。它不仅实现了标准的 Java Security API,还提供了一些额外的功能和算法。

通过调用 Security.addProvider(new BouncyCastleProvider()),将 Bouncy Castle 提供的安全提供者添加到 Java 运行时的安全提供者列表中。这样,在后续的安全操作中,就可以使用 Bouncy Castle 提供的算法和功能。

在提供者列表中,提供者是按照其优先级顺序进行搜索和使用的。因此,通过添加 Bouncy Castle 提供者,可以在使用安全功能时使用 Bouncy Castle 的实现。

需要注意的是,一般情况下,只有在需要使用 Bouncy Castle 特定的算法或功能时才需要将其添加为安全提供者。如果你的应用程序不需要使用 Bouncy Castle 提供的功能,那么添加它可能是不必要的。

以上代码从 HTTP 标头中提取出令牌,并对其进行解析,验证签名,并确保是可信客户端。其期待的令牌解析后的格式如下:

json { iss: https://id6.azurewebsites.net, nbf: 1689651711, iat: 1689651711, exp: 1689653511, aud: abcdef, amr: [ external ], at_hash: roYexupGNPvXDeVliTAHTA, sid: C438CEAB57171B0BBD788008F767EBF9, sub: d9df1959-7a18-4753-8642-c0ec0d20ae67, auth_time: 1689651693, idp: id6 }

总结

如果不使用现有的库,代码量还是挺大的,但是这允许深度定制,可以满足复杂的需求。