当前位置:   article > 正文

No4.搭建基本的授权码模式请求token(一):实现授权服务端的授权码模式操作_固定授权码 转换成token 认证

固定授权码 转换成token 认证

代码地址与接口看总目录:【学习笔记】记录冷冷-pig项目的学习过程,大概包括Authorization Server、springcloud、Mybatis Plus~~~_清晨敲代码的博客-CSDN博客

目录

授权服务端授权码模式动图操作

前言

A1.分析开放平台和第三方应用之间的逻辑流程

B1.逻辑流程图

B2.逻辑流程文字说明

B3.最终需要实现的内容

A2.框架搭建

B1.授权服务端类

A3.授权服务端代码实现

B1.配置类

C1.AuthorizationServerConfiguration

C2.RegisteredClientConfiguration

C3.WebSecurityConfiguration

 C4.FormIdentityLoginConfigurer

C6.application.yml

B2.认证授权核心类

C1.业务类

D1.OAuth2AuthorizationEndpointFilter

D2.OAuth2AuthorizationCodeRequestAuthenticationConverter

D3.OAuth2AuthorizationCodeRequestAuthenticationProvider

D4.OAuth2ClientAuthenticationFilter

D5.ClientSecretAuthenticationProvider

D6.OAuth2TokenEndpointFilter

D7.OAuth2AuthorizationCodeAuthenticationConverter

D8.OAuth2AuthorizationCodeAuthenticationProvider

D9.CustomeOAuth2AccessTokenGenerator

C2.处理类

D1.FormAuthenticationFailureHandler

D2.SsoLogoutSuccessHandler

B3.端点类

 D1.PigTokenEndpoint

B5.页面

A5.客户端后端代码实现(待实现)


授权服务端授权码模式动图操作

8ee4ac9f68ed478ea02f8294194e9473.gif

前言

我们要做的是:一个第三方登录端,一个前后端分离的客户端;第三方登录端就相当于提供授权的开放平台,客户端就相当于使用开放平台的第三方应用;

(只做前后端分离的后端部分)

前几篇文章我们只是使用OAuth2的密码模式,实现了一个微服务环境(还不算真正的微服务,就是实现不同服务之间的调用而已)下的sso单点登录,现在是要将这个sso单点登录系统作为一个开放平台,去开放授权码认证模式,让其他第三方应用可以通过它授权使用。

A1.分析开放平台和第三方应用之间的逻辑流程

B1.逻辑流程图

在开始之前,先分析一下开放平台和第三方应用之间的逻辑流程:

70e9ef1e1ab448e0a398558e6a64bae5.png

e75078016e1b48268e5fa6e98b5c5da7.png

B2.逻辑流程文字说明

1.用户向客户端前端发起使用授权服务端登录授权请求;

2.客户端前端收到使用授权服务端登录授权请求后,返回一个向授权服务端发起登录授权的请求链接(该链接会携带客户端id、登录授权类型、登录授权范围、登录授权回调地址等)【此处可以直接在前端保存链接的参数但是不安全,也可以调用客户端后端拿到参数】;

3.用户拿着上面的链接向授权服务端发起登录授权请求;

4.授权服务端校验到该用户没有登陆,就返回登录页面;

5.用户填写登录信息向授权服务端发起登录;

6.授权服务端校验登录信息成功后,会再次返回给用户登录授权的请求链接,让用户再次重定向;

7.用户再次拿着上面的链接向授权服务端发起登录授权请求;

8.授权服务端根据请求,若判断用户已授权的权限<客户端持有的权限(这一块逻辑可自定义),就返回授权确认页面;

9.用户进行授权确认后,将授权的信息返回给授权服务端;

10.授权服务端校验无误后,会将code拼接到登录授权回调地址,然后返回给用户;

11.用户拿着上面的链接会向客户端前端发起请求;

12.客户端前端拿着code调用客户端后端;

13.客户端后端拿到code,向授权服务端获取token;

14.授权服务端校验code成功后向客户端后端返回token和用户信息;

15.客户端后端获取到token,去数据库中获取绑定账号信息;

16.数据库将绑定信息返回给客户端后端,客户端后端发现该授权服务端的用户没有绑定到自己库中的账号;

17.客户端后端返回客户端前端一个信号;

