├── demo ├── __init__.py ├── apps.py ├── templates │ ├── index.html │ ├── visitors │ │ ├── self_service_success.html │ │ └── self_service_request.html │ └── bar.html ├── signals.py ├── urls.py ├── views.py └── settings.py ├── visitors ├── __init__.py ├── migrations │ ├── __init__.py │ ├── 0006_alter_visitor_uuid.py │ ├── 0005_visitorlog_status_code.py │ ├── 0003_visitor_is_active.py │ ├── 0007_visitor_session_expiry.py │ ├── 0004_visitor_expires_at.py │ ├── 0002_visitorlog.py │ └── 0001_initial.py ├── apps.py ├── signals.py ├── exceptions.py ├── templates │ └── visitors │ │ ├── self_service_request.html │ │ └── self_service_success.html ├── urls.py ├── forms.py ├── session.py ├── settings.py ├── admin.py ├── middleware.py ├── decorators.py ├── views.py └── models.py ├── pytest.ini ├── poetry.toml ├── .coveragerc ├── .gitignore ├── tests ├── __init__.py ├── urls.py ├── views.py ├── conftest.py ├── test_forms.py ├── settings.py ├── test_models.py ├── test_views.py ├── test_decorators.py └── test_middleware.py ├── .editorconfig ├── manage.py ├── .prettierrc ├── mypy.ini ├── .pre-commit-config.yaml ├── LICENSE ├── CHANGELOG.md ├── tox.ini ├── pyproject.toml ├── .github └── workflows │ └── tox.yml ├── .ruff.toml └── README.md /demo/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /visitors/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /visitors/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = tests.settings 3 | -------------------------------------------------------------------------------- /poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | create = true 3 | in-project = true 4 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = true 3 | omit = 4 | tests/* 5 | */migrations/*.py 6 | .venv/* 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | .tox 3 | .venv 4 | .vscode 5 | *.bak 6 | *.egg-info 7 | *.pyc 8 | demo.db 9 | dist 10 | htmlcov 11 | node_modules 12 | poetry.lock 13 | test.db 14 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # The package contains the tests for the packaged app, along 2 | # with a standalone `test_app` that contains a barebones Django 3 | # app that can be used for testing the local webserver. 4 | -------------------------------------------------------------------------------- /visitors/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class VisitorsConfig(AppConfig): 5 | name = "visitors" 6 | verbose_name = "Visitor passes" 7 | default_auto_field = "django.db.models.AutoField" 8 | -------------------------------------------------------------------------------- /visitors/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | # sent when a user creates their own Visitor - can 4 | # be used to send the email with the token 5 | # kwargs: visitor 6 | self_service_visitor_created = Signal() 7 | -------------------------------------------------------------------------------- /demo/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DemoConfig(AppConfig): 5 | name = "demo" 6 | 7 | def ready(self) -> None: 8 | from demo import signals # noqa: F401 9 | 10 | return super().ready() 11 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import include, path 3 | 4 | admin.autodiscover() 5 | 6 | urlpatterns = [ 7 | path("admin/", admin.site.urls), 8 | path("visitors", include("visitors.urls")), 9 | ] 10 | -------------------------------------------------------------------------------- /tests/views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpRequest, HttpResponse 2 | 3 | from visitors.decorators import user_is_visitor 4 | 5 | 6 | @user_is_visitor(scope="foo") 7 | def foo(request: HttpRequest) -> HttpResponse: 8 | return HttpResponse("OK") 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.yaml] 13 | indent_style = space 14 | indent_size = 2 15 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "demo.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /visitors/exceptions.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import PermissionDenied 2 | 3 | 4 | class InvalidVisitorPass(Exception): 5 | pass 6 | 7 | 8 | class VisitorAccessDenied(PermissionDenied): 9 | """Error raised by decorator when visitor is invalid.""" 10 | 11 | def __init__(self, msg: str, scope: str) -> None: 12 | super().__init__(msg) 13 | self.scope = scope 14 | -------------------------------------------------------------------------------- /demo/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | 5 | 6 |9 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 4, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": false, 7 | "trailingComma": "none", 8 | "bracketSpacing": true, 9 | "jsxBracketSameLine": false, 10 | "proseWrap": "always", 11 | "endOfLine": "auto", 12 | "overrides": [ 13 | { 14 | "files": "*.yaml,*.yml", 15 | "options": { "tabWidth": 2 } 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /visitors/templates/visitors/self_service_request.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |7 | If you are seeing this page, you are missing the "visitors/self_service_request.html" 8 | template. You must provide your own template. 9 |
10 |7 | If you are seeing this page, you are missing the "visitors/self_service_success.html" 8 | template. You must provide your own template. 9 |
10 |A new visitor pass has been created for you.
6 |7 | In a production environment you should connect to the `self_service_visitor_created` 8 | signal and send the visitor pass to end user. 9 |
10 |11 | Link: 12 | {{ visitor.context.redirect_to }}?vuid={{ visitor.uuid }} 13 |
14 | 15 | 16 | -------------------------------------------------------------------------------- /visitors/migrations/0007_visitor_session_expiry.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.14 on 2022-07-26 09:16 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("visitors", "0006_alter_visitor_uuid"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="visitor", 14 | name="session_expiry", 15 | field=models.PositiveIntegerField( 16 | default=0, 17 | help_text="Time in seconds after which visitor session should expire.", 18 | ), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /visitors/migrations/0004_visitor_expires_at.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.4 on 2021-01-03 12:37 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("visitors", "0003_visitor_is_active"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="visitor", 14 | name="expires_at", 15 | field=models.DateTimeField( 16 | blank=True, 17 | help_text="After this time the link can no longer be used - defaults to VISITOR_TOKEN_EXPIRY.", 18 | null=True, 19 | ), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /visitors/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.core.exceptions import ValidationError 3 | from django.utils.translation import gettext as _ 4 | 5 | 6 | class SelfServiceForm(forms.Form): 7 | """Form for capturing user info.""" 8 | 9 | first_name = forms.CharField(max_length=150) 10 | last_name = forms.CharField(max_length=150) 11 | email = forms.EmailField(required=True) 12 | vuid = forms.CharField(widget=forms.HiddenInput()) 13 | 14 | def clean_email(self) -> str: 15 | email = self.cleaned_data["email"] 16 | if email.endswith("example.com"): 17 | raise ValidationError( 18 | _("Invalid email - you cannot use the example.com domain") 19 | ) 20 | return email 21 | -------------------------------------------------------------------------------- /visitors/session.py: -------------------------------------------------------------------------------- 1 | from django.http.request import HttpRequest 2 | 3 | from visitors.settings import VISITOR_SESSION_KEY 4 | 5 | 6 | def stash_visitor_uuid(request: HttpRequest) -> None: 7 | """Store request visitor data in session.""" 8 | request.session[VISITOR_SESSION_KEY] = request.visitor.session_data 9 | if request.user.is_anonymous: 10 | request.session.set_expiry(request.visitor.session_expiry) 11 | 12 | 13 | def get_visitor_uuid(request: HttpRequest) -> str: 14 | """Return visitor data from session.""" 15 | return request.session.get(VISITOR_SESSION_KEY, "") 16 | 17 | 18 | def clear_visitor_uuid(request: HttpRequest) -> None: 19 | """Remove visitor data from session.""" 20 | request.session.pop(VISITOR_SESSION_KEY, "") 21 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | # python code formatting - will amend files 3 | - repo: https://github.com/astral-sh/ruff-pre-commit 4 | rev: "v0.11.13" 5 | hooks: 6 | - id: ruff-format 7 | args: [format] 8 | 9 | - repo: https://github.com/astral-sh/ruff-pre-commit 10 | # Ruff version. 11 | rev: "v0.11.13" 12 | hooks: 13 | - id: ruff 14 | args: [--fix, --exit-non-zero-on-fix] 15 | 16 | # python static type checking 17 | - repo: https://github.com/pre-commit/mirrors-mypy 18 | rev: v1.16.0 19 | hooks: 20 | - id: mypy 21 | args: 22 | - --disallow-untyped-defs 23 | - --disallow-incomplete-defs 24 | - --check-untyped-defs 25 | - --no-implicit-optional 26 | - --ignore-missing-imports 27 | - --follow-imports=silent 28 | -------------------------------------------------------------------------------- /tests/test_forms.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from uuid import uuid4 4 | 5 | import pytest 6 | 7 | from visitors.forms import SelfServiceForm 8 | 9 | 10 | class TestSelfServiceForm: 11 | @pytest.mark.parametrize( 12 | "first_name,last_name,email,vuid,is_valid", 13 | [ 14 | ("", "", "", None, False), 15 | ("John", "", "", None, False), 16 | ("John", "Doe", "", None, False), 17 | ("John", "Doe", "john@doe.com", None, False), 18 | ("John", "Doe", "john@doe.com", uuid4(), True), 19 | ("John", "Doe", "john.doe@example.com", uuid4(), False), 20 | ], 21 | ) 22 | def test_form(self, first_name, last_name, email, vuid, is_valid): 23 | form = SelfServiceForm( 24 | { 25 | "first_name": first_name, 26 | "last_name": last_name, 27 | "email": email, 28 | "vuid": vuid, 29 | } 30 | ) 31 | assert form.is_valid() == is_valid 32 | -------------------------------------------------------------------------------- /demo/views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpRequest, HttpResponse 2 | from django.http.response import HttpResponseRedirect 3 | from django.shortcuts import render 4 | from django.urls.base import reverse 5 | 6 | from visitors.decorators import user_is_visitor 7 | 8 | 9 | def index(request: HttpRequest) -> HttpResponse: 10 | """Display the homepage.""" 11 | return render(request, template_name="index.html") 12 | 13 | 14 | # Test view that requires a valid Visitor Pass 15 | @user_is_visitor(scope="foo") 16 | def foo(request: HttpRequest) -> HttpResponse: 17 | return HttpResponse("OK") 18 | 19 | 20 | # Test view that supports self-service 21 | @user_is_visitor(scope="bar", self_service=True, self_service_session_expiry=60) 22 | def bar(request: HttpRequest) -> HttpResponse: 23 | return render(request, template_name="bar.html") 24 | 25 | 26 | # Deactivate any current visitor token - this is analagous 27 | # to "logging out" an authenticated user. 28 | def logout(request: HttpRequest) -> HttpResponse: 29 | if request.user.is_visitor: 30 | request.visitor.deactivate() 31 | return HttpResponseRedirect(reverse("bar")) 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 YunoJuno Limited 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## v1.1 6 | 7 | * Add support for Django 5.2 8 | * Add support for Python 3.13 9 | * Drop support for Django versions before 4.2 10 | * Drop support for Python 3.8 and 3.9 11 | * Update ruff commands to use modern syntax 12 | * Update GitHub Actions to only test Django main branch with Python 3.12 and 3.13 13 | * Drop `black` in favor for `ruff` 14 | 15 | ## v1.0 16 | 17 | * Add support for Django 5.0 18 | * Add support for Python 3.11, 3.12 19 | * Drop support for Django 3.1 20 | * Replace isort, flake8 with ruff 21 | 22 | ## v0.7 23 | 24 | * Add support for per-token session expiry (#9) 25 | 26 | ## v0.5 27 | 28 | * Return `HttpReponseBadRequest` (400) on malformed UUID token, h/t @chrisapplegate, issue #7 29 | ## v0.4 30 | 31 | * Add support for self-service tokens 32 | ## v0.2 33 | 34 | * Add `Visitor.expires_at` timestamp to manage expiry 35 | * Add `Visitor.is_active` switch to allow instant disabling of visitor passes 36 | * Add `Visitor.validate()` method to validate pass 37 | * Add `VISITOR_TOKEN_EXPIRY` setting (default: 300s) 38 | * Add `VISITOR_SESSION_EXPIRY` settings (default: 0s - expires on browse close) 39 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | isolated_build = True 3 | envlist = 4 | fmt, lint, mypy, 5 | django-checks, 6 | ; https://docs.djangoproject.com/en/5.2/releases/ 7 | django42-py{310,311,312} 8 | django50-py{310,311,312} 9 | django52-py{310,311,312,313} 10 | djangomain-py{312,313} 11 | 12 | [testenv] 13 | deps = 14 | coverage 15 | pytest 16 | pytest-cov 17 | pytest-django 18 | django42: Django>=4.2,<4.3 19 | django50: Django>=5.0,<5.1 20 | django52: Django>=5.2,<5.3 21 | djangomain: https://github.com/django/django/archive/main.tar.gz 22 | 23 | commands = 24 | pytest --cov=visitors --verbose tests/ 25 | 26 | [testenv:django-checks] 27 | description = Django system checks and missing migrations 28 | deps = Django 29 | commands = 30 | python manage.py check --fail-level WARNING 31 | python manage.py makemigrations --dry-run --check --verbosity 3 32 | 33 | [testenv:fmt] 34 | description = Python source code formatting (ruff) 35 | deps = 36 | ruff 37 | 38 | commands = 39 | ruff format visitors 40 | 41 | [testenv:lint] 42 | description = Python source code linting (ruff) 43 | deps = 44 | ruff 45 | 46 | commands = 47 | ruff check --fix visitors 48 | 49 | [testenv:mypy] 50 | description = Python source code type hints (mypy) 51 | deps = 52 | mypy 53 | 54 | commands = 55 | mypy visitors 56 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-visitor-pass" 3 | version = "1.1" 4 | description = "Django app for managing temporary session-based users." 5 | license = "MIT" 6 | authors = ["YunoJuno"]
7 | maintainers = ["YunoJuno "]
8 | readme = "README.md"
9 | homepage = "https://github.com/yunojuno/django-visitor-pass"
10 | repository = "https://github.com/yunojuno/django-visitor-pass"
11 | documentation = "https://github.com/yunojuno/django-visitor-pass"
12 | classifiers = [
13 | "Development Status :: 4 - Beta",
14 | "Environment :: Web Environment",
15 | "Framework :: Django",
16 | "Framework :: Django :: 4.2",
17 | "Framework :: Django :: 5.0",
18 | "Framework :: Django :: 5.2",
19 | "License :: OSI Approved :: MIT License",
20 | "Operating System :: OS Independent",
21 | "Programming Language :: Python :: 3 :: Only",
22 | "Programming Language :: Python :: 3.10",
23 | "Programming Language :: Python :: 3.11",
24 | "Programming Language :: Python :: 3.12",
25 | "Programming Language :: Python :: 3.13",
26 | ]
27 | packages = [{ include = "visitors" }]
28 |
29 | [tool.poetry.dependencies]
30 | python = "^3.10"
31 | django = "^4.2 || ^5.0 || ^5.2"
32 |
33 | [tool.poetry.dev-dependencies]
34 | coverage = "*"
35 | mypy = "*"
36 | pre-commit = "*"
37 | pytest = "*"
38 | pytest-cov = "*"
39 | pytest-django = "*"
40 | ruff = "*"
41 | tox = "*"
42 |
43 | [build-system]
44 | requires = ["poetry>=0.12"]
45 | build-backend = "poetry.masonry.api"
46 |
--------------------------------------------------------------------------------
/demo/templates/visitors/self_service_request.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
10 |
11 |
12 | Access Denied
13 | Self-Service demonstration
14 |
15 | You have been denied access to a page that requires a Visitor Pass. This page is
16 | marked as 'self-service', which means that you can grant yourself access by filling
17 | in the details below.
18 |
19 | {% if DEBUG %}
20 |
21 | Running in DEBUG mode this demo does not send out email notifications once the
22 | visitor pass is created, but will instead display a success page. In production you
23 | should handle the `self_service_visitor_created` signal to send out the email.
24 |
25 |
26 | NB in production the visitor pass will be emailed to you, as we require confirmation
27 | that you are the owner of the email address.
28 |
29 | {% endif %}
30 |
34 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/visitors/migrations/0002_visitorlog.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.1.4 on 2020-12-23 18:42
2 |
3 | import django.db.models.deletion
4 | import django.utils.timezone
5 | from django.db import migrations, models
6 |
7 |
8 | class Migration(migrations.Migration):
9 | dependencies = [
10 | ("visitors", "0001_initial"),
11 | ]
12 |
13 | operations = [
14 | migrations.CreateModel(
15 | name="VisitorLog",
16 | fields=[
17 | (
18 | "id",
19 | models.AutoField(
20 | auto_created=True,
21 | primary_key=True,
22 | serialize=False,
23 | verbose_name="ID",
24 | ),
25 | ),
26 | ("session_key", models.CharField(blank=True, max_length=40)),
27 | ("http_method", models.CharField(max_length=10)),
28 | ("request_uri", models.URLField()),
29 | ("remote_addr", models.CharField(max_length=100)),
30 | ("query_string", models.TextField(blank=True)),
31 | ("http_user_agent", models.TextField()),
32 | ("http_referer", models.TextField()),
33 | ("timestamp", models.DateTimeField(default=django.utils.timezone.now)),
34 | (
35 | "visitor",
36 | models.ForeignKey(
37 | on_delete=django.db.models.deletion.CASCADE,
38 | related_name="visits",
39 | to="visitors.visitor",
40 | ),
41 | ),
42 | ],
43 | ),
44 | ]
45 |
--------------------------------------------------------------------------------
/visitors/settings.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from django.conf import settings
4 |
5 |
6 | def _setting(key: str, default: str) -> str:
7 | return getattr(settings, key, default)
8 |
9 |
10 | # session key used to store visitor.uuid
11 | VISITOR_SESSION_KEY: str = _setting("VISITOR_SESSION_KEY", "visitor:session")
12 |
13 | # key used to store visitor uuid on querystring
14 | VISITOR_QUERYSTRING_KEY: str = _setting("VISITOR_QUERYSTRING_KEY", "vuid")
15 |
16 | # Value used to `request.session.set_expiry` for anonymous users. If the
17 | # request.user is already authenticated we let them carry on with the site
18 | # default - however if the visitor is anonymous we can override the session
19 | # expiry. By default this will be set to 0, which means that the session expires
20 | # when the browser is closed. We do not support datetime or timedelta values
21 | # here - it must be an integer (seconds), or None. see
22 | # https://docs.djangoproject.com/en/3.1/topics/http/sessions/#django.contrib.sessions.backends.base.SessionBase.set_expiry # noqa
23 | # * If value is an integer, the session will expire after that many seconds of
24 | # inactivity.
25 | # * If value is None, the session reverts to using the global session expiry
26 | # policy.
27 | VISITOR_SESSION_EXPIRY: int | None = _setting("VISITOR_SESSION_EXPIRY", 0)
28 |
29 | # Value used to set the expiry of the visitor token - the point at which it can
30 | # no longer be used. NB this is separate from the session expiry. Once the token
31 | # is stashed in the session the visitor will remain a visitor until the session
32 | # expires. This value is used by the VisitorRequestMiddleware.
33 | VISITOR_TOKEN_EXPIRY: int = _setting("VISITOR_TOKEN_EXPIRY", 300)
34 |
--------------------------------------------------------------------------------
/visitors/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.1.4 on 2020-12-23 17:10
2 |
3 | import uuid
4 |
5 | import django.utils.timezone
6 | from django.db import migrations, models
7 |
8 |
9 | class Migration(migrations.Migration):
10 | initial = True
11 |
12 | dependencies = []
13 |
14 | operations = [
15 | migrations.CreateModel(
16 | name="Visitor",
17 | fields=[
18 | (
19 | "id",
20 | models.AutoField(
21 | auto_created=True,
22 | primary_key=True,
23 | serialize=False,
24 | verbose_name="ID",
25 | ),
26 | ),
27 | ("uuid", models.UUIDField(default=uuid.uuid4)),
28 | ("first_name", models.CharField(blank=True, max_length=150)),
29 | ("last_name", models.CharField(blank=True, max_length=150)),
30 | ("email", models.EmailField(db_index=True, max_length=254)),
31 | (
32 | "scope",
33 | models.CharField(
34 | help_text="Used to map request to view function", max_length=100
35 | ),
36 | ),
37 | ("created_at", models.DateTimeField(default=django.utils.timezone.now)),
38 | (
39 | "context",
40 | models.JSONField(
41 | blank=True,
42 | help_text="Used to store arbitrary contextual data.",
43 | null=True,
44 | ),
45 | ),
46 | ("last_updated_at", models.DateTimeField(auto_now=True)),
47 | ],
48 | options={
49 | "verbose_name": "Visitor pass",
50 | "verbose_name_plural": "Visitor passes",
51 | },
52 | ),
53 | ]
54 |
--------------------------------------------------------------------------------
/demo/templates/bar.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
17 |
18 |
19 |
20 | Hello, {{ request.visitor.first_name }}.
21 |
22 | If you are seeing this page, you have successfully "self-served" and have visitor access
23 | to this page.
24 |
25 | The details of your visitor pass are as follows:
26 |
27 | - id:
28 | - {{ request.visitor.uuid }}
29 | - First name:
30 | - {{ request.visitor.first_name }}
31 | - Last name:
32 | - {{ request.visitor.last_name }}
33 | - Email:
34 | - {{ request.visitor.email }}
35 | - Scope:
36 | - {{ request.visitor.scope }}
37 | - Created at:
38 | - {{ request.visitor.created_at }}
39 | - Expires at:
40 | - {{ request.visitor.expires_at }}
41 | - Has expired:
42 | - {{ request.visitor.has_expired }}
43 | - Is active:
44 | - {{ request.visitor.is_active }}
45 | - Is valid:
46 | - {{ request.visitor.is_valid }}
47 | - Session expiry:
48 | - {{ request.visitor.session_expiry }}
49 | - Session expires at:
50 | - {{ request.session.get_expiry_date }}
51 |
52 | Now that you are 'authenticated' as a visitor, you can refresh the page
53 | without the `vuid=` querystring param. To refresh the page, click here.
54 | To deactivate your visitor token, click here.
55 |
56 |
57 |
--------------------------------------------------------------------------------
/.github/workflows/tox.yml:
--------------------------------------------------------------------------------
1 | name: Python / Django
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 |
8 | pull_request:
9 | types: [opened, synchronize, reopened]
10 |
11 | jobs:
12 | format:
13 | name: Check formatting
14 | runs-on: ubuntu-latest
15 | strategy:
16 | matrix:
17 | toxenv: [fmt, lint, mypy]
18 | env:
19 | TOXENV: ${{ matrix.toxenv }}
20 |
21 | steps:
22 | - name: Check out the repository
23 | uses: actions/checkout@v4
24 |
25 | - name: Set up Python (3.12)
26 | uses: actions/setup-python@v4
27 | with:
28 | python-version: "3.12"
29 |
30 | - name: Install and run tox
31 | run: |
32 | pip install tox
33 | tox
34 |
35 | checks:
36 | name: Run Django checks
37 | runs-on: ubuntu-latest
38 | strategy:
39 | matrix:
40 | toxenv: ["django-checks"]
41 | env:
42 | TOXENV: ${{ matrix.toxenv }}
43 |
44 | steps:
45 | - name: Check out the repository
46 | uses: actions/checkout@v4
47 |
48 | - name: Set up Python (3.12)
49 | uses: actions/setup-python@v4
50 | with:
51 | python-version: "3.12"
52 |
53 | - name: Install and run tox
54 | run: |
55 | pip install tox
56 | tox
57 |
58 | test:
59 | name: Run tests
60 | runs-on: ubuntu-latest
61 | strategy:
62 | matrix:
63 | python: ["3.10", "3.11", "3.12", "3.13"]
64 | # build LTS version, next version, HEAD
65 | django: ["42", "50", "52", "main"]
66 | exclude:
67 | - python: "3.10"
68 | django: "main"
69 | - python: "3.11"
70 | django: "main"
71 | - python: "3.13"
72 | django: "42"
73 | - python: "3.13"
74 | django: "50"
75 |
76 | env:
77 | TOXENV: django${{ matrix.django }}-py${{ matrix.python }}
78 |
79 | steps:
80 | - name: Check out the repository
81 | uses: actions/checkout@v4
82 |
83 | - name: Set up Python ${{ matrix.python }}
84 | uses: actions/setup-python@v4
85 | with:
86 | python-version: ${{ matrix.python }}
87 |
88 | - name: Install and run tox
89 | run: |
90 | pip install tox
91 | tox
92 |
--------------------------------------------------------------------------------
/tests/settings.py:
--------------------------------------------------------------------------------
1 | from os import path
2 |
3 | DEBUG = True
4 | TEMPLATE_DEBUG = True
5 | USE_TZ = True
6 | USE_L10N = True
7 |
8 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": "test.db"}}
9 |
10 | INSTALLED_APPS = (
11 | "django.contrib.admin",
12 | "django.contrib.auth",
13 | "django.contrib.contenttypes",
14 | "django.contrib.sessions",
15 | "django.contrib.messages",
16 | "django.contrib.staticfiles",
17 | "visitors",
18 | )
19 |
20 | MIDDLEWARE = [
21 | # default django middleware
22 | "django.contrib.sessions.middleware.SessionMiddleware",
23 | "django.middleware.common.CommonMiddleware",
24 | "django.middleware.csrf.CsrfViewMiddleware",
25 | "django.contrib.auth.middleware.AuthenticationMiddleware",
26 | "django.contrib.messages.middleware.MessageMiddleware",
27 | "visitors.middleware.VisitorRequestMiddleware",
28 | "visitors.middleware.VisitorSessionMiddleware",
29 | "visitors.middleware.VisitorDebugMiddleware",
30 | ]
31 |
32 | PROJECT_DIR = path.abspath(path.join(path.dirname(__file__)))
33 |
34 | TEMPLATES = [
35 | {
36 | "BACKEND": "django.template.backends.django.DjangoTemplates",
37 | "DIRS": [path.join(PROJECT_DIR, "templates")],
38 | "APP_DIRS": True,
39 | "OPTIONS": {
40 | "context_processors": [
41 | "django.contrib.messages.context_processors.messages",
42 | "django.contrib.auth.context_processors.auth",
43 | "django.template.context_processors.request",
44 | ]
45 | },
46 | }
47 | ]
48 |
49 | STATIC_URL = "/static/"
50 |
51 | SECRET_KEY = "secret" # noqa: S105
52 |
53 | LOGGING = {
54 | "version": 1,
55 | "disable_existing_loggers": False,
56 | "formatters": {"simple": {"format": "%(levelname)s %(message)s"}},
57 | "handlers": {
58 | "console": {
59 | "level": "DEBUG",
60 | "class": "logging.StreamHandler",
61 | "formatter": "simple",
62 | }
63 | },
64 | "loggers": {
65 | "": {"handlers": ["console"], "propagate": True, "level": "DEBUG"},
66 | # 'django': {
67 | # 'handlers': ['console'],
68 | # 'propagate': True,
69 | # 'level': 'WARNING',
70 | # },
71 | },
72 | }
73 |
74 | ROOT_URLCONF = "tests.urls"
75 |
76 | if not DEBUG:
77 | raise Exception("This settings file can only be used with DEBUG=True")
78 |
--------------------------------------------------------------------------------
/demo/settings.py:
--------------------------------------------------------------------------------
1 | from os import path
2 |
3 | DEBUG = True
4 | TEMPLATE_DEBUG = True
5 | USE_TZ = True
6 | USE_L10N = True
7 |
8 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": "demo.db"}}
9 |
10 | INSTALLED_APPS = (
11 | "django.contrib.admin",
12 | "django.contrib.auth",
13 | "django.contrib.contenttypes",
14 | "django.contrib.sessions",
15 | "django.contrib.messages",
16 | "django.contrib.staticfiles",
17 | "visitors",
18 | "demo",
19 | )
20 |
21 | MIDDLEWARE = [
22 | # default django middleware
23 | "django.contrib.sessions.middleware.SessionMiddleware",
24 | "django.middleware.common.CommonMiddleware",
25 | "django.middleware.csrf.CsrfViewMiddleware",
26 | "django.contrib.auth.middleware.AuthenticationMiddleware",
27 | "django.contrib.messages.middleware.MessageMiddleware",
28 | "visitors.middleware.VisitorRequestMiddleware",
29 | "visitors.middleware.VisitorSessionMiddleware",
30 | "visitors.middleware.VisitorDebugMiddleware",
31 | ]
32 |
33 | PROJECT_DIR = path.abspath(path.join(path.dirname(__file__)))
34 |
35 | TEMPLATES = [
36 | {
37 | "BACKEND": "django.template.backends.django.DjangoTemplates",
38 | "DIRS": [path.join(PROJECT_DIR, "templates")],
39 | "APP_DIRS": True,
40 | "OPTIONS": {
41 | "context_processors": [
42 | "django.contrib.messages.context_processors.messages",
43 | "django.contrib.auth.context_processors.auth",
44 | "django.template.context_processors.request",
45 | ]
46 | },
47 | }
48 | ]
49 |
50 | STATIC_URL = "/static/"
51 |
52 | SECRET_KEY = "secret" # noqa: S105
53 |
54 | LOGGING = {
55 | "version": 1,
56 | "disable_existing_loggers": False,
57 | "formatters": {"simple": {"format": "%(levelname)s %(message)s"}},
58 | "handlers": {
59 | "console": {
60 | "level": "DEBUG",
61 | "class": "logging.StreamHandler",
62 | "formatter": "simple",
63 | }
64 | },
65 | "loggers": {
66 | "": {"handlers": ["console"], "propagate": True, "level": "DEBUG"},
67 | # 'django': {
68 | # 'handlers': ['console'],
69 | # 'propagate': True,
70 | # 'level': 'WARNING',
71 | # },
72 | },
73 | }
74 |
75 | ROOT_URLCONF = "demo.urls"
76 |
77 | if not DEBUG:
78 | raise Exception("This settings file can only be used with DEBUG=True")
79 |
--------------------------------------------------------------------------------
/.ruff.toml:
--------------------------------------------------------------------------------
1 | line-length = 88
2 | indent-width = 4
3 |
4 | [lint]
5 | ignore = [
6 | "D100", # Missing docstring in public module
7 | "D101", # Missing docstring in public class
8 | "D102", # Missing docstring in public method
9 | "D103", # Missing docstring in public function
10 | "D104", # Missing docstring in public package
11 | "D105", # Missing docstring in magic method
12 | "D106", # Missing docstring in public nested class
13 | "D107", # Missing docstring in __init__
14 | "D203", # 1 blank line required before class docstring
15 | "D212", # Multi-line docstring summary should start at the first line
16 | "D213", # Multi-line docstring summary should start at the second line
17 | "D404", # First word of the docstring should not be "This"
18 | "D405", # Section name should be properly capitalized
19 | "D406", # Section name should end with a newline
20 | "D407", # Missing dashed underline after section
21 | "D410", # Missing blank line after section
22 | "D411", # Missing blank line before section
23 | "D412", # No blank lines allowed between a section header and its content
24 | "D416", # Section name should end with a colon
25 | "D417", # Missing argument description in the docstring
26 | ]
27 | select = [
28 | "A", # flake8 builtins
29 | "C9", # mcabe
30 | "D", # pydocstyle
31 | "E", # pycodestyle (errors)
32 | "F", # Pyflakes
33 | "I", # isort
34 | "S", # flake8-bandit
35 | "T2", # flake8-print
36 | "W", # pycodestype (warnings)
37 | ]
38 |
39 | [lint.isort]
40 | combine-as-imports = true
41 |
42 | [lint.mccabe]
43 | max-complexity = 8
44 |
45 | [lint.per-file-ignores]
46 | "*tests/*" = [
47 | "D205", # 1 blank line required between summary line and description
48 | "D400", # First line should end with a period
49 | "D401", # First line should be in imperative mood
50 | "D415", # First line should end with a period, question mark, or exclamation point
51 | "E501", # Line too long
52 | "E731", # Do not assign a lambda expression, use a def
53 | "S101", # Use of assert detected
54 | "S105", # Possible hardcoded password
55 | "S106", # Possible hardcoded password
56 | "S113", # Probable use of requests call with timeout set to {value}
57 | ]
58 | "*/migrations/*" = [
59 | "E501", # Line too long
60 | ]
61 | "*/settings.py" = [
62 | "F403", # from {name} import * used; unable to detect undefined names
63 | "F405", # {name} may be undefined, or defined from star imports:
64 | ]
65 | "*/settings/*" = [
66 | "F403", # from {name} import * used; unable to detect undefined names
67 | "F405", # {name} may be undefined, or defined from star imports:
68 | ]
69 |
--------------------------------------------------------------------------------
/visitors/admin.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import json
4 |
5 | from django.contrib import admin, messages
6 | from django.db.models.query import QuerySet
7 | from django.http.request import HttpRequest
8 | from django.utils.html import format_html
9 |
10 | from .models import Visitor, VisitorLog
11 |
12 |
13 | def pretty_print(data: dict | None) -> str:
14 | """Convert dict into formatted HTML."""
15 | if data is None:
16 | return ""
17 | pretty = json.dumps(data, sort_keys=True, indent=4, separators=(",", ": "))
18 | html = pretty.replace(" ", " ").replace("\n", "
")
19 | return format_html("%s
", html)
20 |
21 |
22 | @admin.register(Visitor)
23 | class VisitorsAdmin(admin.ModelAdmin):
24 | """Admin model for Visitor objects."""
25 |
26 | def deactivate(self, request: HttpRequest, queryset: QuerySet) -> None:
27 | """Call deactivate on all selected Visitor objects."""
28 | count = queryset.count()
29 | for obj in queryset:
30 | obj.deactivate()
31 | self.message_user(
32 | request, f"{count} passes have been disabled.", messages.SUCCESS
33 | )
34 |
35 | deactivate.short_description = "Deactivate selected Visitor passes" # type: ignore
36 |
37 | def reactivate(self, request: HttpRequest, queryset: QuerySet) -> None:
38 | """Reactivate all selected Visitor objects."""
39 | count = queryset.count()
40 | for obj in queryset:
41 | obj.reactivate()
42 | self.message_user(
43 | request, f"{count} passes have been activated.", messages.SUCCESS
44 | )
45 |
46 | reactivate.short_description = "Reactivate selected Visitor passes" # type: ignore
47 |
48 | actions = (deactivate, reactivate)
49 | list_filter = ("scope",)
50 | list_display = (
51 | "scope",
52 | "email",
53 | "created_at",
54 | "expires_at",
55 | "is_active",
56 | "_is_valid",
57 | )
58 | readonly_fields = (
59 | "uuid",
60 | "created_at",
61 | "last_updated_at",
62 | "_context",
63 | "is_active",
64 | "expires_at",
65 | "session_expiry",
66 | )
67 | search_fields = (
68 | "first_name",
69 | "last_name",
70 | "email",
71 | "scope",
72 | "created_at",
73 | )
74 |
75 | def _is_valid(self, obj: Visitor) -> bool:
76 | return obj.is_valid
77 |
78 | _is_valid.boolean = True # type: ignore
79 |
80 | def _context(self, obj: Visitor) -> str:
81 | return pretty_print(obj.context)
82 |
83 | _context.short_description = "Context (prettified)" # type: ignore
84 |
85 |
86 | @admin.register(VisitorLog)
87 | class VisitorLogAdmin(admin.ModelAdmin):
88 | list_display = (
89 | "visitor",
90 | "session_key",
91 | "remote_addr",
92 | "request_uri",
93 | "status_code",
94 | "timestamp",
95 | )
96 | readonly_fields = [f.name for f in VisitorLog._meta.fields]
97 | pass
98 |
--------------------------------------------------------------------------------
/tests/test_models.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import uuid
3 |
4 | import pytest
5 | from django.utils.timezone import now as tz_now
6 |
7 | from visitors.models import InvalidVisitorPass, Visitor
8 |
9 | TEST_UUID: str = "68201321-9dd2-4fb3-92b1-24367f38a7d6"
10 |
11 | TODAY: datetime.datetime = tz_now()
12 | ONE_DAY: datetime.timedelta = datetime.timedelta(days=1)
13 | TOMORROW: datetime.datetime = TODAY + ONE_DAY
14 | YESTERDAY: datetime.datetime = TODAY - ONE_DAY
15 |
16 |
17 | @pytest.mark.parametrize(
18 | "url_in,url_out",
19 | (
20 | ("google.com", f"google.com?vuid={TEST_UUID}"),
21 | ("google.com?vuid=123", f"google.com?vuid={TEST_UUID}"),
22 | ),
23 | )
24 | def test_visitor_tokenise(url_in, url_out):
25 | visitor = Visitor(uuid=uuid.UUID(TEST_UUID))
26 | assert visitor.tokenise(url_in) == url_out
27 |
28 |
29 | @pytest.mark.django_db
30 | def test_deactivate():
31 | visitor = Visitor.objects.create(email="foo@bar.com")
32 | assert visitor.is_active
33 | visitor.deactivate()
34 | assert not visitor.is_active
35 | visitor.refresh_from_db()
36 | assert not visitor.is_active
37 |
38 |
39 | @pytest.mark.django_db
40 | def test_reactivate():
41 | visitor = Visitor.objects.create(
42 | email="foo@bar.com", is_active=False, expires_at=YESTERDAY
43 | )
44 | assert not visitor.is_active
45 | assert visitor.has_expired
46 | assert not visitor.is_valid
47 | visitor.reactivate()
48 | assert visitor.is_active
49 | assert not visitor.has_expired
50 | assert visitor.is_valid
51 | visitor.refresh_from_db()
52 | assert visitor.is_active
53 | assert not visitor.has_expired
54 | assert visitor.is_valid
55 |
56 |
57 | @pytest.mark.parametrize(
58 | "is_active,expires_at,is_valid",
59 | (
60 | (True, TOMORROW, True),
61 | (False, TOMORROW, False),
62 | (False, YESTERDAY, False),
63 | (True, YESTERDAY, False),
64 | ),
65 | )
66 | def test_validate(is_active, expires_at, is_valid):
67 | visitor = Visitor(is_active=is_active, expires_at=expires_at)
68 | assert visitor.is_active == is_active
69 | assert visitor.has_expired == bool(expires_at < TODAY)
70 | if is_valid:
71 | visitor.validate()
72 | return
73 | with pytest.raises(InvalidVisitorPass):
74 | visitor.validate()
75 |
76 |
77 | @pytest.mark.parametrize(
78 | "is_active,expires_at,is_valid",
79 | (
80 | (True, TOMORROW, True),
81 | (False, TOMORROW, False),
82 | (False, YESTERDAY, False),
83 | (True, YESTERDAY, False),
84 | (True, None, True),
85 | (False, None, False),
86 | ),
87 | )
88 | def test_is_valid(is_active, expires_at, is_valid):
89 | visitor = Visitor(is_active=is_active, expires_at=expires_at)
90 | assert visitor.is_valid == is_valid
91 |
92 |
93 | def test_defaults():
94 | visitor = Visitor()
95 | assert visitor.created_at
96 | assert visitor.expires_at == visitor.created_at + Visitor.DEFAULT_TOKEN_EXPIRY
97 |
98 |
99 | @pytest.mark.parametrize(
100 | "expires_at,has_expired",
101 | (
102 | (TOMORROW, False),
103 | (YESTERDAY, True),
104 | (None, False),
105 | ),
106 | )
107 | def test_has_expired(expires_at, has_expired):
108 | visitor = Visitor()
109 | visitor.expires_at = expires_at
110 | assert visitor.has_expired == has_expired
111 |
--------------------------------------------------------------------------------
/visitors/middleware.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import logging
4 | from typing import Callable
5 |
6 | from django.conf import settings
7 | from django.core.exceptions import MiddlewareNotUsed, ValidationError
8 | from django.http.request import HttpRequest
9 | from django.http.response import HttpResponse, HttpResponseBadRequest
10 |
11 | from . import session
12 | from .models import InvalidVisitorPass, Visitor
13 | from .settings import VISITOR_QUERYSTRING_KEY
14 |
15 | logger = logging.getLogger(__name__)
16 |
17 |
18 | class VisitorRequestMiddleware:
19 | """Extract visitor token from incoming request."""
20 |
21 | def __init__(self, get_response: Callable):
22 | self.get_response = get_response
23 |
24 | def __call__(self, request: HttpRequest) -> HttpResponse:
25 | request.visitor = None
26 | request.user.is_visitor = False
27 | visitor_uuid = request.GET.get(VISITOR_QUERYSTRING_KEY)
28 | if not visitor_uuid:
29 | return self.get_response(request)
30 | try:
31 | visitor = Visitor.objects.get(uuid=visitor_uuid)
32 | visitor.validate()
33 | except Visitor.DoesNotExist:
34 | logger.debug("Visitor pass does not exist: %s", visitor_uuid)
35 | return self.get_response(request)
36 | except InvalidVisitorPass as ex:
37 | logger.debug("Invalid access request: %s", ex)
38 | return self.get_response(request)
39 | except ValidationError as ex:
40 | logger.debug("Malformed visitor token: %s", ex)
41 | return HttpResponseBadRequest("Malformed visitor token.")
42 | else:
43 | request.visitor = visitor
44 | request.user.is_visitor = True
45 | return self.get_response(request)
46 |
47 |
48 | class VisitorSessionMiddleware:
49 | """Extract visitor info from session and update request user."""
50 |
51 | def __init__(self, get_response: Callable):
52 | self.get_response = get_response
53 |
54 | def __call__(self, request: HttpRequest) -> HttpResponse:
55 | """
56 | Update request.user if any visitor vars are found in session.
57 |
58 | This middleware should run after the VisitorRequestMiddleware. If
59 | a request comes in without a visitor token in the querystring, we
60 | need to check the request.session to see if any visitor data was
61 | stashed previously.
62 |
63 | The first time this middleware runs after a new session is created
64 | this will push the `request.visitor` info into the session.
65 | Subsequent requests will then get the data out of the session.
66 |
67 | """
68 | # This will only be true directly after VisitorRequestMiddleware
69 | # has set the values. All subsequent requests in the session will
70 | # start with is_visitor=False and pick up the visitor info from
71 | # the session.
72 | if request.visitor:
73 | session.stash_visitor_uuid(request)
74 | return self.get_response(request)
75 |
76 | # We don't have a visitor object, but there may be one in the session
77 | if not (visitor_uuid := session.get_visitor_uuid(request)):
78 | return self.get_response(request)
79 |
80 | try:
81 | visitor = Visitor.objects.get(
82 | uuid=visitor_uuid,
83 | is_active=True,
84 | )
85 | except Visitor.DoesNotExist:
86 | session.clear_visitor_uuid(request)
87 | return self.get_response(request)
88 | else:
89 | request.visitor = visitor
90 | request.user.is_visitor = True
91 |
92 | return self.get_response(request)
93 |
94 |
95 | class VisitorDebugMiddleware:
96 | """Print out visitor info - DEBUG only."""
97 |
98 | def __init__(self, get_response: Callable):
99 | if not settings.DEBUG:
100 | raise MiddlewareNotUsed("VisitorDebugMiddleware disabled")
101 | self.get_response = get_response
102 |
103 | def __call__(self, request: HttpRequest) -> HttpResponse:
104 | logger.debug("request.user.is_visitor: %s", request.user.is_visitor)
105 | if request.user.is_visitor:
106 | logger.debug("request.visitor: %s", request.visitor)
107 | logger.debug(
108 | "request.visitor.session_expiry: %s",
109 | request.visitor.session_expiry,
110 | )
111 | logger.debug(
112 | "request.session.get_expiry_date: %s",
113 | request.session.get_expiry_date(),
114 | )
115 | return self.get_response(request)
116 |
--------------------------------------------------------------------------------
/tests/test_views.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from datetime import timedelta
4 | from unittest import mock
5 | from uuid import uuid4
6 |
7 | import pytest
8 | from django.http import Http404
9 | from django.test import RequestFactory
10 | from django.urls import reverse
11 | from django.utils.timezone import now as tz_now
12 |
13 | from visitors.exceptions import InvalidVisitorPass
14 | from visitors.models import Visitor
15 | from visitors.views import SelfServiceRequest
16 |
17 |
18 | @pytest.mark.django_db
19 | class TestSelfService:
20 | @mock.patch("visitors.views.render")
21 | @pytest.mark.parametrize(
22 | "is_active,has_expired,error",
23 | [
24 | (True, True, InvalidVisitorPass),
25 | (True, False, InvalidVisitorPass),
26 | (False, True, InvalidVisitorPass),
27 | (False, False, None),
28 | ],
29 | )
30 | def test_get(
31 | self,
32 | mock_render,
33 | rf: RequestFactory,
34 | visitor: Visitor,
35 | is_active: bool,
36 | has_expired: bool,
37 | error: Exception,
38 | ) -> None:
39 | request = rf.get("/")
40 | request.visitor = visitor
41 | visitor.is_active = is_active
42 | if has_expired:
43 | visitor.expires_at = tz_now() - timedelta(seconds=1)
44 | visitor.save()
45 | view = SelfServiceRequest()
46 | assert visitor.has_expired == has_expired
47 | assert visitor.is_active == is_active
48 | if error:
49 | with pytest.raises(error):
50 | view.dispatch(request, visitor.uuid)
51 | else:
52 | resp = view.dispatch(request, visitor.uuid)
53 | assert resp.status_code == mock_render.return_value.status_code
54 | mock_render.assert_called_once_with(
55 | request,
56 | template_name="visitors/self_service_request.html",
57 | context={"visitor": visitor, "form": mock.ANY},
58 | )
59 |
60 | @mock.patch.object(SelfServiceRequest, "get_template_name")
61 | @mock.patch("visitors.views.render")
62 | def test_template_override(
63 | self,
64 | mock_render,
65 | mock_template_name,
66 | rf: RequestFactory,
67 | visitor: Visitor,
68 | ) -> None:
69 | request = rf.get("/")
70 | request.visitor = visitor
71 | visitor.is_active = False
72 | visitor.save()
73 | view = SelfServiceRequest(visitor=visitor)
74 | _ = view.dispatch(request, visitor.uuid)
75 | mock_template_name.assert_called_once_with()
76 | mock_render.assert_called_once_with(
77 | request,
78 | template_name=mock_template_name.return_value,
79 | context={"visitor": visitor, "form": mock.ANY},
80 | )
81 |
82 | def test_post_404(self, rf: RequestFactory) -> None:
83 | request = rf.post("/")
84 | view = SelfServiceRequest()
85 | with pytest.raises(Http404):
86 | view.dispatch(request, visitor_uuid=uuid4())
87 |
88 | def test_post_valid(self, rf: RequestFactory, temp_visitor: Visitor) -> None:
89 | assert not temp_visitor.is_active
90 | request = rf.post(
91 | "/",
92 | {
93 | "vuid": temp_visitor.uuid,
94 | "first_name": "Henry",
95 | "last_name": "Root",
96 | "email": "henry@altavista.com",
97 | },
98 | )
99 | request.visitor = temp_visitor
100 | view = SelfServiceRequest()
101 | resp = view.dispatch(request, visitor_uuid=temp_visitor.uuid)
102 | assert resp.status_code == 302
103 | temp_visitor.refresh_from_db()
104 | assert temp_visitor.is_active
105 | assert temp_visitor.first_name == "Henry"
106 | assert temp_visitor.last_name == "Root"
107 | assert temp_visitor.email == "henry@altavista.com"
108 | assert resp.url == reverse(
109 | "visitors:self-service-success",
110 | kwargs={"visitor_uuid": temp_visitor.uuid},
111 | )
112 |
113 | def test_post_invalid(self, rf: RequestFactory, temp_visitor: Visitor) -> None:
114 | assert not temp_visitor.is_active
115 | request = rf.post(
116 | "/",
117 | {
118 | "vuid": temp_visitor.uuid,
119 | "first_name": "Henry",
120 | "last_name": "Root",
121 | "email": "henry",
122 | },
123 | )
124 | request.visitor = temp_visitor
125 | view = SelfServiceRequest()
126 | resp = view.dispatch(request, visitor_uuid=temp_visitor.uuid)
127 | assert resp.status_code == 200
128 |
--------------------------------------------------------------------------------
/tests/test_decorators.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import pytest
4 | from django.contrib.auth.models import AnonymousUser, User
5 | from django.contrib.sessions.backends.base import SessionBase
6 | from django.core.exceptions import PermissionDenied
7 | from django.http import HttpRequest, HttpResponse
8 | from django.test import RequestFactory
9 | from django.urls import reverse
10 |
11 | from visitors.decorators import user_is_visitor
12 | from visitors.models import Visitor, VisitorLog
13 |
14 |
15 | @pytest.mark.django_db
16 | class TestDecorators:
17 | def _request(
18 | self, user: User | None = None, visitor: Visitor | None = None
19 | ) -> HttpRequest:
20 | factory = RequestFactory()
21 | request = factory.get("/")
22 | request.user = user or AnonymousUser()
23 | request.visitor = visitor
24 | request.user.is_visitor = visitor is not None
25 | request.session = SessionBase()
26 | return request
27 |
28 | def test_no_access(self) -> None:
29 | request = self._request()
30 |
31 | @user_is_visitor(scope="foo")
32 | def view(request: HttpRequest) -> HttpResponse:
33 | return HttpResponse("OK")
34 |
35 | with pytest.raises(PermissionDenied):
36 | _ = view(request)
37 |
38 | def test_incorrect_scope(self, visitor: Visitor) -> None:
39 | request = self._request(visitor=visitor)
40 |
41 | @user_is_visitor(scope="bar")
42 | def view(request: HttpRequest) -> HttpResponse:
43 | return HttpResponse("OK")
44 |
45 | with pytest.raises(PermissionDenied):
46 | _ = view(request)
47 |
48 | def test_correct_scope(self, visitor: Visitor) -> None:
49 | request = self._request(visitor=visitor)
50 |
51 | @user_is_visitor(scope="foo")
52 | def view(request: HttpRequest) -> HttpResponse:
53 | return HttpResponse("OK")
54 |
55 | response = view(request)
56 | assert response.status_code == 200
57 | assert response.content == b"OK"
58 |
59 | def test_any_scope(self, visitor: Visitor) -> None:
60 | request = self._request(visitor=visitor)
61 |
62 | @user_is_visitor(scope="*")
63 | def view(request: HttpRequest) -> HttpResponse:
64 | return HttpResponse("OK")
65 |
66 | response = view(request)
67 | assert response.status_code == 200
68 | assert response.content == b"OK"
69 |
70 | def test_bypass__True(self, user: User) -> None:
71 | """Check that the bypass param works."""
72 | request = self._request(user=user)
73 |
74 | @user_is_visitor(scope="foo", bypass_func=lambda r: True)
75 | def view(request: HttpRequest) -> HttpResponse:
76 | return HttpResponse("OK")
77 |
78 | response = view(request)
79 | assert response.status_code == 200
80 | assert response.content == b"OK"
81 |
82 | def test_bypass__False(self, user: User) -> None:
83 | request = self._request(user=user)
84 |
85 | @user_is_visitor(scope="foo", bypass_func=lambda r: False)
86 | def view(request: HttpRequest) -> HttpResponse:
87 | return HttpResponse("OK")
88 |
89 | with pytest.raises(PermissionDenied):
90 | _ = view(request)
91 |
92 | def test_logging(self, visitor: Visitor) -> None:
93 | request = self._request(visitor=visitor)
94 |
95 | @user_is_visitor(scope="foo")
96 | def view(request: HttpRequest) -> HttpResponse:
97 | return HttpResponse("OK")
98 |
99 | response = view(request)
100 | log: VisitorLog = VisitorLog.objects.get()
101 | assert response.status_code == 200
102 | assert log.status_code == 200
103 |
104 | def test_logging__False(self, visitor: Visitor) -> None:
105 | request = self._request(visitor=visitor)
106 |
107 | @user_is_visitor(scope="foo", log_visit=False)
108 | def view(request: HttpRequest) -> HttpResponse:
109 | return HttpResponse("OK")
110 |
111 | _ = view(request)
112 | assert VisitorLog.objects.count() == 0
113 |
114 | def test_self_service_redirect(self):
115 | request = self._request(visitor=None)
116 |
117 | @user_is_visitor(scope="foo", self_service=True, self_service_session_expiry=66)
118 | def view(request: HttpRequest) -> HttpResponse:
119 | return HttpResponse("OK")
120 |
121 | response = view(request)
122 | assert response.status_code == 302
123 | visitor = Visitor.objects.get()
124 | assert visitor.is_self_service
125 | assert not visitor.is_active
126 | assert response.url == reverse(
127 | "visitors:self-service", kwargs={"visitor_uuid": visitor.uuid}
128 | )
129 | assert visitor.session_expiry == 66
130 |
--------------------------------------------------------------------------------
/tests/test_middleware.py:
--------------------------------------------------------------------------------
1 | import uuid
2 | from typing import Optional
3 |
4 | import pytest
5 | from django.contrib.auth.models import AnonymousUser, User
6 | from django.http.request import HttpRequest
7 | from django.test import RequestFactory
8 |
9 | from visitors.middleware import VisitorRequestMiddleware, VisitorSessionMiddleware
10 | from visitors.models import Visitor
11 | from visitors.settings import VISITOR_SESSION_KEY
12 |
13 |
14 | @pytest.fixture
15 | def visitor() -> Visitor:
16 | return Visitor.objects.create(email="fred@example.com", scope="foo")
17 |
18 |
19 | class Session(dict):
20 | """Fake Session model used to support `session_key` property."""
21 |
22 | @property
23 | def session_key(self) -> str:
24 | return "foobar"
25 |
26 | def set_expiry(self, expiry: int) -> None:
27 | self.expiry = expiry
28 |
29 |
30 | class TestVisitorMiddlewareBase:
31 | def request(self, url: str, user: Optional[User] = None) -> HttpRequest:
32 | factory = RequestFactory()
33 | request = factory.get(url)
34 | request.user = user or AnonymousUser()
35 | request.session = Session()
36 | return request
37 |
38 |
39 | @pytest.mark.django_db
40 | class TestVisitorRequestMiddleware(TestVisitorMiddlewareBase):
41 | def test_no_token(self) -> None:
42 | request = self.request("/", AnonymousUser())
43 | middleware = VisitorRequestMiddleware(lambda r: r)
44 | middleware(request)
45 | assert not request.user.is_visitor
46 | assert not request.visitor
47 |
48 | def test_token_does_not_exist(self) -> None:
49 | request = self.request(f"/?vuid={uuid.uuid4()}")
50 | middleware = VisitorRequestMiddleware(lambda r: r)
51 | middleware(request)
52 | assert not request.user.is_visitor
53 | assert not request.visitor
54 |
55 | def test_token_is_invalid(self, visitor: Visitor) -> None:
56 | visitor.deactivate()
57 | request = self.request(visitor.tokenise("/"))
58 | middleware = VisitorRequestMiddleware(lambda r: r)
59 | middleware(request)
60 | assert not request.user.is_visitor
61 | assert not request.visitor
62 |
63 | def test_token_validation_error(self, visitor: Visitor) -> None:
64 | request = self.request("/?vuid=123")
65 | middleware = VisitorRequestMiddleware(lambda r: r)
66 | resp = middleware(request)
67 | assert resp.status_code == 400
68 | assert not request.user.is_visitor
69 | assert not request.visitor
70 |
71 | def test_valid_token(self, visitor: Visitor) -> None:
72 | request = self.request(visitor.tokenise("/"))
73 | middleware = VisitorRequestMiddleware(lambda r: r)
74 | middleware(request)
75 | assert request.user.is_visitor
76 | assert request.visitor == visitor
77 |
78 |
79 | @pytest.mark.django_db
80 | class TestVisitorSessionMiddleware(TestVisitorMiddlewareBase):
81 | def request(
82 | self,
83 | url: str,
84 | user: Optional[User] = None,
85 | is_visitor: bool = False,
86 | visitor: Visitor = None,
87 | ) -> HttpRequest:
88 | request = super().request(url, user)
89 | request.user.is_visitor = is_visitor
90 | request.visitor = visitor
91 | return request
92 |
93 | def test_visitor(self, visitor: Visitor) -> None:
94 | """Check that request.visitor is stashed in session."""
95 | request = self.request("/", is_visitor=True, visitor=visitor)
96 | assert not request.session.get(VISITOR_SESSION_KEY)
97 | middleware = VisitorSessionMiddleware(lambda r: r)
98 | middleware(request)
99 | assert request.session[VISITOR_SESSION_KEY] == visitor.session_data
100 |
101 | def test_no_visitor_no_session(self) -> None:
102 | """Check that no visitor on request or session passes."""
103 | request = self.request("/", is_visitor=False, visitor=None)
104 | middleware = VisitorSessionMiddleware(lambda r: r)
105 | middleware(request)
106 | assert not request.user.is_visitor
107 | assert not request.visitor
108 |
109 | def test_visitor_in_session(self, visitor: Visitor) -> None:
110 | """Check no visitor on request, but in session."""
111 | request = self.request("/", is_visitor=False, visitor=None)
112 | request.session[VISITOR_SESSION_KEY] = visitor.session_data
113 | middleware = VisitorSessionMiddleware(lambda r: r)
114 | middleware(request)
115 | assert request.user.is_visitor
116 | assert request.visitor == visitor
117 |
118 | def test_visitor_does_not_exist(self) -> None:
119 | """Check non-existant visitor in session."""
120 | request = self.request("/", is_visitor=False, visitor=None)
121 | request.session[VISITOR_SESSION_KEY] = str(uuid.uuid4())
122 | middleware = VisitorSessionMiddleware(lambda r: r)
123 | middleware(request)
124 | assert not request.user.is_visitor
125 | assert not request.visitor
126 | assert not request.session.get(VISITOR_SESSION_KEY)
127 |
128 | def test_visitor_session_expiry(self, visitor: Visitor) -> None:
129 | """Check that request.visitor.session_expiry is set from visitor."""
130 | visitor.session_expiry = 327 # something memorable
131 | visitor.save()
132 | request = self.request("/", is_visitor=True, visitor=visitor)
133 | middleware = VisitorSessionMiddleware(lambda r: r)
134 | middleware(request)
135 | assert request.session.expiry == 327
136 |
--------------------------------------------------------------------------------
/visitors/decorators.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import functools
4 | import logging
5 | from typing import Any, Callable
6 |
7 | from django.conf import settings
8 | from django.http import HttpRequest, HttpResponse
9 | from django.http.response import HttpResponseRedirect
10 | from django.urls import reverse
11 | from django.utils.translation import gettext as _
12 |
13 | from .exceptions import VisitorAccessDenied
14 | from .models import Visitor, VisitorLog
15 | from .settings import VISITOR_SESSION_EXPIRY
16 |
17 | logger = logging.getLogger(__name__)
18 |
19 | # universal scope - essentially unscoped access
20 | SCOPE_ANY = "*"
21 |
22 | # for typing
23 | BypassFunc = Callable[[HttpRequest], bool]
24 |
25 |
26 | def is_visitor(user: settings.AUTH_USER_MODEL) -> bool:
27 | """Shortcut function for use with user_passes_test decorator."""
28 | return user.is_visitor
29 |
30 |
31 | def is_staff(user: settings.AUTH_USER_MODEL) -> bool:
32 | """Shortcut function for use with user_passes_test decorator."""
33 | return user.is_staff
34 |
35 |
36 | def is_superuser(user: settings.AUTH_USER_MODEL) -> bool:
37 | """Shortcut function for use with user_passes_test decorator."""
38 | return user.is_superuser
39 |
40 |
41 | def is_authenticated(user: settings.AUTH_USER_MODEL) -> bool:
42 | """Shortcut function for use with user_passes_test decorator."""
43 | return user.is_authenticated
44 |
45 |
46 | def _get_request_arg(*args: Any) -> HttpRequest | None:
47 | """Extract the arg that is an HttpRequest object."""
48 | for arg in args:
49 | if isinstance(arg, HttpRequest):
50 | return arg
51 | return None
52 |
53 |
54 | def user_is_visitor( # noqa: C901
55 | view_func: Callable | None = None,
56 | scope: str = "",
57 | bypass_func: BypassFunc | None = None,
58 | log_visit: bool = True,
59 | self_service: bool = False,
60 | self_service_session_expiry: int | None = VISITOR_SESSION_EXPIRY,
61 | ) -> Callable:
62 | """
63 | Decorate view functions that supports Visitor access.
64 |
65 | The 'scope' param is mapped to the request.visitor.scope attribute - if
66 | the scope is SCOPE_ANY then this is ignored.
67 |
68 | The 'bypass_func' is a callable that can be used to provide exceptions
69 | to the scope - e.g. allowing authenticate users, or staff, to bypass the
70 | visitor restriction. Defaults to None (only visitors with appropriate
71 | scope allowed).
72 |
73 | The 'log_visit' arg can be used to override the default logging - if this
74 | is too noisy, for instance.
75 |
76 | If 'self_service' is True, then instead of a straight PermissionDenied error
77 | we raise VisitorAccessDenied, passing along the scope. This is then picked
78 | up in the middleware, and the user redirected to a page where they can
79 | enter their details and effectively invite themselves. Caveat emptor.
80 |
81 | """
82 | if not scope:
83 | raise ValueError("Decorator scope cannot be empty.")
84 |
85 | if view_func is None:
86 | return functools.partial(
87 | user_is_visitor,
88 | scope=scope,
89 | bypass_func=bypass_func,
90 | log_visit=log_visit,
91 | self_service=self_service,
92 | self_service_session_expiry=self_service_session_expiry,
93 | )
94 |
95 | @functools.wraps(view_func)
96 | def inner(*args: Any, **kwargs: Any) -> HttpResponse:
97 | # HACK: if this is decorating a method, then the first arg will be
98 | # the object (self), and not the request. In order to make this work
99 | # with functions and methods we need to determine where the request
100 | # arg is.
101 | request = _get_request_arg(*args)
102 | if not request:
103 | raise ValueError("Request argument missing.")
104 |
105 | # Allow custom rules to bypass the visitor checks
106 | if bypass_func and bypass_func(request):
107 | return view_func(*args, **kwargs)
108 |
109 | if not is_valid_request(request, scope):
110 | if self_service:
111 | return redirect_to_self_service(
112 | request,
113 | scope,
114 | self_service_session_expiry,
115 | )
116 | raise VisitorAccessDenied(_("Visitor access denied"), scope)
117 |
118 | response = view_func(*args, **kwargs)
119 | if log_visit:
120 | VisitorLog.objects.create_log(request, response.status_code)
121 | return response
122 |
123 | return inner
124 |
125 |
126 | def is_valid_request(request: HttpRequest, scope: str) -> bool:
127 | """Return True if the request matches the scope."""
128 | if not request.user.is_visitor:
129 | return False
130 | if scope == SCOPE_ANY:
131 | return True
132 | return request.visitor.scope == scope
133 |
134 |
135 | def redirect_to_self_service(
136 | request: HttpRequest,
137 | scope: str,
138 | session_expiry: int | None = VISITOR_SESSION_EXPIRY,
139 | ) -> HttpResponseRedirect:
140 | """Create inactive Visitor token and redirect to enable self-service."""
141 | # create an inactive token for the time being. This will be used by
142 | # the auto-enroll view. The user fills in their name and email, which
143 | # overwrites the blank values here, and sets the token to be active.
144 | visitor = Visitor.objects.create_temp_visitor(
145 | scope=scope,
146 | redirect_to=request.get_full_path(),
147 | session_expiry=session_expiry,
148 | )
149 | return HttpResponseRedirect(
150 | reverse(
151 | "visitors:self-service",
152 | kwargs={"visitor_uuid": visitor.uuid},
153 | )
154 | )
155 |
--------------------------------------------------------------------------------
/visitors/views.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import uuid
4 | from typing import Any
5 |
6 | from django import forms
7 | from django.http import Http404, HttpRequest, HttpResponse
8 | from django.http.response import HttpResponseRedirect
9 | from django.shortcuts import get_object_or_404, render
10 | from django.urls import reverse
11 | from django.views import View
12 |
13 | from visitors.exceptions import InvalidVisitorPass
14 |
15 | from .forms import SelfServiceForm
16 | from .models import Visitor
17 | from .signals import self_service_visitor_created
18 |
19 |
20 | class SelfServiceBase(View):
21 | """Base view for self-service pages."""
22 |
23 | # default visitor request templates
24 | template_name = ""
25 |
26 | def __init__(self, **kwargs: Any) -> None:
27 | self.visitor: Visitor | None = None
28 | super().__init__(**kwargs)
29 |
30 | def get_template_name(self) -> str:
31 | """Return the path to the template to render."""
32 | if self.template_name:
33 | return self.template_name
34 | raise NotImplementedError("Missing template_name property.")
35 |
36 | def get_context_data(self, **form_kwargs: object) -> dict:
37 | """
38 | Return the context passed to the template.
39 |
40 | Default passes the Visitor object and anything that is passed in
41 | as form_kwargs. This is essentially a noop that exists only so
42 | that it can be overridden.
43 |
44 | """
45 | context: dict = {"visitor": self.visitor}
46 | context.update(form_kwargs)
47 | return context
48 |
49 | def dispatch(self, request: HttpRequest, visitor_uuid: uuid.UUID) -> HttpResponse:
50 | """Override default view dispatch to set visitor attr."""
51 | self.visitor = get_object_or_404(Visitor, uuid=visitor_uuid)
52 | return super().dispatch(request, visitor_uuid)
53 |
54 |
55 | class SelfServiceRequest(SelfServiceBase):
56 | """
57 | Enable users to create their own visitor passes.
58 |
59 | If a visitor pass is marked as `auto-enroll` then a user can
60 | create their own pass. In this model, an user without access
61 | would be redirected to this view, where they can input their
62 | own name and email. They receive their own visitor pass.
63 |
64 | The purpose of this is to add some protection to 'obscure'
65 | urls. e.g. those that rely on an unguessable uuid - instead
66 | of making those URLs completely public, we can force the user
67 | to give us their name / email (name can be faked, but they
68 | will require access to the email).
69 |
70 | """
71 |
72 | # default visitor request templates
73 | template_name = "visitors/self_service_request.html"
74 | form_class = SelfServiceForm
75 |
76 | def get_form_class(self) -> forms.Form:
77 | """Return the SelfServiceForm used by the template."""
78 | return self.form_class
79 |
80 | def get_form_initial(self) -> dict:
81 | """Return the initial data used for the form."""
82 | if not self.visitor: # should never occur - set in dispatch()
83 | raise Http404
84 | return {"vuid": self.visitor.uuid}
85 |
86 | def get_redirect_url(self) -> str:
87 | """Return the post-POST success url."""
88 | if not self.visitor: # should never occur - set in dispatch()
89 | raise Http404
90 | return reverse(
91 | "visitors:self-service-success",
92 | kwargs={"visitor_uuid": self.visitor.uuid},
93 | )
94 |
95 | def validate_visitor(self) -> Visitor:
96 | """Validate the local Visitor object."""
97 | if not self.visitor: # should never occur - set in dispatch()
98 | raise Http404
99 | if self.visitor.is_active:
100 | raise InvalidVisitorPass("Visitor pass has already been activated")
101 | if self.visitor.has_expired:
102 | raise InvalidVisitorPass("Visitor pass has expired")
103 | return self.visitor
104 |
105 | def get(self, request: HttpRequest, visitor_uuid: uuid.UUID) -> HttpResponse:
106 | """Render the initial form."""
107 | _ = self.validate_visitor()
108 | template = self.get_template_name()
109 | form = self.get_form_class()(initial=self.get_form_initial())
110 | context = self.get_context_data(form=form)
111 | return render(request, template_name=template, context=context)
112 |
113 | def post(self, request: HttpRequest, visitor_uuid: uuid.UUID) -> HttpResponse:
114 | """
115 | Process the form and send the pass email.
116 |
117 | The core function here is to update the visitor object with the
118 | details from the form. Once that is done the visitor pass is
119 | active and can be sent to the user. This view fires the
120 | `self_service_visitor_created` signal - you should use this to
121 | send out the notification.
122 |
123 | """
124 | visitor = self.validate_visitor()
125 | form = self.get_form_class()(request.POST)
126 | if form.is_valid():
127 | visitor.first_name = form.cleaned_data["first_name"]
128 | visitor.last_name = form.cleaned_data["last_name"]
129 | visitor.email = form.cleaned_data["email"]
130 | visitor.reactivate()
131 | # hook into this to send the email notification to the user.
132 | self_service_visitor_created.send(sender=self.__class__, visitor=visitor)
133 | return HttpResponseRedirect(self.get_redirect_url())
134 | template = self.get_template_name()
135 | context = self.get_context_data(form=form)
136 | return render(request, template_name=template, context=context)
137 |
138 |
139 | class SelfServiceSuccess(SelfServiceBase):
140 | """Render the page that appears after a successful self-service request."""
141 |
142 | template_name = "visitors/self_service_success.html"
143 |
144 | def get(self, request: HttpRequest, visitor_uuid: uuid.UUID) -> HttpResponse:
145 | return render(
146 | request,
147 | template_name=self.get_template_name(),
148 | context=self.get_context_data(),
149 | )
150 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Django Visitor Pass
2 |
3 | Django app for managing temporary session-based users.
4 |
5 | ### Support
6 |
7 | This project currently supports Python 3.10+, Django 4.2+.
8 |
9 | ### Background
10 |
11 | This package has been extracted out of `django-request-token` as a specific use
12 | case - that of temporary site "visitors". It enables a type of ephemeral user
13 | who is neither anonymous nor authenticated, but somewhere in between - known for
14 | the duration of their session.
15 |
16 | ### Motivation
17 |
18 | We've been using `django-request-token` for a while, and have issued over
19 | 100,000 tokens. A recent analysis showed two main use cases - single-use "magic
20 | links" for logging people in, and a more involved case where we invite
21 | unregistered users on to the platform to perform some action - providing a
22 | reference perhaps, or collaborating on something with (registered) users. The
23 | former we have extracted out into `django-magic-links` - and this package
24 | addresses the latter.
25 |
26 | ### What is a "visitor"?
27 |
28 | In the standard Django model you have the concept of an `AnonymousUser`, and an
29 | authenticated `User` - someone who has logged in. We have a third, intermediate,
30 | type of user - which we have historically referred to as a "Temp User", which is
31 | someone we know _of_, but who has not yet registered.
32 |
33 | The canonical example of this is leaving a reference: user A on the site invites
34 | user B to leave a reference for them. They (A) give us B's name and email, we
35 | invite them to click on a link and fill out a form. That's it. We store their
36 | name and email so that we can contact them, but it's ephemeral - we don't need
37 | it, and we don't use it. Storing this data in the User table made sense (as it
38 | has name and email fields), but it led to a lot of `user_type=TEMP` munging to
39 | determine who is a 'real' user on the site.
40 |
41 | What we really want is to 'stash' this information somewhere outside of the auth
42 | system, and to enable these temp users to have restricted access to specific
43 | areas of the application, for a limited period, after which we can forget about
44 | them and clear out the data.
45 |
46 | We call these users "visitors".
47 |
48 | ### Use Case - request a reference
49 |
50 | Fred is a registered user on the site, and would like a reference from Ginger,
51 | his dance partner.
52 |
53 | 1. Fred fills out the reference request form:
54 |
55 | ```
56 | Name: Ginger
57 | Email: ginger@[...].com
58 | Message: ...
59 | Scope: REFERENCE_REQUEST [hidden field]
60 | ```
61 |
62 | 2. We save this information, and generate a unique link which we send to Ginger,
63 | along with the message.
64 |
65 | 3. Ginger clicks on the link, at which point we recognise that this is someone
66 | we know about - a "visitor" - but who is in all other respects an
67 | `AnonymousUser`.
68 |
69 | 4. We stash the visitor info in the standard session object - so that even
70 | though Ginger is not authenticated, we know who she is, and more importantly
71 | we know why she's here (REFERENCE_REQUEST).
72 |
73 | 5. Ginger submits the reference - which may be a multi-step process, involving
74 | GETs and POSTs, all of which are guarded by a decorator that restricts access
75 | to visitors with the appropriate Scope (just like `django-request-token`).
76 |
77 | 6. At the final step we can (optionally) choose to clear the session info
78 | immediately, effectively removing all further access.
79 |
80 | ### Implementation
81 |
82 | This code has been extracted out of `django-request-token` and simplified. It
83 | stores the visitor data in the `Visitor` model, and on each successful first
84 | request (where the token is 'processed' and the session filled) we record a
85 | `VisitorLog` record. This includes HTTP request info (session_key, referer,
86 | client IP, user-agent). This information is for analytics only - for instance
87 | determining whether links are being shared.
88 |
89 | The app works by adding some attributes to the `request` and `request.user`
90 | objects. The user has a boolean `user.is_visitor` property, and the request has
91 | a `request.visitor` property which is the relevant `Visitor` object.
92 |
93 | This is done via two bits of middleware, `VisitorRequestMiddleware` and
94 | `VisitSessionMiddleware`.
95 |
96 | #### `VisitorRequestMiddleware`
97 |
98 | This middleware looks for a visitor token (uuid) on the incoming request
99 | querystring. If it finds a token, it will look up the `Visitor` object, add it
100 | to the request, and then set the `request.user.is_visitor` attribute. It sets
101 | the properties from the request, and has no interaction with the session. This
102 | happens in the second piece of middleware.
103 |
104 | #### `VisitorSessionMiddleware`
105 |
106 | This middleware must come after the `VisitorRequestMiddleware` (it will blow up
107 | if it can't access `request.visitor`). It has two responsibilities:
108 |
109 | 1. If the request object has a visitor object on it, then it _must_ have been
110 | set by the request middleware on the current request - so it's a new visitor,
111 | and we immediately stash it in the `request.session`.
112 |
113 | 1. If `request.visitor` is None, then we don't have a _new_ visitor, but there
114 | may be one already stashed in the `request.session`, in which case we want to
115 | add it on the to the request.
116 |
117 | Note: splitting this in two seems over-complicated, but because we are moving
118 | values from request-into-session-into-request it's a lot simpler to run two
119 | completely separate passes.
120 |
121 | ### Configuration
122 |
123 | #### Django Settings
124 |
125 | 1. Add `visitors` to `INSTALLED_APPS`
126 | 1. Add `visitors.middleware.VisitorRequestMiddleware` to `MIDDLEWARE`
127 | 1. Add `visitors.middleware.VisitorSessionMiddleware` to `MIDDLEWARE`
128 |
129 | #### Environment Settings
130 |
131 | * `VISITOR_SESSION_KEY`: session key used to stash visitor info (default:
132 | `visitor:session`)
133 |
134 | * `VISITOR_SESSION_EXPIRY`: session expiry in seconds (default: 0 - meaning
135 | that it will expire when the browser is closed.) This settings applies
136 | as the default for all new visitor objects, but can be overridden on a
137 | per-object basis.
138 |
139 | * `VISITOR_QUERYSTRING_KEY`: querystring param used on tokenised links (default:
140 | `vuid`)
141 |
142 | ### Usage
143 |
144 | Once you have the package configured, you can use the `user_is_visitor`
145 | decorator to protect views that you want to restricted to visitors only. The
146 | decorator requires a `scope` kwarg, which must match the `Visitor.scope` value
147 | set when the `Visitor` object is created.
148 |
149 | ```python
150 | @user_is_visitor(scope="foo")
151 | def protected_view(request):
152 | pass
153 | ```
154 |
155 | By default the decorator will allow visitors with the correct scope only. If you
156 | want more control over the access, you can pass a callable as the `bypass_func`
157 | param:
158 |
159 | ```python
160 | # allow authenticated users as well as visitors
161 | @user_is_visitor(scope="foo", bypass_func=lambda r: r.user.is_staff)
162 | def allow_visitors_and_staff(request):
163 | pass
164 | ```
165 |
166 | If you don't care about the scope (you should), you can use `"*"` to allow all
167 | visitors access:
168 |
169 | ```python
170 | @user_is_visitor(scope="*") # see also SCOPE_ANY
171 | def allow_all_scopes(request):
172 | pass
173 | ```
174 |
175 | Alternatively, for more complex use cases, you can ignore the decorator and just
176 | inspect the request itself:
177 |
178 | ```python
179 | def complicated_rules(request):
180 | if request.user.is_visitor:
181 | pass
182 | elif is_national_holiday():
183 | pass
184 | elif is_sunny_day():
185 | pass
186 | else:
187 | raise PermissionDenied
188 | ```
189 |
--------------------------------------------------------------------------------
/visitors/models.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import datetime
4 | import uuid
5 | from typing import Any
6 | from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
7 |
8 | from django.db import models
9 | from django.db.models.deletion import CASCADE
10 | from django.http.request import HttpRequest
11 | from django.utils.timezone import now as tz_now
12 | from django.utils.translation import gettext_lazy as _lazy
13 |
14 | from .exceptions import InvalidVisitorPass
15 | from .settings import (
16 | VISITOR_QUERYSTRING_KEY,
17 | VISITOR_SESSION_EXPIRY,
18 | VISITOR_TOKEN_EXPIRY,
19 | )
20 |
21 |
22 | class VisitorManager(models.Manager):
23 | def create_temp_visitor(
24 | self,
25 | scope: str,
26 | redirect_to: str,
27 | session_expiry: int | None = VISITOR_SESSION_EXPIRY,
28 | ) -> Visitor:
29 | """Create empty Visitor object for self-service."""
30 | return self.create(
31 | email=Visitor.DEFAULT_SELF_SERVICE_EMAIL,
32 | scope=scope,
33 | is_active=False,
34 | context={"self-service": True, "redirect_to": redirect_to},
35 | session_expiry=session_expiry,
36 | )
37 |
38 |
39 | class Visitor(models.Model):
40 | """A temporary visitor (betwixt anonymous and authenticated)."""
41 |
42 | DEFAULT_TOKEN_EXPIRY = datetime.timedelta(seconds=VISITOR_TOKEN_EXPIRY)
43 | DEFAULT_SELF_SERVICE_EMAIL = "anon@example.com"
44 |
45 | uuid = models.UUIDField(default=uuid.uuid4, unique=True)
46 | first_name = models.CharField(max_length=150, blank=True)
47 | last_name = models.CharField(max_length=150, blank=True)
48 | email = models.EmailField(db_index=True)
49 | scope = models.CharField(
50 | max_length=100, help_text=_lazy("Used to map request to view function")
51 | )
52 | created_at = models.DateTimeField(default=tz_now)
53 | context = models.JSONField(
54 | null=True,
55 | blank=True,
56 | help_text=_lazy("Used to store arbitrary contextual data."),
57 | )
58 | last_updated_at = models.DateTimeField(auto_now=True)
59 | expires_at = models.DateTimeField(
60 | blank=True,
61 | null=True,
62 | help_text=_lazy(
63 | "After this time the link can no longer be used - "
64 | "defaults to VISITOR_TOKEN_EXPIRY."
65 | ),
66 | )
67 | is_active = models.BooleanField(
68 | default=True,
69 | help_text=_lazy(
70 | "Set to False to disable the visitor link and prevent further access."
71 | ),
72 | )
73 | session_expiry = models.PositiveIntegerField(
74 | default=VISITOR_SESSION_EXPIRY,
75 | help_text=_lazy("Time in seconds after which visitor session should expire."),
76 | )
77 |
78 | objects = VisitorManager()
79 |
80 | class Meta:
81 | verbose_name = "Visitor pass"
82 | verbose_name_plural = "Visitor passes"
83 |
84 | def __str__(self) -> str:
85 | return f"Visitor pass {self.id} (scope='{self.scope}')"
86 |
87 | def __repr__(self) -> str:
88 | return (
89 | f""
91 | )
92 |
93 | def __init__(self, *args: Any, **kwargs: Any):
94 | super().__init__(*args, **kwargs)
95 | if not self.expires_at:
96 | self.expires_at = self.created_at + self.DEFAULT_TOKEN_EXPIRY
97 |
98 | @property
99 | def full_name(self) -> str:
100 | return f"{self.first_name} {self.last_name}"
101 |
102 | @property
103 | def session_data(self) -> str:
104 | return str(self.uuid)
105 |
106 | @property
107 | def has_expired(self) -> bool:
108 | """Return True if the expires_at timestamp has been passed (or not yet set)."""
109 | if not self.expires_at:
110 | return False
111 | return self.expires_at < tz_now()
112 |
113 | @property
114 | def is_valid(self) -> bool:
115 | """Return True if the token is active and not yet expired."""
116 | return self.is_active and not self.has_expired
117 |
118 | def is_self_service(self) -> bool:
119 | """Return True if the token was a self-service token."""
120 | return self.context.get("self-service", False)
121 |
122 | def validate(self) -> None:
123 | """Raise InvalidVisitorPass if inactive or expired."""
124 | if not self.is_active:
125 | raise InvalidVisitorPass("Visitor pass is inactive")
126 | if self.has_expired:
127 | raise InvalidVisitorPass("Visitor pass has expired")
128 |
129 | def serialize(self) -> dict:
130 | """
131 | Return JSON-serializable representation.
132 |
133 | Useful for template context and session data.
134 |
135 | """
136 | return {
137 | "uuid": str(self.uuid),
138 | "first_name": self.first_name,
139 | "last_name": self.last_name,
140 | "full_name": self.full_name,
141 | "email": self.email,
142 | "scope": self.scope,
143 | "context": self.context,
144 | }
145 |
146 | def tokenise(self, url: str) -> str:
147 | """Combine url with querystring token."""
148 | # from https://stackoverflow.com/a/2506477/45698
149 | parts = list(urlparse(url))
150 | query = parse_qs(parts[4])
151 | query.update({VISITOR_QUERYSTRING_KEY: self.uuid})
152 | parts[4] = urlencode(query)
153 | return urlunparse(parts)
154 |
155 | def deactivate(self) -> None:
156 | """Deactivate the token so it can no longer be used."""
157 | self.is_active = False
158 | self.save()
159 |
160 | def reactivate(self) -> None:
161 | """Reactivate the token so it can be reused."""
162 | self.is_active = True
163 | self.expires_at = tz_now() + self.DEFAULT_TOKEN_EXPIRY
164 | self.save()
165 |
166 |
167 | class VisitorLogManager(models.Manager):
168 | def create_log(self, request: HttpRequest, status_code: int) -> VisitorLog:
169 | """Extract values from HttpRequest and store locally."""
170 | return self.create(
171 | visitor=request.visitor,
172 | session_key=request.session.session_key or "",
173 | http_method=request.method,
174 | request_uri=request.path,
175 | query_string=request.META.get("QUERY_STRING", ""),
176 | http_user_agent=request.META.get("HTTP_USER_AGENT", ""),
177 | # we care about the domain more than the URL itself, so truncating
178 | # doesn't lose much useful information
179 | http_referer=request.META.get("HTTP_REFERER", ""),
180 | # X-Forwarded-For is used by convention when passing through
181 | # load balancers etc., as the REMOTE_ADDR is rewritten in transit
182 | remote_addr=(
183 | request.META.get("HTTP_X_FORWARDED_FOR")
184 | if "HTTP_X_FORWARDED_FOR" in request.META
185 | else request.META.get("REMOTE_ADDR")
186 | ),
187 | status_code=status_code,
188 | )
189 |
190 |
191 | class VisitorLog(models.Model):
192 | """Log visitors."""
193 |
194 | visitor = models.ForeignKey(Visitor, related_name="visits", on_delete=CASCADE)
195 | session_key = models.CharField(blank=True, max_length=40)
196 | http_method = models.CharField(max_length=10)
197 | request_uri = models.URLField()
198 | remote_addr = models.CharField(max_length=100)
199 | query_string = models.TextField(blank=True)
200 | http_user_agent = models.TextField()
201 | http_referer = models.TextField()
202 | status_code = models.PositiveIntegerField("HTTP Response", default=0)
203 | timestamp = models.DateTimeField(default=tz_now)
204 |
205 | objects = VisitorLogManager()
206 |
--------------------------------------------------------------------------------