Spring Security 详解与实操第四节 主题保护和Oauth2.0

564 篇文章 138 订阅

案例实战:使用 Spring Security 高级主题保护 Web 应用

前面几讲我们系统介绍了 Spring Security 提供的一些高级功能,包括过滤器、CSRF 保护、CORS 以及全局方法,这些都是非常实用的功能特性。作为阶段性的总结,今天的内容将利用这些功能特性构建在安全领域中的一种典型的认证机制,即多因素认证(Multi-Factor Authentication,MFA)机制。

案例设计和初始化

在今天的案例中,我们构建多因素认证的思路并不是采用第三方成熟的解决方案,而是基于 Spring Security 的功能特性来自己设计并实现一个简单而完整的认证机制。

多因素认证设计

多因素认证是一种安全访问控制的方法,基本的设计理念在于用户想要访问最终的资源,至少需要通过两种以上的认证机制

那么,我们如何实现多种认证机制呢?一种常见的做法是分成两个步骤,第一步通过用户名和密码获取一个认证码(Authentication Code),第二步基于用户名和这个认证码进行安全访问。基于这种多因素认证的基本执行流程如下图所示:

Drawing 0.png

多因素认证的实现方式示意图

系统初始化

为了实现多因素认证,我们需要构建一个独立的认证服务 Auth-Service,该服务同时提供了基于用户名+密码以及用户名+认证码的认证形式。当然,实现认证的前提是构建用户体系,因此我们需要提供如下所示的 User 实体类:

@Entity
public class User {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;
    
    private String username;
    private String password;
}

可以看到,User 对象中包含了用户名 Username 和密码 Password 的定义。同样的,在如下所示的代表认证码的 AuthCode 对象中包含了用户名 Username 和具体的认证码 Code 字段的定义:

@Entity
public class AuthCode {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;
    
    private String username;
    private String code;   
}

基于 User 和 AuthCode 实体对象,我们也给出创建数据库表的对应 SQL 定义,如下所示:

CREATE TABLE IF NOT EXISTS `spring_security_demo`.`user` (
    `id` INT NOT NULL AUTO_INCREMENT,
    `username` VARCHAR(45) NULL,
    `password` TEXT NULL,
    PRIMARY KEY (`id`));
 
CREATE TABLE IF NOT EXISTS `spring_security_demo`.`auth_code` (
    `id` INT NOT NULL AUTO_INCREMENT,
    `username` VARCHAR(45) NOT NULL,
    `code` VARCHAR(45) NULL,
    PRIMARY KEY (`id`));

有了认证服务,接下来我们需要构建一个业务服务 Business-Service,该业务服务通过集成认证服务,完成具体的认证操作,并返回访问令牌(Token)给到客户端系统。因此,从依赖关系上讲,Business-Service 会调用 Auth-Service,如下图所示:

Drawing 2.png

Business-Service 调用 Auth-Service 关系图

接下来,我们分别从这两个服务入手,实现多因素认证机制。

实现多因素认证机制

对于多因素认证机制而言,实现认证服务是基础,但难度并不大,我们往下看。

实现认证服务

从表现形式上看,认证服务也是一个 Web 服务,所以内部需要通过构建 Controller 层组件实现 HTTP 端点的暴露。为此,我们构建了如下所示的 AuthController:

@RestController
public class AuthController {
 
    @Autowired
    private UserService userService;
 
    //添加User
    @PostMapping("/user/add")
    public void addUser(@RequestBody User user) {
        userService.addUser(user);
    }
 
    //通过用户名+密码对用户进行首次认证
    @PostMapping("/user/auth")
    public void auth(@RequestBody User user) {
        userService.auth(user);
    }
 
    //通过用户名+认证码进行二次认证
    @PostMapping("/authcode/check")
    public void check(@RequestBody AuthCode authCode, HttpServletResponse response) {
        if (userService.check(authCode)) {
            response.setStatus(HttpServletResponse.SC_OK);
        } else {
            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        }
    }
}

可以看到,这里除了一个添加用户信息的 HTTP 端点之外,我们分别实现了通过用户名+密码对用户进行首次认证的"/user/auth"端点,以及通过用户名+认证码进行二次认证的"/authcode/check"端点。

这两个核心端点背后的实现逻辑都位于 UserService 中,我们先来看其中的 auth() 方法:

public void auth(User user) {
        Optional<User> o =
                userRepository.findUserByUsername(user.getUsername());
 
        if(o.isPresent()) {
            User u = o.get();
            if (passwordEncoder.matches(user.getPassword(), u.getPassword())) {
                 //生成或刷新认证码
                generateOrRenewAutoCode(u);
            } else {
                throw new BadCredentialsException("Bad credentials.");
            }
        } else {
            throw new BadCredentialsException("Bad credentials.");
        }
}

上述代码中的关键流程就是在完成用户密码匹配之后的刷新认证码流程,负责实现该流程的 generateOrRenewAutoCode() 方法如下所示:

private void generateOrRenewAutoCode (User u) {
        String generatedCode = GenerateCodeUtil.generateCode();
 
        Optional<AuthCode> autoCode = autoCodeRepository.findAuthCodeByUsername(u.getUsername());
        if (autoCode.isPresent()) {//如果存在认证码,则刷新该认证码
            AuthCode code = autoCode.get();
            code.setCode(generatedCode);
        } else {//如果没有找到认证码,则生成并保存一个新的认证码
            AuthCode code = new AuthCode();
            code.setUsername(u.getUsername());
            code.setCode(generatedCode);
            autoCodeRepository.save(code);
        }
}

上述方法的流程也很明确,首先通过调用工具类 GenerateCodeUtil 的 generateCode() 方法生成一个认证码,然后根据当前数据库中的状态决定是否对已有的认证码进行刷新,或者直接生成一个新的认证码并保存。因此,每次调用 UserService 的 auth() 方法就相当于对用户的认证码进行了动态重置

一旦用户获取了认证码,并通过该认证码访问系统,认证服务就可以对该认证码进行校验,从而确定其是否有效。对认证码进行验证的方法如下所示:

public boolean check(AuthCode authCodeToValidate) {
        Optional<AuthCode> authCode = autoCodeRepository.findAuthCodeByUsername(authCodeToValidate.getUsername());
        if (authCode.isPresent()) {
            AuthCode authCodeInStore = authCode.get();
            if (authCodeToValidate.getCode().equals(authCodeInStore.getCode())) {
                return true;
            }
        }
 
        return false;
}

这里的逻辑也很简单,就是把从数据库中获取的认证码与用户传入的认证码进行比对

至此,认证服务的核心功能已经构建完毕,下面我们来看业务服务的实现过程。

实现业务服务

在业务服务中,势必需要调用认证服务提供的 HTTP 端点来完成用户认证和认证码认证这两个核心的认证操作。因此,我们需要构建一个认证服务的客户端组件完成远程调用。在案例中,我们参考设计模式中的门面(Facade)模式的设计理念,将这个组件命名为 AuthenticationServerFacade,也就是说它是认证服务的一种门面组件,定义如下:

@Component
public class AuthenticationServerFacade {
 
    @Autowired
    private RestTemplate rest;
 
    @Value("${auth.server.base.url}")
    private String baseUrl;
 
    public void checkPassword(String username, String password) {
        String url = baseUrl + "/user/auth";
 
        User body = new User();
        body.setUsername(username);
        body.setPassword(password);
 
        HttpEntity<User> request = new HttpEntity<User>(body);
 
        rest.postForEntity(url, request, Void.class);
    }
 
    public boolean checkAuthCode(String username, String code) {
        String url = baseUrl + "/authcode/check";
 
        User body = new User();
        body.setUsername(username);
        body.setCode(code);
 
        HttpEntity<User> request = new HttpEntity<User>(body);
        
        ResponseEntity<Void> response = rest.postForEntity(url, request, Void.class);
 
        return response.getStatusCode().equals(HttpStatus.OK);
    }
}

这里的 baseUrl 就是认证服务暴露的服务地址,我们使用 RestTemplate 模板类发起对认证服务的远程调用,并根据返回值来判断认证是否通过。

有了 AuthenticationServerFacade,我们就可以在业务服务中集成认证服务了。我们在每次请求的处理过程中完成这种集成工作,此时需要用到拦截器,而这种集成工作显然需要依赖于认证管理器 AuthenticationManager。因此,我们可以先来设计并实现如下所示的 CustomAuthenticationFilter 代码结构:

@Component
public class CustomAuthenticationFilter extends OncePerRequestFilter {
 
    @Autowired
    private AuthenticationManager manager;
 
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String username = request.getHeader("username");
        String password = request.getHeader("password");
        String code = request.getHeader("code");
 
        //使用 AuthenticationManager 处理认证过程
    }
}

上述代码中第一个需要关注的点是 CustomAuthenticationFilter 所扩展的基类 OncePerRequestFilter。顾名思义,OncePerRequestFilter 能够确保在一次请求中只执行一次过滤器逻辑,不会发生多次重复执行的情况。这里我们分别从 HTTP 请求头中获取了用户名 username、密码 password 以及认证码 code 这三个参数,并尝试使用 AuthenticationManager 完成认证。基于03 讲“认证体系:如何深入理解 Spring Security 的用户认证机制?”中的讨论,我们知道 AuthenticationManager 的背后实际上使用了 AuthenticationProvider 执行具体的认证操作。

再来回想一下认证服务中提供的两种认证操作,一种是基于用户名和密码完成用户认证,一种是基于用户名和认证码完成针对认证码的认证。因此,我们需要针对这两种操作分别实现不同的 AuthenticationProvider。例如,如下所示的 UsernamePasswordAuthenticationProvider 就实现了针对用户名和密码的认证操作:

@Component
public class UsernamePasswordAuthenticationProvider implements AuthenticationProvider {
 
    @Autowired
    private AuthenticationServerFacade authServer;
 
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String username = authentication.getName();
        String password = String.valueOf(authentication.getCredentials());
        
        //调用认证服务完成认证
        authServer.checkPassword(username, password);
 
        return new UsernamePasswordAuthenticationToken(username, password);
    }
 
    public boolean supports(Class<?> aClass) {
        return UsernamePasswordAuthentication.class.isAssignableFrom(aClass);
    }
}

可以看到,这里使用了 AuthenticationServerFacade 门面类来完成对认证服务的远程调用。类似地 ,我们也可以构建针对认证码的 AuthenticationProvider,即如下所示的 AuthCodeAuthenticationProvider:

@Component
public class AuthCodeAuthenticationProvider implements AuthenticationProvider {
 
    @Autowired
    private AuthenticationServerFacade authServer;
 
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String username = authentication.getName();
        String code = String.valueOf(authentication.getCredentials());
        
        //调用认证服务完成认证
        boolean result = authServer.checkAuthCode(username, code);
 
        if (result) {
            return new AuthCodeAuthentication(username, code);
        } else {
            throw new BadCredentialsException("Bad credentials.");
        }
    }
 
    public boolean supports(Class<?> aClass) {
        return AuthCodeAuthentication.class.isAssignableFrom(aClass);
    }
}

请注意,无论是 UsernamePasswordAuthenticationProvider 还是 AuthCodeAuthenticationProvider,所返回的 UsernamePasswordAuthentication 和 AuthCodeAuthentication 都是自定义的认证信息类,它们都继承了 Spring Security 自带的 UsernamePasswordAuthenticationToken。

现在,让我们回到过滤器组件 CustomAuthenticationFilter,并提供对它的完整实现,如下所示:

@Component
public class CustomAuthenticationFilter extends OncePerRequestFilter {
 
    @Autowired
    private AuthenticationManager manager;
 
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String username = request.getHeader("username");
        String password = request.getHeader("password");
        String code = request.getHeader("code");
 
        //如果认证码为空,说明需要先执行用户名/密码认证
        if (code == null) {
            Authentication a = new UsernamePasswordAuthentication(username, password);
            manager.authenticate(a);
        } else {
	        //如果认证码不为空,则执行认证码认证
            Authentication a = new AuthCodeAuthentication(username, code);
            manager.authenticate(a);
 
            //如果认证码认证通过,则通过 UUID 生成一个 Token 并添加在响应的消息头中
            String token = UUID.randomUUID().toString();
            response.setHeader("Authorization", token);
        }
 
    }
 
    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        return !request.getServletPath().equals("/login");
    }
}

