OAuth2.0授權碼模式理論+實踐

分類:IT技術 時間:2017-08-16

OAuth是一個關於授權(Authorization)的開放網絡標準,目前的版本是2.0版。註意是Authorization(授權),而不是Authentication(認證)。用來做Authentication(認證)的標準叫做openid connect。

一、OAuth2.0理論普及

1、OAuth2.0中的角色說明:

資源擁有者(resource owner:能授權訪問受保護資源的一個實體,可以是一個人,那我們稱之為最終用戶;如新浪微博用戶zhangsan;

資源服務器(resource server:存儲受保護資源,客戶端通過access token請求資源,資源服務器響應受保護資源給客戶端;存儲著用戶zhangsan的微博等信息。

授權服務器(authorization server:成功驗證資源擁有者並獲取授權之後,授權服務器頒發授權令牌(Access Token)給客戶端。

客戶端(client:如新浪微博客戶端weico、微格等第三方應用,也可以是它自己的官方應用;其本身不存儲資源,而是資源擁有者授權通過後,使用它的授權(授權令牌)訪問受保護資源,然後客戶端把相應的數據展示出來/提交到服務器。“客戶端”術語不代表任何特定實現(如應用運行在一臺服務器、桌面、手機或其他設備)。

2、OAuth2.0客戶端的授權模式:

2.1、Oautho2.0為客戶端定義了4種授權模式:

1)授權碼模式

2)簡化模式

3)密碼模式

4)客戶端模式

 2.2、授權碼模式:

授權碼模式是功能最完整、流程最嚴密的授權模式。它的特點就是通過客戶端的後臺服務器,與"服務提供商"的認證服務器進行互動。

授權碼模式的認證流程:

(A)用戶訪問客戶端,後者將前者導向認證服務器。

(B)用戶選擇是否給予客戶端授權。

(C)假設用戶給予授權,認證服務器首先生成一個授權碼,並返回給用戶,認證服務器將用戶導向客戶端事先指定的"重定向URI"(redirection URI),同時附上一個授權碼。

(D)客戶端收到授權碼,附上早先的"重定向URI",向認證服務器申請令牌。這一步是在客戶端的後臺的服務器上完成的,對用戶不可見。

(E)認證服務器核對了授權碼和重定向URI,確認無誤後,向客戶端發送訪問令牌(access token)和更新令牌(refresh token)。

註意:(C)和(D)中兩個重定向URI是不一樣的,(C)中的重定向URI是用來核對的,這個是服務器事先指定並保存在數據庫裏面。而(D)中的重定向URI指的是生成access_token的url。

3、選擇合適的OAuth模式打造自己的webApi認證服務

場景:你自己實現了一套webApi,想供自己的客戶端調用,又想做認證。

這種場景下你應該選擇模式3或者4,特別是當你的的客戶端是js+html應該選擇3,當你的客戶端是移動端(ios應用之類)可以選擇3,也可以選擇4。

密碼模式(resource owner password credentials)的流程:

oauth2.0

我在另一篇博客中

《深入聊聊微服務架構的身份認證問題》,詳細介紹了各種微服務身份認證的技術方案。

如果覺得以上理論信息意猶未盡的話,請繼續關註鄙人博客,或者搜索相關的資料,做進一步的研究。

下面就以授權碼授權模式為例,進行代碼的實踐。

二、OAuth2.0實踐

  這裏是以Asp.Net mvc5為例,具體步驟如下:

 首先引用Owin OAuth相關的類庫。

Microsoft.AspNet.Identity;
Microsoft.Owin;
Microsoft.Owin.Security.cookies;
Microsoft.Owin.Security.Infrastructure;
Microsoft.Owin.Security.OAuth;

添加Owin啟動類,代碼如下:

using system;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNet.Identity;
using Microsoft.Owin;
using Microsoft.Owin.Security.Cookies;
using Microsoft.Owin.Security.Infrastructure;
using Microsoft.Owin.Security.OAuth;
using Owin;
using System.Security.Claims;
using System.Collections.Concurrent;

/*=================================================================================================
*
* Title:XXXXXXXX
* Author:李朝強
* Description:模塊描述
* CreatedBy:lichaoqiang.com
* CreatedOn:2017-8-4 11:16:42
* ModifyBy:暫無...
* ModifyOn:2017-8-4 11:16:42
* Blog:http://www.lichaoqiang.com
* Mark:
*
*================================================================================================*/
[assembly: OwinStartup(typeof(OAuthCode.Startup))]
namespace OAuthCode
{
    /// <summary>
    /// 應用程序啟動類
    /// </summary>
    public class Startup
    {

        /// <summary>
        /// 用來存放臨時授權碼 線程安全
        /// </summary>
        private readonly ConcurrentDictionary<string, string> _authenticationCodes = new ConcurrentDictionary<string, string>(StringComparer.Ordinal);

        /// <summary>
        /// 配置授權
        /// </summary>
        /// <param name="app"></param>
        public void Configuration(IAppBuilder app)
        {
            //創建OAuth授權服務器
            app.UseOAuthAuthorizationServer(new Microsoft.Owin.Security.OAuth.OAuthAuthorizationServerOptions()
            {
                AllowInsecureHttp = true,//開啟
                AuthenticationType = "Bearer",
                AuthorizeEndpointPath = new PathString("/OAuth/Authorize"),
                TokenEndpointPath = new PathString("/OAuth/Token"),
                AccessTokenExpireTimeSpan = TimeSpan.FromMinutes(30),
                Provider = new OAuthAuthorizationServerProvider()
                {
                    //授權碼 authorization_code
                    OnGrantAuthorizationCode = ctx =>
                    {
                        if (ctx.Ticket != null &&
                            ctx.Ticket.Identity != null &&
                            ctx.Ticket.Identity.IsAuthenticated)
                        {
                            ctx.Validated(ctx.Ticket.Identity);//
                        }
                        return Task.FromResult(0);
                    },
                    OnGrantRefreshToken = ctx =>
                    {
                        if (ctx.Ticket != null &&
                            ctx.Ticket.Identity != null &&
                            ctx.Ticket.Identity.IsAuthenticated)
                        {
                            ctx.Validated();
                        }
                        return Task.FromResult(0);
                    },
                    //OnGrantResourceOwnerCredentials = (context) =>
                    //{
                    //    context.Validated(context.Ticket.Identity);
                    //    return Task.FromResult(0);
                    //},
                    OnValidateAuthorizeRequest = ctx =>
                    {
                        ctx.Validated();
                        return Task.FromResult(ctx);
                    },
                    //驗證redirect_uri是否合法
                    OnValidateClientRedirectUri = context =>
                    {
                        context.Validated(redirectUri: context.RedirectUri);
                        return Task.FromResult(context);
                    },
                    //用來驗證請求中的client_id和client_secret
                    OnValidateClientAuthentication = context =>
                    {
                        string clientId;
                        string clientSecret;
                        //這是通過Basic或form的方式,獲取client_id和client_secret
                        if (context.TryGetBasicCredentials(out clientId, out clientSecret) ||
                            context.TryGetFormCredentials(out clientId, out clientSecret))
                        {
                            context.Validated(clientId);
                        }
                        return Task.FromResult(context);
                    },
                    OnAuthorizeEndpoint = context =>
                    {
                        return Task.FromResult(context);
                    },
                    OnTokenEndpoint = (context) =>
                    {
                        return Task.FromResult(context);
                    },
                    //OnGrantClientCredentials = (context) =>
                    //{
                    //    context.Validated();
                    //    return Task.FromResult(context);
                    //}
                },
                //Code授權
                AuthorizationCodeProvider = new AuthenticationTokenProvider()
                {

                    OnCreate = context =>
                    {
                        context.SetToken(DateTime.Now.Ticks.ToString());
                        string token = context.Token;
                        string ticket = context.SerializeTicket();
                        var redirect_uri = context.Request.Query["redirect_uri"];
                        context.Response.Redirect(string.Format("{0}?code={1}&state=1", redirect_uri, token));
                        _authenticationCodes[token] = ticket;//這裏存放授權碼
                    },
                    //當接收到code時
                    OnReceive = context =>
                    {
                        string token = context.Token;
                        string ticket;
                        if (_authenticationCodes.TryRemove(token, out ticket))
                        {
                            context.DeserializeTicket(ticket);
                        }

                    },
                },
                //(可選)訪問令牌
                AccessTokenProvider = new AuthenticationTokenProvider()
                {
                    //創建訪問令牌
                    OnCreate = (context) =>
                    {
                        string token = context.SerializeTicket();
                        context.SetToken(token);
                    },
                    //接收
                    OnReceive = (context) =>
                    {
                        context.DeserializeTicket(context.Token);
                    },
                },
                //刷新令牌
                RefreshTokenProvider = new AuthenticationTokenProvider()
                {
                    OnCreate = context =>
                    {
                        context.SetToken(context.SerializeTicket());
                    },
                    OnReceive = context =>
                    {
                        context.DeserializeTicket(context.Token);
                    },
                }
            });

            //本地Cookie身份認證
            app.UseCookieAuthentication(new CookieAuthenticationOptions()
            {
                LoginPath = new PathString("/Account/Login"),
                AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie
            });

        }
    }
}

以上是啟動類的所有代碼,你也可以在碼雲中獲取。

接下來,我們需要一個登錄授權頁面,這裏有兩個控制器

AccountController及OAuthController,分別負責登錄及認證授權。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Security.Claims;
using System.Web;
using System.Web.Mvc;
using Microsoft.AspNet.Identity;
using Microsoft.Owin.Security;
using OAuthCode.Models;


namespace OAuthCode.Controllers
{
    public class AccountController : Controller
    {

        /// <summary>
        /// 
        /// </summary>
        public IAuthenticationManager AuthenticationManager
        {
            get { return HttpContext.GetOwinContext().Authentication; }
        }

        //
        // GET: /Account/
        public ActionResult Index()
        {
            return View();
        }


        /// <summary>
        /// 
        /// </summary>
        /// <returns></returns>
        public ActionResult Login(string returnUrl)
        {
            ViewBag.returnUrl = Uri.EscapeDataString(returnUrl);
            return View();
        }

        /// <summary>
        /// 
        /// </summary>
        /// <param name="model"></param>
        /// <param name="returnUrl"></param>
        /// <returns></returns>
        [HttpPost]
        public ActionResult Login(LoginViewModel model, string returnUrl)
        {
            string userId = "1";
            //可以在這裏將用戶所屬的role或者Claim添加到此
            ClaimsIdentity claims = new ClaimsIdentity(new[] {
                new Claim(ClaimTypes.Name, model.account)
                ,new Claim(ClaimTypes.NameIdentifier,userId)
                ,new Claim("http://schemas.microsoft.com/accesscontrolservice/2010/07/claims/identityprovider",userId)},
                DefaultAuthenticationTypes.ApplicationCookie);

            AuthenticationProperties properties = new AuthenticationProperties
            {
                IsPersistent = true
            };

            ClaimsPrincipal principal = new ClaimsPrincipal(claims);
            //System.Threading.Thread.CurrentPrincipal = principal;
            this.AuthenticationManager.SignIn(properties, new[] { claims });

            return Redirect(returnUrl);
        }
    }
}

以上是登錄控制器有關的代碼。

接下來,我們編寫下OAuthController控制器相關的Action.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Security.Claims;
using System.Web;
using System.Web.Http;
using System.Web.Mvc;
using Microsoft.AspNet.Identity;
using Microsoft.Owin.Security;
using System.Threading.Tasks;
using DotNetOpenAuth.OAuth2;
/******************************************************************************************************************
* 
* 
* 說 明: (版本:version1.0.0)
* 作 者:李朝強
* 日 期:2015/05/19
* 修 改:
* 參 考:http://my.oschina.net/lichaoqiang/
* 備 註:暫無...
* 
* 
* ***************************************************************************************************************/
namespace OAuthCode.Controllers
{
    /// <summary>
    /// <![CDATA[驗證授權]]>
    /// </summary>
    public class OAuthController : Controller
    {
        /// <summary>
        /// <!--授權-->
        /// </summary>
        /// <returns></returns>
        public ActionResult Authorize()
        {
            //驗證是否登錄,如果沒有,
            IAuthenticationManager authentication = HttpContext.GetOwinContext().Authentication;
            AuthenticateResult ticket = authentication.AuthenticateAsync(DefaultAuthenticationTypes.ApplicationCookie).Result;
            ClaimsIdentity identity = ticket == null ? null : ticket.Identity;

            if (identity == null)
            {
                //如果沒有驗證通過,則必須先通過身份驗證,跳轉到驗證方法
                authentication.Challenge();
                return new HttpUnauthorizedResult();
            }

            identity = new ClaimsIdentity(identity.Claims, "Bearer");
            //hardcode添加一些Claim,正常是從數據庫中根據用戶ID來查找添加
            identity.AddClaim(new Claim(ClaimTypes.Role, "Admin"));
            identity.AddClaim(new Claim(ClaimTypes.Role, "Normal"));
            identity.AddClaim(new Claim("MyType", "MyValue"));

            authentication.SignIn(new AuthenticationProperties() { IsPersistent = true }, identity);

            return new EmptyResult();
        }




        /// <summary>
        ///<![CDATA[獲取訪問令牌]]>
        /// </summary>
        /// <returns></returns>
        public async Task<ActionResult> GetAccessToken()
        {

            #region 使用DotNetOpenOAuth獲取訪問令牌
            //var authServer = new AuthorizationServerDescription
            //{
            //    AuthorizationEndpoint = new Uri("http://localhost:3335/OAuth/Authorize"),
            //    TokenEndpoint = new Uri("http://localhost:3335/OAuth/Token"),

            //};

            //var autoServerClient = new DotNetOpenAuth.OAuth2.WebServerClient(authServer, clientIdentifier: "fNm0EDIXbfuuDowUpAoq5GTEiywV8eg0TpiIVnV8", clientSecret: "clientSecret");
            //var authorizationState = autoServerClient.ProcessUserAuthorization();
            //if (authorizationState != null)
            //{
            //    if (!string.IsNullOrEmpty(authorizationState.AccessToken))
            //    {
            //        var token = authorizationState.AccessToken;
            //    }
            //} 
            #endregion 使用DotNetOpenOAuth獲取訪問令牌

            #region 根據授權碼,獲取訪問令牌 模擬第三方回調地址 redirect_uri
            string strCode = Request.QueryString["code"];//訪問令牌

            if (string.IsNullOrEmpty(strCode) == false)
            {
                HttpClient client = new HttpClient();
                HttpRequestmessage message = new HttpRequestMessage(HttpMethod.Post, "http://localhost:3335/OAuth/Token");

                Dictionary<string, string> dict = new Dictionary<string, string>();
                dict["grant_type"] = "authorization_code";
                dict["client_id"] = "fNm0EDIXbfuuDowUpAoq5GTEiywV8eg0TpiIVnV8";
                dict["client_secret"] = "111111";
                dict["code"] = strCode;
                dict["redirect_uri"] = "http://localhost:3335/OAuth/GetAccessToken";
                dict["scope"] = "scope1";
                message.Content = new FormUrlEncodedContent(dict);

                var response = await client.SendAsync(message);
                string strResponseText = await response.Content.ReadAsStringAsync();
                return Content(strResponseText, "text/Javascript");
            }
            #endregion 根據授權碼,獲取訪問令牌

            return Content("invalid code!");
        }
    }
}

其中包含了認證及回調獲取令牌的處理邏輯。

以上步驟中,註意的事項比較多,

認證終結點:/OAuth/Authorize

下面是我本地演示的認證地址:

http://localhost:3335//OAuth/Authorize?redirect_uri=http://localhost:3335/OAuth/GetAccessToken&client_id=fNm0EDIXbfuuDowUpAoq5GTEiywV8eg0TpiIVnV8&client_secret=111111&response_type=code&scope=scope1

授權碼模式下需要註意以下事項:

註意:

認證的時候,response_type必須為code,scope可選參數。

授權(獲取令牌的)的時候:grant_type必須為authorization_code

完成以上工作,接下來讓我們進行嘗試,首先,打開請求認證授權的地址,

http://localhost:3335//OAuth/Authorize?redirect_uri=http://localhost:3335/OAuth/GetAccessToken&client_id=fNm0EDIXbfuuDowUpAoq5GTEiywV8eg0TpiIVnV8&client_secret=111111&response_type=code&scope=scope1

它請求的是認證終結點,也就是頒發授權碼的入口。

第一次,由於沒有登錄,於是會出現登錄授權的界面。

這個過程就像,第三方登錄,如QQ,點擊QQ登錄,會出現QQ的授權頁面,這裏只是省略了,可以根據實際情況進行定制。

我們點擊登錄授權

返回結果如下:

{"access_token":"p8Jd6YwYmBgDkyTt4zTBMWNzTRbZRAM30vO3gfOiqzEw_8dCft-emDrbCC4o6_DGHW2zX0HuQus_4GJ1mYio6meCGeNP4tyEz_la4_zP8vJPsWG0TyXIwzyZth0ioWJ9JJc453MXNMH7EPMevrRsYyQpPG387gEaQFia1Q3EL7EOV7_LIkpmmMyfHGuxaTbevCbekWqR8YJdpigFd4WSwOOlode_PwL23qtneu-ezE3YitFoRIicD4rLk62lCme5pc9gFHBo2d0hRjyu7sHbqiwotWISDm290ddkhlhGlS2cPNJKYJZeCXMb7EPOdTuWMWBoOO1tpFZUsWZDVsbsu2tf42O5SNvQwzNw_o-oDW3riDVwle6aW5IqwFDk2cBIXVU3_ewbNhx13r4HfoeyhzFqUBmOjmUcB2qaER5UaEDsNVf8d0-KukUPEW-MHwl42flCTB_qqFn7ZjiKOIbjZJbVlbVj8vDvzTYjrc3msjc","token_type":"bearer","expires_in":1799,"refresh_token":"bR4OmKey_ex9JJApDP8-3O1gFZ0X9yefaXGS95At5q8NDt_v8CIM825jJklg0hrMd-yvpK_9ZG_ev8jViK78G7XN6jmy882bZPZAgcKT4tf879rKtMR_m7v4SJQAy7Jf1WnDr-U_Ty5s8bAnTCZFj99kK-S0mSoeBbgyepk1Cvez0fsw60jovxH8q_DPJPFfFETGQKYmDWQ34T1MeBllgtfZ2_Ayp5Dd4RBewDQTb_c1-cgmXy4rE_rcHz751aRsYSvhHU07QnzpjtFO6oo0i3bjWD84SCxhoevXEm-5TgBLNeX_WGv1raazwgAoV_7lpbvbGgsVbEcjyAkx05j81wp9RU3NnaKxQOpstOFW3C7ER-jx9niGplwpFS_As7t2L3Z0_ww2XwS1LHyMDXEYU3UPSP3EA7aW12qoNpxfe1ep0Ky-4kc1tFf3qq9syIvTgmXhXjGqxD8m3PvZsxlpHV89RVqFrrbCkTqIH3gm9fw"}

 

項目源碼:https://gitee.com/lichaoqiang/RichCodeBox

RichCodeBox其中包含了代碼JWT、客戶端模式、授權碼模式等。

另外,我們也可以自己定制OAuthAuthorizationServerProvider。我在JWT中有用到,以下代碼只做了解代碼如下:

using Microsoft.Owin.Security;
using Microsoft.Owin.Security.DataHandler.Encoder;
using Microsoft.Owin.Security.OAuth;
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using System.Web;

namespace WebApplication3.OAuth
{
    /// <summary>
    /// 
    /// </summary>
    public class CustomAuthorizationServerProvider : OAuthAuthorizationServerProvider
    {

        /// <summary>
        /// 
        /// </summary>
        public CustomAuthorizationServerProvider()
        {

        }

        /// <summary>
        /// 
        /// </summary>
        private TokenValidationParameters _validationParameters;

        /// <summary>
        /// 
        /// </summary>
        /// <param name="validationParameters"></param>
        public CustomAuthorizationServerProvider(TokenValidationParameters validationParameters)
        {
            _validationParameters = validationParameters;
        }

        /// <summary>
        /// <![CDATA[授權資源擁有者證書]]>
        /// </summary>
        /// <param name="context"></param>
        /// <returns></returns>
        public override Task GrantResourceOwnerCredentials(OAuthGrantResourceOwnerCredentialsContext context)
        {
            var secret = TextEncodings.Base64Url.Decode("IxrAjDoa2FqElO7IhrSrUJELhUckePEPVpaePlS_Xaw");//秘鑰
            var username = context.UserName;
            var password = context.Password;
            string userid;
            if (!CheckCredential(username, password, out userid))
            {
                context.SetError("invalid_grant", "The user name or password is incorrect");
                context.Rejected();//拒絕訪問
                return Task.FromResult<object>(context);
            }

            var ticket = new AuthenticationTicket(SetClaimsIdentity(context, userid, username), new AuthenticationProperties());
            context.Validated(ticket);

            return Task.FromResult<object>(context);
        }

        /// <summary>
        /// <![CDATA[驗證客戶端授權]]>
        /// </summary>
        /// <param name="context"></param>
        /// <returns></returns>
        public override Task ValidateClientAuthentication(OAuthValidateClientAuthenticationContext context)
        {
            string client_id;
            string client_secret;
            context.TryGetFormCredentials(out client_id, out client_secret);
            //驗證clientid
            if (string.IsNullOrEmpty(context.ClientId) ||
                context.ClientId != Constants.Const.OAuth2.CLIENT_ID)
            {
                context.SetError("ClientId is incorrect!");
                context.Rejected();
            }
            //正常
            else
            {
                context.Validated();
            }
            return Task.FromResult<object>(context);
        }

        /// <summary>
        /// 
        /// </summary>
        /// <param name="context"></param>
        /// <returns></returns>
        public override Task GrantRefreshToken(OAuthGrantRefreshTokenContext context)
        {
            return Task.FromResult<object>(context);
        }

        /// <summary>
        /// 驗證客戶端重定向URL
        /// </summary>
        /// <param name="context"></param>
        /// <returns></returns>
        public override Task ValidateClientRedirectUri(OAuthValidateClientRedirectUriContext context)
        {
            if (string.IsNullOrEmpty(context.ClientId) ||
                context.ClientId != Constants.Const.OAuth2.CLIENT_ID)
            {
                context.SetError("client_id is incorrect!");
                context.Rejected();
            }
            if (string.IsNullOrEmpty(context.RedirectUri))
            {
                context.SetError("redirect_uri is null!");
                context.Rejected();
            }
            else
            {
                context.Validated(context.RedirectUri);
            }
            return Task.FromResult<object>(0);
        }

        /// <summary>
        /// 驗證令牌
        /// </summary>
        /// <param name="context"></param>
        /// <returns></returns>
        public override Task ValidateTokenRequest(OAuthValidateTokenRequestContext context)
        {
            context.Validated();
            return Task.FromResult<object>(context);
        }

        #region 私有方法
        /// <summary>
        /// 設置聲明
        /// </summary>
        /// <param name="context"></param>
        /// <param name="userid"></param>
        /// <param name="usercode"></param>
        /// <returns></returns>
        private static ClaimsIdentity SetClaimsIdentity(OAuthGrantResourceOwnerCredentialsContext context, string userid, string usercode)
        {
            var identity = new ClaimsIdentity("JWT");
            identity.AddClaim(new Claim(JwtRegisteredClaimNames.Sub, userid));
            identity.AddClaim(new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()));
            identity.AddClaim(new Claim(ClaimTypes.Name, usercode));
            identity.AddClaim(new Claim(ClaimTypes.Role, "1"));
            identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, usercode));
            return identity;
        }

        /// <summary>
        /// 檢測用戶憑證
        /// </summary>
        /// <param name="usernme"></param>
        /// <param name="password"></param>
        /// <param name="userid"></param>
        /// <returns></returns>
        private static bool CheckCredential(string usernme, string password, out string userid)
        {
            var success = false;
            // 用戶名和密碼驗證
            if (usernme == "admin" && password == "admin")
            {
                userid = "1";
                success = true;
            }
            else
            {
                userid = string.Empty;
            }
            return success;
        }
        #endregion 私有方法
    }
}

另外,建議在使用Microsoft.Owin.Security.OAuth默認的AccessToken生成類時,在

配置文件中,添加machineKey的有配置,關於machineKey的生成工具,不在討論範圍。


Tags: 授權 客戶端 模式 資源 服務器 用戶

文章來源:


ads
ads

相關文章
ads

相關文章

ad