양식은 사용자가 웹 애플리케이션과 상호 작용할 수 있게 해주는 기본 요소입니다. Flask 자체는 양식에 도움이 되지 않지만 Flask-WTF 확장을 사용하면 Flask 애플리케이션에서 널리 사용되는 WTForms 패키지를 사용할 수 있습니다. 이 패키지를 사용하면 양식을 정의하고 제출물을 더 쉽게 처리할 수 있습니다.
플라스크-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 필드 및 유효성 검사기에 대한 자체 패키징을 제공했습니다. wtforms 대신 플라스크.ext.wtforms에서 TextField 및 PasswordField를 가져오는 코드가 외부에 많이 표시될 수 있습니다.
Flask-WTF 버전 0.9 이후에는 이러한 필드와 유효성 검사기를 wtforms에서 직접 가져와야 합니다.
우리가 정의하는 양식은 사용자 로그인 양식입니다. 이를 EmailPasswordForm()이라고 부르며, 이 동일한 양식 클래스(양식)를 재사용하여 등록 양식과 같은 다른 작업을 수행할 수 있습니다. 여기서는 길고 쓸모없는 형식을 정의하지 않고 매우 일반적으로 사용되는 형식을 선택하여 Flask-WTF를 사용하여 형식을 정의하는 방법을 소개합니다. 어쩌면 특히 복잡한 형태가 미래의 공식 프로젝트에서 정의될 수도 있습니다. 필드 이름이 포함된 양식의 경우 양식 내에서 명확하고 고유한 이름을 사용하는 것이 좋습니다. 긴 형식의 경우 위와 더 일치하는 필드 이름을 제공해야 할 수도 있습니다.
로그인 양식을 사용하면 몇 가지 작업을 수행할 수 있습니다. CSRF 취약성으로부터 애플리케이션을 보호하고, 사용자 입력의 유효성을 검사하며, 양식에 대해 정의한 필드에 대한 적절한 마크업을 렌더링합니다.
CSRF 보호 및 검증
CSRF는 Cross-Site Request Forgery의 약자입니다. CSRF 공격은 제3자가 애플리케이션 서버에 대한 요청(예: 양식 제출)을 위조할 때 발생합니다. 취약한 서버는 양식의 데이터가 자체 웹사이트에서 온 것으로 가정하고 그에 따라 행동합니다.
예를 들어 이메일 제공업체에서 양식을 제출하여 계정을 삭제할 수 있다고 가정해 보겠습니다. 양식은 서버의 account_delete 끝점에 POST 요청을 보내고 양식이 제출되면 로그인된 계정을 삭제합니다. 동일한 account_delete 엔드포인트에 POST 요청을 보내는 양식을 웹사이트에서 생성할 수 있습니다. 이제 누군가가 양식에서 제출 버튼을 클릭하도록 하면(또는 JavaScript를 통해 클릭하면) 이메일 공급자가 제공한 로그인이 제거됩니다. 물론 이메일 제공업체는 웹사이트에서 양식 제출이 이루어지지 않는다는 사실을 아직 모릅니다.
그렇다면 다른 웹사이트에서 POST 요청이 들어오는 것을 어떻게 방지할 수 있나요? WTForms는 각 양식을 렌더링할 때 고유한 토큰을 생성함으로써 이를 가능하게 합니다. 생성된 토큰은 POST 요청의 데이터와 함께 서버로 다시 전달되며 양식을 수락하기 전에 서버에서 토큰의 유효성을 검사해야 합니다. 중요한 점은 토큰이 사용자 세션(쿠키)에 저장된 값과 연결되어 있으며 특정 시간(기본값은 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에서 제공하는 내장 양식 유효성 검사기(예: 필수(), 이메일() 등) 외에도 자체 유효성 검사기를 만들 수 있습니다. 데이터베이스를 확인하고 사용자가 제공한 값이 데이터베이스에 존재하지 않는지 확인하는 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 链接后显示的截图: