Skip to content

Login and Registration

A login form and a registration form, with the checks that only your app can make (is the password right? is the username free?) shown in the form like any other error.

models.py
from typing import Literal

from pydantic import EmailStr, ValidationInfo, field_validator

from pydantic_schemaforms import Field, FormModel


class LoginForm(FormModel):
    username: str = Field(title='Username', ui_autofocus=True)
    password: str = Field(title='Password', ui_element='password')
    remember_me: bool = Field(False, title='Keep me signed in')


class RegistrationForm(FormModel):
    username: str = Field(
        title='Username',
        min_length=3,
        max_length=30,
        pattern=r'^[a-z0-9_]+$',
        description='Lowercase letters, numbers and underscores.',
    )
    email: EmailStr = Field(title='Email')
    password: str = Field(title='Password', ui_element='password', min_length=12)
    confirm_password: str = Field(title='Confirm password', ui_element='password')
    accept_terms: Literal[True] = Field(
        title='I accept the terms of service',
        unchecked_message='You must accept the terms to create an account',
    )

    @field_validator('username', mode='before')
    @classmethod
    def normalize_username(cls, value: str) -> str:
        return value.strip().lower()

    @field_validator('confirm_password')
    @classmethod
    def passwords_match(cls, value: str, info: ValidationInfo) -> str:
        if value != info.data.get('password'):
            raise ValueError('Passwords do not match')
        return value
main.py
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from models import LoginForm, RegistrationForm

from pydantic_schemaforms import render_form_html_async

app = FastAPI()
ASSETS = {'include_framework_assets': True, 'asset_mode': 'cdn'}


def authenticate(username: str, password: str) -> bool:
    return False  # look the user up and verify the password hash


def username_taken(username: str) -> bool:
    return username == 'admin'


def create_user(data: dict) -> None: ...  # hash the password, then store the user


@app.get('/login', response_class=HTMLResponse)
async def show_login():
    return await render_form_html_async(LoginForm, submit_url='/login', **ASSETS)


@app.post('/login', response_class=HTMLResponse)
async def login(request: Request):
    result = LoginForm.validate(await request.form(), submit_url='/login', **ASSETS)
    if not result.is_valid:
        return await result.render_with_errors_async()
    if not authenticate(result.data['username'], result.data['password']):
        return await render_form_html_async(
            LoginForm,
            form_data={'username': result.data['username']},
            errors={'_errors': 'Wrong username or password'},
            submit_url='/login',
            **ASSETS,
        )
    return RedirectResponse('/', status_code=303)


@app.get('/register', response_class=HTMLResponse)
async def show_register():
    return await render_form_html_async(RegistrationForm, submit_url='/register', **ASSETS)


@app.post('/register', response_class=HTMLResponse)
async def register(request: Request):
    form = await request.form()
    result = RegistrationForm.validate(form, submit_url='/register', **ASSETS)
    if not result.is_valid:
        return await result.render_with_errors_async()
    if username_taken(result.data['username']):
        return await render_form_html_async(
            RegistrationForm,
            form_data=dict(form),
            errors={'username': 'That username is taken'},
            submit_url='/register',
            **ASSETS,
        )
    create_user(result.data)
    return RedirectResponse('/login', status_code=303)
main.py
from flask import Flask, redirect, request
from models import LoginForm, RegistrationForm

from pydantic_schemaforms import render_form_html

app = Flask(__name__)
ASSETS = {'include_framework_assets': True, 'asset_mode': 'cdn'}


def authenticate(username: str, password: str) -> bool:
    return False  # look the user up and verify the password hash


def username_taken(username: str) -> bool:
    return username == 'admin'


def create_user(data: dict) -> None: ...  # hash the password, then store the user


@app.get('/login')
def show_login():
    return render_form_html(LoginForm, submit_url='/login', **ASSETS)


@app.post('/login')
def login():
    result = LoginForm.validate(request.form, submit_url='/login', **ASSETS)
    if not result.is_valid:
        return result.render_with_errors()
    if not authenticate(result.data['username'], result.data['password']):
        return render_form_html(
            LoginForm,
            form_data={'username': result.data['username']},
            errors={'_errors': 'Wrong username or password'},
            submit_url='/login',
            **ASSETS,
        )
    return redirect('/', code=303)


@app.get('/register')
def show_register():
    return render_form_html(RegistrationForm, submit_url='/register', **ASSETS)


@app.post('/register')
def register():
    result = RegistrationForm.validate(request.form, submit_url='/register', **ASSETS)
    if not result.is_valid:
        return result.render_with_errors()
    if username_taken(result.data['username']):
        return render_form_html(
            RegistrationForm,
            form_data=request.form.to_dict(),
            errors={'username': 'That username is taken'},
            submit_url='/register',
            **ASSETS,
        )
    create_user(result.data)
    return redirect('/login', code=303)

How it works

  • The confirmation must match. passwords_match reads the password from info.data and puts its error under the confirmation field.
  • The terms box must be ticked. Literal[True] accepts only a ticked box, and unchecked_message is the error shown when it's left unticked.
  • The username is cleaned up first. mode="before" lowercases and trims it before pattern checks it.
  • Errors from your own checks go through render_form_html()'s errors=: {"username": "..."} shows under that field, and "_errors" shows a message for the whole form in the summary at the top. Pass form_data= so the user's input stays filled in. The login form only puts back the username, never the password.
  • After success, redirect (303) so reloading the page doesn't send the form again.

Before going live

Store password hashes, never passwords (use a library such as argon2-cffi or bcrypt). Protect both forms with a CSRF token once users are logged in; see CSRF Protection.