Spring Cloud OAuth2 微服务认证授权

OAuth 2.0 是用于授权的行业标准协议,它致力于简化客户端开发人员的工作,同时为 Web 应用、桌面应用、移动应用等各种客户端应用提供了特定的授权流程。本文讲解如何使用 OAuth2 协议来授权客户端应用访问 Spring Cloud 微服务。

Spring Cloud OAuth2 微服务认证授权

本文隶属于 Spring API 服务开发系列。

  1. Spring Boot API 服务开发指南
  2. Spring Boot API 服务测试指南
  3. Spring Cloud 微服务开发指南
  4. Spring Cloud OAuth2 微服务认证与授权

架构

前面我们在网关里实现了应用自己的登录和退出 API,然后通过在网关和微服务之间,以及微服务和微服务之间传递 X-User-Id 头来传递用户身份信息,解决了微服务应用的认证问题。有些时候应用需要支持第三方帐号登录,比如 Google、GitHub、微信等,这些用户身份服务商都采用了 OAuth2 认证授权协议,接下来我们来看如何在我们的微服务应用里增加 OAuth2 认证支持。

OAuth2 是一个用于授权客户端访问服务端资源的规范,其中包含三种角色,认证服务器、客户端和资源服务器,三种角色可能由不同的团队甚至是不同的公司来承担。客户端引导用户跳转到认证服务器进行身份认证并授权客户端访问指定资源,然后认证服务器颁发访问令牌(AccessToken)给客户端,最后客户端带着令牌去访问资源服务器,资源服务器检验令牌合法性后返回相应资源。

下面再从 OAuth2 认证授权的视角回顾一下之前的架构图:

spring-cloud-microservice-architecture

可以看到增加 OAuth2 认证授权支持对我们原有的架构没有任何影响,只需要在网关里增加 OAuth2 客户端相关功能。为了避免修改波及到各个微服务,网关会在 OAuth2 认证流程完成后将每个外部用户绑定到一个内部用户上,传递给各个微服务的仍然是内部用户身份。

前面提到 OAuth2 协议里涉及到认证服务器、客户端和资源服务器三种角色,认证服务器由第三方服务来扮演,那么网关在这里到底扮演的是客户端还是资源服务器角色,或者两种兼而有之?网关作为后端 API 服务,为了简化前端访问以及提升安全性,我们希望由网关来引导用户到认证服务器进行认证授权,从而获取到访问令牌,因此网关肯定具备客户端角色。那么资源服务器角色了?可能大家觉得前端需要请求网关暴露的 API,因此网关还需要同时扮演资源服务器角色。如果网关需要扮演资源服务器角色,那么前端每次请求 API 时,需要通过 Authorization 头传递访问令牌。这就要求网关以某种方式把访问令牌发送给前端,这会增加前端的复杂性,也会影响到令牌的安全性。换种角度,我们可以把前端和网关看作一个整体应用,这个应用只是认证服务器的一个客户端,不是资源服务器。那么鉴权在哪里做?前面的文章里我们已看到,网关里已经对资源访问做了鉴权(使用 Spring Security),可以理解为在客户端内部就已完成鉴权,因此在其访问各微服务资源时无需再做,也就不再需要资源服务器角色了,这样也简化了网关的功能。

实现

选择一个 OAuth2 认证服务

可以选择一个任何支持 OAuth2 协议的身份服务商,比如 Google、GitHub 和微信等,也可以使用开源的 OAuth2 Server 来搭建自己的。为了更加灵活可控,这里我们选择使用开源的 Keycloak 来搭建自己的 OAuth2 认证服务器,具体搭建方法可以参考代码仓库里的 README。

修改 Maven 配置

前面说过,我们的网关只需扮演 OAuth2 客户端这一个角色,因此添加下面的 OAuth2 Client Starter 这一个依赖即可。

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-oauth2-client</artifactId>
        </dependency>

安全配置

package net.jaggerwang.scip.gateway.adapter.api.config;

