diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 2c9fa98..0000000 --- a/.flake8 +++ /dev/null @@ -1,25 +0,0 @@ -[flake8] -max-line-length = 88 -max-complexity = 8 -# http://flake8.pycqa.org/en/2.5.5/warnings.html#warning-error-codes -ignore = - # pydocstyle - docstring conventions (PEP257) - D1 # [pydocstyle] missing docstring - D203 # [pydocstyle] 1 blank line required before class docstring - D212 # [pydocstyle] Multi-line docstring summary should start at the first line - D406 # [pydocstyle] Section name should end with a newline - D407 # [pydocstyle] Missing dashed underline after section - D412 # [pydocstyle] No blank lines allowed between a section header and its content - # pycodestyle - style checker (PEP8) - W503 # [pycodestyle] line break before binary operator - W504 # [pycodestyle] line break after binary operator - # bandit - security warnings - S105 # [bandit] Possible hardcoded password string - S106 # [bandit] Possible hardcoded password argument - S308 # [bandit] Use of mark_safe - S311 # [bandit] Use of random - S703 # [bandit] Use of django_mark_safe - -[bandit] -exclude_dirs = - tests diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml index 72ba478..9497167 100644 --- a/.github/workflows/tox.yml +++ b/.github/workflows/tox.yml @@ -14,15 +14,38 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - toxenv: [fmt,lint,mypy] + toxenv: [fmt, lint, mypy] env: TOXENV: ${{ matrix.toxenv }} steps: - name: Check out the repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - - name: Set up Python 3.11 + - name: Set up Python (3.11) + uses: actions/setup-python@v4 + with: + python-version: "3.11" + + - name: Install and run tox + run: | + pip install tox + tox + + checks: + name: Run Django checks + runs-on: ubuntu-latest + strategy: + matrix: + toxenv: ["django-checks"] + env: + TOXENV: ${{ matrix.toxenv }} + + steps: + - name: Check out the repository + uses: actions/checkout@v4 + + - name: Set up Python (3.11) uses: actions/setup-python@v4 with: python-version: "3.11" @@ -37,20 +60,31 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python: ["3.8", "3.11"] - django: [32,42,main] + python: ["3.8", "3.9", "3.10", "3.11", "3.12"] + # build LTS version, next version, HEAD + django: ["32", "42", "50", "main"] exclude: + - python: "3.8" + django: "50" - python: "3.8" django: "main" - python: "3.9" + django: "50" + - python: "3.9" + django: "main" + - python: "3.10" django: "main" + - python: "3.11" + django: "32" + - python: "3.12" + django: "32" env: - TOXENV: py${{ matrix.python }}-django${{ matrix.django }} + TOXENV: django${{ matrix.django }}-py${{ matrix.python }} steps: - name: Check out the repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@v4 diff --git a/.gitignore b/.gitignore index 5e01b51..fd03eb4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,13 @@ .coverage +.ruff_cache/ .tox/ *.log *.pot *.pyc delme +dist/ django_perimeter.egg-info/ local_settings.py poetry.lock static +test.db diff --git a/.isort.cfg b/.isort.cfg deleted file mode 100644 index a32eb71..0000000 --- a/.isort.cfg +++ /dev/null @@ -1,8 +0,0 @@ -[settings] -default_section=THIRDPARTY -indent=' ' -sections=FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,TESTS,LOCALFOLDER -multi_line_output=3 -line_length=88 -include_trailing_comma=True -use_parentheses=True diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 0000000..ccc1947 --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,66 @@ +line-length = 88 +ignore = [ + "D100", # Missing docstring in public module + "D101", # Missing docstring in public class + "D102", # Missing docstring in public method + "D103", # Missing docstring in public function + "D104", # Missing docstring in public package + "D105", # Missing docstring in magic method + "D106", # Missing docstring in public nested class + "D107", # Missing docstring in __init__ + "D203", # 1 blank line required before class docstring + "D212", # Multi-line docstring summary should start at the first line + "D213", # Multi-line docstring summary should start at the second line + "D404", # First word of the docstring should not be "This" + "D405", # Section name should be properly capitalized + "D406", # Section name should end with a newline + "D407", # Missing dashed underline after section + "D410", # Missing blank line after section + "D411", # Missing blank line before section + "D412", # No blank lines allowed between a section header and its content + "D416", # Section name should end with a colon + "D417", + "D417", # Missing argument description in the docstring +] +select = [ + "A", # flake8 builtins + "C9", # mcabe + "D", # pydocstyle + "E", # pycodestyle (errors) + "F", # Pyflakes + "I", # isort + "S", # flake8-bandit + "T2", # flake8-print + "W", # pycodestype (warnings) +] + +[isort] +combine-as-imports = true + +[mccabe] +max-complexity = 8 + +[per-file-ignores] +"*tests/*" = [ + "D205", # 1 blank line required between summary line and description + "D400", # First line should end with a period + "D401", # First line should be in imperative mood + "D415", # First line should end with a period, question mark, or exclamation point + "E501", # Line too long + "E731", # Do not assign a lambda expression, use a def + "S101", # Use of assert detected + "S105", # Possible hardcoded password + "S106", # Possible hardcoded password + "S113", # Probable use of requests call with timeout set to {value} +] +"*/migrations/*" = [ + "E501", # Line too long +] +"*/settings.py" = [ + "F403", # from {name} import * used; unable to detect undefined names + "F405", # {name} may be undefined, or defined from star imports: +] +"*/settings/*" = [ + "F403", # from {name} import * used; unable to detect undefined names + "F405", # {name} may be undefined, or defined from star imports: +] diff --git a/CHANGELOG.md b/CHANGELOG similarity index 71% rename from CHANGELOG.md rename to CHANGELOG index 54497ec..6eaae8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG @@ -2,6 +2,13 @@ All notable changes to this project will be documented in this file. +## v0.16.0 + +- Add support for Django 5.0 +- Add support for Python 3.12 + +No functional code changes. + ## v0.15.0 - Drops support for Python 3.7 diff --git a/README b/README new file mode 100644 index 0000000..2cad1ab --- /dev/null +++ b/README @@ -0,0 +1,101 @@ +# Django Perimeter + +Perimeter is a Django app that provides middleware that allows you to +'secure the perimeter' of your django site outside of any existing auth +process that you have. + +## Compatibility + +**This package now requires Python 3.8+ and Django 3.2+.** + +For previous versions please refer to the relevant branch. + +## Why? + +Most django sites have some kind of user registration and security model - +a login process, decorators to secure certain URLs, user accounts - +everything that comes with `django.contrib.auth` and associated apps. + +Sometimes, however, you want to simply secure the entire site to prevent +prying eyes - the classic example being before a site goes live. You +want to erect a secure perimeter fence around the entire thing. If you +have control over your front-end web server (e.g. Apache, Nginx) then +this can be used to do this using their in-built access control +features. However, if you are running your app on a hosting platform you +may not have admin access to these parts. Even if you do have control +over your webserver, you may not want to be re-configuring it every time +you want to grant someone access. + +That's when you need Perimeter. + +Perimeter provides simple tokenised access control over your entire +Django site (everything, including the admin site and login pages). + +## How does it work? + +Once you have installed and enabled Perimeter, everyone requiring access +will need an authorisation token (not authentication - there is nothing +inherent in Perimeter to prevent people swapping / sharing tokens - that +is an accepted use case). + +Perimeter runs as middleware that will inspect the user's `session` +for a token. If they have a valid token, then they continue to use the +site uninterrupted. If they do not have a token, or the token is invalid +(expired or set to inactive), then they are redirected to the Perimeter +'Gateway', where they must enter a valid token, along with their name +and email (for auditing purposes - this is stored in the database). + +To create a new token you need to head to the admin site, and create a +new token under the Perimeter app. If you have `PERIMETER_ENABLED` set +to True already you won't be able to access the admin site (as Perimeter +covers everything except for the perimeter 'gateway' form), and so there +is a management command (`create_access_token`) that you can use to +create your first token. (This is analagous to the Django setup process +where it prompts you to create a superuser.) + +Setup +----- + +1. Add `"perimeter"` to your installed apps. +2. Add `"perimeter.middleware.PerimeterAccessMiddleware"` to the list of MIDDLEWARE_CLASSES +3. Add the perimeter urls, including the `"perimeter"` namespace. +4. Add `PERIMETER_ENABLED = True` to your settings file. This setting can be used to enable or disable Perimeter in different environments. + + +Settings: + +.. code:: python + + PERIMETER_ENABLED = True + + INSTALLED_APPS = ( + ... + "perimeter", + ... + ) + + # Perimeter's middleware must be after SessionMiddleware as it relies on + # request.session + MIDDLEWARE_CLASSES = [ + ... + "django.contrib.sessions.middleware.SessionMiddleware", + "perimeter.middleware.PerimeterAccessMiddleware", + ... + ] + +Site urls: + +.. code:: python + + # in site urls + urlpatterns = [ + ... + # NB you must include the namespace, as it is referenced in the app + path("perimeter/", include("perimeter.urls", namespace="perimeter")), + ... + ] + +## Tests + +The app has a suite of tests, and a ``tox.ini`` file configured to run +them when using ``tox`` (recommended). diff --git a/README.rst b/README.rst deleted file mode 100644 index 06496c8..0000000 --- a/README.rst +++ /dev/null @@ -1,74 +0,0 @@ -**This package now requires Python 3.8+ and Django 3.2+. For previous versions please refer to the relevant branch.** - -Django Perimeter -================ - -Perimeter is a Django app that provides middleware that allows you to 'secure the perimeter' of your django site outside of any existing auth process that you have. - -Why? ----- - -Most django sites have some kind of user registration and security model - a login process, decorators to secure certain URLs, user accounts - everything that comes with django.contrib.auth and associated apps (django-registration). - -Sometimes, however, you want to simply secure the entire site to prevent prying eyes - the classic example being before a site goes live. You want to erect a secure perimeter fence around the entire thing. If you have control over your front-end web server (e.g. Apache, Nginx) then this can be used to do this using their in-built access control features. However, if you are running your app on a hosting platform you may not have admin access to these parts. Even if you do have control over your webserver, you may not want to be re-configuring it every time you want to grant someone access. - -That's when you need Perimeter. - -Perimeter provides simple tokenised access control over your entire Django site (everything, including the admin site and login pages). - -How does it work? ------------------ - -Once you have installed and enabled Perimeter, everyone requiring access will need an authorisation token (not authentication - there is nothing inherent in Perimeter to prevent people swapping / sharing tokens - that is an accepted use case). - -Perimeter runs as middleware that will inspect the user's ``session`` for a -token. If they have a valid token, then they continue to use the site uninterrupted. If they do not have a token, or the token is invalid (expired or set to inactive), then they are redirected to the Perimeter 'Gateway', where they must enter a valid token, along with their name and email (for auditing purposes - this is stored in the database). - -To create a new token you need to head to the admin site, and create a new token under the Perimeter app. If you have ``PERIMETER_ENABLED`` set to True already you won't be able to access the admin site (as Perimeter covers everything except for the perimeter 'gateway' form), and so there is a management command (``create_access_token``) that you can use to create your first token. (This is analagous to the Django setup process where it prompts you to create a superuser.) - -Setup ------ - -1. Add ``"perimeter"`` to your installed apps. -2. Add ``"perimeter.middleware.PerimeterAccessMiddleware"`` to the list of MIDDLEWARE_CLASSES -3. Add the perimeter urls, including the ``"perimeter"`` namespace. -4. Add ``PERIMETER_ENABLED = True`` to your settings file. This setting can be used to enable or disable Perimeter in different environments. - - -Settings: - -.. code:: python - - PERIMETER_ENABLED = True - - INSTALLED_APPS = ( - ... - "perimeter", - ... - ) - - # Perimeter's middleware must be after SessionMiddleware as it relies on - # request.session - MIDDLEWARE_CLASSES = [ - ... - "django.contrib.sessions.middleware.SessionMiddleware", - "perimeter.middleware.PerimeterAccessMiddleware", - ... - ] - -Site urls: - -.. code:: python - - # in site urls - urlpatterns = [ - ... - # NB you must include the namespace, as it is referenced in the app - path("perimeter/", include("perimeter.urls", namespace="perimeter")), - ... - ] - -Tests ------ - -The app has a suite of tests, and a ``tox.ini`` file configured to run them when using ``tox`` (recommended). diff --git a/perimeter/__init__.py b/perimeter/__init__.py index 68cfb03..e69de29 100644 --- a/perimeter/__init__.py +++ b/perimeter/__init__.py @@ -1 +0,0 @@ -__version__ = "0.15.dev0" diff --git a/perimeter/admin.py b/perimeter/admin.py index 14af4ec..3af97bc 100644 --- a/perimeter/admin.py +++ b/perimeter/admin.py @@ -1,6 +1,6 @@ from django.contrib.admin import ModelAdmin, site -from .models import AccessToken, AccessTokenUse +from .models import AccessToken class AccessTokenAdmin(ModelAdmin): @@ -24,15 +24,15 @@ def formfield_for_foreignkey(self, db_field, request, **kwargs): site.register(AccessToken, AccessTokenAdmin) -class AccessTokenUseAdmin(ModelAdmin): - list_display = ("token", "expires_on", "timestamp", "client_ip") - readonly_fields = ("timestamp", "client_user_agent", "client_ip") - raw_id_fields = ("token",) +# class AccessTokenUseAdmin(ModelAdmin): +# list_display = ("token", "expires_on", "timestamp", "client_ip") +# readonly_fields = ("timestamp", "client_user_agent", "client_ip") +# raw_id_fields = ("token",) - def expires_on(self, obj): - return obj.token.expires_on +# def expires_on(self, obj): +# return obj.token.expires_on - expires_on.short_description = "Token Expires" +# expires_on.short_description = "Token Expires" -site.register(AccessTokenUse, AccessTokenUseAdmin) +# site.register(AccessTokenUse, AccessTokenUseAdmin) diff --git a/perimeter/apps.py b/perimeter/apps.py index 168a629..fa72a0a 100644 --- a/perimeter/apps.py +++ b/perimeter/apps.py @@ -4,3 +4,4 @@ class PerimeterAppConfig(AppConfig): name = "perimeter" verbose_name = "Perimeter" + default_auto_field = "django.db.models.AutoField" diff --git a/perimeter/management/commands/create_access_token.py b/perimeter/management/commands/create_access_token.py index 7f7dcbe..4f89dcd 100644 --- a/perimeter/management/commands/create_access_token.py +++ b/perimeter/management/commands/create_access_token.py @@ -13,7 +13,7 @@ class Command(BaseCommand): - help = "Create a perimeter access token." + help = "Create a perimeter access token." # noqa: A003 def add_arguments(self, parser: ArgumentParser) -> None: parser.add_argument( diff --git a/perimeter/management/commands/list_access_tokens.py b/perimeter/management/commands/list_access_tokens.py index 47f28c2..1d3a9c6 100644 --- a/perimeter/management/commands/list_access_tokens.py +++ b/perimeter/management/commands/list_access_tokens.py @@ -8,7 +8,7 @@ class Command(BaseCommand): - help = "List all active tokens." + help = "List all active tokens." # noqa: A003 def handle(self, *args: Any, **options: Any) -> None: self.stdout.write("Listing all tokens:") diff --git a/perimeter/middleware.py b/perimeter/middleware.py index d6c9002..3d509c6 100644 --- a/perimeter/middleware.py +++ b/perimeter/middleware.py @@ -13,9 +13,12 @@ from django.utils.deprecation import MiddlewareMixin from .models import AccessToken, EmptyToken -from .settings import HTTP_X_PERIMETER_TOKEN -from .settings import PERIMETER_BYPASS_FUNCTION as bypass_perimeter -from .settings import PERIMETER_ENABLED, PERIMETER_SESSION_KEY +from .settings import ( + HTTP_X_PERIMETER_TOKEN, + PERIMETER_BYPASS_FUNCTION as bypass_perimeter, + PERIMETER_ENABLED, + PERIMETER_SESSION_KEY, +) def check_middleware(func: Callable) -> Callable: diff --git a/pyproject.toml b/pyproject.toml index 47f99d4..6699799 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,38 +1,38 @@ [tool.poetry] name = "django-perimeter" -version = "0.15.1" +version = "0.16.0" description = "Site-wide perimeter access control for Django projects." license = "MIT" authors = ["YunoJuno "] maintainers = ["YunoJuno "] -readme = "README.rst" +readme = "README" homepage = "https://github.com/yunojuno/django-perimeter" repository = "https://github.com/yunojuno/django-perimeter" classifiers = [ - "Development Status :: 4 - Beta", "Environment :: Web Environment", "Framework :: Django :: 3.2", "Framework :: Django :: 4.0", "Framework :: Django :: 4.1", "Framework :: Django :: 4.2", + "Framework :: Django :: 5.0", "Operating System :: OS Independent", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", ] packages = [{ include = "perimeter" }] [tool.poetry.dependencies] python = "^3.8" -django = "^3.2 || ^4.0" +django = "^3.2 || ^4.0 | ^5.0" [tool.poetry.dev-dependencies] black = "*" coverage = "*" djhtml = "*" -freezegun = "*" mypy = "*" pre-commit = "*" pytest = "*" diff --git a/tests/test_models.py b/tests/test_models.py index 28ae7b3..8beacd4 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -120,8 +120,7 @@ def test_has_expired(self): self.assertFalse(at.has_expired) def test_seconds_to_expiry(self): - "Test that it handles naive and tz-aware times" - + # Test that it handles naive and tz-aware times with self.settings(USE_TZ=False): at = AccessToken(expires_on=TOMORROW) expires_at = datetime.combine(at.expires_on, time.min) diff --git a/tox.ini b/tox.ini index ce02551..7aa5482 100644 --- a/tox.ini +++ b/tox.ini @@ -3,10 +3,13 @@ isolated_build = True envlist = fmt, lint, mypy, django-checks, - py3.8-django{32,40,41,42} - py3.9-django{32,40,41,42} - py3.10-django{32,40,41,42,main} - py3.11-django{32,40,41,42,main} + ; https://docs.djangoproject.com/en/5.0/releases/ + django32-py{38,39,310} + django40-py{38,39,310} + django41-py{38,39,310,311} + django42-py{38,39,310,311} + django50-py{310,311,312} + djangomain-py{311,312} [testenv] deps = @@ -18,6 +21,7 @@ deps = django40: Django>=4.0,<4.1 django41: Django>=4.1,<4.2 django42: Django>=4.2,<4.3 + django50: https://github.com/django/django/archive/stable/5.0.x.tar.gz djangomain: https://github.com/django/django/archive/main.tar.gz commands = @@ -50,9 +54,6 @@ commands = description = Python source code type hints (mypy) deps = mypy - types-requests - types-python-dateutil - types-simplejson commands = mypy perimeter