OAuth2.0授权码模式理论+实践

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的生成工具,不在讨论范围。

转载于:https://my.oschina.net/lichaoqiang/blog/1510730

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值