Sanic-WTF - Sanic meets WTForms

Sanic-WTF makes using WTForms with Sanic and CSRF (Cross-Site Request Forgery) protection a little bit easier.

Quick Start

Installation

pip install --upgrade Sanic-WTF

How to use it

Intialization (of Sanic)

from sanic import Sanic

app = Sanic(__name__)

# either WTF_CSRF_SECRET_KEY or SECRET_KEY should be set
app.config['WTF_CSRF_SECRET_KEY'] = 'top secret!'

@app.middleware('request')
async def add_session_to_request(request):
    # setup session

Defining Forms

from sanic_wtf import SanicForm
from wtforms.fields import PasswordField, StringField, SubmitField
from wtforms.validators import DataRequired

class LoginForm(SanicForm):
    name = StringField('Name', validators=[DataRequired()])
    password = PasswordField('Password', validators=[DataRequired()])
    submit = SubmitField('Sign In')

That’s it, just subclass SanicForm and later on passing in the current request object when you instantiate the form class. Sanic-WTF will do the trick.

Form Validation

from sanic import response

@app.route('/', methods=['GET', 'POST'])
async def index(request):
    form = LoginForm(request)
    if request.method == 'POST' and form.validate():
        name = form.name.data
        password = form.password.data
        # check user password, log in user, etc.
        return response.redirect('/profile')
    # here, render_template is a function that render template with context
    return response.html(await render_template('index.html', form=form))

Note

For WTForms users: please note that SanicForm requires the whole request object instead of some sort of MultiDict.

For more details, please see documentation.

License

BSD New, see LICENSE for details.

Prerequisites

To enable CSRF protection, a session is required, Sanic-WTF expects request.ctx.session is available in this case. For a simple client side only, cookie-based session, similar to Flask’s built-in session, you might want to try Sanic-CookieSession.

Configuration

Option

Description

WTF_CSRF_ENABLED

If True, CSRF protection is enabled. Default is True

WTF_CSRF_FIELD_NAME

The field name used in the form and session to store the CSRF token. Default is csrf_token

WTF_CSRF_SECRET_KEY

bytes used for CSRF token generation. If it is unset, SECRET_KEY will be used instead. Either one of these have to be set to enable CSRF protection.

WTF_CSRF_TIME_LIMIT

How long CSRF tokens are valid for, in seconds. Default is 1800. (Half an hour)

API

Note

For users of versions prior to 0.3.0, there is backward incompatible changes in API. The module-level helper object is not longer required, the new form SanicForm is smart enough to figure out how to get user defined settings.

class sanic_wtf.FileAllowed(extensions, message=None)[source]

Validate that the file (by extention) is one of the listed types

class sanic_wtf.FileRequired(message=None)[source]

Validate that the data is a non-empty sanic.request.File object

class sanic_wtf.SanicForm(*args, **kwargs)[source]

Form with session-based CSRF Protection.

Upon initialization, the form instance will setup CSRF protection with settings fetched from provided Sanic style request object. With no request object provided, CSRF protection will be disabled.

class Meta[source]
validate_on_submit()[source]

Return True if this form is submited and all fields verified

sanic_wtf.file_allowed

alias of FileAllowed

sanic_wtf.file_required

alias of FileRequired

Full Example

Guest Book

from datetime import datetime
from sanic import Sanic, response
from sanic_wtf import SanicForm
from wtforms.fields import SubmitField, TextAreaField
from wtforms.validators import DataRequired, Length


app = Sanic(__name__)
app.config['SECRET_KEY'] = 'top secret !!!'


# NOTE
# session should be setup somewhere, SanicWTF expects request.ctx.session is a
# dict like session object.
# For demonstration purpose, we use a mock-up globally-shared session object.
session = {}
@app.middleware('request')
async def add_session(request):
    request.ctx.session = session


class FeedbackForm(SanicForm):
    note = TextAreaField('Note', validators=[DataRequired(), Length(max=40)])
    submit = SubmitField('Submit')


@app.route('/', methods=['GET', 'POST'])
async def index(request):
    form = FeedbackForm(request)
    if request.method == 'POST' and form.validate():
        note = form.note.data
        msg = '{} - {}'.format(datetime.now(), note)
        session.setdefault('fb', []).append(msg)
        return response.redirect('/')
    # NOTE: trusting user input here, never do that in production
    feedback = ''.join('<p>{}</p>'.format(m) for m in session.get('fb', []))
    content = f"""
    <h1>Form with CSRF Validation</h1>
    <p>Try <a href="/fail">form</a> that fails CSRF validation</p>
    {feedback}
    <form action="" method="POST">
      {'<br>'.join(form.csrf_token.errors)}
      {form.csrf_token}
      {'<br>'.join(form.note.errors)}
      <br>
      {form.note(size=40, placeholder="say something..")}
      {form.submit}
    </form>
    """
    return response.html(content)


@app.route('/fail', methods=['GET', 'POST'])
async def fail(request):
    form = FeedbackForm(request)
    if request.method == 'POST' and form.validate():
        note = form.note.data
        msg = '{} - {}'.format(datetime.now(), note)
        session.setdefault('fb', []).append(msg)
        return response.redirect('/fail')
    feedback = ''.join('<p>{}</p>'.format(m) for m in session.get('fb', []))
    content = f"""
    <h1>Form which fails CSRF Validation</h1>
    <p>This is the same as this <a href="/">form</a> except that CSRF
    validation always fail because we did not render the hidden csrf token</p>
    {feedback}
    <form action="" method="POST">
      {'<br>'.join(form.csrf_token.errors)}
      {'<br>'.join(form.note.errors)}
      <br>
      {form.note(size=40, placeholder="say something..")}
      {form.submit}
    </form>
    """
    return response.html(content)


if __name__ == '__main__':
    app.run(host='127.0.0.1', port=8000, debug=True)

File Upload

from pathlib import Path
from sanic import Sanic, response
from sanic_wtf import FileAllowed, FileRequired, SanicForm
from wtforms import FileField, SubmitField, StringField
from wtforms.validators import Length


app = Sanic(__name__)
app.config['SECRET_KEY'] = 'top secret !!!'
app.config['UPLOAD_DIR'] = './uploaded.tmp'


# NOTE
# session should be setup somewhere, SanicWTF expects request.ctx.session is a
# dict like session object.
# For demonstration purpose, we use a mock-up globally-shared session object.
session = {}


@app.middleware('request')
async def add_session(request):
    request.ctx.session = session


class UploadForm(SanicForm):
    image = FileField('Image', validators=[
        FileRequired(), FileAllowed('bmp gif jpg jpeg png'.split())])
    description = StringField('Description', validators=[Length(max=20)])
    submit = SubmitField('Upload')


app.static('/img', app.config.UPLOAD_DIR)


@app.listener('after_server_start')
async def make_upload_dir(app, loop):
    Path(app.config.UPLOAD_DIR).mkdir(parents=True, exist_ok=True)


@app.route('/', methods=['GET', 'POST'])
async def index(request):
    form = UploadForm(request)
    if form.validate_on_submit():
        image = form.image.data
        # NOTE: trusting user submitted file names here, the name should be
        # sanitized in production.
        uploaded_file = Path(request.app.config.UPLOAD_DIR) / image.name
        uploaded_file.write_bytes(image.body)
        description = form.description.data or 'no description'
        session.setdefault('files', []).append((image.name, description))
        return response.redirect('/')
    img = '<section><img src="/img/{}"><p>{}</p><hr></section>'
    images = ''.join(img.format(i, d) for i, d in session.get('files', []))
    content = f"""
    <h1>Sanic-WTF file field validators example</h1>
    {images}
    <form action="" method="POST" enctype="multipart/form-data">
      {'<br>'.join(form.csrf_token.errors)}
      {form.csrf_token}
      {'<br>'.join(form.image.errors)}
      {'<br>'.join(form.description.errors)}
      <br> {form.image.label}
      <br> {form.image}
      <br> {form.description.label}
      <br> {form.description(size=20, placeholder="description")}
      <br> {form.submit}
    </form>
    """
    return response.html(content)


if __name__ == '__main__':
    app.run(host='127.0.0.1', port=8000, debug=True)

Changelog

  • 0.7.0

    backward incompatible upgrade

    Upgraded to Sanic 21.3.0 and WTForms 3.0.1 Minimum python version 3.7

  • 0.6.0

    backward incompatible upgrade

    Supporting python 3.6, 2.7, 3.8, 3.9, and Sanic 20.3.0

  • 0.5.0

    Added file upload support and filefield validators FileAllowed and FileRequired.

  • 0.4.0

    backward incompatible upgrade

    Removed property hidden_tag

  • 0.3.0

    backward incompatible upgrade

    Re-designed the API to fixed #6 - possible race condition. The new API is much simplified, easier to use, while still getting things done, and more.

    Added new setting: WTF_CSRF_TIME_LIMIT Added new method validate_on_submit() in the style of Flask-WTF.

  • 0.2.0

    Made SanicWTF.Form always available so that one can create the form classes before calling SanicWTF.init_app()

  • 0.1.0

    First public release.

Indices and tables