Flask Web开发 8.0 Flask的认证扩展 & 本系列结语

从这一章进入本书的第二部分,社交博客程序。

大多数程序都需要进行用户跟踪。用户连接程序时会进行身份验证,通过这一过程,让程序知道自己的身份。程序知道用户是谁后,就能提供有针对性的体验。

最常用的认证方法要求用户提供一个身份证明(用户的电子邮件或用户名)和一个密码。本章要为 Flasky 开发一个完整的认证系统。

8.1 Flask的认证扩展

优秀的 Python 认证包很多,但没有一个能实现所有功能。本章介绍的认证方案使用了多个包,并编写了胶水代码让其良好协作。本章使用的包列表如下。

  • Flask-Login:管理已登录用户的用户会话。
  • Werkzeug:计算密码散列值并进行核对。
  • itsdangerous:生成并核对加密安全令牌。
本章还用到如下常规用途的扩展。
  • Flask-Mail:发送与认证相关的电子邮件。
  • Flask-Bootstrap:HTML 模板。
  • Flask-WTF:Web 表单。

8.2 密码安全性

设计 Web 程序时,人们往往会高估数据库中用户信息的安全性。如果攻击者入侵服务器获取了数据库,用户的安全就处在风险之中,这个风险比你想象的要大。众所周知,大多数用户都在不同的网站中使用相同的密码,因此,即便不保存任何敏感信息,攻击者获得存储在数据库中的密码之后,也能访问用户在其他网站中的账户。
若想保证数据库中用户密码的安全,关键在于不能存储密码本身,而要存储密码的散列值。计算密码散列值的函数接收密码作为输入,使用一种或多种加密算法转换密码,最终得到一个和原始密码没有关系的字符序列。 核对密码时,密码散列值可代替原始密码,因为计算散列值的函数是可复现的:只要输入一样,结果就一样
计算密码散列值是个复杂的任务,很难正确处理。因此强烈建议你不要自己实现,而是使用经过社区成员审查且声誉良好的库。如果你对生成安全密码散列值的过程感兴趣,“Salted Password Hashing - Doing it Right”(计算加盐密码散列值的正确方法,https://crackstation.net/hashing-security.htm)这篇文章值得一读。
使用Werkzeug实现密码散列
Werkzeug 中的 security 模块能够很方便地实现密码散列值的计算。这一功能的实现只需要两个函数,分别用在注册用户和验证用户阶段。
  • generate_password_hash(password, method=pbkdf2:sha1, salt_length=8):这个函数将原始密码作为输入,以字符串形式输出密码的散列值,输出的值可保存在用户数据库中。method 和 salt_length 的默认值就能满足大多数需求。
  •  check_password_hash(hash, password):这个函数的参数是从数据库中取回的密码散列值和用户输入的密码。返回值为 True 表明密码正确。
下面例子展示了第5章创建的User模型为支持密码散列所做的改动。
示例 8-1 app/models.py:在 User 模型中加入密码散列
class User(db.Model):
    #....
    #password 
    password_hash = db.Column(db.String(128))

    @property
    def password(self):
        raise AttributeError('password is not a readable attribute.')

    @password.setter
    def password(self,password):
        self.password_hash = generate_password_hash(password)
        
    def verify_password(self,password):
        return check_password_hash(self.password_hash,password)

#....
这里用到了python的属性函数,如果不明白 @property 和 @password.setter是什么意思,参见 Python进阶之“属性”property 。
计算密码散列值的函数通过名为 password 的只写属性实现。设定这个属性的值时,赋值方法会调用 Werkzeug 提供的 generate_password_hash() 函数,并把得到的结果赋值给password_hash 字段。如果试图读取 password 属性的值,则会返回错误,原因很明显,因为生成散列值后就无法还原成原来的密码了。
verify_password方法接受一个参数(即密码), 将其传给Werkzeug提供 的check_password_hash() 函数,和存储在 User 模型中的密码散列值进行比对。如果这个方法返回True,就表明密码是正确的。
密码散列功能已经完成,可以在shell中进行测试: 输入命令行python manage.py shell
即使两个用户创建了相同的密码,他们的密码散列值也完全不一样。为确保这个功能持续可用,我们可以把上述测试写成单元测试,以便于重复执行。我们要在tests包里新建一个模块,编写三个新测试,测试最近对User模型所做的修改,如下所示。
示例 tests/test_user_model.py:密码散列化测试
import unittest
from app.models import User

class UserModelTestCase(unittest.Testcase):
    def test_password_setter(self):
        u = User(password='cat')
        self.assertTrue(u.password_hash is not None)
        
    def test_no_password_getter(self):
        u = User(password='cat')
        with self.assertRaises(AttributeError):
            u.password
    def test_password_verification(self):
        u = User(password='cat')
        self.assertTrue(u.verify_password('cat'))
        self.assertFalse(u.verify_password('dog'))
        
    def test_password_salts_are_random(self):
        u = User(password = 'cat')
        u2 = User(password = 'cat')
        self.assertTrue(u.password_hash != h2.password_hash)

8.3 创建认证蓝本

我们在第 7 章介绍过蓝本,把创建程序的过程移入工厂函数后,可以使用蓝本在全局作用域中定义路由。与用户认证系统相关的路由可在 auth 蓝本中定义。对于不同的程序功能,我们要使用不同的蓝本,这是保持代码整齐有序的好方法。
auth 蓝本保存在同名 Python 包中。蓝本的包构造文件创建蓝本对象,再从 我们在这个包中新建的一个views.py 模块中引入路由,代码如下所示。
示例 app/auth/__init__.py:创建蓝本
from flask import Blueprint

auth = Blueprint('auth', __name__)

from . import views

app/auth/views.py 模块引入蓝本,然后使用蓝本的 route 修饰器定义与认证相关的路由,如下所示。这段代码中添加了一个 /login 路由,渲染同名占位模板。
示例 app/auth/views.py:蓝本中的路由和视图函数
from . import auth
from flask import render_template

@auth.route('/login')
def login():
    return render_template('auth/login.html')

注意,为 render_template() 指定的模板文件保存在auth文件夹。 这个文件夹必须在app/templates 中创建,因为 Flask 认为模板的路径是相对于程序模板文件夹而言的。为避免与 main 蓝本和后续添加的蓝本发生模板命名冲突,可以把蓝本使用的模板保存在单独的文件夹中。我们也可将蓝本配置成使用其独立的文件夹保存模板。如果配置了多个模板文件夹,render_template() 函数会首先搜索程序配置的模板文件夹,然后再搜索蓝本配置的模板文件夹。
auth 蓝本要在 create_app() 工厂函数中附加到程序上,如示例所示。
示例 app/__init__.py:附加蓝本
    #.....
    from .main import main as main_blueprint
    app.register_blueprint(main_blueprint)
    from .auth import auth as auth_blueprint
    app.register_blueprint(auth_blueprint,url_prefix='/auth')
注册蓝本时使用的 url_prefix 是可选参数。如果使用了这个参数,注册后蓝本中定义的所有路由都会加上指定的前缀,即这个例子中的 /auth。例如,/login 路由会注册成 /auth/login,在开发 Web 服务器中,完整的 URL 就变成了 http://localhost:5000/auth/login。

8.4 使用Flask-Login认证用户

用户登录程序后,他们的认证状态要被记录下来,这样浏览不同的页面时才能记住这个状态。Flask-Login 是个非常有用的小型扩展,专门用来管理用户认证系统中的认证状态,且不依赖特定的认证机制。
安装扩展包:pip install flask-login
8.4.1 准备用于登录的用户模型
要想使用 Flask-Login 扩展,程序的 User 模型必须实现几个方法。需要实现的方法如下表所示。

这 4 个方法可以在模型类中作为方法直接实现,不过还有一种更简单的替代方案。Flask-Login 提供了一个 UserMixin 类,其中包含这些方法的默认实现,且能满足大多数需求。想要在models.py中使用UserMixin很简单,只需要 from flask-login import UserMixin ,然后使得User模型类继承它:
class User(UserMixin,db.Model):
    #...
    email = db.Column(db.String(64),unique=True,index=True)
    #...
注意,示例中同时还添加了 email 字段。在这个程序中,用户使用电子邮件地址登录,因为相对于用户名而言,用户更不容易忘记自己的电子邮件地址。models.py的其余代码都不需要改动。
Flask-Login 在程序的工厂函数中初始化,如示例所示
示例 app/__init__.py:初始化 Flask-Login
from flask-login import LoginManager

login_manager = LoginManager() 
login_manager.session_protection = 'strong' 
login_manager.login_view = 'auth.login' 
 
def create_app(config_name): 
    # ... 
    login_manager.init_app(app) 
    # ...

LoginManager 对象的 session_protection 属性可以设为 None、'basic' 或 'strong',以提供不同的安全等级防止用户会话遭篡改。设为 'strong' 时,Flask-Login 会记录客户端 IP地址和浏览器的用户代理信息,如果发现异动就登出用户。login_view 属性设置登录页面的端点。回忆一下,登录路由在蓝本中定义,因此要在前面加上蓝本的名字。
最后,Flask-Login 要求程序实现一个回调函数,使用指定的标识符加载用户。这个函数的定义如示例所示。
示例 app/models.py:加载用户的回调函数
from . import login_manager 
 
@login_manager.user_loader 
def load_user(user_id): 
    return User.query.get(int(user_id))
加载用户的回调函数接收以 Unicode 字符串形式表示的用户标识符。如果能找到用户,这个函数必须返回用户对象;否则应该返回 None。
8.4.2 保护路由
为了保护路由只让认证用户访问,Flask-Login 提供了一个 login_required 修饰器。用法演示如下:
from flask-login import login_required 
 
@app.route('/secret') 
@login_required 
def secret(): 
    return 'Only authenticated users are allowed!'
如果未认证的用户访问这个路由,Flask-Login 会拦截请求,把用户发往登录页面。
8.4.3 添加登录表单
呈现给用户的登录表单中包含一个用于输入电子邮件地址的文本字段、一个密码字段、一个“记住我”复选框和提交按钮。这个表单使用的 Flask-WTF 类如示例所示。
示例 app/auth/forms.py:登录表单
from flask-wtf import FlaskForm
from wtforms import StringField,PasswordFied,BooleanField,SubmitField
from wtfroms.validators import DaraRequired,Length,Email

class LoginForm(FlaskForm):
    email = StringField('Email',validators=[DataRequired(),Length(1,64)
                                            Email()])
    password = PasswordField('Password', validators=[DataRequired()])
    remember_me = BooleanField('Keep me looged in .')
    submit=SubmitField('log in')
电子邮件字段用到了 WTForms 提供的 Length() 和 Email() 验证函数。PasswordField 类表示属性为 type="password" 的 <input> 元素。BooleanField 类表示复选框。登录页面使用的模板保存在 auth/login.html 文件中。这个模板只需使用 Flask-Bootstrap 提供的 wtf.quick_form() 宏渲染表单即可。
base.html 模板中的导航条使用 Jinja2 条件语句,并根据当前用户的登录状态分别显示“Sign In”或“Sign Out”链接。这个条件语句:
示例 app/templates/base.html:导航条中的 Sign In 和 Sign Out 链接

	<div class="navbar-collapse collapse">
            <ul class="nav navbar-nav">
                <li><a href="{{ url_for('main.index') }}">Home</a></li>
            </ul>
            <ul class="nav navbar-nav navbar-right">
                {% if current_user.is_authenticated()  %}
                <li><a href="{{ url_for('auth.logout') }}">Log Out</a></li>
                {% else %}
                <li><a href="{{ url_for('auth.login') }}">Log In</a></li>
                {% endif %}
            </ul>
        </div>
判断条件中的变量 current_user 由 Flask-Login 定义,且在视图函数和模板中自动可用。这个变量的值是当前登录的用户,如果用户尚未登录,则是一个匿名用户代理对象。如果是匿名用户,is_authenticated() 方法返回 False。所以这个方法可用来判断当前用户是否已经登录。

8.4.4 登入用户

视图函数login()的实现如下所示。

示例 app/auth/views.py:登录路由

from flask import render_template, redirect, request, url_for, flash
from flask_login import login_user
from . import auth
from ..models import User
from .forms import LoginForm


@auth.route('/login', methods=['GET', 'POST'])
def login():
    form = LoginForm()
    if form.validate_on_submit():
        user = User.query.filter_by(email=form.email.data).first()
        if user is not None and user.verify_password(form.password.data):
            login_user(user, form.remember_me.data)
            next = request.args.get('next')
            if next is None or not next.startswith('/'):
                next = url_for('main.index')
            return redirect(next)
        flash('Invalid username or password.')
    return render_template('auth/login.html', form=form)
这个视图函数创建了一个 LoginForm 对象,用法和第 4 章中的那个简单表单一样。当请求类型是 GET 时,视图函数直接渲染模板,即显示表单。当表单在 POST 请求中提交时,Flask-WTF 中的 validate_on_submit() 函数会验证表单数据,然后尝试登入用户。

为了登入用户,视图函数首先使用表单中填写的 email 从数据库中加载用户。如果电子邮件地址对应的用户存在,再调用用户对象的 verify_password() 方法,其参数是表单中填写的密码。如果密码正确,则调用 Flask-Login 中的 login_user() 函数,在用户会话中把用户标记为已登录。login_user() 函数的参数是要登录的用户,以及可选的“记住我”布尔值,“记住我”也在表单中填写。如果值为 False,那么关闭浏览器后用户会话就过期了,所以下次用户访问时要重新登录。如果值为 True,那么会在用户浏览器中写入一个长期有效的 cookie,使用这个 cookie 可以复现用户会话。

按照第 4 章介绍的“Post/ 重定向 /Get 模式”,提交登录密令的 POST 请求最后也做了重定向,不过目标 URL 有两种可能。用户访问未授权的 URL 时会显示登录表单,Flask-Login会把原地址保存在查询字符串的 next 参数中,这个参数可从 request.args 字典中读取。如果查询字符串中没有 next 参数,则重定向到首页。如果用户输入的电子邮件或密码不正确,程序会设定一个 Flash 消息,再次渲染表单,让用户重试登录。

在生产服务器上,登录路由必须使用安全的 HTTP,从而加密传送给服务器的表单数据。如果没使用安全的 HTTP,登录密令在传输过程中可能会被截取,在服务器上花再多的精力用于保证密码安全都无济于事。

我们需要更新登录模板以渲染表单。修改内容如示例所示。

示例 app/templates/auth/login.html:渲染登录表单

{% extends "base.html" %} 
{% import "bootstrap/wtf.html" as wtf %} 
{% block title %}Flasky - Login{% endblock %} 
 
{% block page_content %} 
<div class="page-header"> 
    <h1>Login</h1> 
</div> 
<div class="col-md-4"> 
    {{ wtf.quick_form(form) }} 
</div> 
{% endblock %}

8.4.5 登出用户

退出路由的实现如示例所示。
示例 app/auth/views.py:退出路由

from flask-login import logout_user, login_required 
 
@auth.route('/logout') 
@login_required 
def logout(): 
    logout_user() 
    flash('You have been logged out.') 
    return redirect(url_for('main.index'))
为了登出用户,这个视图函数调用 Flask-Login 中的 logout_user() 函数,删除并重设用户会话。随后会显示一个 Flash 消息,确认这次操作,再重定向到首页,这样登出就完成了。
注意:
我们可以在作者的GitHub上找到此时对应版本的源码:flasky-8c 。将它下载下来。由于作者在新修改的代码中已经不再使用flask_script而改用了flask_cli作为命令行工具,而后者的使用方法并不需要manage.py(差异不大,具体请自行百度),因此manage.py将被抛弃。
下载下来之后,进入该程序所在的目录。为保证安装了所有依赖,你要运行pip install -r requirements.txt。  这次更新包含一个数据库迁移,所以签出代码后记得要运行python manage.py db upgrade。
后面我们也不再使用manage.py。
8.4.6 测试登录

为验证登录功能可用,可以更新首页,使用已登录用户的名字显示一个欢迎消息。模板中生成欢迎消息的部分如示例所示。

示例 app/templates/index.html:为已登录的用户显示一个欢迎消息

Hello,
{% if current_user.is_authenticated %}
    {{ current_user.username }}
{% else %}
    Stranger
{% endif %}!
在这个模板中再次使用current_user.is_authenticated判断用户是否已经登录。注意:is_authenticated是current_user的一个属性,而不是方法,因此后面没有()。

因为还未创建用户注册功能,所以新用户可在shell 中注册:


刚刚创建的用户现在可以登录了。



8.5 注册新用户

如果新用户想成为程序的成员,必须在程序中注册,这样程序才能识别并登入用户。程序的登录页面中要显示一个链接,把用户带到注册页面,让用户输入电子邮件地址、用户名和密码。

8.5.1 添加用户注册表单

注册页面使用的表单要求用户输入电子邮件地址、用户名和密码。这个表单如示例所示。

示例 app/auth/forms.py:用户注册表单

from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Length, Email,Regexp,EqualTo
from wtforms import ValidationError
from ..models import User

#之前原有的代码
class LoginForm(FlaskForm):
    email = StringField('Email', validators=[DataRequired(), Length(1, 64),
                                             Email()])
    password = PasswordField('Password', validators=[DataRequired()])
    remember_me = BooleanField('Keep me logged in')
    submit = SubmitField('Log In')

#新用户注册表单的代码
class RegistrationForm(FlaskForm):
    email = StringField('Email',validators=[DataRequired(),Length(1,64),Email()])
    username = StringField('Username',validators=[DataRequired(),Length(1,64),Regexp('^[A-Za-z][A-Za-z0-9_.]*$', 0,
                                                                                     'Usernames must have only letters, '
                                                                                     'numbers, dots or underscores')])
    password = PasswordField('Password',validators=[DataRequired(),EqualTo('password2',message='Passwords must match.')])
    password2 = PasswordField('Confirm password',validators=[DataRequired()])
    submit = SubmitField('Register')

    def validate_email(self,filed):
        if User.query.filter_by(email=filed.data).first():
            raise ValidationError('Email already register.')
    def validate_username(self,field):
        if User.query.filter_by(username=field.data).first():
            raise ValidationError('Username already in use.')
    这个表单使用WTForms 提供的Regexp 验证函数,确保username 字段只包含字母、数字、下划线和点号。这个验证函数中正则表达式后面的两个参数分别是正则表达式的旗标和验证失败时显示的错误消息。
    安全起见,密码要输入两次。此时要验证两个密码字段中的值是否一致,这种验证可使用WTForms 提供的另一验证函数实现,即EqualTo。这个验证函数要附属到两个密码字段中的一个上,另一个字段则作为参数传入。
    这个表单还有两个自定义的验证函数,以方法的形式实现。如果表单类中定义了以validate_ 开头且后面跟着字段名的方法,这个方法就和常规的验证函数一起调用。本例分别为email 和username 字段定义了验证函数,确保填写的值在数据库中没出现过。自定义的验证函数要想表示验证失败,可以抛出ValidationError 异常,其参数就是错误消息。
    显示这个表单的模板是/templates/auth/register.html。和登录模板一样,这个模板也使用wtf.quick_form() 渲染表单。
示例 app/templates/auth/register.html:注册页面
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}