CustomAuthenticationFilter 的实现过程比较简单,代码也都是自解释的,唯一需要注意的是在基于认证码的认证过程通过之后,我们会在响应中添加一个“Authorization”消息头,并使用 UUID 值作为 Token 进行返回

针对上述代码,我们可以通过如下所示的类图进行总结:

Drawing 4.png

多因素认证执行核心类图

最后,我们需要通过 Spring Security 中的配置体系确保各个类之间的有效协作。为此,我们构建了如下所示的 SecurityConfig 类:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
 
    @Autowired
    private CustomAuthenticationFilter customAuthenticationFilter;
 
    @Autowired
    private AuthCodeAuthenticationProvider authCodeAuthenticationProvider;
 
    @Autowired
    private UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProvider;
 
    @Override
    protected void configure(AuthenticationManagerBuilder auth) {
        auth.authenticationProvider(authCodeAuthenticationProvider)
            .authenticationProvider(usernamePasswordAuthenticationProvider);
    }
 
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable();
 
        http.addFilterAt(
                customAuthenticationFilter,
                BasicAuthenticationFilter.class);
 
        http.authorizeRequests()
                .anyRequest().authenticated();
    }
 
    @Override
    @Bean
    protected AuthenticationManager authenticationManager() throws Exception {
        return super.authenticationManager();
    }
}

上述配置内容中,我们看到可以通过 addFilterAt() 方法添加自定义过滤器。关于过滤器使用方式的更多内容,你也可以参考08 讲“管道过滤:如何基于 Spring Security 过滤器扩展安全性?”的内容进行回顾。

关于案例的完整代码你可以在这里进行下载:https://github.com/lagouEdAnna/SpringSecurity-jianxiang/tree/main/MultiFactorAuthenticationDemo

案例演示

现在,让我们分别在本地启动认证服务和业务服务,请注意:认证服务的启动端口是 8080,而业务服务的启动端口是 9090。然后我们打开模拟 HTTP 请求的 Postman 并输入相关参数,如下所示:

Drawing 5.png

多因素认证的第一步认证示意图:基于用户名+密码

显然,该请求只传入了用户名和密码,所以会基于 UsernamePasswordAuthenticationProvider 执行认证过程,从而为用户“jianxiang”生成认证码。认证码是动态生成的,所以每次请求对应的结果都是不一样的,我通过查询数据库,获取该认证码为“9750”,你也可以自己做一些尝试。

有了认证码,相当于完成了多因素认证机制的第一步。接下来,我们再次基于这个认证码构建请求并获取响应结果,如下所示:

Drawing 7.png

多因素认证的第二步认证示意图:基于用户名+认证码

可以看到,通过传入正确的认证码,我们基于 AuthCodeAuthenticationProvider 完成了多因素认证机制中的第二步认证,并最终在 HTTP 响应中生成了一个“Authorization”消息头。

小结与预告

这一讲我们基于多因素认证机制展示了如何利用 Spring Security 中的一些高级主题保护 Web 应用程序的实现方法。多因素认证机制的实现需要构建多个自定义的 AuthenticationProvider,并通过拦截器完成对请求的统一处理。相信案例中展示的这些开发技巧会给你的日常开发工作带来帮助。

本讲内容总结如下:

Drawing 9.png

这里给你留一道思考题:在 Spring Security 中,如何利用过滤器实现对用户请求的定制化认证?

介绍完今天的案例,我们将进入 Spring Security 关于 OAuth2 协议的介绍部分,并给出 Spring Security 与 Spring Cloud 进行集成的系统方法。下一讲,我们先从 OAuth2 协议的基本概念开始讲起,帮助你深入理解该协议的应用场景、角色以及多种授权模式。


开放协议:OAuth2 协议解决的是什么问题?

从今天开始,我们针对安全性的讨论将从单体服务上升到微服务架构。对于微服务架构而言,安全性设计的最核心考虑点还是认证和授权。由于一个微服务系统中各服务之间存在相互调用的关系,因此针对每一个服务,我们既需要考虑来自客户端的请求,同时也要考虑可能来自另一个服务的请求。安全访问控制就面临着从客户端请求到服务、从服务到服务的多种授权场景。因此,我们需要引入专门用于处理分布式环境下的授权体系,OAuth2 协议就是应对这种应用场景的有效解决方案。

OAuth2 协议详解

OAuth 是 Open Authorization 的简称,该协议解决的是授权问题而不是认证问题,目前普遍被采用的是 OAuth 2.0 版协议。OAuth2 是一个相对复杂的协议,对涉及的角色和授权模式给出了明确的定义,我们继续往下看。

OAuth2 协议的应用场景

在常见的电商系统中,通常会存在类似工单处理的系统,而工单的生成在使用用户基本信息的同时,势必也依赖于用户的订单记录等数据。为了降低开发成本,假设我们的整个商品订单模块并不是自己研发的,而是集成了外部的订单管理平台,此时为了生成工单记录,就必须让工单系统读取用户在订单管理平台上的订单记录。

在这个场景中,难点在于只有得到用户的授权,才能同意工单系统读取用户在订单管理平台上的订单记录。那么问题就来了,工单系统如何获得用户的授权呢?一般我们想到的方法是用户将自己在订单管理平台上的用户名和密码告诉工单系统,然后工单系统通过用户名和密码登录到订单管理平台并读取用户的订单记录,整个过程如下图所示:

Drawing 0.png

案例系统中用户认证和授权交互示意图

上图中的方案虽然可行,但显然存在几个严重的缺点:

  • 工单系统为了开展后续的服务,会保存用户在订单管理平台上的密码,这样很不安全;如果用户密码不小心被泄露了,就会导致订单管理平台上的用户数据发生泄露;

  • 工单系统拥有了获取用户存储在订单管理平台上所有资料的权限,用户无法限制工单系统获得授权的范围和有效期;

  • 如果用户修改了订单管理平台的密码,那么工单系统就无法正常访问订单管理平台了,这会导致业务中断,但我们又不能限制用户修改密码。

既然这个方案存在如此多的问题,那么有没有更好的办法呢?答案是肯定的,OAuth2 协议的诞生就是为了解决这些问题。