18.客户端前端返回给用户一个绑定账号页面;

19.用户输入绑定账号需要的信息,发给客户端前端;

20.客户端前端将信息发给客户端后端;

21.客户端后端将账号信息与授权服务端的用户信息绑定,并保存到库中;

22.数据库返回执行语句的返回信息给客户端后端;

23.客户端后端将用户信息进行一系列登录认证保存后,返回给客户端前端token等信息;

24.客户端前端拿到token进行一系列保存后,返回给用户一个默认页面(或用户一开始请求的页面)

25.用户携带着客户端token向客户端前端发起业务功能请求;

26.客户端前端携带着token向客户端后端发起业务功能请求;

27.客户端后端会进行校验客户端token,如果是自己的业务功能,则直接返回给客户端前端;

28.客户端前端根据返回的信息,打开指定页面;

29.如果是授权服务端的公开接口则客户端后端会从客户端token中拿到授权服务端的token,然后端携带着token向授权服务端发起业务功能请求;

30.授权服务端返回给客户端后端确认token有效后返回信息以及用户信息;

31.客户端后端保存用户信息,执行业务功能,并返回给客户端前端;

32.客户端前端根据返回的信息,打开指定页面;

B3.最终需要实现的内容

所以,我们整理之后发现流程会有:

授权服务端需要有:

1.登录的请求页面;

2.登录请求;

3.授权确认页面;

4.确认授权请求;

5.获取token的接口;

6.校验token的接口;

客户端前端需要有:

1.向授权服务器发起授权请求的页面操作;

2.接收code的回调地址/页面;

3.携带code向客户端后端发起认证的请求,拿到返回值token后记得保存;

4.绑定用户页面(当客户端后端拿到token和用户信息时发现未绑定到账号时返回的页面);

客户端后端需要有:

1.接收客户端前端code并携带code向授权服务端获取token,然后处理用户认证登录并返回token的接口(大多用拦截器拦截处理这个操作);

2.根据access_token和用户信息进行用户登录绑定(就不做注册绑定了);

3.发起校验token的操作(需要执行授权服务端业务接口时执行的操作);

注意:springsecurity authorization 授权服务端的登录认证过滤链(还有一个是认证授权过滤链)是使用的默认的session机制,即通过SessionManagementConfigurer创建一个HttpSessionSecurityContextRepository来持久化session信息;

如果想不使用session,可以自己实现一个过滤器处理用户登录

(例如使用JWT,还需要

1.后续请求中需要携带jwt_token,

2.自定义一个拦截器拦截jwt_token并获取用户信息到SecurityContextHolder中,

3.给httpsecurity设置http.sessionManagement()

.sessionCreationPolicy(SessionCreationPolicy.STATELESS);,这样就不会持久化session信息了,

4.没有登录用户持久化就得自己写一个,可以使用redis等进行存储)

现在直接使用默认的session机制。

A2.框架搭建

B1.授权服务端类

79d4da6a1ea14c50bd33f58dcd080cc6.png

A3.授权服务端代码实现

B1.配置类

C1.AuthorizationServerConfiguration

1.添加授权码模式的授权端点配置,和添加统一认证的配置。

授权码模式的授权端点配置是为了客户端访问时能拿到code值,然后携带code值获取accesstoken,获取accesstoken还是用原来的认证授权端点就可以,会直接使用我们自定义的token生成器。

统一认证的授权端点是为了访问这个端点时,如果未登录能直接跳转到指定登录页面;

