Skip to content

Commit

Permalink
added session_signup (#141)
Browse files Browse the repository at this point in the history
* added session_signup

* returned accidentally changed lines

* small docstring fix

* comment formatting

* decoupled signup from session create

* cleaning up signup template

* created separate register package

* fix mypy error on new starlette version

* added docs about rate limiting for registration endpoint

* added `user_defaults` arg to `register` endpoint

Co-authored-by: Daniel Townsend <dan@dantownsend.co.uk>
  • Loading branch information
sinisaos and dantownsend committed Jun 3, 2022
1 parent 7693a97 commit 7e24d16
Show file tree
Hide file tree
Showing 15 changed files with 507 additions and 36 deletions.
1 change: 1 addition & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ ASGI app, covering authentication, security, and more.
./session_auth/index
./token_auth/index
./advanced_auth/index
./register/index

.. toctree::
:caption: Contributing
Expand Down
97 changes: 97 additions & 0 deletions docs/source/register/endpoints.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
Endpoints
=========

register
--------

An endpoint for registering a user. If you send a GET request to this endpoint,
a simple registration form is rendered, where a user can manually sign up.

.. image:: images/register_template.png

.. hint::
You can use a custom template, which matches the look and feel of your
application. See the ``template_path`` parameter.

Alternatively, you can register a user programatically by sending a POST
request to this endpoint (passing in ``username``, ``email``, ``password`` and
``confirm_password`` parameters as JSON, or as form data).

When registration is successful, the user can be redirected to a login endpoint.
The destination can be configured using the ``redirect_to`` parameter.

Examples
~~~~~~~~

Here's a Starlette example:

.. code-block:: python
from piccolo_api.session_auth.endpoints import register
from starlette import Starlette
app = Starlette()
app.mount('/register/', register(redirect_to="/login/"))
Here's a FastAPI example:

.. code-block:: python
from piccolo_api.session_auth.endpoints import register
from fastapi import FastAPI
app = FastAPI()
app.mount('/register/', register(redirect_to="/login/"))
Security
~~~~~~~~

The endpoint is fairly simple, and works well for building a quick prototype,
or internal application. If it's being used on the public internet, then extra
precautions are required.

As a minimum, rate limiting should be applied to this endpoint. This can be
done using :class:`RateLimitingMiddleware <piccolo_api.rate_limiting.middleware.RateLimitingMiddleware>`.
Modifying the FastAPI example above:

.. code-block:: python
from piccolo_api.rate_limiting.middleware import (
RateLimitingMiddleware, InMemoryLimitProvider
)
from piccolo_api.session_auth.endpoints import register
from fastapi import FastAPI
app = FastAPI()
app.mount(
'/register/',
RateLimitingMiddleware(
register(redirect_to="/login/"),
InMemoryLimitProvider(
timespan=3600, # 1 hour
limit=20,
block_duration=86400 # 24 hours
)
)
)
We have used quite aggressive rate limiting here - there is no reason for
a user to visit a registration page a large number of times.

Building your own
~~~~~~~~~~~~~~~~~

There is no one-size-fits-all registration solution. You can use this endpoint
as a basis for your own solution, which fits the needs of your
application. For example, you can add extra registration fields, and a
`CAPTCHA <https://en.wikipedia.org/wiki/ReCAPTCHA>`_.

Source
~~~~~~

.. currentmodule:: piccolo_api.register.endpoints

.. autofunction:: register
Binary file added docs/source/register/images/register_template.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions docs/source/register/index.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Register
========

An important part of any application is being able to register new users.

.. toctree::
:maxdepth: 1

./endpoints
2 changes: 1 addition & 1 deletion docs/source/session_auth/endpoints.rst
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
Endpoints
=========

Endpoints are provided for session login and logout. They are designed to
Endpoints are provided for session register, login and logout. They are designed to
integrate with an ASGI app, such as Starlette or FastAPI.

-------------------------------------------------------------------------------
Expand Down
6 changes: 6 additions & 0 deletions piccolo_api/rate_limiting/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,12 @@ def __init__(
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
if not request.client:
# If we can't get the client, we have to reject the request.
return Response(
content="Client host can't be found.", status_code=400
)

identifier = request.client.host
try:
self.rate_limit.increment(identifier)
Expand Down
Empty file.
206 changes: 206 additions & 0 deletions piccolo_api/register/endpoints.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
from __future__ import annotations

import os
import re
import typing as t
from abc import ABCMeta, abstractproperty
from json import JSONDecodeError

from jinja2 import Environment, FileSystemLoader
from piccolo.apps.user.tables import BaseUser
from starlette.datastructures import URL
from starlette.endpoints import HTTPEndpoint, Request
from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse, RedirectResponse
from starlette.status import HTTP_303_SEE_OTHER

if t.TYPE_CHECKING: # pragma: no cover
from jinja2 import Template
from starlette.responses import Response


SIGNUP_TEMPLATE_PATH = os.path.join(
os.path.dirname(os.path.dirname(__file__)), "templates", "register.html"
)


EMAIL_REGEX = re.compile(r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)")


class RegisterEndpoint(HTTPEndpoint, metaclass=ABCMeta):
@abstractproperty
def _auth_table(self) -> t.Type[BaseUser]:
raise NotImplementedError

@abstractproperty
def _redirect_to(self) -> t.Union[str, URL]:
"""
Where to redirect to after login is successful.
"""
raise NotImplementedError

@abstractproperty
def _register_template(self) -> Template:
raise NotImplementedError

@abstractproperty
def _user_defaults(self) -> t.Optional[t.Dict[str, t.Any]]:
raise NotImplementedError

def render_template(
self, request: Request, template_context: t.Dict[str, t.Any] = {}
) -> HTMLResponse:
# If CSRF middleware is present, we have to include a form field with
# the CSRF token. It only works if CSRFMiddleware has
# allow_form_param=True, otherwise it only looks for the token in the
# header.
csrftoken = request.scope.get("csrftoken")
csrf_cookie_name = request.scope.get("csrf_cookie_name")

return HTMLResponse(
self._register_template.render(
csrftoken=csrftoken,
csrf_cookie_name=csrf_cookie_name,
request=request,
**template_context,
)
)

async def get(self, request: Request) -> HTMLResponse:
return self.render_template(request)

async def post(self, request: Request) -> Response:
# Some middleware (for example CSRF) has already awaited the request
# body, and adds it to the request.
body = request.scope.get("form")

if not body:
try:
body = await request.json()
except JSONDecodeError:
body = await request.form()

username = body.get("username", None)
email = body.get("email", None)
password = body.get("password", None)
confirm_password = body.get("confirm_password", None)

if (
(not username)
or (not email)
or (not password)
or (not confirm_password)
):
raise HTTPException(
status_code=401,
detail="Form is invalid. Missing one or more fields.",
)

if not EMAIL_REGEX.fullmatch(email):
if body.get("format") == "html":
return self.render_template(
request,
template_context={"error": "Invalid email address."},
)
else:
raise HTTPException(
status_code=401, detail="Invalid email address."
)

if len(password) < 6:
if body.get("format") == "html":
return self.render_template(
request,
template_context={
"error": "Password must be at least 6 characters long."
},
)
else:
raise HTTPException(
status_code=401,
detail="Password must be at least 6 characters long.",
)

if confirm_password != password:
if body.get("format") == "html":
return self.render_template(
request,
template_context={"error": "Passwords do not match."},
)
else:
raise HTTPException(
status_code=401, detail="Passwords do not match."
)

if await self._auth_table.count().where(
self._auth_table.email == email,
self._auth_table.username == username,
):
if body.get("format") == "html":
return self.render_template(
request,
template_context={
"error": "User with email or username already exists."
},
)
else:
raise HTTPException(
status_code=401,
detail="User with email or username already exists.",
)

extra_params = self._user_defaults or {}

await self._auth_table.create_user(
username=username, password=password, email=email, **extra_params
)

return RedirectResponse(
url=self._redirect_to, status_code=HTTP_303_SEE_OTHER
)


def register(
auth_table: t.Optional[t.Type[BaseUser]] = None,
redirect_to: t.Union[str, URL] = "/login/",
template_path: t.Optional[str] = None,
user_defaults: t.Optional[t.Dict[str, t.Any]] = None,
) -> t.Type[RegisterEndpoint]:
"""
An endpoint for register user.
:param auth_table:
Which ``Table`` to create the user in. If not specified, it defaults to
:class:`BaseUser <piccolo.apps.user.tables.BaseUser>`.
:param redirect_to:
Where to redirect to after successful registration.
:param template_path:
If you want to override the default register HTML template, you can do
so by specifying the absolute path to a custom template. For example
``'/some_directory/register.html'``. Refer to the default template at
``piccolo_api/templates/register.html`` as a basis for
your custom template.
:param user_defaults:
These values are assigned to the new user. An example use case is
setting ``active = True`` on each new user, so they can immediately
login (not recommended for production, as it's better to verify their
email address first, but OK for a prototype app)::
register(user_defaults={'active': True})
"""
template_path = (
SIGNUP_TEMPLATE_PATH if template_path is None else template_path
)

directory, filename = os.path.split(template_path)
environment = Environment(loader=FileSystemLoader(directory))
register_template = environment.get_template(filename)

class _RegisterEndpoint(RegisterEndpoint):
_auth_table = auth_table or BaseUser
_redirect_to = redirect_to
_register_template = register_template
_user_defaults = user_defaults

return _RegisterEndpoint

0 comments on commit 7e24d16

Please sign in to comment.