Skip to content

Login/Logout (Sessions)

The session (authentication) API will need to provide routes to login and logout i.e. to create and delete sessions. Login should result in a cookie being set, and logout result in the cookie being deleted. As per the authentication setup, login should require an email and matching password.

Creating the blueprint

The blueprint itself can be created with the following code in backend/src/backend/blueprints/sessions.py,

from quart import Blueprint

blueprint = Blueprint("sessions", __name__)

and activated by adding the following to backend/src/backend/run.py,

from backend.blueprints.sessions import blueprint as sessions_blueprint

def create_app() -> Quart:
    ...
    app.register_blueprint(sessions_blueprint)

Login

For a RESTful API the session login (creation) route should be POST, expecting an email, password and remember flag returning 200 on success and 401 on invalid credentials. We'll need to rate limit the route to prevent malicious actors brute forcing the login and ensure that username enumeration is not possible. The following should be added to backend/src/backend/blueprints/sessions.py,

from dataclasses import dataclass
from datetime import timedelta

import bcrypt
from quart import current_app, ResponseReturnValue
from quart_auth import AuthUser, login_user
from quart_rate_limiter import rate_limit
from quart_schema import validate_request

from backend.lib.api_error import APIError
from backend.models.member import select_member_by_email

# Random characters valid bcrypt hash, for reference testing
REFERENCE_HASH = "$2b$12$VD7REWo6sjWiTF4T0QBOYumC0UAf/YIXZFvjkJNSixN7GBMmwC5rS"

@dataclass
class LoginData:
    email: str
    password: str
    remember: bool = False

@blueprint.post("/sessions/")
@rate_limit(5, timedelta(minutes=1))
@validate_request(LoginData)
async def login(data: LoginData) -> ResponseReturnValue:
    """Login to the app.

    By providing credentials and then saving the returned cookie.
    """
    result = await select_member_by_email(current_app.db, data.email)
    password_hash = REFERENCE_HASH if result is None else result.password_hash
    passwords_match = bcrypt.checkpw(
        data.password.encode("utf-8"), password_hash.encode("utf-8")
    )
    if result is not None and passwords_match:
        login_user(AuthUser(str(result.id)), data.remember)
        return {}, 200
    else:
        raise APIError(401, "INVALID_CREDENTIALS")

A key part is that checkpw is called even if there is no member with the given email. This is to mitigate malicious clients from attempting to enumerate the member's emails.

Logout

For a RESTful API the session logout (deletion) route should be DELETE, returning 200. The following should be merged (removing duplicated imports) to backend/src/backend/blueprints/sessions.py,

from quart_auth import logout_user
from quart_rate_limiter import rate_exempt

@blueprint.delete("/sessions/")
@rate_exempt
async def logout() -> ResponseReturnValue:
    """Logout from the app.

    Deletes the session cookie.
    """
    logout_user()
    return {}

Status

It is useful to have a route that returns the current session (status). We'll use it for debugging and testing but it is likely to serve more purposes in your app.

For a RESTful API this is should be a GET route,

from quart_auth import current_user, login_required
from quart_schema import validate_response

@dataclass
class Status:
    member_id: int

@blueprint.get("/sessions/")
@rate_limit(10, timedelta(minutes=1))
@login_required
@validate_response(Status)
async def status() -> ResponseReturnValue:
    return Status(member_id=int(current_user.auth_id))

Testing

We should test that these routes work as a user would expect, starting with the login, get status, and then logout flow by adding the following to backend/tests/blueprints/test_sessions.py.

import pytest
from quart import Quart

@pytest.mark.asyncio
async def test_session_flow(app: Quart) -> None:
    test_client = app.test_client()
    await test_client.post(
        "/sessions/",
        json={"email": "test@tozo.invalid", "password": "password"},
    )
    response = await test_client.get("/sessions/")
    assert (await response.get_json())["memberId"] == 1
    await test_client.delete("/sessions/")
    response = await test_client.get("/sessions/")
    assert response.status_code == 401

In addition lets ensure that the crucial checkpw is called even for emails that aren't registered by adding,

from unittest.mock import Mock

import bcrypt
from _pytest.monkeypatch import MonkeyPatch

@pytest.mark.asyncio
async def test_checkpw_called(app: Quart, monkeypatch: MonkeyPatch) -> None:
    checkpw_mock = Mock()
    monkeypatch.setattr(bcrypt, "checkpw", checkpw_mock)
    test_client = app.test_client()
    await test_client.post(
        "/sessions/",
        json={"email": "not-registered@tozo.invalid", "password": "password"},
    )
    checkpw_mock.assert_called()