2.添加授权码模式涉及到的Authentication转换器,都使用authorization-server自带的,一个是处理获取code的,一个是获取token的。

  1. @Bean
  2. @Order(Ordered.HIGHEST_PRECEDENCE)
  3. public SecurityFilterChain authenticationServerSecurityFilterChain(HttpSecurity http) throws Exception {
  4. 。。。
  5. //配置处理授权码模式认证过程中获取 code 的请求
  6. http.apply(authorizationServerConfigurer.authorizationEndpoint(authorizationEndpoint -> {
  7. // 配置授权码端点个性化confirm页面
  8. authorizationEndpoint.consentPage(SecurityConstants.CUSTOM_CONSENT_PAGE_URI);
  9. // 注入个性化的授权认证Converter
  10. authorizationEndpoint.authorizationRequestConverter(accessTokenRequestConverter());
  11. }));
  12. //设置为统一的自定义表单登录配置
  13. http.apply(new FormIdentityLoginConfigurer());
  14. 。。。
  15. return securityFilterChain;
  16. }
  17. public AuthenticationConverter accessTokenRequestConverter(){
  18. //new一个token转换器委托器,其中包含自定义密码模式认证转换器和刷新令牌认证转换器
  19. return new DelegatingAuthenticationConverter(Arrays.asList(
  20. 。。。
  21. // 访问令牌请求用于OAuth 2.0授权码授权 ——授权码模式获取token
  22. new OAuth2AuthorizationCodeAuthenticationConverter(),
  23. // 授权请求(或同意)用于OAuth 2.0授权代码授权 ——授权码模式获取code
  24. new OAuth2AuthorizationCodeRequestAuthenticationConverter()
  25. ));
  26. }

C2.RegisteredClientConfiguration

 重新创建一个客户端对象,重点是设置授权方法为授权码模式。然后并添加到客户端持久层里面。

  1. /**
  2. * @Description: 注册一个客户端应用
  3. *
  4. * @return the registered client repository
  5. */
  6. @Bean
  7. public RegisteredClientRepository registeredClientRepository() {
  8. //new一个客户端管理业务,这里用的基于内存持久的,一定要注入一个客户端信息,否则无法使用
  9. //每次启动都会初始化的;生产的话,只初始化RegisteredClientRepository,不初始化里面的客户端信息
  10. InMemoryRegisteredClientRepository inMemoryRegisteredClientRepository = new InMemoryRegisteredClientRepository(this.createRegisteredClient_umps("8081"),this.createRegisteredClient_client("8082"));
  11. return inMemoryRegisteredClientRepository;
  12. }
  13. /**
  14. * @Description: 创建客户端信息
  15. * @param id
  16. * @return
  17. */
  18. private RegisteredClient createRegisteredClient_client(final String id) {
  19. return RegisteredClient.withId(UUID.randomUUID().toString())
  20. // 客户端ID和密码
  21. .clientId("client")
  22. // 此处为了避免频繁启动重复写入仓库
  23. .id(id)
  24. .clientSecret(PasswordEncoderFactories.createDelegatingPasswordEncoder()
  25. .encode("client"))
  26. // 名称 可不定义
  27. .clientName("client")
  28. // 授权方法
  29. .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
  30. // 授权类型
  31. .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
  32. .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
  33. // 回调地址名单,不在此列将被拒绝 而且只能使用IP或者域名 不能使用 localhost
  34. .redirectUri("http://127.0.0.1:8082/login/oauth2/code/qingchen-client")
  35. .redirectUri("https://pig4cloud.com")
  36. // 其它Scope
  37. .scope("client")
  38. // JWT的配置项 包括TTL 是否复用refreshToken等等
  39. .tokenSettings(TokenSettings.builder().accessTokenFormat(OAuth2TokenFormat.REFERENCE).build())
  40. // 配置客户端相关的配置项,包括验证密钥或者 是否需要授权页面
  41. .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
  42. .build();
  43. }

C3.WebSecurityConfiguration

由于授权码模式,是需要用户在开放平台进行登录,然后授权跳转到客户端应用。

所以把授权服务端默认的表单登陆认证模式也改成我们统一的认证模式,并添加userservice,否则会登陆不上去~

由于自定义登录页面和授权页面,涉及到访问css等静态资源样式,所以就直接将静态资源设置为permit,并且不需要认证等操作。

  1. @RequiredArgsConstructor
  2. @Configuration(proxyBeanMethods = false)
  3. public class WebSecurityConfiguration {
  4. private final UserDetailsService userDetailsService;
  5. /**
  6. * @Description: spring security 默认的安全策略
  7. * @param http
  8. * @Return: org.springframework.security.web.SecurityFilterChain
  9. */
  10. @Bean
  11. SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
  12. //根据业务开放自定义的部分端点
  13. //其余端点都需要登录
  14. //配置自定义用户认证提供方
  15. http.authorizeRequests(authorizeRequests -> authorizeRequests
  16. .antMatchers("/token/*").permitAll() // 开放自定义的部分端点
  17. .anyRequest().authenticated()); // 其余都需要登录
  18. //设置为统一的自定义表单登录配置
  19. http.apply(new FormIdentityLoginConfigurer());
  20. // 处理 UsernamePasswordAuthenticationToken
  21. PigDaoAuthenticationProvider pigDaoAuthenticationProvider = new PigDaoAuthenticationProvider();
  22. pigDaoAuthenticationProvider.setUserDetailsService(userDetailsService);
  23. http.authenticationProvider(pigDaoAuthenticationProvider);
  24. return http.build();
  25. }
  26. /**
  27. * 暴露静态资源
  28. *
  29. * https://github.com/spring-projects/spring-security/issues/10938
  30. * @param http
  31. * @return
  32. * @throws Exception
  33. */
  34. @Bean
  35. @Order(0)
  36. SecurityFilterChain resources(HttpSecurity http) throws Exception {
  37. http.requestMatchers((matchers) -> matchers.antMatchers("/actuator/**", "/css/**", "/error"))
  38. .authorizeHttpRequests((authorize) -> authorize.anyRequest().permitAll()).requestCache().disable()
  39. .securityContext().disable().sessionManagement().disable();
  40. return http.build();
  41. }
  42. }

 C4.FormIdentityLoginConfigurer

由于前两个配置(认证授权过滤链和默认过滤链)都会涉及到认证登录,所以我们就进行一下统一,把这一块抽象出来,就可以多处复用了。

这一块儿主要的逻辑是:

1.配置表单认证模式,并且自定义访问登录接口、提交登录接口、设置登录失败处理器;这里的访问登录接口是自定义的端点,并且返回的是一个页面。

2.配置登出,设置登出成功处理器,删除cookies和session

3.设置 csrf 为无效(否则会出现跨域问题,如何解决跨域问题,后面涉及到了再具体分析)

  1. public class FormIdentityLoginConfigurer extends AbstractHttpConfigurer<FormIdentityLoginConfigurer, HttpSecurity> {
  2. @Override
  3. public void init(HttpSecurity http) throws Exception {
  4. http.formLogin(httpSecurityFormLoginConfigurer -> {
  5. httpSecurityFormLoginConfigurer.loginPage("/token/login");// 统一认证的登录页面 url
  6. httpSecurityFormLoginConfigurer.loginProcessingUrl("/token/form");//统一登录授权action
  7. httpSecurityFormLoginConfigurer.failureHandler(new FormAuthenticationFailureHandler());
  8. });
  9. http.logout(httpSecurityLogoutConfigurer -> {// SSO登出成功处理
  10. httpSecurityLogoutConfigurer.logoutSuccessHandler(new SsoLogoutSuccessHandler())
  11. .deleteCookies("JSESSIONID");//一个显式添加CookieClearingLogoutHandler的快捷方式。
  12. httpSecurityLogoutConfigurer.invalidateHttpSession(true);
  13. });
  14. http.csrf().disable();
  15. }
  16. }

C6.application.yml

配置文件,主要配置Freemarker相关的

  1. # freemark配置
  2. spring:
  3. freemarker:
  4. allow-request-override: false
  5. allow-session-override: false
  6. cache: true
  7. charset: UTF-8
  8. check-template-location: true
  9. content-type: text/html
  10. enabled: true
  11. expose-request-attributes: false
  12. expose-session-attributes: false
  13. expose-spring-macro-helpers: true
  14. prefer-file-system-access: true
  15. suffix: .ftl
  16. template-loader-path: classpath:/templates/

B2.认证授权核心类

C1.业务类

业务类没有需要我们增加的,一部分用authorization-server自带的,一部分用NO1中密码模式添加的。所以到授权码模式这里,我们就没有要新增的了。

但是为了以防我忘掉,还是过一下都需要哪些类吧~

1.authorization-server自带的

D1.OAuth2AuthorizationEndpointFilter

获取授权码模式的code的过滤器,默认拦截的是"/oauth2/authorize"。拦截之后进行操作,只有路径匹配不上和路径匹配上但是授权认证不通过才会进入下一个过滤器,其余情况直接调用处理器。

授权认证通过后会有两种情况,一种是1.当前请求需要确认授权;2.当前请求已确认授权。

1.就是现在认证成功啦,但是需要用户进行授权,就会返回一个确认授权页面;(一般GET请求会走这个)

2.当前的请求本身就是确认授权请求的操作,那么就处理并返回code;(一般POST请求会走这个)

至于如何确认走那个情况,是由D3.确定的。

D2.OAuth2AuthorizationCodeRequestAuthenticationConverter

在D1.中被拦截到调用的,会通过这个类将request信息转换成想要的OAuth2AuthorizationCodeRequestAuthenticationToken,此时是未认证的哦~(仅仅是将信息转化,并没有进行认证授权)

此处我们用的是authorization-server提供的,那么就需要按照这个类的要求来设置接口属性和入参,当然也可以自定义,不过就需要修改对应的filter了

D3.OAuth2AuthorizationCodeRequestAuthenticationProvider

也是在D1.中被拦截到调用的,会对D2.提供的AuthenticationToken进行认证。

这里有两个情况:1.当前请求需要确认授权;2.当前请求确认授权。是否确认是由D2.converter确定的!

在设置为需要确认授权(是在创建客户端对象的时通过.clientSettings()设置的)的情况下:

当通过GET来访问"/oauth2/authorize"时,会设置为consent = false,会走1.当前请求需要确认授权,会创建一个已认证的AuthenticationToken,同时设置consentRequired = true,那么就会走D1.的情况1.。

当通过POST来访问"/oauth2/authorize"时(即在确认授权页面点击确认按钮),会设置为consent = true,会走2.当前请求确认授权,会创建一个已认证的AuthenticationToken,同时设置consentRequired = false,那么就会走D1.的情况2.。

D4.OAuth2ClientAuthenticationFilter

进行客户端认证,这个过滤器会拦截[pattern='/oauth2/token', POST]、[pattern='/oauth2/introspect', POST]、[pattern='/oauth2/revoke', POST]。

根据当前请求的入参等属性,判断使用不同客户端授权方法的provider拿到Authentication。

拿到之后进行存储到SecurityContextHolder里面,会继续调用下一个过滤器。

D5.ClientSecretAuthenticationProvider

由于我们接口发起的是基于Auth头部模式,所以会通过这个provider拿到Authentication。

D6.OAuth2TokenEndpointFilter

这个就很熟悉了,是获取到token。他也是根据当前的request属性在不同converter里面进行判断,直到拿到Authentication,这里只要请求接口按照授权码模式的请求规定,提供参数,就能转换成未认证的OAuth2AuthorizationCodeAuthenticationToken。

(授权码模式是这个类型的token,我们自定义的密码模式是OAuth2ResourceOwnerPasswordAuthenticationToken,由此可见这里可能拿到不同类型的token)

然后根据token类型,从多个provider中匹配中对应的,最终拿到Authentication。

D7.OAuth2AuthorizationCodeAuthenticationConverter

根据request里面的"grant_type"、"code"等属性判断,最终转换成未认证的OAuth2AuthorizationCodeAuthenticationToken。

D8.OAuth2AuthorizationCodeAuthenticationProvider

授权码模式的token匹配中的是OAuth2AuthorizationCodeAuthenticationProvider,在这个里面会根据当前客户端的配置,拿到token。

这里面的流程就跟我们自定义密码模式时的OAuth2ResourceOwnerBaseAuthenticationProvider类似~~~

最终使用的token生成器和refreshtoken生成器都是我们自定义的!

2.密码模式中已经添加的

D9.CustomeOAuth2AccessTokenGenerator

C2.处理类

D1.FormAuthenticationFailureHandler

登录失败处理器,实现 AuthenticationFailureHandler接口,重写方法,逻辑是,重定向到"/token/login?error=%s"。

  1. @Override
  2. public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
  3. log.debug("表单登录失败:{}", exception.getLocalizedMessage());
  4. String url = HttpUtil.encodeParams(String.format("/token/login?error=%s", exception.getMessage()),
  5. CharsetUtil.CHARSET_UTF_8);
  6. response.sendRedirect(url);
  7. }

D2.SsoLogoutSuccessHandler

