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

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

微服务认证授权概述

单点登录

相比于单体应用,微服务应用需要在多个服务之间共享认证授权信息(单点登录),因此认证授权实现起来会更复杂。前面我们开发的微服务应用统一在网关进行认证授权,避免了单点登录需求,因此可以采用跟单体应用一样的认证授权方式。除了多服务场景,即便只有一个服务,如果该服务有多节点,那么也有单点登录需求。所谓单点登录,就是当用户在某个服务或节点里完成认证授权后,再访问其它服务或节点时无需再进行认证授权,之前的认证授权信息仍然有效。对于单点登录,简单场景可以使用分布式会话方案,其实现方式有粘滞(Sticky)会话、会话复制(Replication)和集中式(Centralized)会话存储。这几种方式都各有其限制:

  1. 粘滞会话要求负载均衡器将同一用户的所有请求发送到固定的处理节点,以便可以获取到该用户之前的会话数据。这种方式把多节点应用简化为单节点应用来处理,但对于多服务应用这种方式就不适用了。此外当因某种原因(比如节点故障)需要切换处理节点时,原节点里的会话数据将不可用。
  2. 会话复制要求每个处理节点保存所有会话数据,当某个节点新建或更新会话时,需要同步数据给所有其它节点。这种方式会造成网络带宽和存储空间的浪费,同时对服务性能也会有影响。
  3. 集中式会话将会话数据存储到 Redis 这样的外部存储中,所有服务和节点共享一份数据。这种方式避免了前面两种方式的缺点,不过由于所有服务和节点每次请求都会去访问会话数据存储服务,因此该服务容易成为应用性能瓶颈。

分布式会话方案要求所有服务或节点位于同一内部网络,以便可以安全地相互通信,因此只适合单个应用内的多个服务或一个公司内的多个应用。如果想跨应用实现单点登录,那么就需要一个独立的认证授权服务,比如 OAuth2 服务。除了可提供单点登录服务,OAuth2 还有一个典型的使用场景,那就是授权内部资源给第三方使用。

OAuth2 授权流程

OAuth2 本身只是一个用于授权客户端访问服务端资源的规范,要想使用它,除了客户端要遵循规范获取访问令牌并在访问资源时提供该令牌,最重要的是服务端需要有一个 Server 来提供 OAuth2 认证授权服务。这个服务可以使用第三方提供的,也可以使用开源的 OAuth2 Server 来自己搭建。

下面是 Spring Cloud 微服务应用采用 OAuth2 授权方式后的授权流程:

OAuth2 微服务认证流程

图中的 SSO 假设为 OAuth2 Server,当然也可以是其它任何支持单点登录的认证服务器。

  1. 在网关统一配置安全策略,当发现某个客户端请求不满足权限要求时,重定向客户端到认证服务器。
  2. 认证服务器提示用户登录和授权,完成后认证服务器通过回调地址返回授权码(Authorization Token)给客户端(客户端也可以是一个服务端应用)。
  3. 客户端从回调地址的 Query 参数里获取授权码,使用授权码到认证服务器换取访问令牌(Access Token)并保存起来。
  4. 客户端带上访问令牌再次请求网关,网关发现已有令牌则放行,转发请求给资源服务器(Resource Server)。
  5. 如果令牌是不透明的(Opaque),资源服务器使用令牌去认证服务器换取该令牌代表的认证授权信息。如果令牌是透明的(Transparent),比如图中的 JWT(JSON Web Token),则从认证服务器获取密钥(只需获取一次)来验证令牌的合法性并从令牌里提取认证授权信息。
  6. 资源服务器得到认证授权信息之后,验证当前用户是否有权访问正在请求的资源,通过则返回该资源,否则拒绝。

逻辑架构

接下来将把前面开发的 Spring Cloud 微服务应用改造为使用 OAuth2 认证方式,为了避免跟现有认证方式冲突,将在一个新的特性分支 oauth2 上进行。

下面是采用 OAuth2 认证方式后的逻辑架构:

spring-cloud-micro-service-with-oauth2-architecture-1