首先,针对密码的安全性,在 OAuth2 协议中,密码还是由用户自己保管,避免了敏感信息的泄露;其次,OAuth2 协议中提供的授权具有明确的应用范围和有效期,用户可以根据需要限制工单系统所获取授权信息的作用效果;最后,如果用户对自己的密码等身份凭证信息进行了修改,只需通过 OAuth2 协议重新进行一次授权即可,不会影响到相关联的其他第三方应用程序。

Drawing 1.png

传统认证授权机制与 OAuth2 协议的对比图

OAuth2 协议的角色

OAuth2 协议之所有能够具备这些优势,一个主要的原因在于它把整个系统涉及的各个角色及其职责做了很好地划分。OAuth2 协议中定义了四个核心的角色:资源、客户端、授权服务器和资源服务器

Drawing 2.png

OAuth2 协议中的角色定义

我们可以把 OAuth2 中的角色与现实中的应用场景对应起来。

  • OAuth2 协议中把需要访问的接口或服务统称为资源(Resource),每个资源都有一个拥有者(Resource Owner),也就是案例中的用户。

  • 案例的工单系统代表的是一种第三方应用程序(Third-party Application),通常被称为客户端(Client)。

  • 与客户端相对应的,OAuth2 协议中还存在一个服务提供商,案例中的订单管理平台就扮演了这个角色。服务提供商拥有一个资源服务器(Resource Server)和一个授权服务器(Authorization Server),其中资源服务器存放着用户资源,案例中的订单记录就是一种用户资源;而授权服务器的作用就是完成针对用户的授权流程,并最终颁发一个令牌,也就是我们所说的 Token。

OAuth2 协议的 Token

看到这里,你可能会提问,所谓的访问令牌是什么?令牌是 OAuth2 协议中非常重要的一个概念,本质上也是一种代表用户身份的授权凭证,但与普通的用户名和密码信息不同,令牌具有针对资源的访问权限范围和有效期。如下所示就是一种常见的令牌信息:

{
    "access_token": "0efa61be-32ab-4351-9dga-8ab668ababae",
    "token_type": "bearer",
    "refresh_token": "738c42f6-79a6-457d-8d5a-f9eab0c7cc5e",
    "expires_in": 43199,
    "scope": "webclient"
}

上述令牌信息中的各个字段都很重要,我们展开分析。

  • access_token:代表 OAuth2 的令牌,当访问每个受保护的资源时,用户都需要携带这个令牌以便进行验证。

  • token_type:代表令牌类型,OAuth2 协议中有多种可选的令牌类型,包括 Bearer 类型、MAC 类型等,这里指定的 Bearer 类型是最常见的一种类型。

  • expires_in:用于指定 access_token 的有效时间,当超过这个有效时间,access_token 将会自动失效。

  • refresh_token:其作用在于当 access_token 过期后,重新下发一个新的 access_token。

  • scope:指定了可访问的权限范围,这里指定的是访问 Web 资源的“webclient”。

现在我们已经介绍完令牌,你可能会好奇这样一个令牌究竟有什么用?接下来,我们就来看如何使用令牌完成基于 OAuth2 协议的授权工作流程。整个流程如下图所示:
Drawing 3.png

基于 OAuth2 协议的授权工作流程图

我们可以把上述流程进一步展开梳理。

  • 首先,客户端向用户请求授权,请求中一般包含资源的访问路径、对资源的操作类型等信息。如果用户同意授权,就会将这个授权返回给客户端。

  • 现在,客户端已经获取了用户的授权信息,可以向授权服务器请求访问令牌。

  • 接下来,授权服务器向客户端发放访问令牌,这样客户端就能携带访问令牌访问资源服务器上的资源。

  • 最后,资源服务器获取访问令牌后会验证令牌的有效性和过期时间,并向客户端开放其需要访问的资源。

OAuth2 协议的授权模式

在整个工作流程中,最为关键的是第二步,即获取用户的有效授权。那么如何获取用户授权呢?在 OAuth 2.0 中,定义了四种授权方式,即授权码模式(Authorization Code)、简化模式(Implicit)、密码模式(Password Credentials)和客户端模式(Client Credentials)

我们先来看最具代表性的授权码模式。当用户同意授权后,授权服务器返回的只是一个授权码,而不是最终的访问令牌。在这种授权模式下,需要客户端携带授权码去换令牌,这就需要客户端自身具备与授权服务器进行直接交互的后台服务。

Drawing 4.png

授权码模式工作流程图

我们简单梳理一下授权码模式下的执行流程。

首先,用户在访问客户端时会被客户端导向授权服务器,此时用户可以选择是否给予客户端授权。一旦用户同意授权,授权服务器会调用客户端的后台服务提供的一个回调地址,并在调用过程中将一个授权码返回给客户端。客户端收到授权码后进一步向授权服务器申请令牌。最后,授权服务器核对授权码并向客户端发送访问令牌。

这里要注意的是,通过授权码向授权服务器申请令牌的过程是系统自动完成的,不需要用户的参与,用户需要做的就是在流程启动阶段同意授权

接下来,我们再来看另一种比较常用的密码模式,其授权流程如下图所示:

Drawing 5.png

密码模式工作流程图

可以看到,密码模式比较简单,也更加容易理解。用户要做的就是提供自己的用户名和密码,然后客户端会基于这些用户信息向授权服务器请求令牌。授权服务器成功执行用户认证操作后将会发放令牌。

OAuth2 中的客户端模式和简化模式因为在日常开发过程中应用得不是很多,这里就不详细介绍了。

你可能注意到了,虽然 OAuth2 协议解决的是授权问题,但它也应用到了认证的概念,这是因为只有验证了用户的身份凭证,我们才能完成对他的授权。所以说,OAuth2 实际上是一款技术体系比较复杂的协议,综合应用了信息摘要、签名认证等安全性手段,并需要提供令牌以及背后的公私钥管理等功能

OAuth2 协议与微服务架构

对应到微服务系统中,服务提供者充当的角色就是资源服务器,而服务消费者就是客户端。所以每个服务本身既可以是客户端,也可以作为资源服务器,或者两者兼之。当客户端拿到 Token 之后,该 Token 就能在各个服务之间进行传递。如下图所示:
Drawing 6.png

OAuth2 协议在服务访问场景中的应用

在整个 OAuth2 协议中,最关键的问题就是如何获取客户端授权。就目前主流的微服架构来说,当我们发起 HTTP 请求时,关注的是如何通过 HTTP 协议透明而高效地传递令牌,此时授权码模式下通过回调地址进行授权管理的方式就不是很实用,密码模式反而更加简洁高效。因此,在本专栏中,我们将使用密码模式作为 OAuth2 协议授权模式的默认实现方式。

小结与预告

今天我们进入微服务安全性领域展开了探讨,在这个领域中,认证和授权仍然是最基本的安全性控制手段。通过系统分析微服务架构中的认证和授权解决方案,我们引入了 OAuth2 协议,这也是微服务架构体系下主流的授权协议。我们对 OAuth2 协议具备的角色、授权模式以及与微服务架构之间的集成关系做了详细展开。

本讲内容总结如下:

Drawing 7.png

最后给你留一道思考题:你能描述 OAuth2 协议中所具备的四大角色以及四种授权模式吗?欢迎在留言区和我分享你的收获。

介绍完 OAuth2 协议后,下一讲我们将引入微服务开发框架 Spring Cloud,并介绍如何在 Spring Cloud 中集成 OAuth2 协议。


授权体系:如何构建 OAuth2 授权服务器?

上一讲我们讨论了 OAuth2 协议的详细内容,相信你已经了解了可以使用 OAuth2 协议实现微服务之间访问的授权。但是在此之前,我们需要在微服务系统中构建 OAuth2 授权服务器。今天我们就基于 Spring Security 框架,讨论如何构建这一授权服务器,并基于常用的密码模式生成对应的 Token,从而为下一讲中的服务访问控制提供基础。

构建 OAuth2 授权服务器

从表现形式上看,OAuth2 授权服务器也是一个独立的微服务,因此构建授权服务器的方法也是创建一个 Spring Boot 应用程序,我们需要引入对应的 Maven 依赖,如下所示:

<dependency>
    <groupId>org.springframework.security.oauth</groupId>
    <artifactId>spring-security-oauth2</artifactId>
</dependency>

这里的 spring-security-oauth2 就是来自 Spring Security 中的 OAuth2 库。现在 Maven 依赖已经添加完毕,下一步就是构建 Bootstrap 类作为访问的入口:

@SpringBootApplication
@EnableAuthorizationServer
public class AuthorizationServer {

    public static void main(String[] args) {
        SpringApplication.run(AuthorizationServer.class, args);
    }
}

请注意,这里出现了一个新的注解 @EnableAuthorizationServer,这个注解的作用在于为微服务运行环境提供一个基于 OAuth2 协议的授权服务,该授权服务会暴露一系列基于 RESTful 风格的端点(例如 /oauth/authorize 和 /oauth/token)供 OAuth2 授权流程使用。

构建 OAuth2 授权服务只是集成 OAuth2 协议的第一步,授权服务器是一种集中式系统,管理着所有与安全性流程相关的客户端和用户信息。因此,接下来我们需要在授权服务器中对这些基础信息进行初始化,而 Spring Security 为我们提供了各种配置类来实现这一目标。

设置客户端和用户认证信息

上一讲我们提到 OAuth2 协议存在四种授权模式,并提到在微服务架构中,密码模式以其简单性得到了广泛的应用。在接下来的内容中,我们就以密码模式为例展开讲解。

在密码模式下,用户向客户端提供用户名和密码,并将用户名和密码发给授权服务器从而请求 Token。授权服务器首先会对密码凭证信息进行认证,确认无误后,向客户端发放 Token。整个流程如下图所示:

Drawing 0.png

密码模式授权流程示意图

请注意,授权服务器在这里执行认证操作的目的是验证传入的用户名和密码是否正确。在密码模式下,这一步是必需的,如果采用其他授权模式,不一定会有用户认证这一环节。

确定采用密码模式后,我们来看为了实现这一授权模式,需要对授权服务器做哪些开发工作。首先我们需要设置一些基础数据,包括客户端信息和用户信息。

设置客户端信息

我们先来看如何设置客户端信息。设置客户端时,用到的配置类是 ClientDetailsServiceConfigurer,该配置类用来配置客户端详情服务 ClientDetailsService。用于描述客户端详情的 ClientDetails 接口则包含了与安全性控制相关的多个重要方法,该接口中的部分方法定义如下:

public interface ClientDetails extends Serializable {
 
    	//客户端唯一性 Id
    	String getClientId();
	<span class="hljs-comment">//客户端安全码</span>
	<span class="hljs-function">String <span class="hljs-title">getClientSecret</span><span class="hljs-params">()</span></span>;

	<span class="hljs-comment">//客户端的访问范围</span>
	<span class="hljs-function">Set&lt;String&gt; <span class="hljs-title">getScope</span><span class="hljs-params">()</span></span>;

	<span class="hljs-comment">//客户端可以使用的授权模式</span>
	<span class="hljs-function">Set&lt;String&gt; <span class="hljs-title">getAuthorizedGrantTypes</span><span class="hljs-params">()</span></span>;
	…

}

上述代码中的几个属性都与日常开发工作息息相关。

首先 ClientId 是一个必备属性,用来唯一标识客户的 Id,而 ClientSecret 代表客户端安全码。这里的 Scope 用来限制客户端的访问范围,如果这个属性为空,客户端就拥有全部的访问范围。常见的设置方式可以是 webclient 或 mobileclient,分别代表 Web 端和移动端。

最后,authorizedGrantTypes 代表客户端可以使用的授权模式,可选的范围包括代表授权码模式的 authorization_code、代表隐式授权模式 implicit、代表密码模式的 password 以及代表客户端凭据模式的 client_credentials。这个属性在设置上也可以添加 refresh_token,通过刷新操作获取以上授权模式下产生的新 Token

和实现认证过程类似,Spring Security 也提供了 AuthorizationServerConfigurerAdapter 这个配置适配器类来简化配置类的使用方式。我们可以通过继承该类并覆写其中的 configure(ClientDetailsServiceConfigurer clients) 方法进行配置。使用 AuthorizationServerConfigurerAdapter 进行客户端信息配置的基本代码结构如下:

@Configuration
public class SpringAuthorizationServerConfigurer extends AuthorizationServerConfigurerAdapter {
 
	@Override
	public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
 
		clients.inMemory()
            .withClient("spring")
            .secret("{noop}spring_secret")
            .authorizedGrantTypes("refresh_token", "password", "client_credentials")
            .scopes("webclient", "mobileclient");
	}
}

可以看到,我们创建了一个 SpringAuthorizationServerConfigure r类来继承 AuthorizationServerConfigurerAdapter,并通过 ClientDetailsServiceConfigurer 配置类设置了授权模式为密码模式。在授权服务器中存储客户端信息有两种方式,一种就是如上述代码所示的基于内存级别的存储,另一种则是通过 JDBC 在数据库中存储详情信息。为了简单起见,这里使用了内存级别的存储方式。

同时我们注意到,在设置客户端安全码时使用了"{noop}spring_secret"这种格式。这是因为在 Spring Security 5 中统一使用 PasswordEncoder 对密码进行编码,在设置密码时要求格式为“{id}password”。而这里的前缀“{noop}”就是代表具体 PasswordEncoder 的 id,表示我们使用的是 NoOpPasswordEncoder。关于 PasswordEncoder,你可以回顾“密码安全:Spring Security 中包含哪些加解密技术?”一讲中的内容。

我们已经在前面的内容中提到,@EnableAuthorizationServer 注解会暴露一系列的端点,而授权过程是使用 AuthorizationEndpoint 这个端点进行控制的。要想对该端点的行为进行配置,你可以使用 AuthorizationServerEndpointsConfigurer 这个配置类。和 ClientDetailsServiceConfigurer 配置类一样,我们也通过使用 AuthorizationServerConfigurerAdapter 配置适配器类进行配置。

因为我们指定了授权模式为密码模式,而密码模式包含认证环节。所以针对 AuthorizationServerEndpointsConfigurer 配置类需要指定一个认证管理器 AuthenticationManager,用于对用户名和密码进行认证。同样因为我们指定了基于密码的授权模式,所以需要指定一个自定义的 UserDetailsService 来替换全局的实现。关于 UserDetailsService 我们已经在“用户认证:如何使用 Spring Security 构建用户认证体系?”一讲中做了详细的讨论,我们明确可以通过如下代码来配置 AuthorizationServerEndpointsConfigurer:

@Configuration
public class SpringAuthorizationServerConfigurer extends AuthorizationServerConfigurerAdapter {
 
	@Autowired
	private AuthenticationManager authenticationManager;
 
	@Autowired
	private UserDetailsService userDetailsService;
 
	@Override
	public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
		endpoints.authenticationManager(authenticationManager).userDetailsService(userDetailsService);
	} 
}

至此,客户端设置工作全部完成,我们所做的事情就是实现了一个自定义的 SpringAuthorizationServerConfigurer 配置类并覆写了对应的配置方法

设置用户认证信息

设置用户认证信息所依赖的配置类是 WebSecurityConfigurer 类,Spring Security 同样提供了 WebSecurityConfigurerAdapter 类来简化该配置类的使用方式,我们可以继承 WebSecurityConfigurerAdapter 类并且覆写其中的 configure() 的方法来完成配置工作。

关于 WebSecurityConfigurer 配置类,我们首先需要明确配置的内容。实际上,设置用户信息非常简单,只需要指定用户名(User)、密码(Password)和角色(Role)这三项数据即可,如下所示:

@Configuration
public class SpringWebSecurityConfigurer extends WebSecurityConfigurerAdapter {
 
	@Override
	@Bean
	public AuthenticationManager authenticationManagerBean() throws Exception {
		return super.authenticationManagerBean();
	}
 
	@Override
	@Bean
	public UserDetailsService userDetailsServiceBean() throws Exception {
		return super.userDetailsServiceBean();
	}
 
	@Override
	protected void configure(AuthenticationManagerBuilder builder) throws Exception {
		builder
            .inMemoryAuthentication()
            .withUser("spring_user")
            .password("{noop}password1")
            .roles("USER")
            .and()
			.withUser("spring_admin")
            .password("{noop}password2")
            .roles("USER", "ADMIN");
	}
}

结合上面的代码,我们看到构建了具有不同角色和密码的两个用户,请注意"spring_user"代表的角色是一个普通用户,"spring_admin"则具有管理员角色。我们在设置密码时,同样需要添加前缀“{noop}”。同时,我们还看到 authenticationManagerBean()和 userDetailsServiceBean() 方法分别返回了父类的默认实现,而这里返回的 UserDetailsService 和 AuthenticationManager 在前面设置客户端时会用到。这里使用的用户认证机制你也可以回顾“用户认证:如何使用 Spring Security 构建用户认证体系?”中的内容。

生成 Token

现在,OAuth2 授权服务器已经构建完毕,启动这个授权服务器,我们就可以获取 Token。我们在构建 OAuth2 服务器时已经提到授权服务器中会暴露一批端点供 HTTP 请求进行访问,而获取 Token 的端点就是http://localhost:8080/oauth/token。在使用该端点时,我们需要提供前面配置的客户端信息和用户信息。

这里使用 Postman 来模拟 HTTP 请求,客户端信息设置方式如下图所示:

Drawing 1.png

客户端信息设置示意图

我们在“Authorization”请求头中指定认证类型为“Basic Auth”,然后设置客户端名称和客户端安全码分别为“spring”和“spring_secret”。

接下来我们指定针对授权模式的专用配置信息。首先是用于指定授权模式的 grant_type 属性,以及用于指定客户端访问范围的 scope 属性,这里分别设置为 “password”和“webclient”。既然设置了密码模式,所以也需要指定用户名和密码用于识别用户身份,这里,我们以“spring_user”这个用户为例进行设置,如下图所示:

Drawing 2.png

用户信息设置示意图

在 Postman 中执行这个请求,会得到如下所示的返回结果:

{
    "access_token": "0efa61be-32ab-4351-9dga-8ab668ababae",
    "token_type": "bearer",
    "refresh_token": "738c42f6-79a6-457d-8d5a-f9eab0c7cc5e",
    "expires_in": 43199,
    "scope": "webclient"
}

可以看到,除了作为请求参数的 scope,这个返回结果中还包含了 access_token、token_type、refresh_token 和 expires_in 等属性。这些属性都很重要,我们在上一讲中都做了详细的介绍。当然,因为每次请求生成的 Token 都是唯一的,所以你在尝试时获取的结果可能与我的不同。