登出成功处理器,实现 LogoutSuccessHandler 接口,重写方法

  1. private static final String REDIRECT_URL = "redirect_url";
  2. @Override
  3. public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
  4. throws IOException {
  5. if (response == null) {
  6. return;
  7. }
  8. // 获取请求参数中是否包含 回调地址
  9. String redirectUrl = request.getParameter(REDIRECT_URL);
  10. if (StrUtil.isNotBlank(redirectUrl)) {
  11. response.sendRedirect(redirectUrl); //注意哦,这里重定向的时候如果是完整的路径会直接重定向,如果不是则会拼接当前系统的路径
  12. }
  13. else if (StrUtil.isNotBlank(request.getHeader(HttpHeaders.REFERER))) {
  14. // 默认跳转referer 地址
  15. String referer = request.getHeader(HttpHeaders.REFERER);
  16. response.sendRedirect(referer);
  17. }
  18. }

B3.端点类

 D1.PigTokenEndpoint

我们需要添加三个接口。一个是请求认证页面,使用的是Freemark,由于自定义的登录失败处理器直接返回的是/token/login?error=XXX的格式,所以我们可以直接在登录页面显示失败原因。

一个是请求确认授权页面,这个页面是在过滤器中进行重定向的,而且也会传参数,我们就直接拿到参数。

一个是获取用户信息接口,这个接口是根据accesstoken拿到对应的用户认证信息,一定返回一个用户唯一标识,其余的不可暴露自己系统的用户信息即可。

  1. /**
  2. * 认证页面
  3. * @param modelAndView
  4. * @param error 表单登录失败处理回调的错误信息
  5. * @return ModelAndView
  6. */
  7. @GetMapping("/login")
  8. public ModelAndView loginPage(ModelAndView modelAndView, @RequestParam(required = false) String error) {
  9. modelAndView.setViewName("ftl/login");
  10. modelAndView.addObject("error", error);
  11. return modelAndView;
  12. }
  13. /**
  14. * @Description: 授权确认页面
  15. * @param principal
  16. * @param modelAndView
  17. * @param clientId
  18. * @param scope
  19. * @param state
  20. * @Return: org.springframework.web.servlet.ModelAndView
  21. */
  22. @GetMapping("/confirm_access")
  23. public ModelAndView confirm(Principal principal, ModelAndView modelAndView,
  24. @RequestParam(OAuth2ParameterNames.CLIENT_ID) String clientId,
  25. @RequestParam(OAuth2ParameterNames.SCOPE) String scope,
  26. @RequestParam(OAuth2ParameterNames.STATE) String state) {
  27. RegisteredClient clientDetails = Optional.ofNullable(registeredClientRepository.findByClientId(clientId)).orElseThrow(() -> new OAuth2AuthenticationException("clientId 不合法"));
  28. Set<String> authorizedScopes = clientDetails.getScopes();
  29. modelAndView.addObject("clientId", clientId);
  30. modelAndView.addObject("state", state);
  31. modelAndView.addObject("scopeList", authorizedScopes);
  32. modelAndView.addObject("principalName", principal.getName());
  33. modelAndView.setViewName("ftl/confirm");
  34. return modelAndView;
  35. }
  36. /**
  37. * 第三方客户端获取用户信息。必须有用户唯一标识哦!
  38. */
  39. @GetMapping("/getUserInfo")
  40. public R getUserInfo(String token) {
  41. if (!StringUtils.hasText(token)) {
  42. return R.failed("Invalid Bearer Token, token missing :" + token);
  43. }
  44. OAuth2Authorization authorization = this.oAuth2AuthorizationService.findByToken(token, OAuth2TokenType.ACCESS_TOKEN);
  45. // 如果令牌不存在 返回401
  46. if (authorization == null || authorization.getAccessToken() == null) {
  47. return R.failed("Invalid Bearer Token, token invalid :" + token);
  48. }
  49. Map<String, Object> claims = authorization.getAccessToken().getClaims();
  50. return R.ok(claims);
  51. }

B5.页面

主要就是下面四个文件。静态文件默认就是/static路径开始。freemarker模板路径是在application里面配置的。

模板文件就不贴出来了,都挺简单的。

9f3b5e69cad24a8492691178b38e4f2f.png

A4.客户端后端代码实现(待实现)


声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/不正经/article/detail/716448
推荐阅读
相关标签
  

闽ICP备14008679号