import net.jaggerwang.scip.common.usecase.port.service.dto.UserDTO;
import net.jaggerwang.scip.common.usecase.port.service.dto.user.UserBindRequestDTO;
import net.jaggerwang.scip.gateway.adapter.api.security.BindedOidcUser;
import net.jaggerwang.scip.gateway.adapter.api.security.LoggedUser;
import net.jaggerwang.scip.gateway.usecase.port.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.ReactiveAuthenticationManager;
import org.springframework.security.authentication.UserDetailsRepositoryReactiveAuthenticationManager;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcReactiveOAuth2UserService;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
import org.springframework.security.oauth2.client.userinfo.ReactiveOAuth2UserService;
import org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizedClientRepository;
import org.springframework.security.oauth2.client.web.server.WebSessionServerOAuth2AuthorizedClientRepository;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.web.server.SecurityWebFilterChain;

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

/**
 * @author Jagger Wang
 */
@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {
    @Autowired
    private UserService userService;

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

    @Bean
    public ReactiveAuthenticationManager reactiveAuthenticationManager(
            ReactiveUserDetailsService userDetailsService) {
        var authenticationManager = new UserDetailsRepositoryReactiveAuthenticationManager(
                userDetailsService);
        authenticationManager.setPasswordEncoder(passwordEncoder());
        return authenticationManager;
    }

    /**
     * Store access token in session.
     */
    @Bean
    ServerOAuth2AuthorizedClientRepository serverOAuth2AuthorizedClientRepository() {
        return new WebSessionServerOAuth2AuthorizedClientRepository();
    }

    /**
     * Customize OAuth2User (or OidcUser if using OpendID Connect protocol), such as bind user from
     * OAuth2 provider to a client's internal user, and get authorities of this internal user.
     */
    @Bean
    public ReactiveOAuth2UserService<OidcUserRequest, OidcUser> reactiveOAuth2UserService() {
        var delegate = new OidcReactiveOAuth2UserService();
        return userRequest -> delegate
                .loadUser(userRequest)
                .flatMap(oidcUser -> {
                    // Bind OAuth2 provider's user to client's internal user
                    var clientRegistration = userRequest.getClientRegistration();
                    var userInfo = oidcUser.getUserInfo();
                    var userDTO = UserDTO.builder()
                            .username(clientRegistration.getRegistrationId() + "_" +
                                    userInfo.getPreferredUsername())
                            .password("")
                            .email(userInfo.getEmail())
                            .build();
                    return userService.bind(UserBindRequestDTO.builder()
                            .externalAuthProvider(clientRegistration.getRegistrationId())
                            .externalUserId(userInfo.getSubject())
                            .internalUser(userDTO)
                            .build())
                            .map(apiResult -> {
                                var bindedUserDTO = apiResult.getData();

                                // Extract client roles in access token
                                var authorities = oidcUser.getAuthorities();
                                var jwtDecoder = NimbusJwtDecoder.withJwkSetUri(
                                        clientRegistration.getProviderDetails().getJwkSetUri())
                                        .build();
                                var jwt = jwtDecoder.decode(userRequest.getAccessToken()
                                        .getTokenValue());
                                var resourceAccess = jwt.getClaimAsMap("resource_access");
                                if (resourceAccess != null) {
                                    var resource = (Map<String, Object>) resourceAccess.get(
                                            clientRegistration.getClientId());
                                    if (resource != null) {
                                        var roles = (Collection<String>) resource.get("roles");
                                        if (roles != null) {
                                            authorities = Stream.concat(authorities.stream(),
                                                    roles.stream()
                                                            .map(role -> new SimpleGrantedAuthority(
                                                                    "ROLE_" + role)))
                                                    .collect(Collectors.toList());
                                        }
                                    }
                                }

                                var loggedUser = new LoggedUser(bindedUserDTO.getId(),
                                        bindedUserDTO.getUsername(), bindedUserDTO.getPassword(),
                                        authorities);
                                return new BindedOidcUser(loggedUser, authorities,
                                        oidcUser.getIdToken(), oidcUser.getUserInfo());
                            });
                });
    }

    @Bean
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
        return http
                .csrf().disable()
                .authorizeExchange(authorizeExchangeSpec -> authorizeExchangeSpec
                        .pathMatchers("/", "/actuator/**", "/login", "/logout", "/auth/**",
                                "/user/user/register", "/file/files/**").permitAll()
                        .pathMatchers("/user/**").hasRole("user")
                        .pathMatchers("/post/**").hasRole("post")
                        .pathMatchers("/file/**").hasRole("file")
                        .pathMatchers("/stat/**").hasRole("stat")
                        .anyExchange().authenticated())
                .oauth2Login(oAuth2LoginSpec -> {})
                .build();
    }
}