小结与预告

对微服务访问进行安全性控制的首要条件是生成一个访问 Token。这一讲我们从构建 OAuth2 服务器讲起,基于密码模式给出了如何设置客户端信息、用户认证信息以及最终生成 Token 的实现过程。这个过程中需要开发人员熟悉 OAuth2 协议的相关概念以及 Spring Security 框架中提供的各项配置功能。

本讲内容总结如下:

![Drawing 3.png]13-2.jpg

最后给你留一道思考题:基于密码模式,你能说明生成 Token 需要哪些具体的开发步骤吗?

现在,我们已经成功获取了可用于访问各个服务的 Token 信息。在下一讲中,我们将会具体演示如何使用该 Token 进行服务访问控制。


资源保护:如何基于 OAuth2 协议配置授权过程?

上一讲我们学习了如何构建 OAuth2 授权服务器,并掌握了生成 Token 的系统方法。今天我们关注的重点是如何使用 Token 实现对服务访问的具体授权。在日常开发过程中,我们需要对每个服务的不同功能进行不同粒度的权限控制,并且希望这种控制方法足够灵活,能够确保不同服务根据业务场景动态调整权限控制体系。同时,在微服务架构中,我们还需要考虑如何在多个服务中对 Token 进行有效的传播,确保整个服务访问的链路都得到授权管理。借助 Spring Security 框架,实现这些需求都很简单,下面我们就来展开学习。

在微服务中集成 OAuth2 授权机制

我们知道在 OAuth2 协议中,单个微服务的定位就是资源服务器。Spring Security 框架为此提供了专门的 @EnableResourceServer 注解。通过在 Bootstrap 类中添加 @EnableResourceServer 注解,相当于声明该服务中的所有内容都是受保护的资源,示例代码如下所示:

@SpringBootApplication
@EnableResourceServer
public class UserApplication {
 
    public static void main(String[] args) {
        SpringApplication.run(UserApplication.class, args);
    }
}

一旦我们在微服务中添加了 @EnableResourceServer 注解,该服务就会对所有的 HTTP 请求进行验证以确定 Header 部分中是否包含 Token 信息。如果没有 Token 信息,就会直接限制访问;如果有 Token 信息,则通过访问 OAuth2 服务器进行 Token 的验证。那么问题来了,每个微服务是如何与 OAuth2 服务器进行通信并获取传入 Token 的验证结果的呢?

要想回答这个问题,我们需要明确将 Token 传递给 OAuth2 授权服务器的目的是获取该 Token 中包含的用户和授权信息。这样,我们势必需要在各个微服务和 OAuth2 授权服务器之间建立起一种交互关系。我们可以在配置文件中添加如下所示的 security.oauth2.resource.userInfoUri 配置项来实现这一目标:

security:
  oauth2:
    resource:
	  userInfoUri: http://localhost:8080/userinfo

这里的 http://localhost:8080/userinfo指向 OAuth2 授权服务器中的一个自定义端点,实现方式如下所示:

@RequestMapping(value = "/userinfo", produces = "application/json")
public Map<String, Object> user(OAuth2Authentication user) {
        Map<String, Object> userInfo = new HashMap<>();
        userInfo.put("user", user.getUserAuthentication().getPrincipal());
        userInfo.put("authorities", AuthorityUtils.authorityListToSet(
user.getUserAuthentication().getAuthorities()));
        return userInfo;
}

这个端点的作用就是获取可访问的那些受保护服务的用户信息。这里我们用到了 OAuth2Authentication 类,该类保存着用户的身份(Principal)和权限(Authority)信息。

当使用 Postman 访问 http://localhost:8080/userinfo 端点时,我们就需要传入一个有效的 Token。这里我们以上一讲中生成的 Token“0efa61be-32ab-4351-9dga-8ab668ababae”为例,在 HTTP 请求中添加一个“Authorization”请求头。请注意,因为我们使用的是 bearer 类型的 Token,所以需要在 access_token 的具体值之前加上“bearer”前缀。当然,我们也可以直接在“Authorization”页中选择协议类型为 OAuth 2.0,然后输入 Access Token,这样就相当于添加了请求头信息,如下图所示:

图片.png

通过 Token 发起 HTTP 请求示意图

在后续的 HTTP 请求中,我们都将以这种方式发起对微服务的调用。该请求的结果如下所示:

{
     "user":{
         "password":null,
         "username":"spring_user",
         "authorities":[
             {
                 "autority":"ROLE_USER"
             }
         ],
         "accountNonExpired":true,
         "accountNonLocker":true,
         "credentialsNonExpired":true,
         "enabled":true
     },
     "authorities":[
         "ROLE_USER"
     ]
 }

我们知道“0efa61be-32ab-4351-9dga-8ab668ababae”这个 Token 是由“spring_user”这个用户生成的,可以看到该结果中包含了用户的用户名、密码以及该用户名所拥有的角色,这些信息与我们在上一讲中初始化的“spring_user”用户信息保持一致。我们也可以尝试使用“spring_admin”这个用户来重复上述过程。

在微服务中嵌入访问授权控制

在一个微服务系统中,每个微服务作为独立的资源服务器,对自身资源的保护粒度并不是固定的,可以根据需求对访问权限进行精细化控制。在 Spring Security 中,对访问的不同控制层级进行了抽象,形成了用户、角色和请求方法这三种粒度,如下图所示:

image-2.png

用户、角色和请求方法三种控制粒度示意图

基于上图,我们可以对这三种粒度进行排列组合,形成用户、用户+角色以及用户+角色+请求方法这三种层级,这三种层级能够访问的资源范围逐一递减。用户层级是指只要是认证用户就能访问服务内的各种资源;而用户+角色层级在用户层级的基础上,还要求用户属于某一个或多个特定角色;最后的用户+角色+请求方法层级要求最高,能够对某些 HTTP 操作进行访问限制。接下来我们针对这三个层级展开讨论。

用户层级的权限访问控制

通过上一讲的学习,我们已经熟悉了通过扩展各种 ConfigurerAdapter 配置适配器类来实现自定义配置信息的方法。对于资源服务器而言,也存在一个 ResourceServerConfigurerAdapter 类,而我们的做法同样是继承该类并覆写它的 configure 方法,如下所示:

@Configuration
public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {
 
    @Override
    public void configure(HttpSecurity httpSecurity) throws Exception{
        httpSecurity.authorizeRequests()
             .anyRequest()
             .authenticated();
    }
}

我们注意到,这个方法的入参是一个 HttpSecurity 对象,而上述配置中的 anyRequest().authenticated() 方法指定了访问该服务的任何请求都需要进行验证。因此,当我们使用普通的 HTTP 请求来访问 user-service 中的任何 URL 时,将会得到一个“unauthorized”的 401 错误信息。解决办法就是在 HTTP 请求中设置“Authorization”请求头并传入一个有效的 Token 信息,你可以模仿前面的示例做一些练习。

用户+角色层级的权限访问控制

对于某些安全性要求比较高的资源,我们不应该开放资源访问入口给所有的认证用户,而是需要限定访问资源的角色。针对不同的业务场景,我们可以判断哪些服务涉及核心业务流程,这些服务的 HTTP 端口不应该开放给普通用户,而是限定只有角色为“ADMIN”的管理员才能访问该服务。要想达到这种效果,实现方式也比较简单,就是在HttpSecurity 中通过 antMatchers() 和 hasRole() 方法指定想要限制的资源和角色。我们可以创建一个新 ResourceServerConfiguration 类实例并覆写它的 configure 方法,如下所示:

@Configuration
public class ResourceServerConfiguration extends 
	ResourceServerConfigurerAdapter{
 
    @Override
	public void configure(HttpSecurity httpSecurity) throws Exception {
	 
        httpSecurity.authorizeRequests()
                .antMatchers("/order/**")
                .hasRole("ADMIN")
                .anyRequest()
                .authenticated();
    }
}

可以看到,这里使用了 05 讲“访问授权:如何对请求的安全访问过程进行有效配置?”中介绍的 Ant 匹配器实现了授权管理。现在,如果我们使用角色为“User”的 Token 访问这个服务,就会得到一个“access_denied”的错误信息。然后,我们使用上一讲中初始化的一个具有“ADMIN”角色的用户“spring_admin”来创建新的 Token,并再次访问该服务,就能得到正常的返回结果。

用户+角色+操作层级的权限访问控制

更进一步,我们还可以针对某个端点的某个具体 HTTP 方法进行控制。例如,如果我们认为对某个微服务中的“user”端点下的资源进行更新的风险很高,那么就可以在 HttpSecurity 的 antMatchers() 中添加 HttpMethod.PUT 限定。

@Configuration
public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {
 
    @Override
    public void configure(HttpSecurity httpSecurity) throws Exception{
        httpSecurity.authorizeRequests()
                .antMatchers(HttpMethod.PUT, "/user/**")
                .hasRole("ADMIN")
                .anyRequest()
                .authenticated();
    }
}

现在,我们使用普通“USER”角色生成的 Token,并调用"/order/"端点中的 Update 操作,同样会得到“access_denied”的错误信息。而尝试使用“ADMIN”角色生成的 Token 进行访问,就可以得到正常响应。

在微服务中传播 Token

我们知道一个微服务系统势必涉及多个服务之间的调用,并形成一个链路。因为访问所有服务的过程都需要进行访问权限的控制,所以我们需要确保生成的 Token 能够在服务调用链路中进行传播,如下图所示:

image-3.png

微服务中 Token 传播示意图

那么,如何实现上图中的 Token 传播效果呢?Spring Security 基于 RestTemplate 进行了封装,专门提供了一个用在 HTTP 请求中传播 Token 的 OAuth2RestTemplate 工具类。想要在业务代码中构建一个 OAuth2RestTemplate 对象,可以使用如下所示的示例代码:

@Bean
public OAuth2RestTemplate oauth2RestTemplate(
	OAuth2ClientContext oauth2ClientContext,
    OAuth2ProtectedResourceDetails details) {
 
        return new OAuth2RestTemplate(details, oauth2ClientContext);
}

可以看到,通过传入 OAuth2ClientContext 和 OAuth2ProtectedResourceDetails,我们就可以创建一个 OAuth2RestTemplate 类。OAuth2RestTemplate 会把从 HTTP 请求头中获取的 Token 保存到一个 OAuth2ClientContext 上下文对象中,而OAuth2ClientContext 会把每个用户的请求信息控制在会话范围内,以确保不同用户的状态分离。另一方面,OAuth2RestTemplate 还依赖于 OAuth2ProtectedResourceDetails 类,该类封装了我们在上一讲中介绍过的clientId、客户端安全码 clientSecret、访问范围 scope 等属性。

一旦 OAuth2RestTemplate 创建成功,我们就可以使用它对某一个远程服务进行访问,实现代码如下所示:

@Component
public class OrderServiceClient {
 
    @Autowired
	OAuth2RestTemplate restTemplate;
 
    public Order getOrderById(String orderId){

        ResponseEntity<Order> result =
                restTemplate.exchange(
                        “http://orderservice/order/{orderId}”,
                        HttpMethod.GET,
                        null, Order.class, orderId);

        Order order = result.getBody();

        return order;
    }
}

显然,基于这种远程调用方式,我们唯一要做的就是使用 OAuth2RestTemplate 替换原有的 RestTemplate,所有关于 Token 传播的细节已经被完整地封装在每次请求中。

小结与预告

这一讲我们的关注点在于对服务访问进行授权。通过今天的学习,我们明确了在微服务中嵌入访问授权控制的三种粒度。同时,在微服务系统中,因为涉及多个服务之间的交互,所以需要实现 Token 在这些服务之间的有效传播,我们可以借助 Spring Security 提供的工具类轻松实现这些需求。

本讲内容总结如下:
资源保护:如何基于 OAuth2 协议配置授权过程?.png

最后给你留一道思考题:你能描述对服务访问进行授权的三种层级,以及每个层级对应的实现方法吗?欢迎在留言区分享你的学习收获。

介绍完授权机制,接下来我们要回归到微服务系统中 Token 的表现形式以及对应的认证机制。在下一讲中,我们将详细介绍 JWT 机制的实现过程以及它提供的扩展性。


  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

办公模板库 素材蛙

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值