Python个人博客程序开发实例用户验证功能
皮皮要HAPPY 人气:0在Python个人博客程序开发实例框架设计中,我们已经完成了 数据库设计、数据准备、模板架构、表单设计、视图函数设计、电子邮件支持 等总体设计的内容。
在Python个人博客程序开发实例信息显示中,我们一起实现了 显示文章列表、博客信息、文章内容和评论 等功能。
那么,本篇文章将会介绍如何 初始化博客、利用 Flask-Login 管理用户认证、使用 CSRFProtect 实现 CSRF 保护。
1.安全存储密码
创建管理员用户需要存储用户名和密码,密码的存储需要特别注意。密码不能直接以明文的形式存储在数据库中,因为一旦数据库被窃取或是被攻击者使用暴 力破解或字典法破解,用户的账户、密码将被直接泄露。如果发生泄漏,常常会导致用户在其他网站上的账户处于危险状态,因为通常用户会在多个网站使用同一个密码。一般的做法是不存储密码本身,而是存储通过密码生成的散列值(hash)。每一个密码对应着独一无二的散列值,从而避免明文存储密码。
如果只是简单地计算散列值,攻击者可以使用彩虹表的方式逆向破解密码。这时我们需要加盐计算散列值。加盐后,散列值的随机性会显著提高。但仅仅把盐和散列值连接在一起可能还不够,我们还需要使用 HMAC(hash-based message authentication code)
来重复计算很多次(比如 5000 次)最终获得派生密钥,这会增大攻击者暴 力破解密码的难度,这种方式被称为 密钥扩展(key stretching
)。
在密码学中,盐(salt)是一串随机生成的字符,用来增加散列值计算的随机性。经过这一系列处理后,即使攻击者获取到了密码的散列值,也无法逆向获取真实的密码值。在生产环节中,尽管对密码加密存储安全性很强,仍然需要使用安全的 HTTP 以加密传输数据,避免密码在传输过程中被截获。
Werkzeug 在 security
模块中提供了一个 generate_password_hash(password,method=‘pbkdf2:sha256’,salt_length=8)
函数用于为给定的密码生成散列值,参数 method
用来指定计算散列值的方法,salt_length
参数用来指定盐(salt)的长度。security
模块中的 check_password_hash(pwhash,password)
函数接收散列值(pwhash)和密码(password)作为参数,用于检查密码散列值与密码是否对应。
>>> from werkzeug.security import generate_password_hash, check_password_hash >>> password_hash = generate_password_hash('cat') >>> password_hash 'pbkdf2:sha256:50000$mIeMzTvb$ba3c0a274c6b53fda2ab39f864254dfb0a929848b7ec99f81e3bf721d8860fdc' >>> check_password_hash(password_hash, 'dog') False >>> check_password_hash(password_hash, 'cat') True >>> password_hash = generate_password_hash('cat') >>> password_hash 'pbkdf2:sha256:150000$AITKk6jv$5c0b732535cae83677fdf2e666153f82b5db30e6f40ec7a625678ad2b5f4ad25'
generate_password_hash()
函数生成的密码散列值的格式如下:
method$salt$hash
因为在计算散列值时会加盐,而盐是随机生成的,所以即使两个用户的密码相同,最终获得的密码散列值也是不同的。我们没法从密码散列值逆向获取密码,但是如果密码、计算方法、盐相同,最终获得的散列值结果也会是相同的,所以 check_password_hash()
函数会根据密码散列值中的方法、盐重新对传入的密码进行散列值计算,然后对比散列值。
from werkzeug.security import generate_password_hash, check_password_hash class User(db.Model): ... password_hash = db.Column(db.String(128)) ... def set_password(self, password): self.password_hash = generate_password_hash(password) def validate_password(self, password): return check_password_hash(self.password_hash, password)
set_password()
方法用来设置密码,它接收密码的原始值作为参数,将密码的散列值设为 password_hash
的值。validate_password()
方法用于验证密码是否和对应的散列值相符,返回布尔值。
2.使用Flask-Login管理用户认证
博客程序需要根据用户的身份开放不同的功能,对于程序使用者——管理员来说,他可以撰写文章、管理博客;而普通的用户(匿名用户)则只能阅读文章、发表评论。为了让程序识别出用户的身份,我们需要添加用户认证功能。具体来说,使用用户名和密码登入博客程序的用户被视为管理员,而未登录的用户则被视为匿名用户。
扩展 Flask-Login 为 Flask 提供了用户会话管理功能,使用它可以轻松的处理用户登录、登出等操作。
在 extensions.py
脚本中实例化扩展提供的 LoginManager
类,创建一个 login_manager
或 login
对象。
from flask_login import LoginManager ... login_manager = LoginManager(app)
然后在程序包的工厂函数中对 login
对象调用 init_app()
方法进行初始化扩展
login_manager.init_app(app)
Flask-Login 要求表示用户的类必须实现下表中所示的这几个属性和方法,以便用来判断用户的认证状态。
通过对用户对象调用各种方法和属性即可判断用户的状态,比如是否登录等。方便的做法是让用户类继承 Flask-Login 提供的 UserMixin
类,它包含了这些方法和属性的默认实现。
from flask_login import UserMixin class Admin(db.Model, UserMixin): ...
UserMinxin
表示通过认证的用户,所以 is_authenticated
和 is_active
属性会返回 True
,而 is_anonymous
则返回 False
。get_id()
默认会查找用户对象的 id
属性值作为 id
,而这正是我们的 Admin
类中的主键字段。
使用 Flask-Login 登入/登出某个用户非常简单,只需要在视图函数中调用 Flask-Login 提供的 login_user()
或 logout_user()
函数,并传入要登入/登出的用户类对象。在这两个函数背后,Flask-Login 使用 Flask 的 session
对象将用户的 id
值存储到用户浏览器的 cookie
中(名为 user_id
),这时表示用户被登入。相对来说,登出则意味着在用户浏览器的 cookie
中删除这个值。默认情况下,关闭浏览器时,通过 Flask 的 session
对象存储在客户端的 session cookie
会被删除,所以用户会登出。
另外,Flask-Login 还支持记住登录状态,通过在 login_user()
中将 remember
参数设为 True
即可实现。这时 Flask-Login 会在用户浏览器中创建一个名为 remember_token
的 cookie
,当通过 session
设置的 user_id cookie
因为用户关闭浏览器而失效时,它会重新恢复 user_id cookie
的值。
为了防止破坏 Flask-Login 提供的认证功能,我们在视图函数中操作 session
时要避免使用 user_id
和 remember_token
作为键。remember_token cookie
的默认过期时间为 365 天。你可以通过配置变量 REMEMBER_COOKIE_DURATION
进行设置,设为 datetime.timedelta
对象即可。
2.1 获取当前用户
那么我们如何判断用户的认证状态呢?答案是使用 Flask-Login 提供的 current_user
对象。它是一个和 current_app
类似的代理对象(Proxy),表示当前用户。调用时会返回与当前用户对应的用户模型类对象。因为 session
中只会存储登录用户的 id
,所以为了让它返回对应的用户对象,我们还需要设置一个用户加载函数。这个函数需要使用 login_manager.user_loader
装饰器,它接收用户 id
作为参数,返回对应的用户对象。
@login_manager.user_loader def load_user(user_id): from bluelog.models import Admin user = Admin.query.get(int(user_id)) return user
现在,当我们调用 current_user
时,Flask-Login 会调用用户加载函数并返回对应的用户对象。如果当前用户已经登录,会返回 Admin
类实例;如果用户未登录,current_user
默认会返回 Flask-Login 内置的 AnonymousUserMixin
类对象,它的 is_authenticated
和 is_active
属性会返回 False
,而 is_anonymous
属性则返回 True
。
current_user
存储在请求上下文堆栈上,所以只有激活请求上下文程序的情况下才可以使用,比如在视图函数中或是模板中调用。
最终,我们可以通过对 current_user
对象调用 is_authenticated
等属性来判断当前用户的认证状态。它也和我们自定义的模板全局变量一样注入到了模板上下文中,可以在所有模板中使用,所以我们可以在模板中根据用户状态渲染不同的内容
2.2 登入用户
个人博客的登录链接可以放在次要的位置,因为只有博客作者才会真正用到它。我们把它放到页脚,并根据用户的状态来选择渲染出不同的链接。
<small> {% if current_user.is_authenticated %} <!-- 如果用户已经登录,显示下面的“登出”链接--> <a href="{{ url_for('auth.logout', next=request.full_path) }}" rel="external nofollow" >Logout</a> {% else %} <!-- 如果没有登录,则显示下面的“登录”按钮 --> <a href="{{ url_for('auth.login', next=request.full_path) }}" rel="external nofollow" >Login</a> {% endif %} </small>
通过 current_user
的 is_authenticated
值判断用户是否登录,如果用户已登录(is_authenticated
为 True
)就渲染注销按钮,否则就渲染登录按钮。按钮中的 URL 分别指向用于登录和登出的 login
和 logout
视图,url_for()
函数中加入的 next
参数用来存储当前页面的路径,以便在执行登录或登出操作后将用户重定向回上一个页面。
from flask_login import login_user from bluelog.forms import LoginForm from bluelog.models import Admin from bluelog.utils import redirect_back ... @auth_bp.route('/login', methods=['GET', 'POST']) def login(): if current_user.is_authenticated: return redirect(url_for('blog.index')) form = LoginForm() if form.validate_on_submit(): username = form.username.data password = form.password.data remember = form.remember.data admin = Admin.query.first() if admin: # 验证用户名和密码 if username == admin.username and admin.validate_password(password): login_user(admin, remember) # 登入用户 flash('Welcome back.', 'info') return redirect_back() # 返回上一个页面 flash('Invalid username or password.', 'warning') else: flash('No account.', 'warning') return render_template('auth/login.html', form=form)
登录视图负责渲染 login.html
模板和验证登录表单。在函数一开始,为了避免已经登录的用户不小心访问这个视图,我们添加一个if判断将已经登录的用户重定向到首页。
与其他表单处理流程相同,当用户提交表单且数据通过验证后,我们分别从表单中获取用户名(username)、密码(password)和 “记住我”(remember)字段的数据。接着,从数据库中查询出 Admin
对象,判断 username
的值,并使用 Admin
类中的 validate_password()
方法验证密码。如果通过验证就调用 login_user()
方法登录用户,传入用户对象和 remember
字段的值作为参数,最后使用 redirect_back()
函数重定向回上一个页面;如果用户名和密码验证出错就发送错误提示,并渲染模板。另外,如果 Admin
对象不存在,就发送一个提示消息,然后重新渲染表单。
登录表单 LoginForm
在新创建的 login.html
模板中使用 Bootstrap-Flask 提供的 render_form()
宏渲染。为了编写一个更简单的登录页面,我们打算不在登录页面显示页脚,因为我们在基模板中为页脚的代码定义了 footer
块,所以在登录页面模板只需要定义这个块并留空就可以覆盖基模板中的对应内容。
{% extends 'base.html' %} {% from 'bootstrap/form.html' import render_form %} {% block title %}Login{% endblock %} {% block content %} <div class="container h-100"> <div class="row h-100 page-header justify-content-center align-items-center"> <h1>Log in</h1> </div> <div class="row h-100 justify-content-center align-items-center"> {{ render_form(form, extra_classes='col-6') }} </div> </div> {% endblock %} {% block footer %}{% endblock %}
2.3 登出用户
注销登录比登录还要简单,只需要调用 Flask-Login 提供的 logout_user()
函数即可。这会登出用户并清除 session
中存储的用户 id
和 “记住我” 的值。
from flask_login import logout_user ... @auth_bp.route('/logout') @login_required def logout(): logout_user() flash('Logout success.', 'info') return redirect_back()
2.4 视图保护
程序中的许多操作要求用户登录后才能进行,因此我们要把这些需要登录才能访问的视图 “保护” 起来。如果用户访问了某个需要认证才能访问的资源,我们不会返回对应的响应,而是把程序重定向到登录页面。
视图保护可以使用 Flask-Login 提供的 login_required
装饰器实现。在需要登录才能访问的视图前附加这个装饰器,比如博客设置页面。
当为视图函数附加多个装饰器时,route()
装饰器应该置于最外层。
from flask_login import login_required @admin_bp.route('/settings') @login_required def settings(): ... return render_template('admin/settings.html')
当未登录的用户访问使用了 login_required
装饰器的视图时,程序会自动重定向到登录视图,并闪现一个消息提示。在此之前,我们还需要在 extension.py
脚本中使用 login_manager
对象的 login_view
属性设置登录视图的端点值(包含蓝本名的完整形式)。
login_manager = LoginManager(app) ... login_manager.login_view = 'auth.login' login_manager.login_message_category = 'warning'
使用可选的 login_message_category
属性可以设置消息的类别,默认类别为 “message”。另外,使用可选的 login_message
属性设置提示消息的内容,默认消息内容为“Please log in to access this page.”
当用户访问某个被保护的 URL 时,在重定向后的登录 URL 中,Flask-Login 会自动附加一个包含上一个页面 URL 的 next
参数,所以我们只需要使用 redirect_back()
函数就可以将登录成功后的用户重定向回上一个页面。
当在未登录状态下访问设置页面 http://localhost:5000/admin/settings 时,程序会重定向到登录页面,并显示提示消息,URL 中包含上一个页面的 next
参数。
仔细观察地址栏,你会看到附加的 next
参数包含上一个页面的地址,我们经常在上网时在地址栏发现类似的参数,比如 ReturnUrl
、RedirectUrl
等。当我们登录后,程序会重定向回我们想要访问的设置页面。
有些时候,你会希望为整个蓝本添加登录保护。比如,管理后台的所有页面都需要登录后才能访问,也就是说,我们需要为所有 admin
蓝本中的视图函数附加 login_required
装饰器。有一个小技巧可以避免这些重复:为 admin
蓝本注册一个 before_request
处理函数,然后为这个函数附加 login_required
装饰器。因为使用 before_request
钩子注册的函数会在每一个请求前运行,所以这样就可以为该蓝本下所有的视图函数添加保护,函数内容可以为空。
@admin_bp.before_request @login_required def login_protect(): pass
- 虽然这个技巧很方便,但是为了避免在书中单独给出视图函数代码时造成误解,Bluelog程序中并没有使用这个技巧。
- 如果没有使用这个技巧,那么
admin
蓝本下的所有视图都需要添加login_required
装饰器,否则会导致博客资源被匿名用户修改。
3.使用CSRFProtect实现CSRF保护
CSRF攻击,全称为 Cross-site request forgery
,中文名为 跨站请求伪造,也被称为 One Click Attack
或者 Session Riding
,通常缩写为 CSRF
或者 XSRF
,是一种对网站的恶意利用。XSS 主要是利用站点内的信任用户,而 CSRF 则通过伪装来自受信任用户的请求,来利用受信任的网站。与 XSS 相比,CSRF 更具危险性。攻击者盗用用户身份,发送恶意请求。比如:模拟用户发送邮件,发消息,以及支付、转账等。
博客管理后台会涉及对资源的局部更新和删除操作,这时我们就要考虑到 CSRF 保护问题。为了应对 CSRF 攻击,当需要创建、修改和删除数据时,我们需要将这类请求通过 POST 方法提交,同时在提交请求的表单中添加 CSRF 令牌。对于删除和某些修改操作来说,单独创建表单类的流程太过烦琐,我们可以使用 Flask-WTF 内置的 CSRFProtect
扩展为这类操作实现更简单和完善的 CSRF 保护。
CSRFProtect
是 Flask-WTF 内置的扩展,也是 Flask-WTF 内部使用的 CSRF 组件,单独使用可以实现对程序的全局 CSRF 保护。它主要提供了生成和验证 CSRF 令牌的函数,方便在不使用 WTForms 表单类的情况下实现 CSRF 保护。因为我们已经安装了 Flask-WTF,所以可以直接使用它。首先在 extensions.py
脚本中实例化 Flask-WTF
提供的 CSRFProtect
类。
from flask_wtf.csrf import CSRFProtect ... csrf = CSRFProtect() ...
在程序包的构造文件中初始化扩展 CSRFProtect
:
from bluelog.extensions import csrf def create_app(config_name=None): ... register_extensions(app) return app def register_extensions(app): ... csrf.init_app(app)
CSRFProtect
在模板中提供了一个 csrf_token()
函数,用来生成 CSRF 令牌值,我们直接在表单中创建这个隐藏字段,将这个字段的 name
值设为 csrf_token
。下面是用来删除文章的表单示例:
<form method="post" action="{{ url_for('.delete_post', post_id=post.id) }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="submit" value="Delete Post"/> </form>
在对应的 delete_post
视图中,我们直接执行相关删除操作,CSRFProtect
会自动获取并验证 CSRF 令牌。注意,在 app.route()
装饰器中使用 methods 参数限制仅监听 POST 请求。
@app.route('/post/delete/<id>', methods=['POST']) def delete_post(id): post = Post.query.get(id) post.delete() return redirect(url_for('index'))
默认情况下,当令牌验证出错或过期时,程序会返回 400 错误,和 Werkzeug 内置的其他 HTTP 异常类一样,CSRFError
将错误描述保存在异常对象的 description
属性中。
如果你想将与 CSRF 相关的错误描述显示在模板中,那么你可以在 400 错误处理函数中将异常对象的 description
属性传入模板,也可以单独创建一个错误处理函数捕捉令牌出错时抛出的 CSRFError
异常。
from flask_wtf.csrf import CSRFError def register_errors(app): ... @app.errorhandler(CSRFError) def handle_csrf_error(e): return render_template('400.html', description=e.description), 400
这个错误处理函数仍然使用 app.errorhandler
装饰器注册,传入 flask_wtf.csrf
模块中的 CSRFError
类。这个错误处理函数返回 400 错误响应,通过异常对象的 description
属性获取内置的错误消息(英文),传入模板 400.html
中。在模板中,我们渲染这个错误消息,并为常规 400 错误设置一个默认值。
<p>{<!--{cke_protected}{C}%3C!%2D%2D%20%2D%2D%3E-->{ description|default('Bad Request') }}</p>
在实际应用中,除了使用内置的错误描述,更合适的方法是自己编写错误描述信息。默认的错误描述为 “Invalid CSRF token.” 和 “The CSRF token is missing.” 因为包含太多术语,不容易理解,所以在实际的程序中,我们应该使用更简单的错误提示,比如 “会话过期或失效,请返回上一页面重试”。
加载全部内容