整体架构跟之前的差不多,唯一的区别就是把 Session Cache 替换为了 OAuth2 Service。这里使用了 JWT 这种透明令牌,以避免每次请求资源服务器都去请求认证服务器。使用透明令牌虽然可以提升性能,但坏处是在令牌到期之前无法收回令牌或让其失效,因此无法实现退出登录。为了缓解这个问题,可以把令牌有效期设短一些,或者干脆使用非透明令牌,让资源服务器每次请求都去认证服务器验证令牌的合法性。这样只要认证服务器收回了令牌,资源服务器就可以及时知道。

Hydra OAuth2 Server

Hydra 介绍

自己开发一个功能完善兼具安全的 OAuth2 Server 需要比较专业的知识,连 Spring Security 官方都宣布不再继续开发其 OAuth2 Server Spring Security OAuth2 Roadmap Update,建议使用第三方服务。由于国内提供 OAuth2 服务的很少,即便有有些时候也不放心使用第三方服务,这时可以使用开源的 OAuth2 Server 来自行搭建。这里我们选择了 ORY/Hydra,它使用 Go 语言开发,性能高效,功能完善,使用者也比较多。

Hydra 支持 OAuth2 的各种 授权类型,除了 Password Grant,具体原因可参考官方文档 Why is the Resource Owner Password Credentials grant not supported?。简单来说,Password Grant 需要用户直接在客户端应用里直接输入帐号密码来从认证服务器换取访问令牌,因此只适合完全受信任的客户端(比如自家应用)。不过即便是这样,也无法防止某个冒牌应用(外观上无法跟原应用区分,不像浏览器里可以通过地址栏)诱骗用户输入并窃取其帐号密码。

除了 Password Grant 类型,Implicit Flow 也不再推荐使用,虽然 Hydra 支持。Implicit Flow 跳过了使用授权码换取访问令牌的步骤,在用户登录授权完成后就直接颁发访问令牌。Implicit Flow 原本是设计给移动应用和单页网页应用这些不方便通过回调地址接受授权码的场景。不过现代浏览器均已支持本地路由,移动应用也可通过启动一个本地 Web 服务来提供回调地址,因此这种方式不再有需求。Implicit Flow 最大的弊端就是无法使用刷新令牌(Refresh Token),每次访问令牌过期都需要重新发起授权流程去获取新的访问令牌。对于 Implicit Flow 原本针对的场景,最新 OAuth2 标准推荐使用标准的 Authorization Code 结合 PKCE 扩展来实现认证授权。更多内容可参考此篇文档 Securely set up OAuth2 for Mobile Apps, Browser Apps, and Single Page Apps

我们的微服务应用将使用标准的 Authorization Code 授权类型,由于认证授权统一到了网关里,因此我们只需改造网关,各个微服务完全不用动。

开发 Login & Consent Provider

Hydra OAuth2 Server 只保存令牌相关信息,不保存用户信息,需要使用者提供一个 Login & Consent Provider,其中 Login 页让用户执行登录操作,Consent 页让用户执行授权操作。虽然用起来有点麻烦,但这种方式更灵活,也更方便将认证服务与现有用户系统集成到一起。Hydra OAuth2 Server 跟 Login & Consent Provider 的交互流程如下(图片来源于官方文档)。

login-consent-flow

由于 Login & Consent Provider 比较简单,只有几个页面,因此将它放在了网关里。

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

...

@Controller
@RequestMapping("/hydra")
public class HydraController extends AbstractController {
    protected ObjectMapper objectMapper;
    protected UserAsyncService userAsyncService;
    protected HydraAsyncService hydraAsyncService;

    public HydraController(ObjectMapper objectMapper, UserAsyncService userAsyncService,
                           HydraAsyncService hydraAsyncService) {
        this.objectMapper = objectMapper;
        this.userAsyncService = userAsyncService;
        this.hydraAsyncService = hydraAsyncService;
    }

    @GetMapping("/login")
    public Mono<String> login(@RequestParam(name = "login_challenge") String challenge,
                              Model model) {
        return hydraAsyncService
                .getLoginRequest(challenge)
                .flatMap(loginRequest -> {
                    if (loginRequest.getSkip()) {
                        var loginAccept = LoginAcceptDto.builder()
                                .subject(loginRequest.getSubject())
                                .build();
                        return hydraAsyncService
                                .directlyAcceptLoginRequest(challenge, loginAccept)
                                .map(redirectTo -> "redirect:"+redirectTo);
                    }

                    model.addAttribute("challenge", challenge);
                    return Mono.just("hydra/login");
                });
    }

    @Data
    static class LoginForm {
        private String challenge;
        private String username;
        private String mobile;
        private String email;
        private String password;
        private Boolean remember = false;
        private String submit;
    }

    @PostMapping("/login")
    public Mono<String> login(@ModelAttribute LoginForm form, Model model) {
        if (form.submit.equals("No")) {
            var loginReject = LoginRejectDto.builder()
                    .error("login_rejected")
                    .errorDescription("The resource owner rejected to log in")
                    .build();
            return hydraAsyncService
                    .rejectLoginRequest(form.challenge, loginReject)
                    .map(redirectTo -> "redirect:" + redirectTo);
        }

        return userAsyncService.verifyPassword(UserDto.builder().username(form.username)
                .mobile(form.mobile).email(form.email).password(form.password).build())
                .flatMap(userDto -> {
                    var loginAccept = LoginAcceptDto.builder()
                            .subject(userDto.getId().toString())
                            .remember(form.remember)
                            .rememberFor(86400)
                            .build();
                    return hydraAsyncService
                            .acceptLoginRequest(form.challenge, loginAccept)
                            .map(redirectTo -> "redirect:"+redirectTo);
                });
    }

    @GetMapping("/consent")
    public Mono<String> consent(@RequestParam(name = "consent_challenge") String challenge,
                                Model model) {
        return hydraAsyncService
                .getConsentRequest(challenge)
                .flatMap(consentRequest -> {
                    if (consentRequest.getSkip()) {
                        var consentAccept = ConsentAcceptDto.builder()
                                .grantScope(consentRequest.getRequestedScope())
                                .grantAccessTokenAudience(consentRequest.getRequestedAccessTokenAudience())
                                .build();
                        return hydraAsyncService
                                .directlyAcceptConsentRequest(challenge, consentAccept)
                                .map(redirectTo -> "redirect:"+redirectTo);
                    }

                    model.addAttribute("challenge", challenge);
                    model.addAttribute("requestedScope", consentRequest.getRequestedScope());
                    model.addAttribute("subject", consentRequest.getSubject());
                    model.addAttribute("client", consentRequest.getClient());
                    return Mono.just("hydra/consent");
                });
    }

    ...
}

由于 Spring Cloud Gateway 基于 Spring WebFlux 实现,因此这里控制器的实现采用了响应式(Reactive)编程方式,所有 IO 操作均为异步。对于登录页,首先使用跳转链接里的 login_challenge 去认证服务器获取登录请求,如果请求里指示跳过本次登录(已处于登录状态),则直接调用认证服务接口接受本次登录请求,否则展示登录页。用户在登录页输入帐号密码后提交表单,服务端验证帐号密码是否匹配,如果匹配则调用认证服务接口接受本次登录请求。同意页的流程与登录页类似,只不过是让用户授权而不是登录。更多说明可参考官方文档 Implementing a Login & Consent Provider

有了 Login & Consent Provider 之后,就可以启动 Hydra OAuth2 Server 了,具体启动命令可参考源代码里的 README 文档。

改造应用

修改 Maven 配置

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

        ...

        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-oauth2-resource-server</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-oauth2-jose</artifactId>
        </dependency>

删除 spring-boot-starter-data-redisspring-session-data-redis 等跟 Session 相关的依赖,添加下面这些跟 OAuth2 相关的依赖。

  1. spring-boot-starter-thymeleaf HTML 模板引擎,用来生成登录和授权页。
  2. spring-boot-starter-oauth2-client 把网关转换为一个 OAuth2 Client,用来测试 OAuth2 授权流程。
  3. spring-security-oauth2-resource-server 把网关转换为一个 OAuth2 资源服务器,以便获取请求里的访问令牌并验证其合法性。
  4. spring-security-oauth2-jose 支持 JWT 类型的访问令牌。

安全配置

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

...

