-
Notifications
You must be signed in to change notification settings - Fork 25
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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
1 parent
7693a97
commit 7e24d16
Showing
15 changed files
with
507 additions
and
36 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.