表單是讓使用者與我們的網頁應用程式互動的基本元素。 Flask 本身並不會幫助我們處理表單,但是 Flask-WTF 擴充功能讓我們在我們的 Flask 應用程式中使用流行的 WTForms 套件。這個包使得定義表單和處理提交變得容易一些。
Flask-WTF
我們想要使用 Flask-WTF 做的第一件事情(在安裝它以後,GitHub專案頁:https://github.com/lepture/flask-wtf )就是在 myapp.forms 套件中定義一個表單。
# ourapp/forms.py from flask_wtf import Form from wtforms import StringField, PasswordField from wtforms.validators import DataRequired, Email class EmailPasswordForm(Form): email = StringField('Email', validators=[DataRequired(), Email()]) password = PasswordField('Password', validators=[DataRequired()])
在 Flask-WTF 0.9 版本以前,Flask-WTF 提供了針對 WTForms 欄位以及驗證器的自己的封裝。你可能會看到外面一大堆的程式碼是從 flask.ext.wtforms 不是從 wtforms 匯入 TextField,PasswordField。
在 Flask-WTF 0.9 版本以後,我們應該直接從 wtforms 中匯入這些欄位和驗證器。
我們定義的表單是使用者登入表單。我們把它叫做 EmailPasswordForm(),我們可以重複使用這個同樣的表單類別(Form)去做它的一些事情,像是註冊表單。這裡我們沒有去定義一個又長又沒用的表單,而是選擇一個很常用的表單,只是為了給你們介紹使用 Flask-WTF 定義表單的方式。也許以後在正式專案中會定義一個特別複雜表單。對於表單中包含欄位名稱,我們建議使用一個清楚的名稱,並且在一個表單中保持唯一。不得不說,對於一個長的表單,我們可能要給出一個更符合上文的欄位名稱。
登入表單可以替我們做些什麼。它能夠保證我們應用程式的安全性以防止 CSRF 漏洞,驗證使用者輸入並且渲染適當的標記,這些標記是我們為表單定義的欄位。
CSRF 保護與驗證
CSRF 表示跨站請求偽造。 CSRF 攻擊是指第三方偽造(像表單提交)請求到一個應用程式的伺服器。一個易受攻擊的伺服器假設從一個表單來的資料是來自它自己的網站並且採取相應的操作。
作為一個例子,比方說,一個郵件提供者可以讓你透過提交一個表單來刪除你的帳號。表單發送一個 POST 請求到伺服器上的 account_delete 端點並且當表單被提交的時候刪除登入的帳號。我們可以在自己的網站上建立一個表單,該表單會傳送一個 POST 請求到同一個 account_delete 端點。現在,如果我們讓某人點擊我們表單的提交按鈕(或透過 JavaScript 來這樣做),郵件提供者提供的登入帳號就會被刪除掉。當然郵件提供者還不知道表單提交並不是發生在他們的網站上。
因此如何才能阻止 POST 請求來自別的網站? WTForms 透過在渲染每個表單的時候產生一個唯一的令牌使得成為可能。產生的令牌會被傳回伺服器,伴隨著 POST 請求的數據,在表單被接受之前令牌必須接受伺服器的驗證。關鍵的是令牌是與儲存在使用者會話(cookies)的一個值有關並且會在一段時間後失效(預設是 30 分鐘)。這種方式就能夠保證提交一個有效表單的人就是載入頁面的人(或至少是使用同一台電腦的人),而且他們只能在載入頁面 30 分鐘內這樣做。
要開始使用 Flask-WTF 保護 CSRF,我們需要為我們的登入頁定義一個視圖。
# ourapp/views.py from flask import render_template, redirect, url_for from . import app from .forms import EmailPasswordForm @app.route('/login', methods=["GET", "POST"]) def login(): form = EmailPasswordForm() if form.validate_on_submit(): # Check the password and log the user in # [...] return redirect(url_for('index')) return render_template('login.html', form=form)
如果表單已經被提交和驗證的話,我們可以繼續登入的邏輯。如果它沒有被提交的話(例如,只是一個 GET 請求),我們就要把表單物件傳遞給我們的模板,以便它能夠被渲染。下面就是我們使用 CSRF 保護的時候模板的樣子。
{# ourapp/templates/login.html #} {% extends "layout.html" %} {% endraw %} <html> <head> <title>Login Page</title> </head> <body> <form action="{{ url_for('login') }}" method="post"> <input type="text" name="email" /> <input type="password" name="password" /> {{ form.csrf_token }} </form> </body> </html>
{% raw %}{{ form.csrf_token }}{% endraw %} 渲染了一個隱藏的字段,該字段包含那些奇特的 CSRF 令牌,並且當 WTForms 驗證表單的時候會尋找這個字段。我們不用擔心包含處理令牌的邏輯,WTForms 會主動幫我們做。好哇!
自訂驗證
除了由 WTForms 提供的內建的表單驗證器(例如,Required(),Email() 等等),我們可以建立我們自己的驗證器。我們將透過編寫一個 Unique() 驗證器來說明如何建立自己的驗證器,Unique() 驗證器是用來檢查資料庫並且確保使用者提供的值在資料庫中不存在。這能夠用於確保使用者名稱或郵箱地址尚未使用。沒有 WTForms 的話,我們可能要在視圖中做這些事情,但是現在我們可以在表單本身做些事情。
現在我們來定義一個簡單的註冊表單,其實這個表單和登入的表單幾乎一樣。只是會在後面給它添加一些自訂的驗證器。
# ourapp/forms.py from flask_wtf import Form from wtforms import StringField, PasswordField from wtforms.validators import DataRequired, Email class EmailPasswordForm(Form): email = StringField('Email', validators=[DataRequired(), Email()]) password = PasswordField('Password', validators=[DataRequired()])
现在我们要添加我们的验证器用来确保它们提供的邮箱地址不存在数据库中。我们把这个验证器放在一个新的 util 模块,util.validators。
# ourapp/util/validators.py from wtforms.validators import ValidationError class Unique(object): def __init__(self, model, field, message=u'This element already exists.'): self.model = model self.field = field def __call__(self, form, field): check = self.model.query.filter(self.field == field.data).first() if check: raise ValidationError(self.message)
这个验证器假设我们是使用 SQLAlchemy 来定义我们的模型。WTForms 期待验证器返回某种可调用的对象(例如,一个可调用的类)。
在 Unique() 的 \_\_init\_\_ 中我们可以指定哪些参数传入到验证器中,在本例中我们要传入相关的模型(例如,在我们例子中是传入 User 模型)以及要检查的字段。当验证器被调用的时候,如果定义模型的任何实例匹配表单中提交的值,它将会抛出一个 ValidationError。我们也可以添加一个具有通用默认值的消息,它将会被包含在 ValidationError 中。
现在我们可以修改 EmailPasswordForm,使用我们自定义的 Unique 验证器。
# ourapp/forms.py from flask_wtf import Form from wtforms import StringField, PasswordField from wtforms.validators import DataRequired from .util.validators import Unique from .models import User class EmailPasswordForm(Form): email = StringField('Email', validators=[DataRequired(), Email(), Unique( User, User.email, message='There is already an account with that email.')]) password = PasswordField('Password', validators=[DataRequired()])
渲染表单
WTForms 也能帮助我们为表单渲染成 HTML 表示。WTForms 实现的 Field 字段能够渲染成该字段的 HTML 表示,所以为了渲染它们,我们只必须在我们模板中调用表单的字段。这就像渲染 csrf_token 字段。下面给出了一个登录模板的示例,在里面我们使用 WTForms 来渲染我们的字段。
{# ourapp/templates/login.html #} {% extends "layout.html" %} <html> <head> <title>Login Page</title> </head> <body> <form action="" method="post"> {{ form.email }} {{ form.password }} {{ form.csrf_token }} </form> </body> </html>
我们可以自定义如何渲染字段,通过传入字段的属性作为参数到调用中。
<form action="" method="post"> {{ form.email.label }}: {{ form.email(placeholder='yourname@email.com') }} <br> {% raw %}{{ form.password.label }}: {{ form.password }}{% endraw %} <br> {% raw %}{{ form.csrf_token }}{% endraw %} </form>
处理 OpenID 登录
现实生活中,我们发现有很多人都不知道他们拥有一些公共账号。一部分大牌的网站或服务商都会为他们的会员提供公共账号的认证。举个栗子,如果你有一个 google 账号,其实你就有了一个公共账号,类似的还有 Yahoo, AOL, Flickr 等。
为了方便我们的用户能简单的使用他们的公共账号,我们将把这些公共账号的链接添加到一个列表,这样用户就不用自手工输入了。
我们要把一些提供给用户的公共账号服务商定义到一个列表里面,这个列表就放到配置文件中吧 (fileconfig.py):
CSRF_ENABLED = True SECRET_KEY = 'you-will-never-guess' OPENID_PROVIDERS = [ { 'name': 'Google', 'url': 'https://www.google.com/accounts/o8/id' }, { 'name': 'Yahoo', 'url': 'https://me.yahoo.com' }, { 'name': 'AOL', 'url': 'http://openid.aol.com/<username>' }, { 'name': 'Flickr', 'url': 'http://www.flickr.com/<username>' }, { 'name': 'MyOpenID', 'url': 'https://www.myopenid.com' }]
接下来就是要在我们的登录视图函数中使用这个列表了:
@app.route('/login', methods = ['GET', 'POST']) def login(): form = LoginForm() if form.validate_on_submit(): flash('Login requested for OpenID="' + form.openid.data + '", remember_me=' + str(form.remember_me.data)) return redirect('/index') return render_template('login.html', title = 'Sign In', form = form, providers = app.config['OPENID_PROVIDERS'])
我们从 app.config 中引入了公共账号服务商的配置列表,然后把它作为一个参数通过 render_template 函数引入到模板。
接下来要做的我想你也猜得到,我们需要在登录模板中把这些服务商链接显示出来。
<!-- extend base layout --> {% extends "base.html" %} {% block content %} <script type="text/javascript"> function set_openid(openid, pr) { u = openid.search('<username>') if (u != -1) { // openid requires username user = prompt('Enter your ' + pr + ' username:') openid = openid.substr(0, u) + user } form = document.forms['login']; form.elements['openid'].value = openid } </script> <h1>Sign In</h1> <form action="" method="post" name="login"> {{form.hidden_tag()}} <p> Please enter your OpenID, or select one of the providers below:<br> {{form.openid(size=80)}} {% for error in form.errors.openid %} <span style="color: red;">[{{error}}]</span> {% endfor %}<br> |{% for pr in providers %} <a href="javascript:set_openid('{{pr.url}}', '{{pr.name}}');">{{pr.name}}</a> | {% endfor %} </p> <p>{{form.remember_me}} Remember Me</p> <p><input type="submit" value="Sign In"></p> </form> {% endblock %}
这次的模板添加的东西似乎有点多。一些公共账号需要提供用户名,为了解决这个我们用了点 javascript。当用户点击相关的公共账号链接时,需要用户名的公共账号会提示用户输入用户名, javascript 会把用户名处理成可用的公共账号,最后再插入到 openid 字段的文本框中。
下面这个是在登录页面点击 google 链接后显示的截图: