asp.net Forms表单验证(角色) 使用经验及验证流程分析

      最近,要做一个登陆的页面,就想到了安全性方面的问题。记得曾经在邵志东老师讲的关于asp.net安全性方面的课程中,提到asp.net提供了4个身份验证程序:1.表单身份验证;2.Windows身份验证;3.Passport身份验证;4.默认身份验证。尤其讲了表单身份验证,想想,正好自己以前也不曾使用过这个验证方式,那就拿来练练手吧。

      表单验证,可以根据用户和角色来限制用户访问。比如,我们有以一个后台管理系统,只有通过后台登陆页面合法登陆的用户才能访问后台管理系统中的任何页面,这个时候我们就可以通过表单验证来实现(过去我都是在每一个页面写判断逻辑。现在想起来,过去的那种方法真是不折不扣的体力劳动,而且如果哪个页面忘记写了,就麻烦了)。

      实验开始(因为只记录经验,所以有些知识点这里并没有提到,需要大家多花点课外时间了。文末提供了些链接供大家参考)

      我接下来就来做一个Forms表单验证的例子。在该例子中,我建立了两个文件夹分别为User和Admin,在每一个文件夹中又有login.aspx、index.aspx和web.config。我希望普通用户访问User文件夹需要首先要在用户登陆界面进行登陆,成功后才能访问用户的index.aspx。而管理员则首先要在Admin的登陆界面进行登陆,才能访问Admin中的index.aspx。而在网站根目录有LoginRedirect.aspx、web.config和Global.asax。
      目录结构图:

       

      如何才能实现表单验证呢?
      1.配置根目录下的web.config (在网站根目录下web.config文件中的system.web标记中,修改原<authentication mode="Windows" />为如下代码)


        <authentication mode="Forms">
            
<forms name="adminlogin" loginUrl="loginRedirect.aspx">
            
</forms>
        
</authentication>
        
<authorization>
            
<allow users="*"/>
        
</authorization>

      上述的配置是什么意思呢?
      首先,这里有两个不同的配置节,authentication和authorization看上去是不是很像? 你可千万不要被眼睛欺骗了,这两个是不同的意思,前者是“验证”,后者是“授权”。
      接着,我们来看下“验证”这个配置节中的东西。
      mode表示的就是验证方式,这里有四个选项:Windows、Forms、Passport、None。默认的是Windows。我们这里选择Forms。
      而在forms元素里,设置了name和loginUrl。
      name表示cookie的名字,我们后面要通过cookie来保存一些用户信息并将包含cookie信息的http请求发送到服务器。服务器端,则会根据cookie信息对用户进行标识,从而进行进一步的验证。
      LoginUrl 顾名思义就是登陆页面的地址。如果说用户没有权限访问某一页面,就会被重定向到这个页面。

      还有其它诸多元素,请大家自己查找相关资料。我也会在文末给出一些我认为比较不错的链接。

      讲完了“验证”节,接着讲讲“授权”节。
      授权,自然是要限制哪些用户或角色可以访问,哪些用户或角色不能访问了。设置的方式就是通过设置<allow>和<deny>。如上所示的<allow users="*">就是表示允许所有用户访问。你可能会奇怪不是要限制用户访问吗,怎么全部允许了?那是因为,我就是希望“根目录下”的所有东西都可以被任何用户访问。

      再来看看两个子文件夹内的web.config。


<configuration>
  
<location path="login.aspx">
    
<system.web>
      
<authorization>
        <allow users="*"/>
      
</authorization>
    
</system.web>
  
</location>
  
<system.web>
    
<authorization>
      
<allow roles="user"/>
      
<deny users="*"/>
    
</authorization>
  
</system.web>
</configuration>


      在这个配置文件中,不能配置“验证”节的内容(该内容只能在虚拟目录的根目录web.config中配置),我们只能对“授权”节进行配置。上述的location的作用是表示该路径不需要进行授权检查,因为我希望任何用户都可以进到登陆页面,原因大家应该都想得到吧。
      而其他路径,则不希望未登陆的用户或网站管理员登陆,因此使用<allow roles="user">来允许只有角色为user的用户访问,而其他任何用户都拒绝访问。
      同理,来看下管理员目录的web.config,请大家自己分析。


<configuration>
  
<location path="login.aspx">
    
<system.web>
      
<authorization>
        
<allow users="*"/>
      
</authorization>
    
</system.web>
  
</location>
  
<system.web>
    
<authorization>
      
<allow roles="Manager"/>
      
<deny users="*"/>
    
</authorization>
  
</system.web>
</configuration>

      配置好了之后,我们还需要写一些cs代码。首先我们来看一下loginRedirect.aspx.cs。因为,我们现在访问上述任何一个子文件的页面时,都会先跳转到这个页面里来,而其实我则希望可以跳到相信子目录的登陆页面中去。因此,需要在这个文件中进行一些判断。


string from = Request.QueryString["ReturnUrl"];//每个跳转过来的页面都会带有ReturnUrl值,以此来获取跳转之前的页面
//获取子目录名称
string fromFilePath = from.Substring(from.IndexOf('/'+ 1, from.IndexOf('/', from.IndexOf('/'+ 1- from.IndexOf('/')-1);
//根据子目录名称来判断跳转的链接
switch (fromFilePath.ToLower())
{
    
case "admin": Response.Redirect("/admin/login.aspx"); break;
    
case "user": Response.Redirect("/user/login.aspx"); break;
}

       有些人可能奇怪了,这么麻烦,既然可以在“验证”节中配置loginUrl,难道就不能对每个目录实现直接跳转到本目录相应登陆页面吗?很遗憾,目前为止,我还没有找到直接的解决办法。如果您有什么办法,请不吝赐教。
       跳转到登陆页面后,那我们就应该对用户的登陆时间进行处理了。


protected void Page_Load(object sender, EventArgs e)
{
    
//判断用户是否已经登陆,且角色为user
    if (User.Identity.IsAuthenticated&&User.IsInRole("user"))
    {
//如果通过验证,则直接跳转到index.aspx
        Response.Redirect("index.aspx");
    }
}

//登陆按钮事件,这里简单起见,我直接以用户名"user",密码"1"来判断,当然你也可以从数据库读取。
protected void btnLogin_Click(object sender, EventArgs e)
{
    
if (tbUserName.Text == "user" && tbPwd.Text == "1")
    {
        
//生成验证票据,其中包括用户名、生效时间、过期时间、是否永久保存和用户数据等。而关于用户角色的信息,我们保存在用户数据中。
        FormsAuthenticationTicket ticket = new FormsAuthenticationTicket(1, tbUserName.Text, DateTime.Now, DateTime.Now.AddMinutes(30), true"User");
        
string cookieStr = FormsAuthentication.Encrypt(ticket);//对票据进行加密
        HttpCookie cookie = new HttpCookie(FormsAuthentication.FormsCookieName, cookieStr);
/*保存到cookie中。cookie的名字要与我们前面在配置文件中所写的name值一样。因为,当cookie保留在本地后,下次再检查用户权限的时候就会自动查找与forms名称相同的cookie,并传送给服务器端进行检验。如果在本地找不到cookie,就自然无法通过验证。*/
        cookie.Expires 
= ticket.Expiration;
        cookie.Path 
= FormsAuthentication.FormsCookiePath;
        Response.Cookies.Add(cookie);
        Response.Redirect(
"index.aspx");//登陆成功后跳转到index.aspx
    }
}
/*
这里突然冒出一个票据,有些朋友是不是很奇怪呀?票据什么用呢?
票据其实也可以理解为凭据(只有有凭据的用户才能通过检查),它包括了上面注释中所写的一些与用户相关的信息。但是票据不能直接传送给服务器必须通过cookie来承载。而服务器端在接受到cookie之后,会从中取出票据的数据,并进行相关操作。
*/

      在Admin文件夹下的login.aspx.cs也是类似。就不再赘述了。
      差点忘了最后的一个东西了,就是为我们的用户配置角色。上面我们在创建票据的时候发现没有提供直接对角色赋值的功能,那我们就只能利用grobal.asax来实现了。
      在global.asax中有一个Application_AuthenticateRequest事件,该事件会在服务器决定该用户浏览器是否应该跳转前发生。因此,我们只要在这里对用户角色进行配置,就可以达到目的。


protected void Application_AuthenticateRequest(object sender, EventArgs e)
{
    
if (HttpContext.Current.User != null)
    {
//如果用户通过验证,则该项不为null
        if (HttpContext.Current.User.Identity.IsAuthenticated)
        {
            
if (HttpContext.Current.User.Identity is FormsIdentity)
            {
                FormsIdentity id 
= (FormsIdentity)HttpContext.Current.User.Identity;
                FormsAuthenticationTicket ticket 
= id.Ticket;

                
string userData = ticket.UserData;//取出角色数据
                string[] roles = userData.Split(',');
                HttpContext.Current.User 
= new GenericPrincipal(id, roles);//重新分配角色
            }
        }
    }
}

      大家可以下载整个工程来看。
      【注意:不能在同一台电脑上即登陆用户界面又登陆管理员界面。因为使用Forms名称所能保留的cookie只可能是一个,所以如果登陆了管理员界面后,接着登陆用户界面,就会覆盖原来的cookie值。有些朋友使用session来保存用户数据的方法,为实验。】
      实验结束


      Forms验证流程
      大致页面处理流程如下:(介绍这个是为了引出FormsAuthenticationModule,因此不详细介绍,大家可以参考:ASP.NET页面生成流程
      客户端的http请求到达服务器端后,首先IIS会接收到该消息,然后调用asp.net isapi扩展,并由该扩展将请求等信息传送给.net运行时。.Net Runtime会调用ProcessRequest方法根据客户端发来的请求信息创建并初始化一个HttpWorkerRequest对象,并根据该对象创建HttpContext对象,该对象中包含HttpRequest、HttpResponse对象,其中HttpRequest对象中又包含cookie和浏览器信息。最终会生成并调用HttpApplication对象的Init方法。在Init方法中会调用HttpApplicaion.InitInternal方法。而该方法又会调用InitModules方法。InitModules方法则会初始化Web.Config中注册的所有模块(HttpModule)。

      我们的主角FormsAuthenticationModule也就是在这里上场了。
      初始化FormsAuthenticationModule的过程中,会先调用该Module的init方法。我们来看一下该方法中有些什么:

public   void  Init(HttpApplication app)
{
    app.AuthenticateRequest 
+=   new  EventHandler( this .OnEnter);
    app.EndRequest 
+=   new  EventHandler( this .OnLeave);
}

      Init方法注册了两个事件:OnEnter和OnLeave。分别在HttpApplication.AuthenticateRequest和EndRequst事件被触发时执行。
      这两个事件具体做了些什么呢? 

private void OnEnter(object source, EventArgs eventArgs)
{
    
if (!_fAuthChecked || _fAuthRequired)
    {
        HttpApplication application 
= (HttpApplication) source;
        HttpContext context 
= application.Context;
        Trace(
"*******************Request path: " + context.Request.PathWithQueryString);
     
//从Web.Config中获取authentication配置信息
        AuthenticationSection authentication = RuntimeConfig.GetAppConfig().Authentication;
        authentication.ValidateAuthenticationMode();
        
if (!_fAuthChecked)
        {

            
//设置是否为Forms验证,如果是_fAuthRequired 设为true
            _fAuthRequired = authentication.Mode == AuthenticationMode.Forms;
            _fAuthChecked 
= true;
        }
        
if (_fAuthRequired)
        {
            
if (!this._fFormsInit)
            {
                Trace(
"Initializing Forms Auth Manager");
                
//初始化验证信息,Initialize方法主要是通过读取配置文件的authentication节来初始化FormsName、LoginUrl等
                FormsAuthentication.Initialize();
                
this._FormsName = authentication.Forms.Name;
                
if (this._FormsName == null)
                {
                    
this._FormsName = ".ASPXAUTH";
                }
                Trace(
"Forms name is: " + this._FormsName);
                
this._LoginUrl = authentication.Forms.LoginUrl;
                
if (this._LoginUrl == null)
                {
                    
this._LoginUrl = "login.aspx";
                }
                
this._fFormsInit = true;
            }
            
//以下方法用于设置通过验证的用户标识[重要]
            this.OnAuthenticate(new FormsAuthenticationEventArgs(context));
            CookielessHelperClass cookielessHelper 
= context.CookielessHelper;
            
//下面的语句,应该是为了修改_skipAuthorization.该值指定 UrlAuthorizationModule 对象是否应跳过对当前请求的授权检查。
     
//Forms 身份验证模块和 Passport 身份验证模块在重定向到已配置的登录页时都设置 SkipAuthorization。[MSDN]
     
//如果为false则要进行授权检查,否则就跳过检查。[我的体会:如果不在代码上进行控制,一般该值都为false。]
            if (AuthenticationConfig.AccessingLoginPage(context, this._LoginUrl))
            {   
                context._skipAuthorization 
= true;
                cookielessHelper.RedirectWithDetectionIfRequired(
null, FormsAuthentication.CookieMode);
            }
            
if (!context.SkipAuthorization)
            {
                context._skipAuthorization 
= AssemblyResourceLoader.IsValidWebResourceRequest(context);
            }
        }
    }
}

      在OnEnter事件中,我们提到一个重要的方法就是OnAuthenticate:

//通过这个方法,我们就可以得到一个通过验证User标识
private void OnAuthenticate(FormsAuthenticationEventArgs e)
{
    HttpCookie cookie 
= null;
   
//_eventHandler是一个FormsAuthenticationModule类的Authenticate事件。可以通过在asp.net应用程序的Global.asax文件中进行处理
    if (this._eventHandler != null)
    {
        
this._eventHandler(this, e);
    }
  
//判断用户是否已经通过验证,如果已经通过验证则方法结束。通过验证的用户,其User标识不为Null。
    if ((e.Context.User != null|| (e.User != null))
    {
        
if (e.Context.User == null)
        {
            e.Context._user 
= e.User;
        }
    }
    
else
    {   
        FormsAuthenticationTicket tOld 
= null;
        
bool cookielessTicket = false;
        
try
        {  
            
//从Cookie数据中提取验证票据的数据
            tOld = ExtractTicketFromCookie(e.Context, this._FormsName, out cookielessTicket);
        }
        
catch
        {
            tOld 
= null;
        }
        
if ((tOld != null&& !tOld.Expired)
        {  
            FormsAuthenticationTicket ticket 
= tOld;
            
if (FormsAuthentication.SlidingExpiration)
            {

              
//更新验证票据,根据所设置的过期时间来判断
                ticket = FormsAuthentication.RenewTicketIfOld(tOld);
            }
            
//根据票据信息来创建用户标识。第二个参数是用于对用户授于某种角色用的,但是从new string[0]可以看出此处不含角色数据。
     
//如果我们需要对用户的角色进行配置,可以在FormsAuthenticationModule类的Authenticate事件中配置
            e.Context._user = new GenericPrincipal(new FormsIdentity(ticket), new string[0]);
            
if (!cookielessTicket && !ticket.CookiePath.Equals("/"))
            {
                cookie 
= e.Context.Request.Cookies[this._FormsName];
                
if (cookie != null)
                {
                    cookie.Path 
= ticket.CookiePath;
                }
            }
     
//如果票据是新的,则生成一个新的Cookie给客户端
            if (ticket != tOld)
            {
                
if ((cookielessTicket && (ticket.CookiePath != "/")) && (ticket.CookiePath.Length > 1))
                {
                    ticket 
= new FormsAuthenticationTicket(ticket.Version, ticket.Name, ticket.IssueDate, ticket.Expiration, ticket.IsPersistent, ticket.UserData, "/");
                }
                
string cookieValue = FormsAuthentication.Encrypt(ticket);
                
if (cookielessTicket)
                {
                    e.Context.CookielessHelper.SetCookieValue(
'F', cookieValue);
                    e.Context.Response.Redirect(e.Context.Request.PathWithQueryString);
                }
                
else
                {
                    
if (cookie != null)
                    {
                        cookie 
= e.Context.Request.Cookies[this._FormsName];
                    }
                    
if (cookie == null)
                    {
                        cookie 
= new HttpCookie(this._FormsName, cookieValue);
                        cookie.Path 
= ticket.CookiePath;
                    }
                    
if (ticket.IsPersistent)
                    {
                        cookie.Expires 
= ticket.Expiration;
                    }
                    cookie.Value 
= cookieValue;
                    cookie.Secure 
= FormsAuthentication.RequireSSL;
                    cookie.HttpOnly 
= true;
                    
if (FormsAuthentication.CookieDomain != null)
                    {
                        cookie.Domain 
= FormsAuthentication.CookieDomain;
                    }
                    e.Context.Response.Cookies.Add(cookie);
                }
            }
        }
    }
}

      在执行了这个module之后,还有一个重要的module我们不得不提的就是UrlAuthorizationModule。在这个module中,对上面所设置的用户进行了授权检查,来确定该用户是否可以访问所请求的页面。如果用户没有权限,则跳转到loginUrl中所指定的页面。主要方法就是OnEnter:

private void OnEnter(object source, EventArgs eventArgs)
{
    HttpApplication application 
= (HttpApplication) source;
    HttpContext context 
= application.Context;
    
if (context.SkipAuthorization)
    {
        
if (!context.User.Identity.IsAuthenticated)
        {
            PerfCounters.IncrementCounter(AppPerfCounter.ANONYMOUS_REQUESTS);
        }
    }
    
else
    {
        
//读取web.config中配置的授权信息
        AuthorizationSection authorization = RuntimeConfig.GetConfig(context).Authorization;
        
//IsUserAllowed便是对用户进行授权检查
        if (!authorization.EveryoneAllowed && !authorization.IsUserAllowed(context.User, context.Request.RequestType))
        {
            context.Response.StatusCode 
= 0x191;//用户没有被授权,[记住这个标识]
            this.WriteErrorMessage(context);
            
if (context.User.Identity.IsAuthenticated)
            {
                WebBaseEvent.RaiseSystemEvent(
this0xfa7);
            }
            application.CompleteRequest();
        }
        
else
        {
            
if (!context.User.Identity.IsAuthenticated)
            {
                PerfCounters.IncrementCounter(AppPerfCounter.ANONYMOUS_REQUESTS);
            }
            WebBaseEvent.RaiseSystemEvent(
this0xfa3);
        }
    }
}

      前面我们介绍了,FormsAuthenticationModule中的2个主要事件中注册了2个方法。这里来说第二个OnLeave方法。在这个方法中才真正设置了跳转的页面。

private void OnLeave(object source, EventArgs eventArgs)
{
    
if (_fAuthChecked && _fAuthRequired)
    {
        HttpApplication application 
= (HttpApplication) source;
        HttpContext context 
= application.Context;
        
//如果标识为0x191,则跳转到loginUrl
        if (context.Response.StatusCode == 0x191)
        {
            
string str3;
            
string strUrl = null;
            
if (!string.IsNullOrEmpty(this._LoginUrl))
            {
                strUrl 
= AuthenticationConfig.GetCompleteLoginUrl(context, this._LoginUrl);
            }
            
if ((strUrl == null|| (strUrl.Length <= 0))
            {
                
throw new HttpException(SR.GetString("Auth_Invalid_Login_Url"));
            }
            CookielessHelperClass cookielessHelper 
= context.CookielessHelper;
            
string pathWithQueryString = context.Request.PathWithQueryString;
            
if (strUrl.IndexOf('?'>= 0)
            {
                str3 
= FormsAuthentication.RemoveQueryStringVariableFromUrl(strUrl, "ReturnUrl"+ "&ReturnUrl=" + HttpUtility.UrlEncode(pathWithQueryString, context.Request.ContentEncoding);
            }
            
else
            {
                str3 
= strUrl + "?ReturnUrl=" + HttpUtility.UrlEncode(pathWithQueryString, context.Request.ContentEncoding);
            }
            
int index = pathWithQueryString.IndexOf('?');
            
if ((index >= 0&& (index < (pathWithQueryString.Length - 1)))
            {
                pathWithQueryString 
= FormsAuthentication.RemoveQueryStringVariableFromUrl(pathWithQueryString, "ReturnUrl");
            }
            index 
= pathWithQueryString.IndexOf('?');
            
if ((index >= 0&& (index < (pathWithQueryString.Length - 1)))
            {
                str3 
= str3 + "&" + pathWithQueryString.Substring(index + 1);
            }
            cookielessHelper.SetCookieValue(
'F'null);
            cookielessHelper.RedirectWithDetectionIfRequired(str3, FormsAuthentication.CookieMode);
            context.Response.Redirect(str3, 
false);
        }
    }
}

      根据这个流程,结合前面的实验,我们来模拟以下这个执行过程。
      下述a~o这些步骤大致描述了整个验证的执行流程。其中涉及到一些Http协议的知识:(这里介绍几个)
      每次我们请求一个页面,就是在发送一个http请求。在这个请求中包含了我们请求的方式(主要是Get、Post)、请求的页面地址、客户端的浏览器信息、Cookie等。而发送一次请求之后,服务器都会返回一个Http应答包。在这个包中会有一些表示状态的代码,比如我们常见的404、200等,而表示跳转的状态代码则是302。另外,如果服务器需要在客户端设置cookie的话会在应答包的包头中加入set-cookie。具体例子如下:



上图为一次Http请求 

 


上图为一次Http应答


      对Http协议有所了解后,现在来假设是第一次访问用户文件夹中的index.aspx页面。那这个过程就如下所示:
      a.由于是第一次访问这个站点,浏览器无法在本地找到与这个网站对应的cookie,因此会发送一个cookie为空的http请求到服务器端。
      b.服务器的IIS在接受到此请求后,会交给asp.net isapi扩展进行处理。.net 运行时会为这个请求创建一个HttpApplication对象。
      c.HttpApplication对象创建后,会执行Init()方法,从而执行了FormsAutheticationModule。
      d.在执行该模块的过程中首先会读取web.config中的配置信息配置loginUrl等数据。
      e.接着会触发OnAuthenticate方法,该方法首先执行gloabl.aspx中的Application_AuthenticateRequest事件,不过往往由于此时用户还并未通过验证,所以在这次请求中,前面global.asax所写的内容执行不到。接着就会检查用户是否通过验证,本次请求显然是没有通过验证。
      f.在UrlAuthorizationModule中,首先从web.config中读取配置信息,然后核对用户。由于e中用户没有通过验证,因此这里肯定核对失败。于是会执行context.Response.StatusCode = 0x191
      g.最后执行到FormsAutheticationModule的OnLeave事件,因为context.Response.StatusCode == 0x191所以设置用户浏览器需要执行跳转。这个实现的方式,是通过http应答包返回302指令(跳转指令),并设置了http头的Location为目的页面。
      h.浏览器在接受到返回的http应答包后,执行跳转。此时目标为LoginRedirect.aspx。(同样有是一次对loginredirect.aspx的请求,过程省略)。
      i .由于我们在LoginRedirect.aspx.cs中也是一个跳转,因此请求这个页面所返回的Http应答包同样是一个跳转指令(跳转到用户登陆的界面Login.aspx)。
      j .输入用户名,密码单击登陆,又发送了一次http请求到服务器端。但是这次包含了用户的登陆数据是按Post方式发送的。
      k.由于此时用户并没有审核通过,所以仍旧执行上面的a~d,由于login.aspx这个页面在配置文件中我们是设置为[location]节中,因此在后面的验证中就是一路绿灯呀。执行完了所有module后的,此时的http应答包中并不包含跳转指令。
      l.接着执行在login.aspx.cs中定义的事件,在该事件中,对用户的登陆信息进行验证,如果验证通过,则设置验证票据,并将其值保存在cookie中,同时在http应答包中加入cookie。也就是执行Response.Cookie.Add(); 并且,还在事件最后要求浏览器跳转到index.aspx。这便又在http应答包中加入了跳转指令
      m.浏览器在接收到这个http应答包后,提取其中的cookie,并保存在本地。同时跳转到相应页面(又发起一次对index.aspx页面的请求)。
      n.在新的请求发出之前,浏览器由于找到了该站点对应的cookie,会把该cookie值加入到请求中发送到服务器。
      o.后续的步骤其实又执行了一遍上述过程,但是这次在验证的时候,因为有了传入的cookie,可以得到合法用户的信息,也就通过了验证。

      流程介绍结束

      写的不当之处,希望大家直言不讳~~

      整个工程下载http://files.cnblogs.com/stg609/LoginAuthentication.rar(开发平台:VS 2008. 不过,基本没用到.net 3.0的东西,可能可以顺利转换到VS 2005上使用)

      推荐阅读http://www.cnblogs.com/cuihongyu3503319/archive/2008/09/11/1288956.html
                     http://book.csdn.net/bookfiles/406/10040614811.shtml
                     http://www.codeproject.com/KB/web-security/formsroleauth.aspx
                     http://blog.csdn.net/virone/archive/2008/04/12/2284173.aspx
                     手把手教你HTTP协议之Session和Cookie


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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值