{% block title %}Flasky - Register{% endblock %}

{% block page_content %}
<div class="page-header">
    <h1>Register</h1>
</div>
<div class="col-md-4">
    {{ wtf.quick_form(form) }}
</div>
{% endblock %}
同时,我们的登录页面需要加入一个指向注册页面的链接,让没有账户的用户能够轻易找到注册页面。改动如下所示:
示例 app/templates/auth/login.html:链接到注册页面
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}

{% block title %}Flasky - Login{% endblock %}

{% block page_content %}
<div class="page-header">
    <h1>Login</h1>
</div>             
<div class="col-md-4">        
    {{ wtf.quick_form(form) }}   <!-- 以下为改动部分 -->
    <br>
    <p>New user? <a href="{{ url_for('auth.register') }}">Click here to register</a>.</p>
</div>
{% endblock %}
8.5.2    注册新用户
处理用户注册的过程没有什么难以理解的地方。提交注册表单,通过验证后,系统就使用用户填写的信息在数据库中添加一个新用户。处理这个任务的视图函数如下所示:
示例 app/templates/auth/views.py:注册新用户的视图函数
from .. import db
@auth.route('/register',methods=['GET','POST'])
def register():
    form = RegistrationForm()
    if form.validate_on_submit():
        user = User(email=form.email.data,username=form.username.data,password=form.password.data)
        db.session.add()
        db.session.commit()
        flash('OK!Now you can login.')
        return redirect(url_for('auth.login'))
    return render_template('auth/register.html',form=form)