相比原先的配置,我们增加了 serverOAuth2AuthorizedClientRepositoryreactiveOAuth2UserService 这两个 Bean,其中 serverOAuth2AuthorizedClientRepository 用来将认证后的 OAuth2 客户端信息(包含 AccessToken)持久化到 Session 里,以避免网关重启后丢失,reactiveOAuth2UserService 用来将外部用户绑定到内部用户,并将 AccessToken 里的客户端角色提取到 Spring Security 的 AuthenticationToken 里,以便 SecurityWebFilterChain 跟原有保持一致。

不过由于 OAuth2 认证跟应用自身认证差别比较大,两种方式的认证结果身份(Principal)难以统一为一种类型,因此应用里所有需要从 AuthenticationToken 里获取 Principal 的地方都要做一下类型判断。类似下面获取登录用户信息这样,其中 OAuth2 认证的结果身份为自定义的 BindedOidcUser,应用自身认证的为 LoggedUser

package net.jaggerwang.scip.gateway.adapter.api.controller;

import net.jaggerwang.scip.gateway.adapter.api.security.BindedOidcUser;
import net.jaggerwang.scip.gateway.adapter.api.security.LoggedUser;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.security.core.context.SecurityContextImpl;
import reactor.core.publisher.Mono;

/**
 * @author Jagger Wang
 */
abstract public class BaseController {
    protected Mono<LoggedUser> loggedUser() {
        return ReactiveSecurityContextHolder.getContext()
                .defaultIfEmpty(new SecurityContextImpl())
                .flatMap(securityContext -> {
                    var auth = securityContext.getAuthentication();
                    if (auth == null || auth instanceof AnonymousAuthenticationToken ||
                            !auth.isAuthenticated()) {
                        return Mono.empty();
                    }

                    LoggedUser loggedUser;
                    var principal = auth.getPrincipal();
                    if (principal instanceof BindedOidcUser) {
                        var bindedOidcUser = (BindedOidcUser) principal;
                        loggedUser = bindedOidcUser.getLoggedUser();
                    } else {
                        loggedUser = (LoggedUser) principal;
                    }
                    return Mono.just(loggedUser);
                });
    }
}

应用配置

spring:
  security:
    oauth2:
      client:
        registration:
          keycloak:
            clientId: scip
            clientSecret: 42f9a259-5b5e-40b4-ad84-dfa2e18e2df4
            scope: openid
            authorizationGrantType: authorization_code
            redirectUri: '{baseUrl}/login/oauth2/code/{registrationId}'
        provider:
          keycloak:
            authorizationUri: ${SCIP_SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KEYCLOAK_AUTHORIZATION_URI:http://localhost:8180/auth/realms/JW/protocol/openid-connect/auth}
            tokenUri: ${SCIP_SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KEYCLOAK_TOKEN_URI:http://localhost:8180/auth/realms/JW/protocol/openid-connect/token}
            userInfoUri: ${SCIP_SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KEYCLOAK_USER_INFO_URI:http://localhost:8180/auth/realms/JW/protocol/openid-connect/userinfo}
            jwkSetUri: ${SCIP_SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KEYCLOAK_JWT_JWK_SET_URI:http://localhost:8180/auth/realms/JW/protocol/openid-connect/certs}
            userNameAttribute: sub

由于我们是自己搭建的 OAuth2 认证服务器,因此要在 provider 下配置认证服务器的信息,如果是使用的 Google、GitHub 这样的知名身份服务商,Spring Security 已内置相关信息,无需再配置。

参考资料

  1. Spring Security
  2. Keycloak