@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
        return http
                .csrf(csrf -> csrf.disable())
                .exceptionHandling(exceptionHandling -> exceptionHandling
                        .authenticationEntryPoint(new HttpStatusServerEntryPoint(
                                HttpStatus.UNAUTHORIZED))
                )
                .authorizeExchange(authorizeExchange -> authorizeExchange
                        .pathMatchers("/favicon.ico", "/csrf", "/vendor/**", "/webjars/**",
                                "/*/actuator/**", "/", "/graphql", "/login", "/logout",
                                "/auth/**", "/hydra/**", "/user/register", "/files/**").permitAll()
                        .pathMatchers("/user/**").hasAuthority("SCOPE_user")
                        .pathMatchers("/post/**").hasAuthority("SCOPE_post")
                        .pathMatchers("/file/**").hasAuthority("SCOPE_file")
                        .pathMatchers("/stat/**").hasAuthority("SCOPE_stat")
                        .anyExchange().authenticated())
                .oauth2ResourceServer(oauth2ResourceServer -> oauth2ResourceServer.jwt())
                .oauth2Client(oauth2Client -> {})
                .oauth2Login(oauth2Login -> {})
                .build();
    }
}

安全配置跟原先差不多,区别在于:

  1. 使用 pathMatchers()hasAuthority() 来为不同的微服务设定不同的权限要求。
  2. 使用 oauth2ResourceServer() 来开启资源服务器支持,并通过 jwt() 配置使用 JWT 类型的访问令牌。
  3. 使用 oauth2Client()oauth2Login() 来开启 OAuth2 客户端和登录功能。

应用配置

security:
    oauth2:
      client:
        registration:
          hydra:
            client-id: scip
            client-secret: ilxzM0AdA7BVaL7c
            authorization-grant-type: authorization_code
            redirect-uri: '{baseUrl}/login/oauth2/code/{registrationId}'
            scope: offline,user,post,file,stat
        provider:
          hydra:
            authorization-uri: ${SCIP_SPRING_SECURITY_OAUTH2_PROVIDER_HYDRA_AUTHORIZATION_URI:http://localhost:4444/oauth2/auth}
            token-uri: ${SCIP_SPRING_SECURITY_OAUTH2_PROVIDER_HYDRA_TOKEN_URI:http://localhost:4444/oauth2/token}
            user-info-uri: ${SCIP_SPRING_SECURITY_OAUTH2_PROVIDER_HYDRA_USER_INFO_URI:http://localhost:4444/userinfo}
            user-name-attribute: sub
            jwk-set-uri: ${SCIP_SPRING_SECURITY_OAUTH2_PROVIDER_HYDRA_JWK_SET_URI:http://localhost:4444/.well-known/jwks.json}
      resourceserver:
        jwt:
          jwk-set-uri: ${SCIP_SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI:http://localhost:4444/.well-known/jwks.json}

配置文件里增加了跟 OAuth2 相关的:

  1. 配置 OAuth2 Client,包括当前应用在认证服务里注册的客户端信息 registration,以及 OAuth2 服务提供者信息 provider,一个应用可在多个 OAuth2 Provider 里注册。由于使用的是自己的 Provider,所以需要提供 Provider 的信息,包括授权地址、令牌获取地址等。如果使用的是 Google、Facebook 等官方支持的 Provider,则无需提供这些信息。
  2. 配置资源服务器,资源服务器需要从认证服务器获取密钥来校验 JWT 令牌的合法性,因此配置了密钥获取地址 jwk-set-uri

此外由于现在是从 JWT 令牌里获取认证主体(Principal),而不是从 Session,获取到的认证主体类型也不再是 LoggedUser,所以还有一些其它的小修改,具体可查看 net.jaggerwang.scip.gateway.api.filter.ReactiveContextWebFilternet.jaggerwang.scip.gateway.api.filter.UserIdExchangeFilternet.jaggerwang.scip.gateway.api.filter.UserIdGatewayGlobalFilternet.jaggerwang.scip.gateway.adapter.controller.AbstractControllernet.jaggerwang.scip.gateway.adapter.graphql.datafetcher.AbstractDataFetcher 等类。

参考资料

  1. ORY/Hydra
  2. Spring Security