此时对应的github代码版本是flasky-8d。

8.6    确认账户

    对于某些特定类型的程序,有必要确认注册时用户提供的信息是否正确。常见要求是能通过提供的电子邮件地址与用户取得联系。
    为验证电子邮件地址,用户注册后,程序会立即发送一封确认邮件。新账户先被标记成待确认状态,用户按照邮件中的说明操作后,才能证明自己可以被联系上。账户确认过程中,往往会要求用户点击一个包含确认令牌的特殊URL 链接。
8.6.1    使用itsdangerous生成确认令牌(详见书)
    确认邮件中最简单的确认链接是http://www.example.com/auth/confirm/<id> 这种形式的URL,其中id 是数据库分配给用户的数字id。用户点击链接后,处理这个路由的视图函数就将收到的用户id 作为参数进行确认,然后将用户状态更新为已确认。
    但这种实现方式显然不是很安全,只要用户能判断确认链接的格式,就可以随便指定URL中的数字,从而确认任意账户。解决方法是把URL 中的id 换成将相同信息安全加密后得到的令牌。
    回忆一下我们在第4 章对用户会话的讨论,Flask 使用加密的签名cookie 保护用户会话,防止被篡改。这种安全的cookie 使用itsdangerous 包签名。同样的方法也可用于确认令牌上。
