Spring Cloud OAuth2 微服务认证授权

凯文86 等级 576 0 0

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

微服务认证授权概述

单点登录

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

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

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

OAuth2 授权流程

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

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

Spring Cloud 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 OAuth2 微服务认证授权

整体架构跟之前的差不多,唯一的区别就是把 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 授权类型,由于认证授权统一到了网关里,因此我们只需改造网关,各个微服务完全不用动。

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

Spring Cloud OAuth2 微服务认证授权

由于 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

本文转自 https://blog.jaggerwang.net/spring-cloud-oauth2-micro-service-authentication-authorization/,如有侵权,请联系删除。

收藏
评论区

相关推荐

一文搞懂Spring依赖注入
前言 提起Spring,大家肯定不陌生,它是每一个Java开发者绕不过去的坎。Spring 框架为基于 java 的企业应用程序提供了一整套解决方案
Spring Cloud OAuth2 微服务认证授权
OAuth 2.0 是用于授权的行业标准协议,它致力于简化客户端开发人员的工作,同时为 Web 应用、桌面应用、移动应用等各种客户端应用提供了特定的授权流程。本文讲解如何使用 OAuth2 协议来授权客户端应用访问 Spring Cloud 微服务。 微服务认证授权概述 单点登录 相比于单体应用,微服务应用需要在多个服务之间共享
Spring Cloud 微服务开发指南
如同 Spring Boot 在 Java Web 开发领域中的统治地位,Spring Cloud 在 Java 微服务应用开发领域中同样处于垄断地位。软件系统从单体升级到微服务架构,随之会出现各种分布式系统所特有的问题,包括服务注册发现、认证授权、限流熔断、调用追踪等。Spring Cloud 提供了各种组件来解决这些问题,本文将通过升级改造一个单体 AP
浅析Spring boot与Spring cloud 之间的关系
浅析Spring boot与Spring cloud 之间的关系 20180515 18:16:10有些童鞋刚接触这块 ,理解不是很深刻会经常问道这样类似的问题,下面我就简单讲解一下Spring boot与Spring cloud 之间的关系!Spring boot 是 Spring 的一
Spring Boot整合Spring Cloud实现微服务架构学习
本文版权归作者所有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接出处:https://blog.csdn.net/qq\_3076499,否则保留追究法律责任的权利。 如果文中有什么错误,欢迎指出。以免更多的人被误导。 当你看到这的时候,温馨提示:在学习springcloud之前,请先学习springboot,因为s
经典JAVA面试题整理,方便统一复习
以下是网上整理的非常全面的面试题,当然,绝大多数人不可能全部用到,但是都罗列在此,大家可根据自己的情况,选择对应的模块进行阅读。面试题模块介绍这份面试题,包含的内容了十九个模块:Java 基础、容器、多线程、反射、对象拷贝、Java Web 模块、异常、网络、设计模式、Spring/Spring MVC、Spring Boot/Spring Cloud、Hi
想搞定大厂面试官?被逼无奈开始狂啃底层技术
Part 1微服务架构设计概述1.1 传统应用架构的问题1.2 微服务架构是什么1.3 微服务架构有哪些特点和挑战1.4 如何搭建微服务架构 Part 2微服务开发框架2.1 Spring Boot 是什么2.2 如何使用Spring Boot框架2.3 Spring Boot生产级特性 Part 3微服务网关3.1 Node.js 是什么3.2 如何使用
SpringCloud升级之路2020.0.x版-1.背景
本系列为之前系列的整理重启版,随着项目的发展以及项目中的使用,之前系列里面很多东西发生了变化,并且还有一些东西之前系列并没有提到,所以重启这个系列重新整理下,欢迎各位留言交流,谢谢!Spring Cloud 官方文档说了,它是一个完整的微服务体系,用户可以通过使用 Spring Cloud 快速搭建一个自己的微服务系统。那么 Spring Cloud 究竟是
SpringCloud升级之路2020.0.x版-7.从Bean到SpringCloud
本系列为之前系列的整理重启版,随着项目的发展以及项目中的使用,之前系列里面很多东西发生了变化,并且还有一些东西之前系列并没有提到,所以重启这个系列重新整理下,欢迎各位留言交流,谢谢!在理解 Spring Cloud 之前,我们先了解下 Spring 框架、Spring Boot、Spring Cloud 这三者的关系,从一个简单的 Bean,是如何发展出一个
SpringCloud升级之路2020.0.x版-8.理解 NamedContextFactory
本系列为之前系列的整理重启版,随着项目的发展以及项目中的使用,之前系列里面很多东西发生了变化,并且还有一些东西之前系列并没有提到,所以重启这个系列重新整理下,欢迎各位留言交流,谢谢!springcloudcommons 中参考了 springcloudnetflix 的设计,引入了 NamedContextFactory 机制,一般用于对于不同微服务的客户端
这个 Redis 连接池的新监控方式针不戳~我再加一点佐料
Lettuce 是一个 Redis 连接池,和 Jedis 不一样的是,Lettuce 是主要基于 Netty 以及 ProjectReactor 实现的异步连接池。由于基于 ProjectReactor,所以可以直接用于 springwebflux 的异步项目,当然,也提供了同步接口。在我们的微服务项目中,使用了 Spring Boot 以及 Spring
SpringCloud升级之路2020.0.x版-9.如何理解并定制一个Spring Cloud组件
本系列为之前系列的整理重启版,随着项目的发展以及项目中的使用,之前系列里面很多东西发生了变化,并且还有一些东西之前系列并没有提到,所以重启这个系列重新整理下,欢迎各位留言交流,谢谢!我们实现的 Spring Cloud 微服务框架,里面运用了许多 Spring Cloud 组件,并且对于某些组件进行了个性化改造。那么对于某个 Spring Cloud 组件,
SpringCloud升级之路2020.0.x版-17.Eureka的实例配置
本系列代码地址:https://github.com/HashZhang/springcloudscaffold/tree/master/springcloudiiford上一节我们提到过,每个注册到 Eureka 上面的实例就是 Eureka 实例。 不论这个实例本身就是 Eureka Server 或者是要注册的微服务,只要作为实例,就需要实例配置。我们
SpringCloud升级之路2020.0.x版-21.Spring Cloud LoadBalancer简介
本系列代码地址:https://github.com/HashZhang/springcloudscaffold/tree/master/springcloudiiford我们使用 Spring Cloud 官方推荐的 Spring Cloud LoadBalancer 作为我们的客户端负载均衡器。 Spring Cloud LoadBalancer背景Sp
SpringCloud升级之路2020.0.x版-23.订制Spring Cloud LoadBalancer
本系列代码地址:https://github.com/HashZhang/springcloudscaffold/tree/master/springcloudiiford我们使用 Spring Cloud 官方推荐的 Spring Cloud LoadBalancer 作为我们的客户端负载均衡器。上一节我们了解了 Spring Cloud LoadBala