8.6.2    发送确认邮件(详见书)
当前的/register 路由把新用户添加到数据库中后,会重定向到/index。在重定向之前,这个路由需要发送确认邮件。

直接获取对应版本:flasky-8e 。 这个版本包含一个数据库迁移,所以签出代码后要执行 flask db upgrade。想要完成发送邮件的功能需要在config.py中设置好smtp服务的各个参数(服务器、账号、密码)。

8.7    管理账户

拥有程序账户的用户有时可能需要修改账户信息。下面这些操作可使用本章介绍的技术添加到验证蓝本中。

  • 修改密码
    安全意识强的用户可能希望定期修改密码。这是一个很容易实现的功能,只要用户处于登录状态,就可以放心显示一个表单,要求用户输入旧密码和替换的新密码。(这个功能的实现参见GitHub 仓库中标签为8f 的提交。)
  • 重设密码
    为避免用户忘记密码无法登入的情况,程序可以提供重设密码功能。安全起见,有必要使用类似于确认账户时用到的令牌。用户请求重设密码后,程序会向用户注册时提供的电子邮件地址发送一封包含重设令牌的邮件。用户点击邮件中的链接,令牌验证后,会显示一个用于输入新密码的表单。(这个功能的实现参见GitHub 仓库中标签为8g 的提交。)
  • 修改电子邮件地址
    程序可以提供修改注册电子邮件地址的功能,不过接受新地址之前,必须使用确认邮件进行验证。使用这个功能时,用户在表单中输入新的电子邮件地址。为了验证这个地址,程序会发送一封包含令牌的邮件。服务器收到令牌后,再更新用户对象。服务器收到令牌之前,可以把新电子邮件地址保存在一个新数据库字段中作为待定地址,或者将其和id 一起保存在令牌中。(这个功能的实现参见GitHub 仓库中标签为8h 的提交。)

结语——本系列博客结束

至此,《Flask Web开发——基于Python的Web应用开发实践》 的前八章已经学习完了,我们学到了Flask的使用方法和进行开发的基础知识、Flask工程框架和一些数据库知识,其中数据库知识是需要更加深层次的学习的,而Flask更多的用法,当在未来的使用中遇到了再参照书目进行学习也为时不晚。这是我的这个系列博客只跟进到第八章的原因之一。

另一个原因在于作者对书的规划。使用这本书进行学习,必须一步一步地跟进,这是好处,也是坏处。好处在于读者有一个很清晰的逻辑层次,坏处在于当我们想侧重于某部分或忽略某些部分的时候会造成一定的麻烦。譬如我不想在Email部分费工夫,而后面的工程中我总不可能把作者在GitHub上的每一个版本的源码的Email功能都删掉,因此对于第二部分的实战项目就变得举步维艰,不知道怎么写到博客上才好,所以匆匆结尾。

不过,如果最近这本书会出个第四版,我也许会把第四版的实战项目跟着过一遍。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值