pax_global_header00006660000000000000000000000064152473141420014515gustar00rootroot0000000000000052 comment=88cf4970423b11f29d6840311f53378d007a3d63 django-otp-django-otp-f5775d4/000077500000000000000000000000001524731414200161665ustar00rootroot00000000000000django-otp-django-otp-f5775d4/.bumpversion.cfg000066400000000000000000000006371524731414200213040ustar00rootroot00000000000000[bumpversion] current_version = 1.7.3 commit = true message = Version {new_version} tag = true [bumpversion:file:CHANGES.rst] search = Unreleased replace = v{new_version} - {now:%B %d, %Y} [bumpversion:file:pyproject.toml] search = version = "{current_version}" replace = version = "{new_version}" [bumpversion:file:docs/source/conf.py] search = release = '{current_version}' replace = release = '{new_version}' django-otp-django-otp-f5775d4/.coveragerc000066400000000000000000000000641524731414200203070ustar00rootroot00000000000000[run] source = django_otp omit = */migrations/* django-otp-django-otp-f5775d4/.devcontainer/000077500000000000000000000000001524731414200207255ustar00rootroot00000000000000django-otp-django-otp-f5775d4/.devcontainer/Dockerfile000066400000000000000000000010471524731414200227210ustar00rootroot00000000000000FROM mcr.microsoft.com/devcontainers/python:1-3.11-bullseye ENV PYTHONUNBUFFERED 1 # [Optional] If your requirements rarely change, uncomment this section to add them to the image. # COPY requirements.txt /tmp/pip-tmp/ # RUN pip3 --disable-pip-version-check --no-cache-dir install -r /tmp/pip-tmp/requirements.txt \ # && rm -rf /tmp/pip-tmp # [Optional] Uncomment this section to install additional OS packages. RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ && apt-get -y install --no-install-recommends postgresql-client django-otp-django-otp-f5775d4/.devcontainer/devcontainer.json000066400000000000000000000017661524731414200243130ustar00rootroot00000000000000// For format details, see https://aka.ms/devcontainer.json. For config options, see the // README at: https://github.com/devcontainers/templates/tree/main/src/postgres { "name": "Python 3 & PostgreSQL", "dockerComposeFile": "docker-compose.yml", "service": "app", "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", "features": { "ghcr.io/devcontainers-contrib/features/hatch:2": { "version": "latest" } }, // Use 'forwardPorts' to make a list of ports inside the container available locally. // This can be used to network with other containers or the host. "forwardPorts": [8000], "containerEnv": {"DJANGO_OTP_CONFIG": "test/config/github.toml"} // Use 'postCreateCommand' to run commands after the container is created. // "postCreateCommand": "pip install --user -r requirements.txt", // Configure tool-specific properties. // "customizations": {}, // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. // "remoteUser": "root" } django-otp-django-otp-f5775d4/.devcontainer/docker-compose.yml000066400000000000000000000017631524731414200243710ustar00rootroot00000000000000version: '3.8' services: app: build: context: .. dockerfile: .devcontainer/Dockerfile volumes: - ../..:/workspaces:cached # Overrides default command so things don't shut down after the process ends. command: sleep infinity # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function. network_mode: service:db # Use "forwardPorts" in **devcontainer.json** to forward an app port locally. # (Adding the "ports" property to this file will not forward from a Codespace.) db: image: postgres:latest restart: unless-stopped volumes: - postgres-data:/var/lib/postgresql/data environment: POSTGRES_USER: postgres POSTGRES_DB: django-otp POSTGRES_PASSWORD: postgres # Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally. # (Adding the "ports" property to this file will not forward from a Codespace.) volumes: postgres-data: django-otp-django-otp-f5775d4/.flake8000066400000000000000000000002421524731414200173370ustar00rootroot00000000000000[flake8] ignore = # line break before binary operator W503 # line break after binary operator W504 # line too long E501 django-otp-django-otp-f5775d4/.github/000077500000000000000000000000001524731414200175265ustar00rootroot00000000000000django-otp-django-otp-f5775d4/.github/workflows/000077500000000000000000000000001524731414200215635ustar00rootroot00000000000000django-otp-django-otp-f5775d4/.github/workflows/test.yaml000066400000000000000000000013611524731414200234270ustar00rootroot00000000000000name: Run tests on: pull_request: branches: [ master ] jobs: test: runs-on: ubuntu-latest services: postgres: image: postgres env: POSTGRES_PASSWORD: postgres options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 ports: - 5432:5432 steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: python-version: | 3.10 3.12 3.14 - name: Install hatch run: pipx install hatch - name: Run Tests run: hatch run test:run env: DJANGO_OTP_CONFIG: test/config/github.toml django-otp-django-otp-f5775d4/.gitignore000066400000000000000000000002011524731414200201470ustar00rootroot00000000000000# Python __pycache__/ *.py[co] # builds /dist/ /docs/build/ # test *.sqlite3 /test/config/env-*.toml # Other tools /.coverage django-otp-django-otp-f5775d4/.hgignore000066400000000000000000000003661524731414200177760ustar00rootroot00000000000000# Don't remove this file. # # When hatch builds an sdist, it includes any .gitignore and .hgignore it can # find here or in parent directories. To avoid information leakage, this file # masks any .hgignore one might have in their home directory. django-otp-django-otp-f5775d4/.readthedocs.yaml000066400000000000000000000002441524731414200214150ustar00rootroot00000000000000version: 2 build: os: ubuntu-22.04 tools: python: '3.12' sphinx: configuration: docs/source/conf.py python: install: - method: pip path: . django-otp-django-otp-f5775d4/CHANGES.rst000066400000000000000000000533411524731414200177760ustar00rootroot00000000000000v1.7.3 - September 06, 2026 - Admin asset fixes -------------------------------------------------------------------------------- - `#191`_: Improve admin page compatibility with CSP (Varun Juneja) .. _#191: https://github.com/django-otp/django-otp/pull/191 v1.7.2 - September 03, 2026 - Fix throttling overflow -------------------------------------------------------------------------------- - `#190`_: OverflowError on the admin login page when a device's throttling failure count is high (Varun Juneja) .. _#190: https://github.com/django-otp/django-otp/pull/190 v1.7.1 - September 02, 2026 - Form improvement -------------------------------------------------------------------------------- - Routine test matrix update - `#188`_: Fix authentication form inappropriately triggering rate-limiting Thanks to Varun Juneja. .. _#188: https://github.com/django-otp/django-otp/pull/188 v1.7.0 - January 07, 2026 - Async support -------------------------------------------------------------------------------- - `#185`_: Make OTPMiddleware async capable Thanks to Aljosha Papsch. .. _#185: https://github.com/django-otp/django-otp/pull/185 v1.6.3 - October 25, 2025 - Spanish update -------------------------------------------------------------------------------- - `#182`_: Correct missing Spanish translations - `#181`_: Wrong :rtype: in StaticToken.random_token docstring .. _#181: https://github.com/django-otp/django-otp/issues/181 .. _#182: https://github.com/django-otp/django-otp/pull/182 v1.6.2 - October 21, 2025 - Cleanup -------------------------------------------------------------------------------- - `#179`_: Add missing gettext strings - `#180`_: Remove tests from wheels .. _#179: https://github.com/django-otp/django-otp/pull/179 .. _#180: https://github.com/django-otp/django-otp/pull/180 v1.6.1 - July 08, 2025 - Small improvements -------------------------------------------------------------------------------- - Allow a {token} placeholder in :setting:`OTP_EMAIL_SUBJECT`. v1.6.0 - April 02, 2025 - Django 5.2 -------------------------------------------------------------------------------- - Update test matrix for Django 5.2. - Remove support for Django 3.2. v1.5.4 - September 06, 2024 - Ignore proxy models when enumerating device classes --------------------------------------------------------------------------------- - `#161`_: Discard proxied models when iterating device models .. _#161: https://github.com/django-otp/django-otp/pull/161 v1.5.3 - September 04, 2024 - Small admin template fix -------------------------------------------------------------------------------- - `#158`_: Remove JS focus() in admin login template .. _#158: https://github.com/django-otp/django-otp/pull/158 v1.5.2 - August 18, 2024 - otp_verification_failed signal -------------------------------------------------------------------------------- - `#150`_: Add signal when OTP verification fails .. _#150: https://github.com/django-otp/django-otp/pull/150 v1.5.1 - July 23, 2024 - Admin search fields -------------------------------------------------------------------------------- - `#147`_: Add search ability in admin using username and email .. _#147: https://github.com/django-otp/django-otp/pull/147 v1.5.0 - April 16, 2024 - Support segno for QR codes -------------------------------------------------------------------------------- - `#141`_: Support alternative QR code library `segno`_. Previously, only the `qrcode`_ library was supported. Use ``segno`` by installing ``django-otp[segno]`` or just install the ``segno`` package. .. _#141: https://github.com/django-otp/django-otp/issues/141 .. _segno: https://pypi.python.org/pypi/segno/ v1.4.1 - April 10, 2024 - Minor EmailDevice updates -------------------------------------------------------------------------------- - `#140`_: Support customization of email delivery. See the :class:`~django_otp.plugins.otp_email.models.EmailDevice` documentation for API details. - Support translation of the "sent by email" message. .. _#140: https://github.com/django-otp/django-otp/pull/140 v1.4.0 - April 09, 2024 - Add TimestampMixin -------------------------------------------------------------------------------- - `#137`_: Add TimestampMixin Add a new TimestampMixin with ``created_at`` and ``last_used_at`` fields for device models. All builtin plugins now have these timestamp fields and require migrating. - Some documentation cleanup. .. _#137: https://github.com/django-otp/django-otp/pull/137 v1.3.0 - November 08, 2023 - Support cooldowns for token generation -------------------------------------------------------------------------------- - `#122`_: Added throttling to token generation. Devices that generate random tokens can take advantage of the new :class:`~django_otp.models.CooldownMixin` to enforce limits on how frequently new tokens can be generated (and presumably delivered). :class:`~django_otp.plugins.otp_email.models.EmailDevice` uses this and has a :setting:`default cooldown ` configured. Thanks to `Demetris Stavrou`_ for this feature. - Note: :class:`~django_otp.models.VerifyNotAllowed` is now an :class:`~enum.Enum`. This will break any code that inadvisably hard-coded the string value of the `N_FAILED_ATTEMPTS` property. .. _#122: https://github.com/django-otp/django-otp/pull/122 .. _Demetris Stavrou: https://github.com/demestav v1.2.4 - October 05, 2023 - Portuguese translation -------------------------------------------------------------------------------- - `#133`_: Add pt-PT translation. .. _#133: https://github.com/django-otp/django-otp/pull/133 v1.2.3 - September 17, 2023 - German translation fix -------------------------------------------------------------------------------- - `#131`_: Fix German translation .. _#131: https://github.com/django-otp/django-otp/pull/131 v1.2.2 - June 16, 2023 - otp_email html support -------------------------------------------------------------------------------- - `#125`_: Support email body_html templates .. _#125: https://github.com/django-otp/django-otp/pull/125 v1.2.1 - May 26, 2023 - pt-BR translations -------------------------------------------------------------------------------- - `#124`_: Add pt-BR translations. .. _#124: https://github.com/django-otp/django-otp/pull/124 v1.2.0 - May 11, 2023 - Tooling, TOTP images -------------------------------------------------------------------------------- - This project is now managed with `hatch`_, which replaces setuptools, pipenv, and tox. Users of the package should not be impacted. Developers can refer to the readme for details. If you're packaging this project from source, I suggest relying on pip's isolated builds rather than using hatch directly. - `#123`_: Add support for passing an image parameter in the otpauth URL. See :setting:`OTP_TOTP_IMAGE`. .. _hatch: https://hatch.pypa.io/ .. _#123: https://github.com/django-otp/django-otp/pull/123 v1.1.6 - March 07, 2023 - German translation -------------------------------------------------------------------------------- - `#116`_: Add German translation .. _#116: https://github.com/django-otp/django-otp/pull/116 v1.1.5 - March 06, 2023 - Bugfix release -------------------------------------------------------------------------------- - `#115`_: Force OTP_EMAIL_SUBJECT to be a string .. _#115: https://github.com/django-otp/django-otp/pull/115 v1.1.4 - November 10, 2022 - Spanish translation -------------------------------------------------------------------------------- - `#106`_: Add Spanish translation .. _#106: https://github.com/django-otp/django-otp/pull/106 v1.1.3 - November 30, 2021 - Admin template fix -------------------------------------------------------------------------------- - `#89`_: Use the standard `username` context variable for compatibility. .. _#89: https://github.com/django-otp/django-otp/pull/89 v1.1.2 - November 29, 2021 - Forward compatibility -------------------------------------------------------------------------------- - `#93`_: Default to AutoField to avoid spurious migrations. .. _#93: https://github.com/django-otp/django-otp/issues/93 v1.1.1 - September 14, 2021 - Throttling message fix -------------------------------------------------------------------------------- - `#87`_: Fix ``locked_until`` key in throttling reason map. .. _#87: https://github.com/django-otp/django-otp/issues/87 v1.1.0 - September 13, 2021 - Concurrent verification -------------------------------------------------------------------------------- Where possible, all APIs now verify tokens atomically. This prevents race conditions that could result in a token being verified twice as well as closing gaps in throttling enforcement. Low-level integrators may still need to :ref:`manage their own transactions `. v1.0.6 - May 28, 2021 - Email customization -------------------------------------------------------------------------------- - `#82`_: Add ability to pass extra context when rendering :class:`~django_otp.plugins.otp_email.models.EmailDevice` templates. .. _#82: https://github.com/django-otp/django-otp/issues/82 v1.0.5 - May 08, 2021 - config_url fix -------------------------------------------------------------------------------- - `#77`_: Force username to a string in `config_url`. Note that this might not produce a very human-friendly result, but it shouldn't throw an exception. .. _#77: https://github.com/django-otp/django-otp/issues/77 v1.0.4 - April 28, 2021 - Dark mode fix -------------------------------------------------------------------------------- - `#76`_: Django 3.2 supports the prefers-color-scheme media query, so we need to force a white background for QR codes. .. _#76: https://github.com/django-otp/django-otp/issues/76 v1.0.3 - April 03, 2021 - Email body template path setting -------------------------------------------------------------------------------- - `#71`_: Provide time at which throttling lock expires. .. _#71: https://github.com/django-otp/django-otp/issues/71 v1.0.2 - October 23, 2020 - Email body template path setting -------------------------------------------------------------------------------- - Added a setting to load the email body template from a template file. v1.0.1 - October 06, 2020 - Add French translations -------------------------------------------------------------------------------- - Added contributed French string translations. v1.0.0 - August 13, 2020 - Update supported Django verisons. -------------------------------------------------------------------------------- - Dropped support for Django < 2.2. v0.9.4 - August 05, 2020 - Django 3.1 support -------------------------------------------------------------------------------- - `#49`_: Hide the navigation sidebar on the login page. .. _#49: https://github.com/django-otp/django-otp/issues/49 v0.9.3 - June 23, 2020 - June 18, 2020 - Admin fix -------------------------------------------------------------------------------- - Stricter authorization checks for qrcodes in the admin interface. v0.9.1 - May 08, 2020 - Admin fix -------------------------------------------------------------------------------- - `#38`_: Update admin fields for :class:`~django_otp.plugins.otp_email.models.EmailDevice`. .. _#38: https://github.com/django-otp/django-otp/pull/38 v0.9.0 - April 17, 2020 - Improved email device -------------------------------------------------------------------------------- :class:`~django_otp.models.SideChannelDevice` is a new abstract device class to simplify writing devices that deliver tokens to the user by other channels (email, SMS, etc.). - `#33`_, `#34`_ (`arjan-s`_): Implement :class:`~django_otp.models.SideChannelDevice`, reimplement :class:`~django_otp.plugins.otp_email.models.EmailDevice` on top of it, and add a few settings for customization. - Add rate limiting to :class:`~django_otp.plugins.otp_email.models.EmailDevice` and :class:`~django_otp.plugins.otp_static.models.StaticDevice`. .. _#33: https://github.com/django-otp/django-otp/pull/33 .. _#34: https://github.com/django-otp/django-otp/pull/34 .. _arjan-s: https://github.com/arjan-s v0.8.1 - February 08, 2020 - Admin fix -------------------------------------------------------------------------------- - `#26`_: Display OTP Token field on the login page even when user has not yet authenticated. .. _#26: https://github.com/django-otp/django-otp/issues/26 v0.8.0 - February 06, 2020 - Drop Python 2 support -------------------------------------------------------------------------------- - `#17`_: Drop Python 2 support. - `#18`_: Back to a single login template for now. - `#23`_: Allow :setting:`OTP_HOTP_ISSUER` and :setting:`OTP_TOTP_ISSUER` to be callable. .. _#17: https://github.com/django-otp/django-otp/pull/17 .. _#18: https://github.com/django-otp/django-otp/pull/18 .. _#23: https://github.com/django-otp/django-otp/pull/23 v0.7.5 - December 27, 2019 - Django 3.0 support -------------------------------------------------------------------------------- - `#15`_: Add admin template for Django 3.0. .. _#15: https://github.com/django-otp/django-otp/issues/15 v0.7.4 - November 21, 2019 - Cleanup -------------------------------------------------------------------------------- - `#10`_: Remove old admin login templates that are confusing some unrelated tools. .. _#10: https://github.com/django-otp/django-otp/issues/10 v0.7.3 - October 22, 2019 - Minor improvements ---------------------------------------------- - Built-in forms have autocomplete disabled for token widgets. - Fixed miscellaneous typos. v0.7.2 - September 17, 2019 - LoginView fix ------------------------------------------- - `#2`_: Fix LoginView for already-authenticated users, with multiple auth backends configured. .. _#2: https://github.com/django-otp/django-otp/issues/2 v0.7.1 - September 12, 2019 - Preliminary Django 3.0 support ------------------------------------------------------------ Removed dependencies on Python 2 compatibility shims in Django < 3.0. v0.7.0 - August 26, 2019 - Housekeeping --------------------------------------- Removed obsolete compatibility shims. The testing and support matrix is unchanged from 0.6.0, so there should be no impact. v0.6.0 - April 22, 2019 - Failure throttling -------------------------------------------- - Built-in :ref:`HOTP ` and :ref:`TOTP ` devices are now rate-limited, enforcing exponentially increasing delays between successive failures. See the device documentation for information on presenting more useful error messages when this happens, as well as for tuning (or disabling) this behavior. Thanks to Luke Plant for the idea and implementation. v0.5.2 - February 11 - 2019 - Fix URL encoding ---------------------------------------------- - Fix encoding of otpauth:// URL parameters. v0.5.1 - October 24, 2018 - Customizable error messages ------------------------------------------------------- - Error messages in :class:`~django_otp.forms.OTPAuthenticationForm` and :class:`~django_otp.forms.OTPTokenForm` can be customized. v0.5.0 - August 14, 2018 - Django 2.1 support --------------------------------------------- - Remove dependencies on old non-class login views. - Drop support for Django < 1.11. v0.4.3 - March 8, 2018 - Minor static token fix ----------------------------------------------- - Fix return type of :meth:`~django_otp.plugins.otp_static.models.StaticToken.random_token`. v0.4.2 - December 15, 2017 - addstatictoken fix ----------------------------------------------- - Fix addstatictoken string handling under Python 3. v0.4.1 - August 29, 2017 - Misc fixes ------------------------------------- - Improved handling of device persistent identifiers. - Make sure default keys are unicode values. v0.4.0 - July 19, 2017 - Update support matrix ---------------------------------------------- - Fix addstatictoken on Django 1.10+. - Drop support for versions of Django that are past EOL. v0.3.14 - May 30, 2017 - addstatictoken fix ------------------------------------------- - Update addstatictoken command for current Django versions. v0.3.13 - April 11, 2017 - Pickle compatibility ----------------------------------------------- - Allow verified users to be pickled. v0.3.12 - April 2, 2017 - Forward compatibility ----------------------------------------------- - Minor fixes for Django 1.11 and 2.0. v0.3.11 - March 8, 2017 - Built-in QR Code support -------------------------------------------------- - Generate HOTP and TOTP otpauth URLs and corresponding QR Codes. To enable this feature, install ``django-otp[qrcode]`` or just install the `qrcode`_ package. - Support for Python 2.6 and Django 1.4 were dropped in this version (long overdue). .. _qrcode: https://pypi.python.org/pypi/qrcode/ v0.3.8 - November 27, 2016 - Forward compatbility for Django 2.0 ---------------------------------------------------------------- - Treat :attr:`~django.contrib.auth.models.User.is_authenticated` and :attr:`~django.contrib.auth.models.User.is_anonymous` as properties in Django 1.10 and later. - Add explict on_delete behavior for all foreign keys. v0.3.7 - September 24, 2016 - Convenience API --------------------------------------------- - Added a convenience API for verifying TOTP tokens: :meth:`django_otp.oath.TOTP.verify`. v0.3.6 - September 4, 2016 - Django 1.10 ---------------------------------------- - Don't break the laziness of ``request.user``. - Improved error message for invalid tokens. - Support the new middleware API in Django 1.10. v0.3.5 - April 13, 2016 - Fix default TOTP key ---------------------------------------------- - The default (random) key for a new TOTP device is now forced to a unicode string. v0.3.4 - January 10, 2016 - Python 3 cleanup -------------------------------------------- - All modules include all four Python 3 __future__ imports for consistency. - Migrations no longer have byte strings in them. v0.3.3 - October 15, 2015 - Django 1.9 -------------------------------------- - Fix the addstatictoken management command under Django 1.9. v0.3.2 - October 11, 2015 - Django 1.8 -------------------------------------- - Stop importing models into the root of the package. - Use ModelAdmin.raw_id_fields for foreign keys to users. - General cleanup and compatibility with Django 1.9a1. v0.3.1 - April 3, 2015 - Django 1.8 ----------------------------------- - Add support for the new app registry, when available. - Add Django 1.8 to the test matrix and fix a few test bugs. v0.3.0 - February 7, 2015 - Support Django migrations ----------------------------------------------------- - All plugins now have both Django and South migrations. Please see the `upgrade notes`_ for details on upgrading from previous versions. .. _upgrade notes: https://pythonhosted.org/django-otp/overview.html#upgrading v0.2.7 - April 26, 2014 - Fix for Custom user models with South --------------------------------------------------------------- - Updated the otp_totp South migrations to support custom user models. Thanks to https://bitbucket.org/robirichter. v0.2.6 - April 18, 2014 - Fix for Python 3.2 with South ------------------------------------------------------- - Removed South-generated unicode string literals. v0.2.4 - April 15, 2014 - TOTP plugin fix (migration warning) ------------------------------------------------------------- - Per the RFC, :class:`~django_otp.plugins.otp_totp.models.TOTPDevice` will no longer verify the same token twice. - Cosmetic fixes to the admin login form on Django 1.6. .. warning:: This includes a model change in TOTPDevice. If you are upgrading and your project uses South, you should first convert it to South with ``manage migrate otp_totp 0001 --fake``. If you're not using South, you will need to generate and run the appropriate SQL manually. v0.2.3 - March 3, 2014 - Fix pickling ------------------------------------- - OTPMiddleware no longer interferes with pickling request.user. v0.2.2 - December 31, 2013 - Require Django 1.4.2 ------------------------------------------------- - Update Django requirement to 1.4.2, the first version with django.utils.six. v0.2.1 - November 19, 2013 - Bug fix ------------------------------------ - Fix unicode representation of devices in some exotic scenarios. v0.2.0 - November 10, 2013 - Django 1.6 --------------------------------------- - Now supports Django 1.4 to 1.6 on Python 2.6, 2.7, 3.2, and 3.3. This is the first release for Python 3. v0.1.8 - August 20, 2013 - user_has_device API ----------------------------------------------- - Add :func:`django_otp.user_has_device` to detect whether a user has any devices configured. This change supports a fix in django-otp-agents 0.1.4. v0.1.7 - July 3, 2013 - Decorator improvement ----------------------------------------------- - Add if_configured argument to :func:`~django_otp.decorators.otp_required`. v0.1.6 - May 9, 2013 - Unit test improvements --------------------------------------------- - Major unit test cleanup. Tests should pass or be skipped under all supported versions of Django, with or without custom users and timzeone support. v0.1.5 - May 8, 2013 - OTPAdminSite improvement ----------------------------------------------- - OTPAdminSite now selects an apporpriate login template automatically, based on the current Django version. Django versions 1.3 to 1.5 are currently supported. - Unit test cleanup. v0.1.3 - March 10, 2013 - Django 1.5 compatibility -------------------------------------------------- - Add support for custom user models in Django 1.5. - Stop using ``Device.objects``: Django doesn't allow access to an abstract model's manager any more. v0.1.2 - October 8, 2012 - Bug fix ---------------------------------- - Fix an exception when an empty login form is submitted. v0.1.0 - August 20, 2012 - Initial Release ------------------------------------------ Initial release. django-otp-django-otp-f5775d4/CONTRIBUTING.rst000066400000000000000000000037121524731414200206320ustar00rootroot00000000000000.. vim: tw=80 lbr Contributing ------------ As mentioned in the README, this project is stable and mature. It's not receiving significant ongoing investment, but well-formed fixes and improvements are welcome. "Well-formed" in this context primarily means that it is fully backward-compatible and includes documentation and test coverage. Improvements that can just as well exist as external plugins should be implemented as such. Before checking in, always run ``hatch run check`` to run linters and the test suite. The CI configuration will actually run ``hatch run test:run``, which will test across multiple Python/Django versions for completeness. If you get linter errors, you may be able to fix them with ``hatch run fix``. An important thing to remember is that this is a framework that supports a wide range of plugins. There's only one rule for a plugin: it must inherit from `django_otp.models.Device` and implement one key method. The project provides a collection of mixins and other abstract model classes to help with common functionality, but they are all optional. The key takeaway here is that when considering backward-compatibility, you can assume virtually nothing. In particular, note that adding a new model field to an abstract model class is a breaking change and should be avoided at almost all costs. Updating an existing abstract model in django-otp will trigger the need for migrations in all projects that rely on it, leading to a special kind of dependency hell. New standard functionality options can be added by defining additional mixins if necessary. Stub methods may be added to the root Device class to define common interfaces for such features. As always, remember that writing the code is often the least part of making a change. Understanding the problem and alternative approaches, writing good documentation, making a testing strategy, and demonstrating backward compatibility collectively tend to dwarf actually typing out the implementation. django-otp-django-otp-f5775d4/LICENSE000066400000000000000000000022741524731414200172000ustar00rootroot00000000000000This is free and unencumbered software released into the public domain. Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means. In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. For more information, please refer to django-otp-django-otp-f5775d4/README.rst000066400000000000000000000134501524731414200176600ustar00rootroot00000000000000.. vim: tw=80 lbr django-otp ========== .. image:: https://img.shields.io/pypi/v/django-otp?color=blue :target: https://pypi.org/project/django-otp/ :alt: PyPI .. image:: https://img.shields.io/readthedocs/django-otp-official :target: https://django-otp-official.readthedocs.io/ :alt: Documentation .. image:: https://img.shields.io/badge/github-django--otp-green :target: https://github.com/django-otp/django-otp :alt: Source This project makes it easy to add support for `one-time passwords `_ (OTPs) to Django. It can be integrated at various levels, depending on how much customization is required. It integrates with ``django.contrib.auth``, although it is not a Django authentication backend. The primary target is developers wishing to incorporate OTPs into their Django projects as a form of `two-factor authentication `_. Several simple OTP plugins are included and more are available separately. This package also includes an implementation of OATH `HOTP `_ and `TOTP `_ for convenience, as these are standard OTP algorithms used by multiple plugins. If you're looking for a higher-level or more opinionated solution, you might be interested in `django-two-factor-auth `_. Status ------ This project is stable and maintained, but is no longer actively used by the author and is not seeing much ongoing investment. Well-formed issues and pull requests are welcome, but please see CONTRIBUTING.rst first. General questions and ideas should be directed to the Discussions section; issues should be reserved for confirmed bugs. .. end-of-doc-intro Development ----------- This project is built and managed with `hatch`_. If you don't have hatch, I recommend installing it with `pipx`_: ``pipx install hatch``. A `justfile`_ is provided with wrappers for scripts in ``pyproject.toml``, so the easiest way to get oriented is to run ``just``. ``pyproject.toml`` defines several useful scripts for development and testing. The default environment includes all dev and test dependencies for quickly running tests. The ``test`` environment defines the test matrix for running the full validation suite. Everything is executed in the context of the Django project in ``test/test_project``. To run the full test matrix, run ``just test-all``. You will need multiple specific Python versions installed for this. You can clean up the hatch environments with ``hatch env prune``, for example to force dependency updates. The project under ``test`` can be run as a simple interactive test environment: run ``just runserver`` and open it in a browser. This has an implementation of the login form and views with different combinations of decorators, which you can experiment with or use to test changes. Configuration ~~~~~~~~~~~~~ By default, the test project uses SQLite. Because SQLite doesn't support row locking, some concurrency tests will be skipped. To test against PostgreSQL, you can add a local configuration file that points to your database. Configuration is taken from TOML files stored under ``test/config``. A config file named ``env-.toml`` will be automatically applied when running inside a matching hatch environment. For example, ``env-default.toml`` applies to the default development environment and ``env-test.toml`` applies to the test matrix environments. With a wide-open PostgreSQL install, an ``env-test.toml`` might look like this: .. code-block:: toml [database] ENGINE = "django.db.backends.postgresql" NAME = "django-otp" USER = "postgres" For development, the config file can also be used to inject Django apps and middleware, or to override arbitrary Django settings. See ``test/config/sample.toml`` for a full description. You can also force a specific config file by setting the environment variable ``DJANGO_OTP_CONFIG`` to a path. The Future ---------- Once upon a time, everything was usernames and passwords. Or even in the case of other authentication mechanisms, a user was either authenticated or not (anonymous in Django's terminology). Then there was two-factor authentication, which could simply be an implementation detail in a binary authentication state, but could also imply levels or degrees of authentication. These days, it's increasingly common to see sites with more nuanced authentication state. A site might remember who you are forever—so you're not anonymous—but if you try to do anything private, you have to re-authenticate. You may be able to choose from among all of the authentication mechanisms you have configured, or only from some of them. Specific mechanisms may be required for specific actions, such as using your U2F device to access your U2F settings. In short, the world seems to be moving beyond the assumptions that originally informed Django's essential authentication design. If I were still investing in Django generally, I would probably start a new multi-factor authentication project that would reflect these changes. It would incorporate the idea that a user may be authenticated by various combinations of mechanisms at any time and that different combinations may be required to satisfy diverse authorization requirements across the site. It would most likely try to disentangle authentication persistence from sessions, at least to some extent. Many sites would not require all of this flexibility, but it would open up possibilities for better experiences by not asking users for more than we require at any point. If anyone has a mind to take on a project like this, I'd be happy to offer whatever advice or lessons learned that I can. .. _hatch: https://hatch.pypa.io/ .. _justfile: https://just.systems/ .. _pipx: https://pypa.github.io/pipx/ django-otp-django-otp-f5775d4/docs/000077500000000000000000000000001524731414200171165ustar00rootroot00000000000000django-otp-django-otp-f5775d4/docs/Makefile000066400000000000000000000130751524731414200205640ustar00rootroot00000000000000# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = build # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " text to make text files" @echo " man to make manual pages" @echo " texinfo to make Texinfo files" @echo " info to make Texinfo files and run them through makeinfo" @echo " gettext to make PO message catalogs" @echo " changes to make an overview of all changed/added/deprecated items" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" clean: -rm -rf $(BUILDDIR)/* html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-otp.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-otp.qhc" devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/django-otp" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-otp" @echo "# devhelp" epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." texinfo: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." @echo "Run \`make' in that directory to run these through makeinfo" \ "(use \`make info' here to do that automatically)." info: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo "Running Texinfo files through makeinfo..." make -C $(BUILDDIR)/texinfo info @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." gettext: $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale @echo @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." zip: rm build/html.zip || true cd build/html && zip -R ../html.zip '*' -x .buildinfo -x '_sources/*' django-otp-django-otp-f5775d4/docs/ext/000077500000000000000000000000001524731414200177165ustar00rootroot00000000000000django-otp-django-otp-f5775d4/docs/ext/otpdocs.py000066400000000000000000000003311524731414200217400ustar00rootroot00000000000000""" Extra stuff for the django-otp Sphinx docs. """ def setup(app): app.add_crossref_type( directivename = "setting", rolename = "setting", indextemplate = "pair: %s; setting", ) django-otp-django-otp-f5775d4/docs/source/000077500000000000000000000000001524731414200204165ustar00rootroot00000000000000django-otp-django-otp-f5775d4/docs/source/.spell.utf-8.add000066400000000000000000000015051524731414200232300ustar00rootroot00000000000000backends OTP OTPs app README django otp Backend backend StaticBackend EmailBackend API APIs contrib admin OTPAuthenticationFormMixin username yubico yubikey HOTP YubiKey www SMS AdminAuthenticationForm OTPAuthenticationForm auth AuthenticationForm func Django's AdminSite OTPAdminSite APPS apps middleware OTPMiddleware AuthenticationMiddleware py syncdb smartphone OTPTokenForm endif autofunction OTPAdminAuthenticationForm urlresolvers UI addstatictoken plugins subpackages plugin EmailDevice StaticDevice StaticToken subclassed automethod DeviceManager db url queryset param noindex TOTP binascii util Sagerson org pypi sns HOTPDevices hotp TOTPDevices totp google hl base32 HOTPDevice TOTPDevice KeyUriFormat wiki p otpauth attr rfc4226 ietf rfc6238 HOTPDeviceAdmin TOTPDeviceAdmin StaticDeviceAdmin EmailDeviceAdmin twilio Twilio's django-otp-django-otp-f5775d4/docs/source/auth.rst000066400000000000000000000221401524731414200221100ustar00rootroot00000000000000Authentication and Authorization ================================ This section describes the process for verifying users against their registered OTP devices as well as limiting access based on this verification. Authenticating Users -------------------- Soliciting an OTP token from a user is more complicated than soliciting a password. For one thing, each user may have any number of OTP devices registered to their account and the token itself won't tell us which one is intended. And, of course, we won't even know which devices we should check until after we've identified the user based on their username and password. Complicating this further is the fact some plugins are interactive, in which case verifying the user is at least a two-step process. Verifying a user can happen in one or two stages. One option is to require an OTP up front along with a password. Alternatively, we can accept single-factor authentication initially, but allow (or require) the user to provide a second factor later on. The following sections begin with the simpler strategies and proceed to the lower-level APIs that will allow you to implement more complex policies. The Easy Way ~~~~~~~~~~~~ .. autoclass:: django_otp.views.LoginView The Authentication Form ~~~~~~~~~~~~~~~~~~~~~~~ Django provides some high-level APIs to make it easy to authenticate users. If you're accustomed to using Django's built-in login view, this section will show you how to turn it into a two-factor login view. In Django, user authentication actually takes place not in a view, but in an :class:`~django.contrib.auth.forms.AuthenticationForm` or a subclass. If you're using Django's :class:`built-in login view `, you're already using the default AuthenticationForm. This form performs authentication as part of its validation; validation only succeeds if the supplied credentials pass :func:`django.contrib.auth.authenticate`. If you want to require two-factor authentication in the default login view, the easiest way is to use :class:`django_otp.forms.OTPAuthenticationForm` instead. This form includes additional fields and behavior to solicit an OTP token from the user and verify it against their registered devices. This form's validation only succeeds if it is able to both authenticate the user with the username and password and also verify them with an OTP token. If the verification fails, the :data:`~django_otp.forms.otp_verification_failed` signal is emitted. The form can be used with :class:`django.contrib.auth.views.LoginView` simply by passing it in the ``authentication_form`` keyword parameter:: from django.contrib.auth.views import LoginView from django_otp.forms import OTPAuthenticationForm urlpatterns = [ url(r'^accounts/login/$', LoginView.as_view(authentication_form=OTPAuthenticationForm)), ) .. autoclass:: django_otp.forms.OTPAuthenticationForm Following is a sample template snippet that's designed for :class:`~django_otp.forms.OTPAuthenticationForm`: .. code-block:: html
{{ form.username.errors }}{{ form.username.label_tag }}{{ form.username }}
{{ form.password.errors }}{{ form.password.label_tag }}{{ form.password }}
{% if form.get_user %}
{{ form.otp_device.errors }}{{ form.otp_device.label_tag }}{{ form.otp_device }}
{% endif %}
{{ form.otp_token.errors }}{{ form.otp_token.label_tag }}{{ form.otp_token }}
{% if form.get_user %}{% endif %}
The Admin Site ~~~~~~~~~~~~~~ In addition to providing :class:`~django_otp.forms.OTPAuthenticationForm` for your normal login views, django-otp includes an :class:`~django.contrib.admin.AdminSite` subclass for admin integration. .. autoclass:: django_otp.admin.OTPAdminSite :members: name, login_form, login_template, has_permission .. autoclass:: django_otp.admin.OTPAdminAuthenticationForm Django has a mechanism for :ref:`overriding-default-admin-site`. .. note:: If you switch to OTPAdminSite before setting up your first device, you'll find yourself with a bit of a chicken-egg problem. Remember that you can always use the :ref:`addstatictoken` management command to bootstrap yourself in. As a convenience, :class:`~django_otp.admin.OTPAdminSite` will override the admin login template. The template is a bit of a moving target, so this may get broken by new Django versions. Users will probably have a better and more consistent experience if you send them through your own login UI instead. The Token Form ~~~~~~~~~~~~~~ If you already have an authenticated user and you just want to ask for an OTP token to verify, you can use :class:`django_otp.forms.OTPTokenForm`. .. autoclass:: django_otp.forms.OTPTokenForm Signals ~~~~~~~ .. data:: django_otp.forms.otp_verification_failed This signal is sent when an OTP verification attempt fails. The source of the signal is :class:`~django_otp.forms.OTPAuthenticationFormMixin`, therefore it will generally be emitted only if using the provided :class:`~django_otp.forms.OTPAuthenticationForm` or :class:`~django_otp.forms.OTPTokenForm`. The signal provides the following arguments: ``sender`` The class of the form that attempted the verification. ``user`` The user that attempted the verification. Custom Forms ~~~~~~~~~~~~ Most of the functionality of :class:`~django_otp.forms.OTPAuthenticationForm` and :class:`~django_otp.forms.OTPTokenForm` is implemented in a mixin class: .. autoclass:: django_otp.forms.OTPAuthenticationFormMixin .. _Low-Level API: The Low-Level API ~~~~~~~~~~~~~~~~~ More customized integrations can use these APIs to manage the verification process directly. .. warning:: Verifying OTP tokens should always take place inside of a transaction. If you're loading the devices yourself, be sure to use :meth:`~django.db.models.query.QuerySet.select_for_update` to prevent concurrent access. Relevant APIs below have a ``for_verify`` parameter for this purpose. .. autofunction:: django_otp.devices_for_user .. autofunction:: django_otp.user_has_device .. autofunction:: django_otp.verify_token .. autofunction:: django_otp.match_token .. autofunction:: django_otp.login .. autoclass:: django_otp.models.Device :members: is_interactive, generate_is_allowed, generate_challenge, verify_token, verify_is_allowed, persistent_id, from_persistent_id .. autoclass:: django_otp.models.DeviceManager :members: devices_for_user .. autoclass:: django_otp.models.GenerateNotAllowed :members: .. autoclass:: django_otp.models.VerifyNotAllowed :members: Authorizing Users ----------------- If you design your site to always require OTP verification in order to log in, then your authorization policies don't need to change. ``request.user.is_authenticated()`` will be effectively synonymous with ``request.user.is_verified()``. If, on the other hand, you anticipate having both verified and unverified users on your site, you're probably intending to limit access to some resources to verified users only. The primary tool for this is otp_required: .. decorator:: django_otp.decorators.otp_required([redirect_field_name='next', login_url=None, if_configured=False]) Similar to :func:`~django.contrib.auth.decorators.login_required`, but requires the user to be :term:`verified`. By default, this redirects users to :setting:`OTP_LOGIN_URL`. :param if_configured: If ``True``, an authenticated user with no confirmed OTP devices will be allowed. Default is ``False``. :type if_configured: bool If you need more fine-grained control over authorization decisions, you can use ``request.user.is_verified()`` to determine whether the user has been verified by an OTP device. if ``is_verified()`` is true, then ``request.user.otp_device`` will be set to the :class:`~django_otp.models.Device` object that verified the user. This can be useful if you want to include the name of the verifying device in the UI. If you want to use OTPs to establish trusted user agents (e.g. a browser that the user claims is on a private and secure computer), look at `django-agent-trust `_ and `django-otp-agents `_. .. _Managing Devices: Managing Devices ---------------- django-otp does not include any standard mechanism for managing a user's devices outside of the admin interface. All plugins are expected to include admin integration, which should be sufficient for many sites. Some sites may want to provide users a self-service API to manage devices, but this will be very site-specific. Fortunately, managing a user's devices is just a matter of managing :class:`~django_otp.models.Device`-derived model objects, so it will be easy to implement. Be sure to note the :ref:`warning ` about unsaved :class:`~django_otp.models.Device` objects. django-otp-django-otp-f5775d4/docs/source/changes.rst000066400000000000000000000000661524731414200225620ustar00rootroot00000000000000Change Log ========== .. include:: ../../CHANGES.rst django-otp-django-otp-f5775d4/docs/source/conf.py000066400000000000000000000211611524731414200217160ustar00rootroot00000000000000# django-otp documentation build configuration file, created by # sphinx-quickstart on Fri Jul 13 09:48:33 2012. # # This file is execfile()d with the current directory set to its containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. import os.path import sys import django import django.conf # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. sys.path.insert(0, os.path.abspath('../ext')) # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.viewcode', 'sphinx.ext.intersphinx', 'otpdocs', ] # autodoc and viewcode need valid settings in order to process Django modules. django.conf.settings.configure( DATABASES={ 'default': { 'ENGINE': 'django.db.backends.sqlite3', } }, INSTALLED_APPS=[ 'django.contrib.auth', 'django.contrib.admin', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django_otp', 'django_otp.plugins.otp_hotp', 'django_otp.plugins.otp_totp', 'django_otp.plugins.otp_static', 'django_otp.plugins.otp_email', ], SECRET_KEY='properly-configured', ) django.setup() intersphinx_mapping = { 'python': ('https://docs.python.org/3/', None), 'django': ('https://docs.djangoproject.com/en/4.2/', 'https://docs.djangoproject.com/en/4.2/_objects/'), } # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # The suffix of source filenames. source_suffix = '.rst' # The encoding of source files. # source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' # General information about the project. project = 'django-otp' # copyright = '' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The full version, including alpha/beta/rc tags. release = '1.7.3' # The short X.Y version. version = '.'.join(release.split('.')[:2]) # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: # today = '' # Else, today_fmt is used as the format for a strftime call. # today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = [] # The reST default role (used for this markup: `text`) to use for all documents. # default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. # add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). # add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. # show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [] # -- Options for HTML output --------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. html_theme = 'default' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. # html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". # html_title = None # A shorter title for the navigation bar. Default is the same as html_title. # html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. # html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. # html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". # html_static_path = ['_static'] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. # html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. # html_use_smartypants = True # Custom sidebar templates, maps document names to template names. # html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. # html_additional_pages = {} # If false, no module index is generated. # html_domain_indices = True # If false, no index is generated. # html_use_index = True # If true, the index is split into individual pages for each letter. # html_split_index = False # If true, links to the reST sources are added to the pages. # html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. # html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. html_show_copyright = False # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. # html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). # html_file_suffix = None # Output file base name for HTML help builder. htmlhelp_basename = 'django-otpdoc' # -- Options for LaTeX output -------------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. # 'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ ('index', 'django-otp.tex', 'django-otp Documentation', 'Peter Sagerson', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of # the title page. # latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. # latex_use_parts = False # If true, show page references after internal links. # latex_show_pagerefs = False # If true, show URL addresses after external links. # latex_show_urls = False # Documents to append as an appendix to all manuals. # latex_appendices = [] # If false, no module index is generated. # latex_domain_indices = True # -- Options for manual page output -------------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ ('index', 'django-otp', 'django-otp Documentation', ['Peter Sagerson'], 1) ] # If true, show URL addresses after external links. # man_show_urls = False # -- Options for Texinfo output ------------------------------------------------ # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ('index', 'django-otp', 'django-otp Documentation', 'Peter Sagerson', 'django-otp', 'One line description of project.', 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. # texinfo_appendices = [] # If false, no module index is generated. # texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. # texinfo_show_urls = 'footnote' django-otp-django-otp-f5775d4/docs/source/contributing.rst000066400000000000000000000000451524731414200236560ustar00rootroot00000000000000.. include:: ../../CONTRIBUTING.rst django-otp-django-otp-f5775d4/docs/source/extend.rst000066400000000000000000000056351524731414200224500ustar00rootroot00000000000000.. vim: tw=80 lbr Extending Django-OTP ==================== A django-otp plugin is defined as a Django app that includes at least one model derived from :class:`django_otp.models.Device`. All Device-derived model objects will be detected by the framework and included in the standard forms and APIs. Writing a Device ---------------- A :class:`~django_otp.models.Device` subclass is only required to implement one method: .. automethod:: django_otp.models.Device.verify_token :noindex: Most devices will also need to define one or more model fields to do anything interesting. Here's a simple implementation of a generic TOTP device:: from binascii import unhexlify from django.db import models from django_otp.models import Device from django_otp.oath import totp from django_otp.util import random_hex, hex_validator class TOTPDevice(Device): key = models.CharField(max_length=80, validators=[hex_validator()], default=lambda: random_hex(20), help_text='A hex-encoded secret key of up to 40 bytes.') @property def bin_key(self): return unhexlify(self.key) def verify_token(self, token): """ Try to verify ``token`` against the current and previous TOTP value. """ try: token = int(token) except ValueError: verified = False else: verified = any(totp(self.bin_key, drift=drift) == token for drift in [0, -1]) return verified This example also shows some of the :ref:`low-level utilities ` django_otp provides for OATH and hex-encoded values. If a device uses a challenge-response algorithm or requires some other kind of user interaction, it should implement an additional method: .. automethod:: django_otp.models.Device.generate_challenge :noindex: Helpers ------- :mod:`django_otp.models` also provides a few mixins and other helpers for common functionality. These are entirely optional, but can be helpful for common functionality. .. autoclass:: django_otp.models.SideChannelDevice :members: generate_token, verify_token .. autoclass:: django_otp.models.CooldownMixin :members: get_cooldown_duration, generate_is_allowed, cooldown_reset, cooldown_set .. autoclass:: django_otp.models.ThrottlingMixin :members: get_throttle_factor, verify_is_allowed, throttle_reset, throttle_increment .. autoclass:: django_otp.models.TimestampMixin :members: set_last_used_timestamp .. _utilities: Utilities --------- django_otp provides several low-level utilities as a convenience to plugin implementors. django_otp.oath ~~~~~~~~~~~~~~~ .. module:: django_otp.oath .. autofunction:: hotp .. autofunction:: totp .. autoclass:: TOTP :members: django_otp.util ~~~~~~~~~~~~~~~ .. automodule:: django_otp.util :members: django-otp-django-otp-f5775d4/docs/source/index.rst000066400000000000000000000003411524731414200222550ustar00rootroot00000000000000.. include:: ../../README.rst :end-before: .. end-of-doc-intro Contents -------- .. toctree:: :maxdepth: 3 overview auth extend contributing changes License ------- .. include:: ../../LICENSE django-otp-django-otp-f5775d4/docs/source/overview.rst000066400000000000000000000500711524731414200230210ustar00rootroot00000000000000Overview and Key Concepts ========================= The django_otp package contains a framework for processing one-time passwords as well as support for :ref:`several types ` of OTP devices. Support for additional devices is handled by plugins, :ref:`distributed separately `. Adding two-factor authentication to your Django site involves four main tasks: #. Installing the django-otp plugins you want to use. #. Adding one or more OTP-enabled login views. #. Restricting access to all or portions of your site based on whether users have been verified by a registered OTP device. #. Providing mechanisms to register OTP devices to user accounts (or relying on the Django admin interface). .. _installation: Installation ------------ Basic installation has only two steps: #. Install :mod:`django_otp` and any :ref:`plugins ` that you'd like to use. These are simply Django apps to be installed in the usual way. #. Add :class:`django_otp.middleware.OTPMiddleware` to :setting:`MIDDLEWARE`. It must be installed *after* :class:`~django.contrib.auth.middleware.AuthenticationMiddleware`. For example:: INSTALLED_APPS = [ 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.sites', 'django.contrib.messages', 'django.contrib.staticfiles', 'django.contrib.admin', 'django.contrib.admindocs', 'django_otp', 'django_otp.plugins.otp_totp', 'django_otp.plugins.otp_hotp', 'django_otp.plugins.otp_static', ] MIDDLEWARE = [ 'django.middleware.common.CommonMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django_otp.middleware.OTPMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', ] The plugins contain models that must be migrated. .. _upgrading: Upgrading --------- Version 0.2.4 of django-otp introduced a South migration to the otp_totp plugin. Version 0.3.0 added Django 1.7 and South migrations to all apps. Care must be taken when upgrading in certain cases. The recommended procedure is: 1. Upgrade django-otp to 0.2.7, as described below. 2. Upgrade Django to 1.7 or later. 3. Upgrade django-otp to the latest version. django-otp 0.4 dropped support for Django < 1.7. Upgrading from 0.2.3 or Earlier ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ If you're using django-otp <= 0.2.3, you need to convert otp_totp to South before going any further:: pip install 'django-otp==0.2.7' python manage.py migrate otp_totp 0001 --fake python manage.py migrate otp_totp If you're not using South, you can run ``python manage.py sql otp_totp`` to see the definition of the new ``last_t`` field and then construct a suitable ``ALTER TABLE`` SQL statement for your database. Upgrading to Django 1.7+ ~~~~~~~~~~~~~~~~~~~~~~~~ Once you've upgraded django-otp to version 0.2.4 or later (up to 0.2.7), it's safe to switch to Django 1.7 or later. You should not have South installed at this point, so any old migrations will simply be ignored. Once on Django 1.7+, it's safe to upgrade django-otp to 0.3 or later. All plugins with models have Django migrations, which will be ignored if the tables have already been created. If you're already on django-otp 0.3 or later when you move to Django 1.7+ (see below), you'll want to make sure Django knows that all migrations have already been run:: python manage.py migrate --fake ... Upgrading to 0.3.x with South ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ If you want to upgrade django-otp to 0.3.x under South, you'll need to convert all of the remaining plugins. First make sure you're running South 1.0, as earlier versions will not find the migrations. Then convert any plugin that you have installed:: pip install 'django-otp>=0.3' python manage.py migrate otp_hotp 0001 --fake python manage.py migrate otp_static 0001 --fake python manage.py migrate otp_yubikey 0001 --fake python manage.py migrate otp_twilio 0001 --fake Authentication and Verification ------------------------------- In a normal Django deployment, the user associated with a request is either authenticated or not. With the introduction of two-factor authentication, the situation becomes a little more complicated: while it is certainly possible to design a site such that two factors are required for any authentication, that's only one option. It's entirely reasonable to allow users to log in with either one or two factors and grant then access accordingly. In this documentation, a user that has passed Django's authentication API is called :term:`authenticated`. A user that has additionally been accepted by a registered OTP device is called :term:`verified`. On an OTP-enabled Django site, there are thus three levels of authentication: - anonymous - authenticated - authenticated + verified When planning your site, you'll want to consider whether different views will require different levels of authentication. As a convenience, we provide the decorator :func:`django_otp.decorators.otp_required`, which is analogous to :func:`~django.contrib.auth.decorators.login_required`, but requires the user to be both authenticated and verified. :class:`~django_otp.middleware.OTPMiddleware` populates ``request.user.otp_device`` to the OTP device object that verified the current user (if any). As a convenience, it also adds ``user.is_verified()`` as a counterpart to ``user.is_authenticated()``. It is not possible for a user to be verified without also being authenticated. [#agents]_ The middleware is async capable. Using ``await request.auser()`` returns the user model augmented with the same properties as when using ``request.user``. Plugins and Devices ------------------- A django-otp plugin is simply a Django app that contains one or more models that are subclassed from :class:`django_otp.models.Device`. Each model class supports a single type of OTP device. Remember that when we use the term :term:`device` in this context, we're not necessarily referring to a physical device. At the code level, a device is a model object that can verify a particular type of OTP. For example, you might have a `YubiKey`_ that supports both the Yubico OTP algorithm and the HOTP standard: these would be represented as different devices and likely served by different plugins. A device that delivered HOTP values to a user by SMS would be a third device defined by another plugin. OTP plugins are distributed as Django apps; to install a plugin, just add it to :setting:`INSTALLED_APPS` like any other. The order can be significant: any time we enumerate a user's devices, such as when we ask the user which device they would like to authenticate with, we will present them according to the order in which the apps are installed. OTP devices come in two general flavors: passive and interactive. A passive device is one that can accept a token from the user and verify it with no preparation. Examples include devices corresponding to dedicated hardware or smartphone apps that generate sequenced or time-based tokens. An interactive device needs to communicate something to the user before it can accept a token. Two common types are devices that use a challenge-response OTP algorithm and devices that deliver a token to the user through an independent channel, such as SMS. Internally, device instances can be flagged as confirmed or unconfirmed. By default, devices are confirmed as soon as they are created, but a plugin or deployment that wishes to include a confirmation step can mark a device unconfirmed initially. Unconfirmed devices will be ignored by the high-level OTP APIs. .. _YubiKey: http://www.yubico.com/yubikey .. _built-in-plugins: Built-in Plugins ~~~~~~~~~~~~~~~~ django-otp includes support for several standard device types. :class:`~django_otp.plugins.otp_hotp.models.HOTPDevice` and :class:`~django_otp.plugins.otp_totp.models.TOTPDevice` handle standard OTP algorithms, which can be used with a variety of OTP generators. For example, it's easy to pair these devices with `Google Authenticator`_ using the `otpauth URL scheme`_. If you have either the `segno`_ or `qrcode`_ packages installed, the admin interface will generate QR Codes for you. .. _Google Authenticator: https://github.com/google/google-authenticator .. _otpauth URL scheme: https://github.com/google/google-authenticator/wiki/Key-Uri-Format .. _segno: https://pypi.python.org/pypi/segno/ .. _qrcode: https://pypi.python.org/pypi/qrcode/ .. _hotp-devices: HOTP Devices ++++++++++++ `HOTP`_ is an algorithm that generates a pseudo-random sequence of codes based on an incrementing counter. Every time a prover generates a new code or a verifier verifies one, they increment their respective counters. This algorithm will fail if the prover generates too many codes without a successful verification. If there is a failed attempt, this plugin will enforce an exponentially increasing delay before allowing verification to succeed (see :setting:`OTP_HOTP_THROTTLE_FACTOR`). The :meth:`~django_otp.models.Device.verify_token` method automatically applies this policy. For a better user experience, before calling :meth:`~django_otp.models.Device.verify_token` check whether verification is disabled by calling the :meth:`~django_otp.models.Device.verify_is_allowed` method. .. module:: django_otp.plugins.otp_hotp .. automodule:: django_otp.plugins.otp_hotp.models :members: .. autoclass:: django_otp.plugins.otp_hotp.admin.HOTPDeviceAdmin .. _HOTP: http://tools.ietf.org/html/rfc4226#section-5 HOTP Settings ''''''''''''' .. setting:: OTP_HOTP_ISSUER **OTP_HOTP_ISSUER** Default: ``None`` The ``issuer`` parameter for the otpauth URL generated by :attr:`~django_otp.plugins.otp_hotp.models.HOTPDevice.config_url`. This can be a string or a callable to dynamically set the value. .. setting:: OTP_HOTP_THROTTLE_FACTOR **OTP_HOTP_THROTTLE_FACTOR** Default: ``1`` This controls the rate of throttling. The sequence of 1, 2, 4, 8... seconds is multiplied by this factor to define the delay imposed after 1, 2, 3, 4... successive failures. Set to ``0`` to disable throttling completely. .. _totp-devices: TOTP Devices ++++++++++++ `TOTP`_ is an algorithm that generates a pseudo-random sequence of codes based on the current time. A typical implementation will change codes every 30 seconds, although this is configurable. This algorithm will fail if the prover and verifier have clocks that drift too far apart. If there is a failed attempt, this plugin will enforce an exponentially increasing delay before allowing verification to succeed (see :setting:`OTP_TOTP_THROTTLE_FACTOR`). The :meth:`~django_otp.models.Device.verify_token` method automatically applies this policy. For a better user experience, before calling :meth:`~django_otp.models.Device.verify_token` check whether verification is disabled by calling the :meth:`~django_otp.models.Device.verify_is_allowed` method. .. module:: django_otp.plugins.otp_totp .. automodule:: django_otp.plugins.otp_totp.models :members: .. autoclass:: django_otp.plugins.otp_totp.admin.TOTPDeviceAdmin .. _TOTP: http://tools.ietf.org/html/rfc6238#section-4 TOTP Settings ''''''''''''' .. setting:: OTP_TOTP_ISSUER **OTP_TOTP_ISSUER** Default: ``None`` The ``issuer`` parameter for the otpauth URL generated by :attr:`~django_otp.plugins.otp_totp.models.TOTPDevice.config_url`. This can be a string or a callable to dynamically set the value. .. setting:: OTP_TOTP_IMAGE **OTP_TOTP_IMAGE** Default: ``None`` The ``image`` parameter for the otpauth URL generated by :attr:`~django_otp.plugins.otp_totp.models.TOTPDevice.config_url`. It should be a HTTPS URL pointing to a square PNG image. This will be read and displayed by some authenticator applications, e.g. FreeOTP. This can be a string or a callable to dynamically set the value. .. setting:: OTP_TOTP_SYNC **OTP_TOTP_SYNC** Default: ``True`` If true, then TOTP devices will keep track of the difference between the prover's clock and our own. Any time a :class:`~django_otp.plugins.otp_totp.models.TOTPDevice` matches a token in the past or future, it will update :attr:`~django_otp.plugins.otp_totp.models.TOTPDevice.drift` to the number of time steps that the two sides are out of sync. For subsequent tokens, we'll slide the window of acceptable tokens by this number. .. setting:: OTP_TOTP_THROTTLE_FACTOR **OTP_TOTP_THROTTLE_FACTOR** Default: ``1`` This controls the rate of throttling. The sequence of 1, 2, 4, 8... seconds is multiplied by this factor to define the delay imposed after 1, 2, 3, 4... successive failures. Set to ``0`` to disable throttling completely. Static Devices ++++++++++++++ .. module:: django_otp.plugins.otp_static .. automodule:: django_otp.plugins.otp_static.models :members: StaticDevice, StaticToken .. autoclass:: django_otp.plugins.otp_static.admin.StaticDeviceAdmin Static Settings ''''''''''''''' .. setting:: OTP_STATIC_THROTTLE_FACTOR **OTP_STATIC_THROTTLE_FACTOR** Default: ``1`` This controls the rate of throttling. The sequence of 1, 2, 4, 8… seconds is multiplied by this factor to define the delay imposed after 1, 2, 3, 4… successive failures. Set to 0 to disable throttling completely. .. _addstatictoken: addstatictoken '''''''''''''' The static plugin also includes a management command called ``addstatictoken``, which will add a single static token to any account. This is useful for bootstrapping and emergency access. Run ``manage.py addstatictoken -h`` for details. Email Devices +++++++++++++ .. module:: django_otp.plugins.otp_email .. automodule:: django_otp.plugins.otp_email.models :members: EmailDevice .. autoclass:: django_otp.plugins.otp_email.admin.EmailDeviceAdmin Email Settings '''''''''''''' .. setting:: OTP_EMAIL_SENDER **OTP_EMAIL_SENDER** Default: ``None`` The email address to use as the sender when we deliver tokens. If not set, this will automatically use :setting:`DEFAULT_FROM_EMAIL`. .. setting:: OTP_EMAIL_SUBJECT **OTP_EMAIL_SUBJECT** Default: ``'OTP token'`` The subject of the email. You probably want to customize this. A ``{token}`` placeholder is available for the generated token. .. setting:: OTP_EMAIL_BODY_TEMPLATE **OTP_EMAIL_BODY_TEMPLATE** Default: ``None`` A raw template string to use for the email body. The render context will include the generated token in the ``token`` key. Additional template context may be passed to :meth:`~django_otp.plugins.otp_email.models.EmailDevice.generate_challenge`. If this and :setting:`OTP_EMAIL_BODY_TEMPLATE_PATH` are not set, we'll render the template 'otp/email/token.txt', which you'll most likely want to override. .. setting:: OTP_EMAIL_BODY_HTML_TEMPLATE **OTP_EMAIL_BODY_HTML_TEMPLATE** Default: ``None`` A raw template string to use for the email html alternative body. The render context will include the generated token in the ``token`` key. Additional template context may be passed to :meth:`~django_otp.plugins.otp_email.models.EmailDevice.generate_challenge`. If this and :setting:`OTP_EMAIL_BODY_HTML_TEMPLATE_PATH` are not set, we won't attach any html alternative to the email. .. setting:: OTP_EMAIL_BODY_TEMPLATE_PATH **OTP_EMAIL_BODY_TEMPLATE_PATH** Default: ``otp/email/token.txt`` A path string to a template file to use for the email body. The render context will include the generated token in the ``token`` key. Additional template context may be passed to :meth:`~django_otp.plugins.otp_email.models.EmailDevice.generate_challenge`. If this and :setting:`OTP_EMAIL_BODY_TEMPLATE` are not set, we'll render the template 'otp/email/token.txt', which you'll most likely want to override. .. setting:: OTP_EMAIL_BODY_HTML_TEMPLATE_PATH **OTP_EMAIL_BODY_HTML_TEMPLATE_PATH** Default: ``None`` A path string to a template file to use for the email html alternative body. The render context will include the generated token in the ``token`` key. Additional template context may be passed to :meth:`~django_otp.plugins.otp_email.models.EmailDevice.generate_challenge`. If this and :setting:`OTP_EMAIL_BODY_HTML_TEMPLATE` are not set, we won't attach any html alternative to the email. .. setting:: OTP_EMAIL_TOKEN_VALIDITY **OTP_EMAIL_TOKEN_VALIDITY** Default: ``300`` The maximum number of seconds a token is valid. .. setting:: OTP_EMAIL_COOLDOWN_DURATION **OTP_EMAIL_COOLDOWN_DURATION** Default: ``60`` This controls the cooldown period after a successful token generation. The token can be regenerated after the designated time period has fully elapsed. Set to 0 to disable cooldown completely. .. setting:: OTP_EMAIL_THROTTLE_FACTOR **OTP_EMAIL_THROTTLE_FACTOR** Default: ``1`` This controls the rate of throttling. The sequence of 1, 2, 4, 8… seconds is multiplied by this factor to define the delay imposed after 1, 2, 3, 4… successive failures. Set to 0 to disable throttling completely. .. _other-plugins: Other Plugins ~~~~~~~~~~~~~~ The framework author also maintains a couple of other plugins for less common devices. Third-party plugins are not listed here. - `django-otp-yubikey`_ supports YubiKey USB devices. - `django-otp-twilio`_ supports delivering tokens via Twilio's SMS service. Asynchronous Support -------------------- :class:`django_otp.middleware.OTPMiddleware` is async capable, ensuring there is no context switch caused by this middleware in ASGI servers. Both ``request.user`` and ``request.auser()`` return the user model augmented with additional properties. Settings -------- .. setting:: OTP_LOGIN_URL **OTP_LOGIN_URL** Default: alias for :setting:`LOGIN_URL` The URL where requests are redirected for two-factor authentication, especially when using the :func:`~django_otp.decorators.otp_required` decorator. .. setting:: OTP_ADMIN_HIDE_SENSITIVE_DATA **OTP_ADMIN_HIDE_SENSITIVE_DATA** Default: `False` This controls showing some sensitive data on the Django admin site (e.g., keys and corresponding QR codes, static tokens). Note, it is respected by built-in plugins, but external ones may or may not support it. Glossary -------- .. glossary:: authenticated A user whose credentials have been accepted by Django's authentication API is considered authenticated. device A mechanism by which a user can acquire an OTP. This might correspond to a physical device dedicated to such a purpose, a virtual device such as a smart phone app, or even a set of stored single-use tokens. OTP A one-time password. This is a generated value that a user can present as evidence of their identity. OTPs are only valid for a single use or, in some cases, for a strictly limited period of time. prover An entity that is using an OTP to prove its identity. For example, a user who is providing an OTP token. token An encoded OTP. Some OTPs consist of structured data, in which case they will be encoded into a printable string for transport. two-factor authentication An authentication policy that requires a user to present two proofs of identity. The first is typically a password and the second is frequently tied to some physical device in the user's possession. verified A user whose credentials have been accepted by Django's authentication API and also by a registered OTP device is considered verified. verifier An entity that verifies tokens generated by a prover. For example, a web service that accepts OTPs as proof of identity. ---- .. rubric:: Footnotes .. [#agents] If you'd like the second factor to persist across sessions, see `django-agent-trust`_ and `django-otp-agents`_. The former deals with assigning trust to user agents (i.e. browsers) across sessions and the latter includes tools to use OTPs to establish that trust. .. _django-agent-trust: http://pypi.python.org/pypi/django-agent-trust .. _django-otp-agents: http://pypi.python.org/pypi/django-otp-agents .. _django-otp-yubikey: https://django-otp-yubikey.readthedocs.io .. _django-otp-twilio: https://django-otp-twilio.readthedocs.io django-otp-django-otp-f5775d4/justfile000066400000000000000000000026741524731414200177470ustar00rootroot00000000000000set minimum-version := "1.53.0" set unstable set lists [private] default: @just --list --unsorted # Run all linters and tests [group('main')] check: hatch run check # Run the test project server [group('main')] runserver *args: (manage ["runserver", args]) # Run all linters [group('test')] lint: hatch run lint # Run all fixers, repairing some lint errors [group('test')] fix: hatch run fix # Run tests in the default environment [group('test')] test *args: hatch run test {{ quote(args) }} # Run tests with all warnings enabled [group('test')] warn *args: hatch run warn {{ quote(args) }} # Run tests with code coverage enabled [group('test')] coverage *args: hatch run cov {{ quote(args) }} alias cov := coverage # Run tests across multiple environments [group('test')] test-all *args: hatch run test:run {{ quote(args) }} # Build the documentation [group('docs')] make-docs: hatch run docs:make # Open the local documentation in a browser [group('docs')] open-docs: hatch run docs:open # Run migrations in the test project [group('django')] migrate *args: (manage ["migrate", args]) # Run a Django management command in the test project [group('django')] manage *args: hatch run manage {{ quote(args) }} # Release a new version (patch/minor/major) [group('admin')] release version *args: hatch run bumpversion {{ quote(version) }} {{ quote(args) }} hatch clean hatch build hatch publish django-otp-django-otp-f5775d4/pyproject.toml000066400000000000000000000066001524731414200211040ustar00rootroot00000000000000[project] name = "django-otp" version = "1.7.3" description = "A pluggable framework for adding two-factor authentication to Django using one-time passwords." readme = "README.rst" requires-python = ">=3.8" license = "Unlicense" authors = [ { name = "Peter Sagerson", email = "psagers@ignorare.net" }, ] classifiers = [ "Development Status :: 5 - Production/Stable", "Framework :: Django", "Intended Audience :: Developers", "License :: OSI Approved :: The Unlicense (Unlicense)", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Topic :: Security", "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ "django >= 4.2", ] [project.optional-dependencies] segno = [ "segno", ] qrcode = [ "qrcode", ] [project.urls] Homepage = "https://github.com/django-otp/django-otp" Documentation = "https://django-otp-official.readthedocs.io/" # # Development # [tool.hatch.envs.default] features = [ "segno", "qrcode", ] dependencies = [ "black ~= 25.1", "bumpversion ~= 0.6.0", "coverage ~= 7.8.0", "flake8 ~= 7.2.0", "freezegun ~= 1.5.1", "isort ~= 6.0.1", "psycopg", "tomli >= 2.2.1; python_version < '3.11'", ] [tool.hatch.envs.default.env-vars] PYTHONPATH = "test" DJANGO_SETTINGS_MODULE = "test_project.settings" [tool.hatch.envs.default.scripts] manage = "python -m django {args}" lint = [ "flake8 src", "isort --check src", "black --check src", ] fix = [ "isort src", "black src", ] test = "python -s -m django test {args:django_otp}" check = ["lint", "test"] warn = "python -Wd -s -m django test {args:django_otp}" cov = [ "coverage run -m django test {args:django_otp}", "coverage report", ] [tool.hatch.envs.test.scripts] run = "test" [tool.hatch.envs.test.overrides] matrix.django.dependencies = [ { value = "django ~= 5.2.0", if = ["5.2"] }, { value = "django ~= 6.0.0", if = ["6.0"] }, { value = "django ~= 6.1.0", if = ["6.1"] }, ] matrix.mode.scripts = [ { key = "run", value = "lint", if = ["lint"] }, { key = "run", value = "cov", if = ["coverage"] }, ] [[tool.hatch.envs.test.matrix]] mode = ["lint"] # WARNING: When you update the Python versions, make sure to update # .github/workflows/* as well. [[tool.hatch.envs.test.matrix]] python = ["3.10"] django = ["5.2"] [[tool.hatch.envs.test.matrix]] python = ["3.12"] django = ["6.0"] [[tool.hatch.envs.test.matrix]] python = ["3.14"] django = ["6.1"] [[tool.hatch.envs.test.matrix]] mode = ["coverage"] [tool.hatch.envs.docs] template = "docs" dependencies = [ "sphinx" ] [tool.hatch.envs.docs.scripts] make = "command make -C docs {args:html}" open = "command xdg-open docs/build/html/index.html" # # Build # [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/django_otp"] exclude = [ "src/django_otp/**/tests/**", "src/django_otp/**/tests.py", ] [tool.hatch.build.targets.sdist] include = [ "/src", "/docs", "/test", "/CHANGES.rst", "/CONTRIBUTING.rst", ] # # Other tools # [tool.isort] profile = "black" force_sort_within_sections = true sections = ["FUTURE", "STDLIB", "THIRDPARTY", "DJANGO", "FIRSTPARTY", "LOCALFOLDER"] known_django = ["django"] skip_glob = ["**/migrations/*.py"] [tool.black] skip-string-normalization = true exclude = 'migrations/.*\.py' django-otp-django-otp-f5775d4/src/000077500000000000000000000000001524731414200167555ustar00rootroot00000000000000django-otp-django-otp-f5775d4/src/django_otp/000077500000000000000000000000001524731414200211015ustar00rootroot00000000000000django-otp-django-otp-f5775d4/src/django_otp/__init__.py000066400000000000000000000124221524731414200232130ustar00rootroot00000000000000from django.contrib.auth.signals import user_logged_in from django.db import transaction DEVICE_ID_SESSION_KEY = 'otp_device_id' def login(request, device): """ Persist the given OTP device in the current session. The device will be rejected if it does not belong to ``request.user``. This is called automatically any time :func:`django.contrib.auth.login` is called with a user having an ``otp_device`` atribute. If you use Django's :class:`~django.contrib.auth.views.LoginView` view with the django-otp authentication forms, then you won't need to call this. :param request: The HTTP request :type request: :class:`~django.http.HttpRequest` :param device: The OTP device used to verify the user. :type device: :class:`~django_otp.models.Device` """ user = getattr(request, 'user', None) if (user is not None) and (device is not None) and (device.user_id == user.pk): request.session[DEVICE_ID_SESSION_KEY] = device.persistent_id request.user.otp_device = device def _handle_auth_login(sender, request, user, **kwargs): """ Automatically persists an OTP device that was set by an OTP-aware AuthenticationForm. """ if hasattr(user, 'otp_device'): login(request, user.otp_device) user_logged_in.connect(_handle_auth_login) def verify_token(user, device_id, token): """ Attempts to verify a :term:`token` against a specific device, identified by :attr:`~django_otp.models.Device.persistent_id`. This wraps the verification process in a transaction to ensure that things like throttling polices are properly enforced. :param user: The user supplying the token. :type user: :class:`~django.contrib.auth.models.User` :param str device_id: A device's persistent_id value. :param str token: An OTP token to verify. :returns: The device that accepted ``token``, if any. :rtype: :class:`~django_otp.models.Device` or ``None`` """ from django_otp.models import Device verified = None with transaction.atomic(): device = Device.from_persistent_id(device_id, for_verify=True) if ( (device is not None) and (device.user_id == user.pk) and device.verify_token(token) ): verified = device return verified def match_token(user, token): """ Attempts to verify a :term:`token` on every device attached to the given user until one of them succeeds. .. warning:: This originally existed for more convenient integration with the admin site. Its use is no longer recommended and it is not guaranteed to interact well with more recent features (such as throttling). Tokens should always be verified against specific devices. :param user: The user supplying the token. :type user: :class:`~django.contrib.auth.models.User` :param str token: An OTP token to verify. :returns: The device that accepted ``token``, if any. :rtype: :class:`~django_otp.models.Device` or ``None`` """ with transaction.atomic(): for device in devices_for_user(user, for_verify=True): if device.verify_token(token): break else: device = None return device def devices_for_user(user, confirmed=True, for_verify=False): """ Return an iterable of all devices registered to the given user. Returns an empty iterable for anonymous users. :param user: standard or custom user object. :type user: :class:`~django.contrib.auth.models.User` :param bool confirmed: If ``None``, all matching devices are returned. Otherwise, this can be any true or false value to limit the query to confirmed or unconfirmed devices, respectively. :param bool for_verify: If ``True``, we'll load the devices with :meth:`~django.db.models.query.QuerySet.select_for_update` to prevent concurrent verifications from succeeding. In which case, this must be called inside a transaction. :rtype: iterable """ if user.is_anonymous: return for model in device_classes(): device_set = model.objects.devices_for_user(user, confirmed=confirmed) if for_verify: device_set = device_set.select_for_update() yield from device_set def user_has_device(user, confirmed=True): """ Return ``True`` if the user has at least one device. Returns ``False`` for anonymous users. :param user: standard or custom user object. :type user: :class:`~django.contrib.auth.models.User` :param confirmed: If ``None``, all matching devices are considered. Otherwise, this can be any true or false value to limit the query to confirmed or unconfirmed devices, respectively. """ try: next(devices_for_user(user, confirmed=confirmed)) except StopIteration: has_device = False else: has_device = True return has_device def device_classes(): """ Returns an iterable of all loaded device models. """ from django.apps import apps # isort: skip from django_otp.models import Device for config in apps.get_app_configs(): for model in config.get_models(): if issubclass(model, Device) and not model._meta.proxy: yield model django-otp-django-otp-f5775d4/src/django_otp/admin.py000066400000000000000000000073731524731414200225550ustar00rootroot00000000000000from django import forms from django.contrib.admin.forms import AdminAuthenticationForm from django.contrib.admin.sites import AdminSite from django.contrib.auth import get_user_model from django.core.exceptions import FieldDoesNotExist from .forms import OTPAuthenticationFormMixin def _admin_template_for_django_version(): """ Returns the most appropriate Django login template available. In the past, we've had more version-specific templates. Perhaps this will be true again in the future. For now, the Django 1.11 version is suitable even with the most recent Django version. """ return 'otp/admin111/login.html' def user_model_search_fields(field_names): """ Check whether the provided field names exist, and return a tuple of search fields and help text for the validated field names. Parameters: field_names (list of str): A list of field names to search for in the user model. Returns: tuple: A tuple containing: - search_fields (list of str): A list of search fields formatted for querying. - search_help_text (str): A help text string describing the valid search fields. """ User = get_user_model() search_fields = [] help_texts = [] for name in field_names: try: field = User._meta.get_field(name) except FieldDoesNotExist: continue else: search_fields.append(f'user__{field.name}') help_texts.append(str(field.verbose_name)) if len(help_texts) == 0: search_help_text = "" else: search_help_text = "Search by user's " if len(help_texts) == 1: search_help_text += help_texts[0] else: search_help_text += ", ".join(help_texts[:-1]) + f" or {help_texts[-1]}" return search_fields, search_help_text class OTPAdminAuthenticationForm(AdminAuthenticationForm, OTPAuthenticationFormMixin): """ An :class:`~django.contrib.admin.forms.AdminAuthenticationForm` subclass that solicits an OTP token. This has the same behavior as :class:`~django_otp.forms.OTPAuthenticationForm`. """ otp_device = forms.CharField(required=False, widget=forms.Select) otp_token = forms.CharField(required=False) # This is a placeholder field that allows us to detect when the user clicks # the otp_challenge submit button. otp_challenge = forms.CharField(required=False) def clean(self): self.cleaned_data = super().clean() self.clean_otp(self.get_user()) return self.cleaned_data class OTPAdminSite(AdminSite): """ This is an :class:`~django.contrib.admin.AdminSite` subclass that requires two-factor authentication. Only users that can be verified by a registered OTP device will be authorized for this admin site. Unverified users will be treated as if :attr:`~django.contrib.auth.models.User.is_staff` is ``False``. """ #: The default instance name of this admin site. You should instantiate #: this class as ``OTPAdminSite(OTPAdminSite.name)`` to make sure the admin #: templates render the correct URLs. name = 'otpadmin' login_form = OTPAdminAuthenticationForm #: We automatically select a modified login template based on your Django #: version. If it doesn't look right, your version may not be supported, in #: which case feel free to replace it. login_template = _admin_template_for_django_version() def __init__(self, name='otpadmin'): super().__init__(name) def has_permission(self, request): """ In addition to the default requirements, this only allows access to users who have been verified by a registered OTP device. """ return super().has_permission(request) and request.user.is_verified() django-otp-django-otp-f5775d4/src/django_otp/conf.py000066400000000000000000000013441524731414200224020ustar00rootroot00000000000000import django.conf from django.utils.functional import cached_property class Settings: """ This is a simple class to take the place of the global settings object. An instance will contain all of our settings as attributes, with default values if they are not specified by the configuration. """ @cached_property def defaults(self): return { 'OTP_LOGIN_URL': django.conf.settings.LOGIN_URL, 'OTP_ADMIN_HIDE_SENSITIVE_DATA': False, } def __getattr__(self, name): if name in self.defaults: return getattr(django.conf.settings, name, self.defaults[name]) else: return getattr(django.conf.settings, name) settings = Settings() django-otp-django-otp-f5775d4/src/django_otp/decorators.py000066400000000000000000000017611524731414200236250ustar00rootroot00000000000000from django.contrib.auth.decorators import user_passes_test from django_otp import user_has_device from django_otp.conf import settings def otp_required( view=None, redirect_field_name='next', login_url=None, if_configured=False ): """ Similar to :func:`~django.contrib.auth.decorators.login_required`, but requires the user to be :term:`verified`. By default, this redirects users to :setting:`OTP_LOGIN_URL`. :param if_configured: If ``True``, an authenticated user with no confirmed OTP devices will be allowed. Default is ``False``. :type if_configured: bool """ if login_url is None: login_url = settings.OTP_LOGIN_URL def test(user): return user.is_verified() or ( if_configured and user.is_authenticated and not user_has_device(user) ) decorator = user_passes_test( test, login_url=login_url, redirect_field_name=redirect_field_name ) return decorator if (view is None) else decorator(view) django-otp-django-otp-f5775d4/src/django_otp/forms.py000066400000000000000000000350121524731414200226020ustar00rootroot00000000000000from django import forms from django.contrib.auth.forms import AuthenticationForm from django.db import transaction from django.dispatch import Signal from django.utils.translation import gettext_lazy as _ from django.utils.translation import ngettext_lazy from . import devices_for_user from .models import Device, VerifyNotAllowed otp_verification_failed = Signal() class OTPAuthenticationFormMixin: """ Shared functionality for :class:`~django.contrib.auth.forms.AuthenticationForm` subclasses that wish to handle OTP tokens. Subclasses must do the following in order to use this: #. Define three additional form fields:: otp_device = forms.CharField(required=False, widget=forms.Select) otp_token = forms.CharField(required=False, widget=forms.TextInput(attrs={'autocomplete': 'off'})) otp_challenge = forms.CharField(required=False) - ``otp_device`` will be a select widget with all of the user's devices listed. Until the user has entered a valid username and password, this will be empty and may be omitted. - ``otp_token`` is where the user will enter their token. - ``otp_challenge`` is a placeholder field that captures an alternate submit button of the same name. #. Override :meth:`~django.forms.Form.clean` to call :meth:`clean_otp` after invoking the inherited :meth:`~django.forms.Form.clean`. See :class:`OTPAuthenticationForm` for an example. #. See :class:`OTPAuthenticationForm` for information about writing a login template for this form. The file ``django_otp/templates/otp/admin/login.html`` is also a useful example. You will most likely be able to use :class:`OTPAuthenticationForm`, :class:`~django_otp.admin.OTPAdminAuthenticationForm`, or :class:`OTPTokenForm` directly. If these do not suit your needs--for instance if your primary authentication is not by password--they should serve as useful examples. This mixin defines some error messages in :attr:`OTPAuthenticationFormMixin.otp_error_messages`, which can be overridden in subclasses (refer to the source for message codes). For example:: class CustomAuthForm(OTPAuthenticationFormMixin, AuthenticationForm): otp_error_messages = dict(OTPAuthenticationFormMixin.otp_error_messages, token_required=_('Please enter your authentication code.'), invalid_token=_('Incorrect authentication code. Please try again.'), ) """ otp_error_messages = { 'token_required': _('Please enter your OTP token.'), 'challenge_exception': _('Error generating challenge: {0}'), 'not_interactive': _('The selected OTP device is not interactive'), 'challenge_message': _('OTP Challenge: {0}'), 'invalid_token': _( 'Invalid token. Please make sure you have entered it correctly.' ), 'n_failed_attempts': ngettext_lazy( "Verification temporarily disabled because of %(failure_count)d failed attempt, please try again soon.", "Verification temporarily disabled because of %(failure_count)d failed attempts, please try again soon.", "failure_count", ), 'verification_not_allowed': _( "Verification of the token is currently disabled" ), 'device_required': _('Please select a device.'), } def clean_otp(self, user): """ Processes the ``otp_*`` fields. :param user: A user that has been authenticated by the first factor (such as a password). :type user: :class:`~django.contrib.auth.models.User` :raises: :exc:`~django.core.exceptions.ValidationError` if the user is not fully authenticated by an OTP token. """ if user is None: return validation_error = None with transaction.atomic(): try: device = self._chosen_device(user) token = self.cleaned_data.get('otp_token') user.otp_device = None try: if self.cleaned_data.get('otp_challenge'): self._handle_challenge(device) elif token: user.otp_device = self._verify_token(user, token, device) else: raise forms.ValidationError( self.otp_error_messages['token_required'], code='token_required', ) finally: if user.otp_device is None: self._update_form(user) except forms.ValidationError as e: # Validation errors shouldn't abort the transaction, so we have # to carefully transport them out. validation_error = e if validation_error: raise validation_error def _chosen_device(self, user): device_id = self.cleaned_data.get('otp_device') if device_id: device = Device.from_persistent_id(device_id, for_verify=True) else: device = None # SECURITY: The form doesn't validate otp_device for us, since we don't # have the list of choices until we authenticate the user. Without the # following, an attacker could authenticate using some other user's OTP # device. if (device is not None) and (device.user_id != user.pk): device = None return device def _handle_challenge(self, device): try: challenge = device.generate_challenge() if (device is not None) else None except Exception as e: raise forms.ValidationError( self.otp_error_messages['challenge_exception'].format(e), code='challenge_exception', ) else: if challenge is None: raise forms.ValidationError( self.otp_error_messages['not_interactive'], code='not_interactive' ) else: raise forms.ValidationError( self.otp_error_messages['challenge_message'].format(challenge), code='challenge_message', ) def _verify_token(self, user, token, device=None): if device is None: # SECURITY: Trying every one of the user's devices in turn (as # this used to do, via match_token()) records a failed # verification attempt against each device that doesn't match, # even though the overall login succeeds. That can throttle # unrelated devices (e.g. recovery codes) that were never # actually used. Require an explicit choice instead. raise forms.ValidationError( self.otp_error_messages['device_required'], code='device_required', ) verify_is_allowed, extra = device.verify_is_allowed() if not verify_is_allowed: # Try to match specific conditions we know about. if ( 'reason' in extra and extra['reason'] == VerifyNotAllowed.N_FAILED_ATTEMPTS ): raise forms.ValidationError( self.otp_error_messages['n_failed_attempts'] % extra ) if 'error_message' in extra: raise forms.ValidationError(extra['error_message']) # Fallback to generic message otherwise. raise forms.ValidationError( self.otp_error_messages['verification_not_allowed'] ) device = device if device.verify_token(token) else None if device is None: otp_verification_failed.send( sender=self.__class__, user=user, ) raise forms.ValidationError( self.otp_error_messages['invalid_token'], code='invalid_token' ) return device def _update_form(self, user): if 'otp_device' in self.fields: self.fields['otp_device'].widget.choices = self.device_choices(user) if 'password' in self.fields: self.fields['password'].widget.render_value = True @staticmethod def device_choices(user): return list((d.persistent_id, d.name) for d in devices_for_user(user)) class OTPAuthenticationForm(OTPAuthenticationFormMixin, AuthenticationForm): """ This form provides an OTP authentication solution. It should only be used when two-factor authentication is required: it does not have an OTP-optional mode. The form has four fields: #. ``username`` is inherited from :class:`~django.contrib.auth.forms.AuthenticationForm`. #. ``password`` is inherited from :class:`~django.contrib.auth.forms.AuthenticationForm`. #. ``otp_device`` uses a :class:`~django.forms.Select` to allow the user to choose one of their registered devices. It will be empty as long as ``form.get_user()`` is ``None`` and should generally be omitted from the template in that case. #. ``otp_token`` is the field for entering an OTP token. It should always be included. In addition, if ``form.get_user()`` is not ``None``, the template should include an additional submit button named ``otp_challenge``. Pressing this button when ``otp_device`` is set to an interactive device will cause us to generate a challenge value for the user. Pressing the challenge button with a non-interactive device selected has no effect. The intended behavior of the form is as follows: - Initially the ``username``, ``password``, and ``otp_token`` fields should be visible. Validation of ``username`` and ``password`` is the same as for :class:`~django.contrib.auth.forms.AuthenticationForm`. If we are able to authenticate the user based on username and password, then one of three things happens: - If the user did not submit an OTP token, a :exc:`~django.core.exceptions.ValidationError` is raised asking for one. - If no device was selected -- which will be the case on the first submission, since ``otp_device`` should be omitted from the template until the user is known -- a :exc:`~django.core.exceptions.ValidationError` is raised asking the user to choose a device. We will not guess by trying the token against each of the user's devices in turn, since a successful login that way would still count as a failed verification attempt against every device that didn't match, which can throttle devices (such as static recovery codes) that were never actually used. - If a device was selected, we will verify the submitted token against it. If it succeeds, then authentication is fully successful and the user is logged in. Otherwise, a :exc:`~django.core.exceptions.ValidationError` is raised. - In either case, as long as the user is authenticated by their password, ``form.get_user()`` will return the authenticated :class:`~django.contrib.auth.models.User` object. From here on, this documentation assumes that username/password authentication succeeds on all subsequent submissions. If validation was not successful, then the form will be displayed again and this time the template should be sure to include the (now populated) ``otp_device`` field as well as the ``otp_challenge`` submit button. - The user will then have to choose a specific device to authenticate against (or accept the default). If they press the ``otp_challenge`` button, we will ask that device to generate a challenge. The device will return a message for the user, which will be incorporated into the :exc:`~django.core.exceptions.ValidationError` message. - If the user presses any other submit button, we will authenticate the username and password as always and then verify the OTP token against the chosen device. When that succeeds, authentication and verification are successful and the user is logged in. Error messages can be customized in subclasses; see :class:`OTPAuthenticationFormMixin`. """ otp_device = forms.CharField(required=False, widget=forms.Select) otp_token = forms.CharField( required=False, widget=forms.TextInput(attrs={'autocomplete': 'off'}) ) # This is a placeholder field that allows us to detect when the user clicks # the otp_challenge submit button. otp_challenge = forms.CharField(required=False) def clean(self): self.cleaned_data = super().clean() self.clean_otp(self.get_user()) return self.cleaned_data class OTPTokenForm(OTPAuthenticationFormMixin, forms.Form): """ A form that verifies an authenticated user. It looks very much like :class:`~django_otp.forms.OTPAuthenticationForm`, but without the username and password. The first argument must be an authenticated user; for an example of using this in a login view, see the source for :class:`django_otp.views.LoginView`. This form will ask the user to choose one of their registered devices and enter an OTP token. Validation will succeed if the token is verified. See :class:`~django_otp.forms.OTPAuthenticationForm` for details on writing a compatible template (leaving out the username and password, of course). Error messages can be customized in subclasses; see :class:`OTPAuthenticationFormMixin`. :param user: An authenticated user. :type user: :class:`~django.contrib.auth.models.User` :param request: The current request. :type request: :class:`~django.http.HttpRequest` """ otp_device = forms.ChoiceField(choices=[]) otp_token = forms.CharField( required=False, widget=forms.TextInput(attrs={'autocomplete': 'off'}) ) otp_challenge = forms.CharField(required=False) def __init__(self, user, request=None, *args, **kwargs): super().__init__(*args, **kwargs) self.user = user self.fields['otp_device'].choices = self.device_choices(user) def clean(self): super().clean() self.clean_otp(self.user) return self.cleaned_data def get_user(self): return self.user django-otp-django-otp-f5775d4/src/django_otp/locale/000077500000000000000000000000001524731414200223405ustar00rootroot00000000000000django-otp-django-otp-f5775d4/src/django_otp/locale/de/000077500000000000000000000000001524731414200227305ustar00rootroot00000000000000django-otp-django-otp-f5775d4/src/django_otp/locale/de/LC_MESSAGES/000077500000000000000000000000001524731414200245155ustar00rootroot00000000000000django-otp-django-otp-f5775d4/src/django_otp/locale/de/LC_MESSAGES/django.mo000066400000000000000000000044551524731414200263240ustar00rootroot00000000000000Þ•œxy$™¾>Ð ) 5@ `�*ž/ÉÌù‚Æ>I)ˆ8² ë: GPn…3•3É"ý< 8]÷–žŽ   Error generating challenge: {0}Forgotten your password or username?Get OTP ChallengeInvalid token. Please make sure you have entered it correctly.Log inOTP Challenge: {0}OTP Device:OTP Token:Please correct the error below.Please correct the errors below.Please enter your OTP token.The selected OTP device is not interactiveVerification of the token is currently disabledVerification temporarily disabled because of %(failure_count)d failed attempt, please try again soon.Verification temporarily disabled because of %(failure_count)d failed attempts, please try again soon.You are authenticated as %(username)s, but are not authorized to access this page. Would you like to login to a different account?Project-Id-Version: django-otp master Report-Msgid-Bugs-To: PO-Revision-Date: 2023-03-07 11:23+0100 Last-Translator: Mats Stottmeister Language-Team: Language: de MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plural-Forms: nplurals=2; plural=(n != 1); Fehler beim Generieren der Challenge: {0}Hast du dein Passwort oder deinen Nutzernamen vergessen?Bekomme Einmalpasswort-ChallengeUngültiges Einmalpasswort. Bitte überprüfe die Eingabe.AnmeldenEinmalpasswort-Challenge: {0}Einmalpasswort-Gerät:Einmalpasswort:Bitte korrigiere den Fehler unter dieser Nachricht.Bitte korrigiere die Fehler unter dieser Nachricht.Bitte gebe das Einmalpasswort ein.Das ausgewählte Einmalpasswort-Gerät ist nicht interaktiv.Verifikation des Einmalpassworts ist derzeit deaktiviertVerifikation vorübergehend deaktiviert wegen %(failure_count)d fehlgeschlagenem Versuch, bitte versuche es gleich erneut.Verifikation vorübergehend deaktiviert wegen %(failure_count)d fehlgeschlagenen Versuchen, bitte versuche es gleich erneut.Du bist mit dem Nutzer %(username)s authentifiziert, hast aber keine Berechtigung diese Seite aufzurufen. Möchtest du dich mit einem anderen Nutzer anmelden?django-otp-django-otp-f5775d4/src/django_otp/locale/de/LC_MESSAGES/django.po000066400000000000000000000065401524731414200263240ustar00rootroot00000000000000# German translation of django-otp. # Copyright (C) Listed translators # This file is distributed under the same license as the django-otp package. # Mats Stottmeister , 2023. # msgid "" msgstr "" "Project-Id-Version: django-otp master\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2024-04-10 09:55-0500\n" "PO-Revision-Date: 2023-03-07 11:23+0100\n" "Last-Translator: Mats Stottmeister \n" "Language-Team: \n" "Language: de\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" #: src/django_otp/forms.py:60 msgid "Please enter your OTP token." msgstr "Bitte gebe das Einmalpasswort ein." #: src/django_otp/forms.py:61 #, python-brace-format msgid "Error generating challenge: {0}" msgstr "Fehler beim Generieren der Challenge: {0}" #: src/django_otp/forms.py:62 msgid "The selected OTP device is not interactive" msgstr "Das ausgewählte Einmalpasswort-Gerät ist nicht interaktiv." #: src/django_otp/forms.py:63 #, python-brace-format msgid "OTP Challenge: {0}" msgstr "Einmalpasswort-Challenge: {0}" #: src/django_otp/forms.py:65 msgid "Invalid token. Please make sure you have entered it correctly." msgstr "Ungültiges Einmalpasswort. Bitte überprüfe die Eingabe." #: src/django_otp/forms.py:68 #, python-format msgid "" "Verification temporarily disabled because of %(failure_count)d failed " "attempt, please try again soon." msgid_plural "" "Verification temporarily disabled because of %(failure_count)d failed " "attempts, please try again soon." msgstr[0] "" "Verifikation vorübergehend deaktiviert wegen %(failure_count)d " "fehlgeschlagenem Versuch, bitte versuche es gleich erneut." msgstr[1] "" "Verifikation vorübergehend deaktiviert wegen %(failure_count)d " "fehlgeschlagenen Versuchen, bitte versuche es gleich erneut." #: src/django_otp/forms.py:73 msgid "Verification of the token is currently disabled" msgstr "Verifikation des Einmalpassworts ist derzeit deaktiviert" #: src/django_otp/plugins/otp_email/models.py:105 msgid "sent by email" msgstr "" #: src/django_otp/templates/otp/admin111/login.html:38 msgid "Please correct the error below." msgstr "Bitte korrigiere den Fehler unter dieser Nachricht." #: src/django_otp/templates/otp/admin111/login.html:38 msgid "Please correct the errors below." msgstr "Bitte korrigiere die Fehler unter dieser Nachricht." #: src/django_otp/templates/otp/admin111/login.html:54 #, python-format msgid "" "You are authenticated as %(username)s, but are not authorized to access this " "page. Would you like to login to a different account?" msgstr "" "Du bist mit dem Nutzer %(username)s authentifiziert, hast aber keine " "Berechtigung diese Seite aufzurufen. Möchtest du dich mit einem anderen " "Nutzer anmelden?" #: src/django_otp/templates/otp/admin111/login.html:74 msgid "OTP Device:" msgstr "Einmalpasswort-Gerät:" #: src/django_otp/templates/otp/admin111/login.html:79 msgid "OTP Token:" msgstr "Einmalpasswort:" #: src/django_otp/templates/otp/admin111/login.html:84 msgid "Forgotten your password or username?" msgstr "Hast du dein Passwort oder deinen Nutzernamen vergessen?" #: src/django_otp/templates/otp/admin111/login.html:88 msgid "Log in" msgstr "Anmelden" #: src/django_otp/templates/otp/admin111/login.html:90 msgid "Get OTP Challenge" msgstr "Bekomme Einmalpasswort-Challenge" django-otp-django-otp-f5775d4/src/django_otp/locale/es/000077500000000000000000000000001524731414200227475ustar00rootroot00000000000000django-otp-django-otp-f5775d4/src/django_otp/locale/es/LC_MESSAGES/000077500000000000000000000000001524731414200245345ustar00rootroot00000000000000django-otp-django-otp-f5775d4/src/django_otp/locale/es/LC_MESSAGES/django.mo000066400000000000000000000052021524731414200263320ustar00rootroot00000000000000Þ•´LÀÁ$á>W^ q }ˆ ¨É*æQ,c/�ÌÀ‚� 84V‹Nœë û  *%.P"1¢nÔ;C<#¼�à p      Error generating challenge: {0}Forgotten your password or username?Get OTP ChallengeInvalid token. Please make sure you have entered it correctly.Log inOTP Challenge: {0}OTP Device:OTP Token:Please correct the error below.Please correct the errors below.Please enter your OTP token.The selected OTP device is not interactiveToken generation cooldown period has not expired yet. Next generation allowed %s.Token generation is not allowed at this timeVerification of the token is currently disabledVerification temporarily disabled because of %(failure_count)d failed attempt, please try again soon.Verification temporarily disabled because of %(failure_count)d failed attempts, please try again soon.You are authenticated as %(username)s, but are not authorized to access this page. Would you like to login to a different account?sent by emailProject-Id-Version: django-otp master Report-Msgid-Bugs-To: PO-Revision-Date: 2022-11-09 19:43+0100 Last-Translator: Language-Team: Language: es MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plural-Forms: nplurals=2; plural=(n != 1); Error al generar el reto: {0}¿Ha olvidado la contraseña o el nombre de usuario?Obtenga reto OTPToken no válido. Por favor asegúrese de que lo ha introducido correctamente.Iniciar sesiónReto OTP: {0}Dispositivo OTP:Token OTP:Por favor corrija el error abajo indicado.Por favor corrija los errores abajo indicados.Por favor introduzca su token OTP.EL dispositivo OTP seleccionado no es interactivoEl periodo de recuperación de la generación de tokens aún no ha expirado. Próxima generaciónpermitida %s.La generación de tokens no está permitida en este momentoLa verificación del token está desactivada en este momentoLa verificación está temporalmente deshabilitada porque ha habido %(failure_count)d intento fallido, por favor inténtelo de nuevo más tarde.La verificación está temporalmente deshabilitada porque ha habido %(failure_count)d intentos fallidos, por favor inténtelo de nuevo más tarde.Usted está autenticado como %(username)s, pero no está autorizado a acceder a esta página. ¿Desea iniciar sesión con una cuenta diferente?enviado por emaildjango-otp-django-otp-f5775d4/src/django_otp/locale/es/LC_MESSAGES/django.po000066400000000000000000000074041524731414200263430ustar00rootroot00000000000000# Spanish translation of django-otp. # Copyright (C) Listed translators # This file is distributed under the same license as the django-otp package. # Miguel González , 2022 # msgid "" msgstr "" "Project-Id-Version: django-otp master\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2025-10-24 17:22-0500\n" "PO-Revision-Date: 2022-11-09 19:43+0100\n" "Last-Translator: \n" "Language-Team: \n" "Language: es\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" #: src/django_otp/forms.py:63 msgid "Please enter your OTP token." msgstr "Por favor introduzca su token OTP." #: src/django_otp/forms.py:64 #, python-brace-format msgid "Error generating challenge: {0}" msgstr "Error al generar el reto: {0}" #: src/django_otp/forms.py:65 msgid "The selected OTP device is not interactive" msgstr "EL dispositivo OTP seleccionado no es interactivo" #: src/django_otp/forms.py:66 #, python-brace-format msgid "OTP Challenge: {0}" msgstr "Reto OTP: {0}" #: src/django_otp/forms.py:68 msgid "Invalid token. Please make sure you have entered it correctly." msgstr "" "Token no válido. Por favor asegúrese de que lo ha introducido correctamente." #: src/django_otp/forms.py:71 #, python-format msgid "" "Verification temporarily disabled because of %(failure_count)d failed " "attempt, please try again soon." msgid_plural "" "Verification temporarily disabled because of %(failure_count)d failed " "attempts, please try again soon." msgstr[0] "" "La verificación está temporalmente deshabilitada porque ha habido " "%(failure_count)d intento fallido, por favor inténtelo de nuevo más tarde." msgstr[1] "" "La verificación está temporalmente deshabilitada porque ha habido " "%(failure_count)d intentos fallidos, por favor inténtelo de nuevo más tarde." #: src/django_otp/forms.py:76 msgid "Verification of the token is currently disabled" msgstr "La verificación del token está desactivada en este momento" #: src/django_otp/plugins/otp_email/models.py:75 #, python-format msgid "" "Token generation cooldown period has not expired yet. Next generation " "allowed %s." msgstr "" "El periodo de recuperación de la generación de tokens aún no ha expirado. Próxima generación" "permitida %s." #: src/django_otp/plugins/otp_email/models.py:81 msgid "Token generation is not allowed at this time" msgstr "La generación de tokens no está permitida en este momento" #: src/django_otp/plugins/otp_email/models.py:108 msgid "sent by email" msgstr "enviado por email" #: src/django_otp/templates/otp/admin111/login.html:38 msgid "Please correct the error below." msgstr "Por favor corrija el error abajo indicado." #: src/django_otp/templates/otp/admin111/login.html:38 msgid "Please correct the errors below." msgstr "Por favor corrija los errores abajo indicados." #: src/django_otp/templates/otp/admin111/login.html:54 #, python-format msgid "" "You are authenticated as %(username)s, but are not authorized to access this " "page. Would you like to login to a different account?" msgstr "" "Usted está autenticado como %(username)s, pero no está autorizado a acceder " "a esta página. ¿Desea iniciar sesión con una cuenta diferente?" #: src/django_otp/templates/otp/admin111/login.html:74 msgid "OTP Device:" msgstr "Dispositivo OTP:" #: src/django_otp/templates/otp/admin111/login.html:79 msgid "OTP Token:" msgstr "Token OTP:" #: src/django_otp/templates/otp/admin111/login.html:84 msgid "Forgotten your password or username?" msgstr "¿Ha olvidado la contraseña o el nombre de usuario?" #: src/django_otp/templates/otp/admin111/login.html:88 msgid "Log in" msgstr "Iniciar sesión" #: src/django_otp/templates/otp/admin111/login.html:90 msgid "Get OTP Challenge" msgstr "Obtenga reto OTP" django-otp-django-otp-f5775d4/src/django_otp/locale/fr/000077500000000000000000000000001524731414200227475ustar00rootroot00000000000000django-otp-django-otp-f5775d4/src/django_otp/locale/fr/LC_MESSAGES/000077500000000000000000000000001524731414200245345ustar00rootroot00000000000000django-otp-django-otp-f5775d4/src/django_otp/locale/fr/LC_MESSAGES/django.mo000066400000000000000000000044331524731414200263370ustar00rootroot00000000000000Þ•œxy$™¾>Ð ) 5@ `�*ž/ÉÌù‚Æ?I,‰.¶åEú @M^ n({)¤ Î5ï7%]¨r   Error generating challenge: {0}Forgotten your password or username?Get OTP ChallengeInvalid token. Please make sure you have entered it correctly.Log inOTP Challenge: {0}OTP Device:OTP Token:Please correct the error below.Please correct the errors below.Please enter your OTP token.The selected OTP device is not interactiveVerification of the token is currently disabledVerification temporarily disabled because of %(failure_count)d failed attempt, please try again soon.Verification temporarily disabled because of %(failure_count)d failed attempts, please try again soon.You are authenticated as %(username)s, but are not authorized to access this page. Would you like to login to a different account?Project-Id-Version: django-otp master Report-Msgid-Bugs-To: PO-Revision-Date: 2020-10-02 23:25+0200 Last-Translator: Claude Paroz Language-Team: French Language: fr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plural-Forms: nplurals=2; plural=(n > 1); Erreur lors de la production du défi : {0}Mot de passe ou nom d’utilisateur oublié ?Obtenir le défi OTPJeton non valable. Assurez-vous de bien l’avoir saisi correctement.Se connecterDéfi OTP : {0}Appareil OTP :Jeton OTP :Veuillez corriger l’erreur ci-dessous.Veuillez corriger les erreurs ci-dessous.Veuillez saisir votre jeton OTP.L’appareil OTP sélectionné n’est pas interactifLa vérification du jeton est actuellement désactivéeLa vérification a été temporairement désactivée car %(failure_count)d essai a échoué, veuillez réessayer dans quelques instants.La vérification a été temporairement désactivée car %(failure_count)d essais ont échoué, veuillez réessayer dans quelques instants.Vous êtes authentifié-e en tant que %(username)s, mais vous n’êtes pas autorisé-e à accéder à cette page. Souhaitez-vous vous connecter avec un autre compte ?django-otp-django-otp-f5775d4/src/django_otp/locale/fr/LC_MESSAGES/django.po000066400000000000000000000065111524731414200263410ustar00rootroot00000000000000# French translation of django-otp. # Copyright (C) Listed translators # This file is distributed under the same license as the django-otp package. # Claude Paroz , 2020 # msgid "" msgstr "" "Project-Id-Version: django-otp master\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2024-04-10 09:57-0500\n" "PO-Revision-Date: 2020-10-02 23:25+0200\n" "Last-Translator: Claude Paroz \n" "Language-Team: French\n" "Language: fr\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" #: src/django_otp/forms.py:60 msgid "Please enter your OTP token." msgstr "Veuillez saisir votre jeton OTP." #: src/django_otp/forms.py:61 #, python-brace-format msgid "Error generating challenge: {0}" msgstr "Erreur lors de la production du défi : {0}" #: src/django_otp/forms.py:62 msgid "The selected OTP device is not interactive" msgstr "L’appareil OTP sélectionné n’est pas interactif" #: src/django_otp/forms.py:63 #, python-brace-format msgid "OTP Challenge: {0}" msgstr "Défi OTP : {0}" #: src/django_otp/forms.py:65 msgid "Invalid token. Please make sure you have entered it correctly." msgstr "Jeton non valable. Assurez-vous de bien l’avoir saisi correctement." #: src/django_otp/forms.py:68 #, python-format msgid "" "Verification temporarily disabled because of %(failure_count)d failed " "attempt, please try again soon." msgid_plural "" "Verification temporarily disabled because of %(failure_count)d failed " "attempts, please try again soon." msgstr[0] "" "La vérification a été temporairement désactivée car %(failure_count)d essai " "a échoué, veuillez réessayer dans quelques instants." msgstr[1] "" "La vérification a été temporairement désactivée car %(failure_count)d essais " "ont échoué, veuillez réessayer dans quelques instants." #: src/django_otp/forms.py:73 msgid "Verification of the token is currently disabled" msgstr "La vérification du jeton est actuellement désactivée" #: src/django_otp/plugins/otp_email/models.py:105 msgid "sent by email" msgstr "" #: src/django_otp/templates/otp/admin111/login.html:38 msgid "Please correct the error below." msgstr "Veuillez corriger l’erreur ci-dessous." #: src/django_otp/templates/otp/admin111/login.html:38 msgid "Please correct the errors below." msgstr "Veuillez corriger les erreurs ci-dessous." #: src/django_otp/templates/otp/admin111/login.html:54 #, python-format msgid "" "You are authenticated as %(username)s, but are not authorized to access this " "page. Would you like to login to a different account?" msgstr "" "Vous êtes authentifié-e en tant que %(username)s, mais vous n’êtes pas " "autorisé-e à accéder à cette page. Souhaitez-vous vous connecter avec un " "autre compte ?" #: src/django_otp/templates/otp/admin111/login.html:74 msgid "OTP Device:" msgstr "Appareil OTP :" #: src/django_otp/templates/otp/admin111/login.html:79 msgid "OTP Token:" msgstr "Jeton OTP :" #: src/django_otp/templates/otp/admin111/login.html:84 msgid "Forgotten your password or username?" msgstr "Mot de passe ou nom d’utilisateur oublié ?" #: src/django_otp/templates/otp/admin111/login.html:88 msgid "Log in" msgstr "Se connecter" #: src/django_otp/templates/otp/admin111/login.html:90 msgid "Get OTP Challenge" msgstr "Obtenir le défi OTP" django-otp-django-otp-f5775d4/src/django_otp/locale/pt_BR/000077500000000000000000000000001524731414200233465ustar00rootroot00000000000000django-otp-django-otp-f5775d4/src/django_otp/locale/pt_BR/LC_MESSAGES/000077500000000000000000000000001524731414200251335ustar00rootroot00000000000000django-otp-django-otp-f5775d4/src/django_otp/locale/pt_BR/LC_MESSAGES/django.mo000066400000000000000000000041161524731414200267340ustar00rootroot00000000000000Þ•œxy$™¾>Ð ) 5@ `�*ž/ÉÌù‚ÆPIš'µÝEñ7>O ` m"Ž$±0Ö6>�¾   Error generating challenge: {0}Forgotten your password or username?Get OTP ChallengeInvalid token. Please make sure you have entered it correctly.Log inOTP Challenge: {0}OTP Device:OTP Token:Please correct the error below.Please correct the errors below.Please enter your OTP token.The selected OTP device is not interactiveVerification of the token is currently disabledVerification temporarily disabled because of %(failure_count)d failed attempt, please try again soon.Verification temporarily disabled because of %(failure_count)d failed attempts, please try again soon.You are authenticated as %(username)s, but are not authorized to access this page. Would you like to login to a different account?Project-Id-Version: django-otp master Report-Msgid-Bugs-To: PO-Revision-Date: 2023-05-24 12:34-0300 Last-Translator: alex Language-Team: LANGUAGE Language: pt_BR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plural-Forms: nplurals=2; plural=(n > 1); Erro ao gerar desafio: {0}Esqueceu sua senha ou nome de usuário?Obter o desafio OTPCódigo inválido. Por favor certifique-se de inseri-lo corretamente.EntrarDesafio OTP: {0}Dispositivo OTP:Código OTP:Por favor corrija o erro abaixo.Por favor corrija os erros abaixo.Por favor entre com seu código OTP.O dispositivo OTP selecionado não é interativoA verificação de código está desativada no momentoVerificação temporariamente desativada devido a %(failure_count)d tentativa(s) falha(s), por favor tente novamente em breve.Você está autenticado como %(username)s, mas não está autorizado a acessar essa página. Gostaria de autenticar-se com uma conta diferente?django-otp-django-otp-f5775d4/src/django_otp/locale/pt_BR/LC_MESSAGES/django.po000066400000000000000000000062001524731414200267330ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , 2023. # msgid "" msgstr "" "Project-Id-Version: django-otp master\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2024-04-10 09:57-0500\n" "PO-Revision-Date: 2023-05-24 12:34-0300\n" "Last-Translator: alex \n" "Language-Team: LANGUAGE \n" "Language: pt_BR\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" #: src/django_otp/forms.py:60 msgid "Please enter your OTP token." msgstr "Por favor entre com seu código OTP." #: src/django_otp/forms.py:61 #, python-brace-format msgid "Error generating challenge: {0}" msgstr "Erro ao gerar desafio: {0}" #: src/django_otp/forms.py:62 msgid "The selected OTP device is not interactive" msgstr "O dispositivo OTP selecionado não é interativo" #: src/django_otp/forms.py:63 #, python-brace-format msgid "OTP Challenge: {0}" msgstr "Desafio OTP: {0}" #: src/django_otp/forms.py:65 msgid "Invalid token. Please make sure you have entered it correctly." msgstr "Código inválido. Por favor certifique-se de inseri-lo corretamente." #: src/django_otp/forms.py:68 #, python-format msgid "" "Verification temporarily disabled because of %(failure_count)d failed " "attempt, please try again soon." msgid_plural "" "Verification temporarily disabled because of %(failure_count)d failed " "attempts, please try again soon." msgstr[0] "" "Verificação temporariamente desativada devido a %(failure_count)d " "tentativa(s) " msgstr[1] "falha(s), por favor tente novamente em breve." #: src/django_otp/forms.py:73 msgid "Verification of the token is currently disabled" msgstr "A verificação de código está desativada no momento" #: src/django_otp/plugins/otp_email/models.py:105 msgid "sent by email" msgstr "" #: src/django_otp/templates/otp/admin111/login.html:38 msgid "Please correct the error below." msgstr "Por favor corrija o erro abaixo." #: src/django_otp/templates/otp/admin111/login.html:38 msgid "Please correct the errors below." msgstr "Por favor corrija os erros abaixo." #: src/django_otp/templates/otp/admin111/login.html:54 #, python-format msgid "" "You are authenticated as %(username)s, but are not authorized to access this " "page. Would you like to login to a different account?" msgstr "" "Você está autenticado como %(username)s, mas não está autorizado a acessar " "essa página. Gostaria de autenticar-se com uma conta diferente?" #: src/django_otp/templates/otp/admin111/login.html:74 msgid "OTP Device:" msgstr "Dispositivo OTP:" #: src/django_otp/templates/otp/admin111/login.html:79 msgid "OTP Token:" msgstr "Código OTP:" #: src/django_otp/templates/otp/admin111/login.html:84 msgid "Forgotten your password or username?" msgstr "Esqueceu sua senha ou nome de usuário?" #: src/django_otp/templates/otp/admin111/login.html:88 msgid "Log in" msgstr "Entrar" #: src/django_otp/templates/otp/admin111/login.html:90 msgid "Get OTP Challenge" msgstr "Obter o desafio OTP" django-otp-django-otp-f5775d4/src/django_otp/locale/pt_PT/000077500000000000000000000000001524731414200233665ustar00rootroot00000000000000django-otp-django-otp-f5775d4/src/django_otp/locale/pt_PT/LC_MESSAGES/000077500000000000000000000000001524731414200251535ustar00rootroot00000000000000django-otp-django-otp-f5775d4/src/django_otp/locale/pt_PT/LC_MESSAGES/django.mo000066400000000000000000000043041524731414200267530ustar00rootroot00000000000000Þ•œxy$™¾>Ð ) 5@ `�*ž/ÉÌù‚Æ>Iˆ7¥ÝIñ;BS d q"’µ0Õ6û=Š9   Error generating challenge: {0}Forgotten your password or username?Get OTP ChallengeInvalid token. Please make sure you have entered it correctly.Log inOTP Challenge: {0}OTP Device:OTP Token:Please correct the error below.Please correct the errors below.Please enter your OTP token.The selected OTP device is not interactiveVerification of the token is currently disabledVerification temporarily disabled because of %(failure_count)d failed attempt, please try again soon.Verification temporarily disabled because of %(failure_count)d failed attempts, please try again soon.You are authenticated as %(username)s, but are not authorized to access this page. Would you like to login to a different account?Project-Id-Version: django-otp master Report-Msgid-Bugs-To: PO-Revision-Date: 2023-10-02 11:00-0500 Last-Translator: João Duarte Language-Team: Language: pt_PT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plural-Forms: nplurals=2; plural=(n > 1); Erro ao gerar o desafio: {0}Esqueceu-se da sua palavra-passe ou nome de utilizador?Obter o desafio OTPCódigo inválido. Certifique-se por favor de que o inseriu corretamente.EntrarDesafio OTP: {0}Dispositivo OTP:Código OTP:Corrija por favor o erro abaixo.Corrija por favor os erros abaixo.Insira por favor o código OTP.O dispositivo OTP selecionado não é interativoA verificação do código está desativada de momentoVerificação desativada temporariamente devido a %(failure_count)d tentativa falhada, por favor tente novamente mais tarde.Verificação desativada temporariamente devido a %(failure_count)d tentativas falhadas, por favor tente novamente mais tarde.Está autenticado como %(username)s, mas não está autorizado a aceder a esta página. Gostaria de autenticar-se com uma conta diferente?django-otp-django-otp-f5775d4/src/django_otp/locale/pt_PT/LC_MESSAGES/django.po000066400000000000000000000063711524731414200267640ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # João Duarte , 2023. # msgid "" msgstr "" "Project-Id-Version: django-otp master\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2024-04-10 09:57-0500\n" "PO-Revision-Date: 2023-10-02 11:00-0500\n" "Last-Translator: João Duarte \n" "Language-Team: \n" "Language: pt_PT\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" #: src/django_otp/forms.py:60 msgid "Please enter your OTP token." msgstr "Insira por favor o código OTP." #: src/django_otp/forms.py:61 #, python-brace-format msgid "Error generating challenge: {0}" msgstr "Erro ao gerar o desafio: {0}" #: src/django_otp/forms.py:62 msgid "The selected OTP device is not interactive" msgstr "O dispositivo OTP selecionado não é interativo" #: src/django_otp/forms.py:63 #, python-brace-format msgid "OTP Challenge: {0}" msgstr "Desafio OTP: {0}" #: src/django_otp/forms.py:65 msgid "Invalid token. Please make sure you have entered it correctly." msgstr "" "Código inválido. Certifique-se por favor de que o inseriu corretamente." #: src/django_otp/forms.py:68 #, python-format msgid "" "Verification temporarily disabled because of %(failure_count)d failed " "attempt, please try again soon." msgid_plural "" "Verification temporarily disabled because of %(failure_count)d failed " "attempts, please try again soon." msgstr[0] "" "Verificação desativada temporariamente devido a %(failure_count)d tentativa " "falhada, por favor tente novamente mais tarde." msgstr[1] "" "Verificação desativada temporariamente devido a %(failure_count)d tentativas " "falhadas, por favor tente novamente mais tarde." #: src/django_otp/forms.py:73 msgid "Verification of the token is currently disabled" msgstr "A verificação do código está desativada de momento" #: src/django_otp/plugins/otp_email/models.py:105 msgid "sent by email" msgstr "" #: src/django_otp/templates/otp/admin111/login.html:38 msgid "Please correct the error below." msgstr "Corrija por favor o erro abaixo." #: src/django_otp/templates/otp/admin111/login.html:38 msgid "Please correct the errors below." msgstr "Corrija por favor os erros abaixo." #: src/django_otp/templates/otp/admin111/login.html:54 #, python-format msgid "" "You are authenticated as %(username)s, but are not authorized to access this " "page. Would you like to login to a different account?" msgstr "" "Está autenticado como %(username)s, mas não está autorizado a aceder a esta " "página. Gostaria de autenticar-se com uma conta diferente?" #: src/django_otp/templates/otp/admin111/login.html:74 msgid "OTP Device:" msgstr "Dispositivo OTP:" #: src/django_otp/templates/otp/admin111/login.html:79 msgid "OTP Token:" msgstr "Código OTP:" #: src/django_otp/templates/otp/admin111/login.html:84 msgid "Forgotten your password or username?" msgstr "Esqueceu-se da sua palavra-passe ou nome de utilizador?" #: src/django_otp/templates/otp/admin111/login.html:88 msgid "Log in" msgstr "Entrar" #: src/django_otp/templates/otp/admin111/login.html:90 msgid "Get OTP Challenge" msgstr "Obter o desafio OTP" django-otp-django-otp-f5775d4/src/django_otp/middleware.py000066400000000000000000000105311524731414200235700ustar00rootroot00000000000000import functools from asgiref.sync import iscoroutinefunction, markcoroutinefunction from django.utils.functional import SimpleLazyObject from django_otp import DEVICE_ID_SESSION_KEY from django_otp.models import Device def is_verified(user): return user.otp_device is not None class OTPMiddleware: """ This must be installed after :class:`~django.contrib.auth.middleware.AuthenticationMiddleware` and performs an analogous function. Just as AuthenticationMiddleware populates ``request.user`` based on session data, OTPMiddleware populates ``request.user.otp_device`` to the :class:`~django_otp.models.Device` object that has verified the user, or ``None`` if the user has not been verified. As a convenience, this also installs ``user.is_verified()``, which returns ``True`` if ``user.otp_device`` is not ``None``. This middleware is async capable. It wraps ``request.auser()`` similarly to ``request.user`` as described above. """ sync_capable = True async_capable = True def __init__(self, get_response): self.get_response = get_response self._is_async = iscoroutinefunction(get_response) if self._is_async: markcoroutinefunction(self) def __call__(self, request): if self._is_async: return self.__acall__(request) self._install_lazy_accessors(request) return self.get_response(request) async def __acall__(self, request): self._install_lazy_accessors(request) return await self.get_response(request) def _install_lazy_accessors(self, request): user = getattr(request, "user", None) if user is not None: request.user = SimpleLazyObject( functools.partial(self._verify_user_sync, request, user) ) auser = getattr(request, "auser", None) if auser is not None: request.auser = functools.partial( self._verify_user_async_via_auser, request, auser ) @staticmethod def _init_user_fields(user): user.otp_device = None user.is_verified = functools.partial(is_verified, user) @staticmethod def _normalize_persistent_id(persistent_id: str) -> str: # Convert legacy persistent_id values (these used to be full import # paths). This won't work for apps with models in sub-modules, but that # should be pretty rare. And the worst that happens is the user has to # log in again. if persistent_id.count(".") > 1: parts = persistent_id.split(".") return ".".join((parts[-3], parts[-1])) return persistent_id @staticmethod def _finalize_device(request, user, device): """ Enforce device-user binding and keep session state consistent. """ if (device is not None) and (device.user_id != user.pk): device = None if (device is None) and (DEVICE_ID_SESSION_KEY in request.session): del request.session[DEVICE_ID_SESSION_KEY] return device def _verify_user_sync(self, request, user): self._init_user_fields(user) if user.is_authenticated: persistent_id = request.session.get(DEVICE_ID_SESSION_KEY) device = ( self._device_from_persistent_id(persistent_id) if persistent_id else None ) user.otp_device = self._finalize_device(request, user, device) return user def _device_from_persistent_id(self, persistent_id: str): persistent_id = self._normalize_persistent_id(persistent_id) return Device.from_persistent_id(persistent_id) async def _verify_user_async_via_auser(self, request, auser): user = await auser() self._init_user_fields(user) if user.is_authenticated: persistent_id = request.session.get(DEVICE_ID_SESSION_KEY) device = ( await self._adevice_from_persistent_id(persistent_id) if persistent_id else None ) user.otp_device = self._finalize_device(request, user, device) return user async def _adevice_from_persistent_id(self, persistent_id: str): persistent_id = self._normalize_persistent_id(persistent_id) return await Device.afrom_persistent_id(persistent_id) django-otp-django-otp-f5775d4/src/django_otp/models.py000066400000000000000000000473361524731414200227530ustar00rootroot00000000000000from contextlib import suppress from datetime import timedelta import enum from django.apps import apps from django.conf import settings from django.core.exceptions import ObjectDoesNotExist from django.db import models from django.utils import timezone from django.utils.functional import cached_property from .util import random_number_token # The exponential throttling delay can grow arbitrarily large with a high # enough failure count, well past what `timedelta` can represent. Cap it at # something that's already far longer than any realistic lockout needs to # be, rather than let the delay calculation overflow. MAX_THROTTLE_DELAY_SECONDS = timedelta(days=365 * 100).total_seconds() class DeviceManager(models.Manager): """ The :class:`~django.db.models.Manager` object installed as ``Device.objects``. """ def devices_for_user(self, user, confirmed=None): """ Returns a queryset for all devices of this class that belong to the given user. :param user: The user. :type user: :class:`~django.contrib.auth.models.User` :param confirmed: If ``None``, all matching devices are returned. Otherwise, this can be any true or false value to limit the query to confirmed or unconfirmed devices, respectively. """ devices = self.model.objects.filter(user=user) if confirmed is not None: devices = devices.filter(confirmed=bool(confirmed)) return devices class Device(models.Model): """ Abstract base model for a :term:`device` attached to a user. Plugins must subclass this to define their OTP models. .. _unsaved_device_warning: .. warning:: OTP devices are inherently stateful. For example, verifying a token is logically a mutating operation on the device, which may involve incrementing a counter or otherwise consuming a token. A device must be committed to the database before it can be used in any way. .. attribute:: user *ForeignKey*: Foreign key to your user model, as configured by :setting:`AUTH_USER_MODEL` (:class:`~django.contrib.auth.models.User` by default). .. attribute:: name *CharField*: A human-readable name to help the user identify their devices. .. attribute:: confirmed *BooleanField*: A boolean value that tells us whether this device has been confirmed as valid. It defaults to ``True``, but subclasses or individual deployments can force it to ``False`` if they wish to create a device and then ask the user for confirmation. As a rule, built-in APIs that enumerate devices will only include those that are confirmed. .. attribute:: objects A :class:`~django_otp.models.DeviceManager`. """ user = models.ForeignKey( getattr(settings, 'AUTH_USER_MODEL', 'auth.User'), help_text="The user that this device belongs to.", on_delete=models.CASCADE, ) name = models.CharField( max_length=64, help_text="The human-readable name of this device." ) confirmed = models.BooleanField( default=True, help_text="Is this device ready for use?" ) objects = DeviceManager() class Meta: abstract = True def __str__(self): try: user = self.user except ObjectDoesNotExist: user = None return "{0} ({1})".format(self.name, user) @property def persistent_id(self): """ A stable device identifier for forms and APIs. """ return '{0}/{1}'.format(self.model_label(), self.id) @classmethod def model_label(cls): """ Returns an identifier for this Django model class. This is just the standard "." form. """ return '{0}.{1}'.format(cls._meta.app_label, cls._meta.model_name) @classmethod def from_persistent_id(cls, persistent_id, for_verify=False): """ Loads a device from its persistent id:: device == Device.from_persistent_id(device.persistent_id) :param bool for_verify: If ``True``, we'll load the device with :meth:`~django.db.models.query.QuerySet.select_for_update` to prevent concurrent verifications from succeeding. In which case, this must be called inside a transaction. """ with suppress(ValueError, LookupError): return cls._filter_persistent_id(persistent_id, for_verify).first() return None @classmethod async def afrom_persistent_id(cls, persistent_id, for_verify=False): with suppress(ValueError, LookupError): return await cls._filter_persistent_id(persistent_id, for_verify).afirst() return None @classmethod def _filter_persistent_id(cls, persistent_id, for_verify=False): model_label, device_id = persistent_id.rsplit("/", 1) app_label, model_name = model_label.split(".") device_cls = apps.get_model(app_label, model_name) if issubclass(device_cls, Device): device_set = device_cls.objects.filter(id=int(device_id)) if for_verify: device_set = device_set.select_for_update() return device_set return None def is_interactive(self): """ Returns ``True`` if this is an interactive device. The default implementation returns ``True`` if :meth:`~django_otp.models.Device.generate_challenge` has been overridden, but subclasses are welcome to provide smarter implementations. :rtype: bool """ return not hasattr(self.generate_challenge, 'stub') def generate_is_allowed(self): """ Checks whether it is permissible to call :meth:`generate_challenge`. If it is allowed, returns ``(True, None)``. Otherwise returns ``(False, data_dict)``, where ``data_dict`` contains extra information, defined by the implementation. This method can be used to implement throttling of token generation for interactive devices. Client code should check this method before calling :meth:`generate_challenge` and report problems to the user. """ return (True, None) def generate_challenge(self): """ Generates a challenge value that the user will need to produce a token. This method is permitted to have side effects, such as transmitting information to the user through some other channel (email or SMS, perhaps). And, of course, some devices may need to commit the challenge to the database. :returns: A message to the user. This should be a string that fits comfortably in the template ``'OTP Challenge: {0}'``. This may return ``None`` if this device is not interactive. :rtype: string or ``None`` :raises: Any :exc:`~exceptions.Exception` is permitted. Callers should trap ``Exception`` and report it to the user. """ return None generate_challenge.stub = True def verify_is_allowed(self): """ Checks whether it is permissible to call :meth:`verify_token`. If it is allowed, returns ``(True, None)``. Otherwise returns ``(False, data_dict)``, where ``data_dict`` contains extra information, defined by the implementation. This method can be used to implement throttling or locking, for example. Client code should check this method before calling :meth:`verify_token` and report problems to the user. To report specific problems, the data dictionary can return include a ``'reason'`` member with a value from the constants in :class:`VerifyNotAllowed`. Otherwise, an ``'error_message'`` member should be provided with an error message. :meth:`verify_token` should also call this method and return False if verification is not allowed. :rtype: (bool, dict or ``None``) """ return (True, None) def verify_token(self, token): """ Verifies a token. As a rule, the token should no longer be valid if this returns ``True``. :param str token: The OTP token provided by the user. :rtype: bool """ return False class SideChannelDevice(Device): """ Abstract base model for a side-channel :term:`device` attached to a user. This model implements token generation, verification and expiration, so the concrete devices only have to implement delivery. .. attribute:: token The token most recently generated for the user. .. attribute:: valid_until The datetime at which the stored token will expire. """ token = models.CharField(max_length=16, blank=True, null=True) valid_until = models.DateTimeField( default=timezone.now, help_text="The timestamp of the moment of expiry of the saved token.", ) class Meta: abstract = True def generate_token(self, length=6, valid_secs=300, commit=True): """ Generates a token of the specified length, then sets it on the model and sets the expiration of the token on the model. :param int length: Number of decimal digits in the generated token. :param int valid_secs: Amount of seconds the token should be valid. :param bool commit: Pass False if you intend to save the instance yourself. """ self.token = random_number_token(length) self.valid_until = timezone.now() + timedelta(seconds=valid_secs) if commit: self.save() def verify_token(self, token): """ Verifies a token by content and expiry. On success, the token is cleared and the device saved. :param str token: The OTP token provided by the user. :rtype: bool """ _now = timezone.now() if ( (self.token is not None) and (token == self.token) and (_now < self.valid_until) ): self.token = None self.valid_until = _now self.save() return True else: return False class GenerateNotAllowed(enum.Enum): """ Constants that may be returned in the ``reason`` member of the extra information dictionary returned by :meth:`~django_otp.models.Device.generate_is_allowed`. .. data:: COOLDOWN_DURATION_PENDING Indicates that a token was generated recently and we're waiting for the cooldown period to expire. """ COOLDOWN_DURATION_PENDING = 'COOLDOWN_DURATION_PENDING' class CooldownMixin(models.Model): """ Mixin class for models requiring a cooldown duration between challenge generations. Subclass must implement :meth:`get_cooldown_duration`, and must use the :meth:`generate_is_allowed` method from within their generate_challenge() method. Further it must use :meth:`cooldown_set` when a token is generated. See the implementation of :class:`~django_otp.plugins.otp_email.models.EmailDevice` for an example. .. attribute:: last_generated_timestamp The last time a token was generated for this device. """ last_generated_timestamp = models.DateTimeField( null=True, blank=True, help_text="The last time a token was generated for this device.", ) class Meta: abstract = True def generate_is_allowed(self): """ If token generation is allowed, returns ``(True, None)``. Otherwise, returns ``(False, data_dict)``. ``data_dict`` contains further information. Currently it can be:: { 'reason': GenerateNotAllowed.COOLDOWN_DURATION_PENDING, 'next_generation_at': when, } where ``when`` is a datetime marking the end of the cooldown period. See :class:`GenerateNotAllowed`. """ dt_now = timezone.now() if ( not self.last_generated_timestamp or (dt_now - self.last_generated_timestamp).total_seconds() > self.get_cooldown_duration() ): return super().generate_is_allowed() else: return False, { 'reason': GenerateNotAllowed.COOLDOWN_DURATION_PENDING, 'next_generation_at': self.last_generated_timestamp + timedelta(seconds=self.get_cooldown_duration()), } def cooldown_reset(self, commit=True): """ Call this method to reset cooldown (normally after a successful verification). :param bool commit: Pass False if you intend to save the instance yourself. """ self.last_generated_timestamp = None if commit: self.save() def cooldown_set(self, commit=True): """ Call this method to set the cooldown timestamp to now (normally when a token is generated). :param bool commit: Pass False if you intend to save the instance yourself. """ self.last_generated_timestamp = timezone.now() if commit: self.save() def verify_token(self, token): """ Reset the throttle if the token is valid. """ verified = super().verify_token(token) if verified: self.cooldown_reset() return verified @cached_property def cooldown_enabled(self): return self.get_cooldown_duration() > 0 def get_cooldown_duration(self): """ This must be implemented to return the cooldown duration in seconds. A duration of 0 disables the cooldown. Normally this is just a wrapper for a plugin-specific setting like :setting:`OTP_EMAIL_COOLDOWN_DURATION`. """ raise NotImplementedError() class VerifyNotAllowed(enum.Enum): """ Constants that may be returned in the ``reason`` member of the extra information dictionary returned by :meth:`~django_otp.models.Device.verify_is_allowed` .. data:: N_FAILED_ATTEMPTS Indicates that verification is disallowed because of ``n`` successive failed attempts. The data dictionary should include the value of ``n`` in member ``failure_count`` """ N_FAILED_ATTEMPTS = 'N_FAILED_ATTEMPTS' class ThrottlingMixin(models.Model): """ Mixin class for models that want throttling behaviour. This implements exponential back-off for verifying tokens. Subclasses must implement :meth:`get_throttle_factor`, and must use the :meth:`verify_is_allowed`, :meth:`throttle_reset` and :meth:`throttle_increment` methods from within their verify_token() method. See the implementation of :class:`~django_otp.plugins.otp_email.models.EmailDevice` for an example. .. attribute:: throttling_failure_timestamp The datetime of the last failed verification attempt. .. attribute:: throttling_failure_count The number of consecutive failed verification attempts. """ throttling_failure_timestamp = models.DateTimeField( null=True, blank=True, default=None, help_text=( "A timestamp of the last failed verification attempt. Null if last attempt" " succeeded." ), ) throttling_failure_count = models.PositiveIntegerField( default=0, help_text="Number of successive failed attempts." ) class Meta: abstract = True def verify_is_allowed(self): """ If verification is allowed, returns ``(True, None)``. Otherwise, returns ``(False, data_dict)``. ``data_dict`` contains further information. Currently it can be:: { 'reason': VerifyNotAllowed.N_FAILED_ATTEMPTS, 'failure_count': n } where ``n`` is the number of successive failures. See :class:`~django_otp.models.VerifyNotAllowed`. """ if ( self.throttling_enabled and self.throttling_failure_count > 0 and self.throttling_failure_timestamp is not None ): now = timezone.now() delay = (now - self.throttling_failure_timestamp).total_seconds() # Required delays should be 1, 2, 4, 8 ... delay_required = min( self.get_throttle_factor() * (2 ** (self.throttling_failure_count - 1)), MAX_THROTTLE_DELAY_SECONDS, ) if delay < delay_required: return ( False, { 'reason': VerifyNotAllowed.N_FAILED_ATTEMPTS, 'failure_count': self.throttling_failure_count, 'locked_until': self.throttling_failure_timestamp + timedelta(seconds=delay_required), }, ) return super().verify_is_allowed() def throttle_reset(self, commit=True): """ Call this method to reset throttling (normally when a verify attempt succeeded). :param bool commit: Pass False if you intend to save the instance yourself. """ self.throttling_failure_timestamp = None self.throttling_failure_count = 0 if commit: self.save() def throttle_increment(self, commit=True): """ Call this method to increase throttling (normally when a verify attempt failed). :param bool commit: Pass False if you intend to save the instance yourself. """ self.throttling_failure_timestamp = timezone.now() self.throttling_failure_count += 1 if commit: self.save() @cached_property def throttling_enabled(self): return self.get_throttle_factor() > 0 def get_throttle_factor(self): # pragma: no cover """ This must be implemented to return the throttle factor. The number of seconds required between verification attempts will be :math:`c2^{n-1}` where `c` is this factor and `n` is the number of previous failures. A factor of 1 translates to delays of 1, 2, 4, 8, etc. seconds. A factor of 0 disables the throttling. Normally this is just a wrapper for a plugin-specific setting like :setting:`OTP_EMAIL_THROTTLE_FACTOR`. """ raise NotImplementedError() class TimestampMixin(models.Model): """ Mixin class that adds timestamps to devices. This mixin adds fields to record when a device was initially created in the system and when it was last used. It enhances the ability to audit device usage and lifecycle. Subclasses can use :meth:`set_last_used_timestamp` to update the `last_used_at` timestamp whenever the device is used for verification. .. attribute:: created_at The datetime at which this device was created. .. attribute:: last_used_at The datetime at which this device was last successfully used for verification. """ created_at = models.DateTimeField( null=True, blank=True, auto_now_add=True, help_text="The date and time when this device was initially created in the system.", ) last_used_at = models.DateTimeField( null=True, blank=True, help_text="The most recent date and time this device was used.", ) class Meta: abstract = True def set_last_used_timestamp(self, commit=True): """ Updates the `last_used_at` field to the current datetime to indicate that the device has been used. :param bool commit: Pass False if you intend to save the instance yourself. """ self.last_used_at = timezone.now() if commit: self.save() django-otp-django-otp-f5775d4/src/django_otp/oath.py000066400000000000000000000126551524731414200224170ustar00rootroot00000000000000from hashlib import sha1 import hmac from struct import pack from time import time def hotp(key, counter, digits=6): """ Implementation of the HOTP algorithm from `RFC 4226 `_. :param bytes key: The shared secret. A 20-byte string is recommended. :param int counter: The password counter. :param int digits: The number of decimal digits to generate. :returns: The HOTP token. :rtype: int >>> key = b'12345678901234567890' >>> for c in range(10): ... hotp(key, c) 755224 287082 359152 969429 338314 254676 287922 162583 399871 520489 """ msg = pack(b'>Q', counter) hs = hmac.new(key, msg, sha1).digest() hs = list(iter(hs)) offset = hs[19] & 0x0F bin_code = ( (hs[offset] & 0x7F) << 24 | hs[offset + 1] << 16 | hs[offset + 2] << 8 | hs[offset + 3] ) hotp = bin_code % pow(10, digits) return hotp def totp(key, step=30, t0=0, digits=6, drift=0): """ Implementation of the TOTP algorithm from `RFC 6238 `_. :param bytes key: The shared secret. A 20-byte string is recommended. :param int step: The time step in seconds. The time-based code changes every ``step`` seconds. :param int t0: The Unix time at which to start counting time steps. :param int digits: The number of decimal digits to generate. :param int drift: The number of time steps to add or remove. Delays and clock differences might mean that you have to look back or forward a step or two in order to match a token. :returns: The TOTP token. :rtype: int >>> key = b'12345678901234567890' >>> now = int(time()) >>> for delta in range(0, 200, 20): ... totp(key, t0=(now-delta)) 755224 755224 287082 359152 359152 969429 338314 338314 254676 287922 """ return TOTP(key, step, t0, digits, drift).token() class TOTP: """ An alternate TOTP interface. This provides access to intermediate steps of the computation. This is a living object: the return values of ``t`` and ``token`` will change along with other properties and with the passage of time. :param bytes key: The shared secret. A 20-byte string is recommended. :param int step: The time step in seconds. The time-based code changes every ``step`` seconds. :param int t0: The Unix time at which to start counting time steps. :param int digits: The number of decimal digits to generate. :param int drift: The number of time steps to add or remove. Delays and clock differences might mean that you have to look back or forward a step or two in order to match a token. >>> key = b'12345678901234567890' >>> totp = TOTP(key) >>> totp.time = 0 >>> totp.t() 0 >>> totp.token() 755224 >>> totp.time = 30 >>> totp.t() 1 >>> totp.token() 287082 >>> totp.verify(287082) True >>> totp.verify(359152) False >>> totp.verify(359152, tolerance=1) True >>> totp.drift 1 >>> totp.drift = 0 >>> totp.verify(359152, tolerance=1, min_t=3) False >>> totp.drift 0 >>> del totp.time >>> totp.t0 = int(time()) - 60 >>> totp.t() 2 >>> totp.token() 359152 """ def __init__(self, key, step=30, t0=0, digits=6, drift=0): self.key = key self.step = step self.t0 = t0 self.digits = digits self.drift = drift self._time = None def token(self): """The computed TOTP token.""" return hotp(self.key, self.t(), digits=self.digits) def t(self): """The computed time step.""" return ((int(self.time) - self.t0) // self.step) + self.drift @property def time(self): """ The current time. By default, this returns time.time() each time it is accessed. If you want to generate a token at a specific time, you can set this property to a fixed value instead. Deleting the value returns it to its 'live' state. """ return self._time if (self._time is not None) else time() @time.setter def time(self, value): self._time = value @time.deleter def time(self): self._time = None def verify(self, token, tolerance=0, min_t=None): """ A high-level verification helper. :param int token: The provided token. :param int tolerance: The amount of clock drift you're willing to accommodate, in steps. We'll look for the token at t values in [t - tolerance, t + tolerance]. :param int min_t: The minimum t value we'll accept. As a rule, this should be one larger than the largest t value of any previously accepted token. :rtype: bool Iff this returns True, `self.drift` will be updated to reflect the drift value that was necessary to match the token. """ drift_orig = self.drift verified = False for offset in range(-tolerance, tolerance + 1): self.drift = drift_orig + offset if (min_t is not None) and (self.t() < min_t): continue elif self.token() == token: verified = True break else: self.drift = drift_orig return verified django-otp-django-otp-f5775d4/src/django_otp/plugins/000077500000000000000000000000001524731414200225625ustar00rootroot00000000000000django-otp-django-otp-f5775d4/src/django_otp/plugins/__init__.py000066400000000000000000000000001524731414200246610ustar00rootroot00000000000000django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_email/000077500000000000000000000000001524731414200245335ustar00rootroot00000000000000django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_email/__init__.py000066400000000000000000000000001524731414200266320ustar00rootroot00000000000000django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_email/admin.py000066400000000000000000000027721524731414200262050ustar00rootroot00000000000000from django.contrib import admin from django.contrib.admin.sites import AlreadyRegistered from django.contrib.auth import get_user_model from django_otp.admin import user_model_search_fields from .models import EmailDevice def _search_fields(): User = get_user_model() candidate_search_field = [User.USERNAME_FIELD, 'email'] search_fields, search_help_text = user_model_search_fields(candidate_search_field) search_fields += ['email'] return search_fields, search_help_text class EmailDeviceAdmin(admin.ModelAdmin): """ :class:`~django.contrib.admin.ModelAdmin` for :class:`~django_otp.plugins.otp_email.models.EmailDevice`. """ list_display = ['user', 'name', 'created_at', 'last_used_at', 'confirmed'] list_filter = ['created_at', 'last_used_at', 'confirmed'] raw_id_fields = ['user'] readonly_fields = ['created_at', 'last_used_at'] search_fields, search_help_text = _search_fields() fieldsets = [ ( 'Identity', { 'fields': ['user', 'name', 'confirmed'], }, ), ( 'Timestamps', { 'fields': ['created_at', 'last_used_at'], }, ), ( 'Configuration', { 'fields': ['email'], }, ), ] # Somehow this is getting imported twice, triggering a useless exception. try: admin.site.register(EmailDevice, EmailDeviceAdmin) except AlreadyRegistered: pass django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_email/apps.py000066400000000000000000000002441524731414200260500ustar00rootroot00000000000000from django.apps import AppConfig class DefaultConfig(AppConfig): name = 'django_otp.plugins.otp_email' default_auto_field = 'django.db.models.AutoField' django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_email/conf.py000066400000000000000000000016611524731414200260360ustar00rootroot00000000000000import django.conf class OTPEmailSettings: """ This is a simple class to take the place of the global settings object. An instance will contain all of our settings as attributes, with default values if they are not specified by the configuration. """ defaults = { 'OTP_EMAIL_SENDER': None, 'OTP_EMAIL_SUBJECT': 'OTP token', 'OTP_EMAIL_BODY_TEMPLATE': None, 'OTP_EMAIL_BODY_TEMPLATE_PATH': 'otp/email/token.txt', 'OTP_EMAIL_BODY_HTML_TEMPLATE': None, 'OTP_EMAIL_BODY_HTML_TEMPLATE_PATH': None, 'OTP_EMAIL_TOKEN_VALIDITY': 300, 'OTP_EMAIL_THROTTLE_FACTOR': 1, 'OTP_EMAIL_COOLDOWN_DURATION': 60, } def __getattr__(self, name): if name in self.defaults: return getattr(django.conf.settings, name, self.defaults[name]) else: return getattr(django.conf.settings, name) settings = OTPEmailSettings() django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_email/migrations/000077500000000000000000000000001524731414200267075ustar00rootroot00000000000000django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_email/migrations/0001_initial.py000066400000000000000000000023051524731414200313520ustar00rootroot00000000000000from django.conf import settings from django.db import migrations, models import django_otp.plugins.otp_email.models class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( name='EmailDevice', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('name', models.CharField(help_text='The human-readable name of this device.', max_length=64)), ('confirmed', models.BooleanField(default=True, help_text='Is this device ready for use?')), ('key', models.CharField(default=django_otp.plugins.otp_email.models.default_key, help_text='A hex-encoded secret key of up to 20 bytes.', max_length=80, validators=[django_otp.plugins.otp_email.models.key_validator])), ('user', models.ForeignKey(help_text='The user that this device belongs to.', to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), ], options={ 'abstract': False, }, bases=(models.Model,), ), ] 0002_sidechanneldevice_email.py000066400000000000000000000014301524731414200344450ustar00rootroot00000000000000django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_email/migrations# Generated by Django 3.0.2 on 2020-04-10 02:36 from django.db import migrations, models import django.utils.timezone class Migration(migrations.Migration): dependencies = [ ('otp_email', '0001_initial'), ] operations = [ migrations.RemoveField( model_name='emaildevice', name='key', ), migrations.AddField( model_name='emaildevice', name='token', field=models.CharField(blank=True, max_length=16, null=True), ), migrations.AddField( model_name='emaildevice', name='valid_until', field=models.DateTimeField(default=django.utils.timezone.now, help_text='The timestamp of the moment of expiry of the saved token.'), ), ] django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_email/migrations/0003_emaildevice_email.py000066400000000000000000000007461524731414200333500ustar00rootroot00000000000000# Generated by Django 3.0.2 on 2020-04-15 04:00 from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ ('otp_email', '0002_sidechanneldevice_email'), ] operations = [ migrations.AddField( model_name='emaildevice', name='email', field=models.EmailField(blank=True, help_text='Optional alternative email address to send tokens to', max_length=254, null=True), ), ] django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_email/migrations/0004_throttling.py000066400000000000000000000014001524731414200321150ustar00rootroot00000000000000# Generated by Django 3.0.5 on 2020-04-16 13:41 from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ ('otp_email', '0003_emaildevice_email'), ] operations = [ migrations.AddField( model_name='emaildevice', name='throttling_failure_count', field=models.PositiveIntegerField(default=0, help_text='Number of successive failed attempts.'), ), migrations.AddField( model_name='emaildevice', name='throttling_failure_timestamp', field=models.DateTimeField(blank=True, default=None, help_text='A timestamp of the last failed verification attempt. Null if last attempt succeeded.', null=True), ), ] 0005_emaildevice_last_generated_timestamp.py000066400000000000000000000007401524731414200372420ustar00rootroot00000000000000django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_email/migrations# Generated by Django 3.2.16 on 2023-05-12 11:37 from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ ('otp_email', '0004_throttling'), ] operations = [ migrations.AddField( model_name='emaildevice', name='last_generated_timestamp', field=models.DateTimeField(blank=True, help_text='The last time a token was generated for this device.', null=True), ), ] django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_email/migrations/0006_add_timestamps.py000066400000000000000000000015611524731414200327270ustar00rootroot00000000000000# Generated by Django 4.2.7 on 2024-04-08 19:19 from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ ('otp_email', '0005_emaildevice_last_generated_timestamp'), ] operations = [ migrations.AddField( model_name='emaildevice', name='created_at', field=models.DateTimeField( auto_now_add=True, help_text='The date and time when this device was initially created in the system.', null=True, ), ), migrations.AddField( model_name='emaildevice', name='last_used_at', field=models.DateTimeField( blank=True, help_text='The most recent date and time this device was used.', null=True, ), ), ] django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_email/migrations/__init__.py000066400000000000000000000000001524731414200310060ustar00rootroot00000000000000django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_email/models.py000066400000000000000000000122021524731414200263650ustar00rootroot00000000000000from django.contrib.humanize.templatetags.humanize import naturaltime from django.core.mail import send_mail from django.db import models from django.template import Context, Template from django.template.loader import get_template from django.utils.translation import gettext from django_otp.models import ( CooldownMixin, GenerateNotAllowed, SideChannelDevice, ThrottlingMixin, TimestampMixin, ) from django_otp.util import hex_validator, random_hex from .conf import settings def default_key(): # pragma: no cover """Obsolete code here for migrations.""" return random_hex(20) def key_validator(value): # pragma: no cover """Obsolete code here for migrations.""" return hex_validator()(value) class EmailDevice(TimestampMixin, CooldownMixin, ThrottlingMixin, SideChannelDevice): """ A :class:`~django_otp.models.SideChannelDevice` that delivers a token to the email address saved in this object or alternatively to the user's registered email address (``user.email``). The tokens are valid for :setting:`OTP_EMAIL_TOKEN_VALIDITY` seconds. Once a token has been accepted, it is no longer valid. Note that if you allow users to reset their passwords by email, this may provide little additional account security. It may still be useful for, e.g., requiring the user to re-verify their email address on new devices. .. attribute:: email *EmailField*: An alternative email address to send the tokens to. """ email = models.EmailField( max_length=254, blank=True, null=True, help_text='Optional alternative email address to send tokens to', ) def generate_challenge(self, extra_context=None): """ Generates a random token and emails it to the user. :param extra_context: Additional context variables for rendering the email template. :type extra_context: dict """ generate_allowed, data_dict = self.generate_is_allowed() if generate_allowed: message = self._deliver_token(extra_context) else: if data_dict['reason'] == GenerateNotAllowed.COOLDOWN_DURATION_PENDING: next_generation_naturaltime = naturaltime( data_dict['next_generation_at'] ) message = ( gettext( "Token generation cooldown period has not expired yet. Next" " generation allowed %s." ) % next_generation_naturaltime ) else: message = gettext("Token generation is not allowed at this time") return message def _deliver_token(self, extra_context): self.cooldown_set(commit=False) self.generate_token(valid_secs=settings.OTP_EMAIL_TOKEN_VALIDITY, commit=True) context = {'token': self.token, **(extra_context or {})} if settings.OTP_EMAIL_BODY_TEMPLATE: body = Template(settings.OTP_EMAIL_BODY_TEMPLATE).render(Context(context)) else: body = get_template(settings.OTP_EMAIL_BODY_TEMPLATE_PATH).render(context) if settings.OTP_EMAIL_BODY_HTML_TEMPLATE: body_html = Template(settings.OTP_EMAIL_BODY_HTML_TEMPLATE).render( Context(context) ) elif settings.OTP_EMAIL_BODY_HTML_TEMPLATE_PATH: body_html = get_template(settings.OTP_EMAIL_BODY_HTML_TEMPLATE_PATH).render( context ) else: body_html = None self.send_mail(body, html_message=body_html) message = gettext("sent by email") return message def get_subject(self): """ Returns a formatted email subject. This renders :setting:`OTP_EMAIL_SUBJECT` with the optional `token` placeholder. Subclasses may customize this. """ return str(settings.OTP_EMAIL_SUBJECT).format(token=self.token) def send_mail(self, body, **kwargs): """ A simple wrapper for :func:`django.core.mail.send_mail`. Subclasses (e.g. proxy models) may override this to customize delivery. """ send_mail( self.get_subject(), body, settings.OTP_EMAIL_SENDER, [self.email or self.user.email], **kwargs, ) def verify_token(self, token): """""" verify_allowed, _ = self.verify_is_allowed() if verify_allowed: verified = super().verify_token(token) if verified: self.throttle_reset(commit=False) self.set_last_used_timestamp(commit=False) self.save() else: self.throttle_increment() else: verified = False return verified def get_cooldown_duration(self): """ Returns :setting:`OTP_EMAIL_COOLDOWN_DURATION`. """ return settings.OTP_EMAIL_COOLDOWN_DURATION def get_throttle_factor(self): """ Returns :setting:`OTP_EMAIL_THROTTLE_FACTOR`. """ return settings.OTP_EMAIL_THROTTLE_FACTOR django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_email/templates/000077500000000000000000000000001524731414200265315ustar00rootroot00000000000000django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_email/templates/otp/000077500000000000000000000000001524731414200273335ustar00rootroot00000000000000django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_email/templates/otp/email/000077500000000000000000000000001524731414200304225ustar00rootroot00000000000000django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_email/templates/otp/email/token.txt000066400000000000000000000000141524731414200322760ustar00rootroot00000000000000{{ token }} django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_email/tests.py000066400000000000000000000235401524731414200262530ustar00rootroot00000000000000from datetime import timedelta from freezegun import freeze_time from django.core import mail from django.db import IntegrityError from django.test.utils import override_settings from django_otp.forms import OTPAuthenticationForm from django_otp.test_utils import ( CooldownTestMixin, TestCase, ThrottlingTestMixin, TimestampTestMixin, ) from .models import EmailDevice class EmailDeviceMixin: def setUp(self): try: alice = self.create_user('alice', 'password') except IntegrityError: self.skipTest("Failed to create user.") else: self.device = alice.emaildevice_set.create() if hasattr(alice, 'email'): alice.email = 'alice@example.com' alice.save() else: self.skipTest("User model has no email.") class AuthFormTest(EmailDeviceMixin, TestCase): @override_settings(OTP_EMAIL_SENDER='test@example.com') def test_email_interaction(self): data = { 'username': 'alice', 'password': 'password', 'otp_device': self.device.persistent_id, 'otp_token': '', 'otp_challenge': '1', } form = OTPAuthenticationForm(None, data) self.assertFalse(form.is_valid()) alice = form.get_user() self.assertEqual(alice.get_username(), 'alice') self.assertIsNone(alice.otp_device) self.assertEqual(len(mail.outbox), 1) self.assertEqual(mail.outbox[0].to, ['alice@example.com']) self.device.refresh_from_db() data['otp_token'] = self.device.token del data['otp_challenge'] form = OTPAuthenticationForm(None, data) self.assertTrue(form.is_valid()) self.assertIsInstance(form.get_user().otp_device, EmailDevice) @override_settings( DEFAULT_FROM_EMAIL="root@localhost", OTP_EMAIL_THROTTLE_FACTOR=0, ) class EmailTest(EmailDeviceMixin, TestCase): def test_token_generator(self): self.device.generate_token() self.device.token.isnumeric() def test_invalid_token(self): self.device.generate_token() self.assertFalse(self.device.verify_token(0)) def test_no_reuse(self): self.device.generate_token() token = self.device.token self.assertTrue(self.device.verify_token(token)) self.assertFalse(self.device.verify_token(token)) def test_token_expiry(self): self.device.generate_token() token = self.device.token with freeze_time() as frozen_time: frozen_time.tick(delta=timedelta(seconds=301)) self.assertFalse(self.device.verify_token(token)) def test_defaults(self): self.device.generate_challenge() self.assertEqual(len(mail.outbox), 1) msg = mail.outbox[0] with self.subTest(field='from_email'): self.assertEqual(msg.from_email, "root@localhost") with self.subTest(field='body'): self.assertEqual( msg.body, "Test template 1: {}\n".format(self.device.token) ) @override_settings( OTP_EMAIL_SENDER="webmaster@example.com", OTP_EMAIL_SUBJECT="Test Subject with token: {token}", OTP_EMAIL_BODY_TEMPLATE="Test template: {{token}}", ) def test_settings_with_token_in_subject(self): self.device.generate_challenge() self.assertEqual(len(mail.outbox), 1) msg = mail.outbox[0] with self.subTest(field='from_email'): self.assertEqual(msg.from_email, "webmaster@example.com") with self.subTest(field='subject'): self.assertEqual( msg.subject, "Test Subject with token: {}".format(self.device.token) ) with self.subTest(field='body'): self.assertEqual(msg.body, "Test template: {}".format(self.device.token)) @override_settings( OTP_EMAIL_SENDER="webmaster@example.com", OTP_EMAIL_SUBJECT="Test subject", OTP_EMAIL_BODY_TEMPLATE="Test template 2: {{token}}", ) def test_settings(self): self.device.generate_challenge() self.assertEqual(len(mail.outbox), 1) msg = mail.outbox[0] with self.subTest(field='from_email'): self.assertEqual(msg.from_email, "webmaster@example.com") with self.subTest(field='subject'): self.assertEqual(msg.subject, "Test subject") with self.subTest(field='body'): self.assertEqual(msg.body, "Test template 2: {}".format(self.device.token)) @override_settings( OTP_EMAIL_SENDER="webmaster@example.com", OTP_EMAIL_SUBJECT="Test subject", OTP_EMAIL_BODY_HTML_TEMPLATE="
{{token}}
", ) def test_settings_html_template(self): self.device.generate_challenge() self.assertEqual(len(mail.outbox), 1) msg = mail.outbox[0] with self.subTest(field='from_email'): self.assertEqual(msg.from_email, "webmaster@example.com") with self.subTest(field='subject'): self.assertEqual(msg.subject, "Test subject") with self.subTest(field='body'): self.assertEqual( msg.body, 'Test template 1: {}\n'.format(self.device.token) ) with self.subTest(field='alternatives'): self.assertEqual( msg.alternatives[0], ('
{}
'.format(self.device.token), 'text/html'), ) @override_settings( OTP_EMAIL_SENDER="webmaster@example.com", OTP_EMAIL_SUBJECT="Test subject", OTP_EMAIL_BODY_TEMPLATE_PATH="otp/email/custom.txt", ) def test_settings_template_path(self): self.device.generate_challenge() self.assertEqual(len(mail.outbox), 1) msg = mail.outbox[0] with self.subTest(field='from_email'): self.assertEqual(msg.from_email, "webmaster@example.com") with self.subTest(field='subject'): self.assertEqual(msg.subject, "Test subject") with self.subTest(field='body'): self.assertEqual( msg.body, "Test template 3: {}\n".format(self.device.token) ) @override_settings( OTP_EMAIL_SENDER="webmaster@example.com", OTP_EMAIL_SUBJECT="Test subject", OTP_EMAIL_BODY_HTML_TEMPLATE_PATH="otp/email/custom_html.html", ) def test_settings_html_template_path(self): self.device.generate_challenge() self.assertEqual(len(mail.outbox), 1) msg = mail.outbox[0] with self.subTest(field='from_email'): self.assertEqual(msg.from_email, "webmaster@example.com") with self.subTest(field='subject'): self.assertEqual(msg.subject, "Test subject") with self.subTest(field='body'): self.assertEqual( msg.body, 'Test template 1: {}\n'.format(self.device.token) ) with self.subTest(field='alternatives'): self.assertEqual( msg.alternatives[0], ('

{}

'.format(self.device.token), 'text/html'), ) @override_settings( OTP_EMAIL_SENDER="webmaster@example.com", OTP_EMAIL_SUBJECT="Test subject", OTP_EMAIL_BODY_TEMPLATE="Test template 4: {{token}} {{foo}} {{bar}}", ) def test_settings_extra_template_options(self): extra_context = {"foo": "extra 1", "bar": "extra 2"} self.device.generate_challenge(extra_context) self.assertEqual(len(mail.outbox), 1) msg = mail.outbox[0] with self.subTest(field='from_email'): self.assertEqual(msg.from_email, "webmaster@example.com") with self.subTest(field='subject'): self.assertEqual(msg.subject, "Test subject") with self.subTest(field='body'): self.assertEqual( msg.body, "Test template 4: {} {} {}".format( self.device.token, extra_context["foo"], extra_context["bar"] ), ) def test_alternative_email(self): self.device.email = 'alice2@example.com' self.device.save() self.device.generate_challenge() self.assertEqual(len(mail.outbox), 1) self.assertEqual(mail.outbox[0].to, ['alice2@example.com']) @override_settings( OTP_EMAIL_THROTTLE_FACTOR=1, ) class ThrottlingTestCase(EmailDeviceMixin, ThrottlingTestMixin, TestCase): def valid_token(self): if self.device.token is None: self.device.generate_token() return self.device.token def invalid_token(self): return -1 @override_settings( OTP_EMAIL_COOLDOWN_DURATION=10, ) class CooldownTestCase(EmailDeviceMixin, CooldownTestMixin, TestCase): def valid_token(self): if self.device.token is None: self.device.generate_token() return self.device.token def invalid_token(self): return -1 def test_cooldown_imposed_message(self): with freeze_time(): message = self.device.generate_challenge() self.device.refresh_from_db() message = self.device.generate_challenge() self.assertTrue( message.startswith( 'Token generation cooldown period has not expired yet. Next generation allowed' ) ) def test_cooldown_imposed_expiration_message(self): with freeze_time() as frozen_time: self.device.generate_challenge() frozen_time.tick(delta=timedelta(seconds=5)) self.device.refresh_from_db() message = self.device.generate_challenge() self.assertIn("Next generation allowed 5\xa0seconds from now.", message) class TimestampTestCase(EmailDeviceMixin, TimestampTestMixin, TestCase): def valid_token(self): if self.device.token is None: self.device.generate_token() return self.device.token def invalid_token(self): return -1 django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_hotp/000077500000000000000000000000001524731414200244165ustar00rootroot00000000000000django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_hotp/__init__.py000066400000000000000000000000001524731414200265150ustar00rootroot00000000000000django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_hotp/admin.py000066400000000000000000000121001524731414200260520ustar00rootroot00000000000000from django.contrib import admin from django.contrib.admin.sites import AlreadyRegistered from django.contrib.auth import get_user_model from django.core.exceptions import PermissionDenied from django.http import HttpResponse from django.template.response import TemplateResponse from django.urls import path, reverse from django.utils.html import format_html from django_otp.admin import user_model_search_fields from django_otp.conf import settings from django_otp.qr import write_qrcode_image from .models import HOTPDevice class HOTPDeviceAdmin(admin.ModelAdmin): """ :class:`~django.contrib.admin.ModelAdmin` for :class:`~django_otp.plugins.otp_hotp.models.HOTPDevice`. """ User = get_user_model() candidate_search_field = [User.USERNAME_FIELD, 'email'] list_display = ['user', 'name', 'created_at', 'last_used_at', 'confirmed'] list_filter = ['created_at', 'last_used_at', 'confirmed'] search_fields, search_help_text = user_model_search_fields(candidate_search_field) raw_id_fields = ['user'] readonly_fields = ['created_at', 'last_used_at', 'qrcode_link'] radio_fields = {'digits': admin.HORIZONTAL} def get_list_display(self, request): list_display = super().get_list_display(request) if not settings.OTP_ADMIN_HIDE_SENSITIVE_DATA: list_display = [*list_display, 'qrcode_link'] return list_display def get_fieldsets(self, request, obj=None): # Show the key value only for adding new objects or when sensitive data # is not hidden. if settings.OTP_ADMIN_HIDE_SENSITIVE_DATA and obj: configuration_fields = ['digits', 'tolerance'] else: configuration_fields = ['key', 'digits', 'tolerance'] fieldsets = [ ( 'Identity', { 'fields': ['user', 'name', 'confirmed'], }, ), ( 'Timestamps', { 'fields': ['created_at', 'last_used_at'], }, ), ( 'Configuration', { 'fields': configuration_fields, }, ), ( 'State', { 'fields': ['counter'], }, ), ( 'Throttling', { 'fields': [ 'throttling_failure_timestamp', 'throttling_failure_count', ], }, ), ] # Show the QR code link only for existing objects when sensitive data # is not hidden. if not settings.OTP_ADMIN_HIDE_SENSITIVE_DATA and obj: fieldsets.append( ( None, { 'fields': ['qrcode_link'], }, ), ) return fieldsets def get_queryset(self, request): queryset = super().get_queryset(request) queryset = queryset.select_related('user') return queryset # # Columns # @admin.display(description="QR Code") def qrcode_link(self, device): try: href = reverse('admin:otp_hotp_hotpdevice_config', kwargs={'pk': device.pk}) link = format_html('qrcode', href) except Exception: link = '' return link # # Custom views # def get_urls(self): urls = [ path( '/config/', self.admin_site.admin_view(self.config_view), name='otp_hotp_hotpdevice_config', ), path( '/qrcode/', self.admin_site.admin_view(self.qrcode_view), name='otp_hotp_hotpdevice_qrcode', ), ] + super().get_urls() return urls def config_view(self, request, pk): if settings.OTP_ADMIN_HIDE_SENSITIVE_DATA: raise PermissionDenied() device = HOTPDevice.objects.get(pk=pk) if not self.has_view_or_change_permission(request, device): raise PermissionDenied() context = dict( self.admin_site.each_context(request), device=device, ) return TemplateResponse(request, 'otp_hotp/admin/config.html', context) def qrcode_view(self, request, pk): if settings.OTP_ADMIN_HIDE_SENSITIVE_DATA: raise PermissionDenied() device = HOTPDevice.objects.get(pk=pk) if not self.has_view_or_change_permission(request, device): raise PermissionDenied() try: response = HttpResponse(content_type='image/svg+xml') write_qrcode_image(device.config_url, response) except ModuleNotFoundError: response = HttpResponse('', status=503) return response try: admin.site.register(HOTPDevice, HOTPDeviceAdmin) except AlreadyRegistered: # A useless exception from a double import pass django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_hotp/apps.py000066400000000000000000000002431524731414200257320ustar00rootroot00000000000000from django.apps import AppConfig class DefaultConfig(AppConfig): name = 'django_otp.plugins.otp_hotp' default_auto_field = 'django.db.models.AutoField' django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_hotp/migrations/000077500000000000000000000000001524731414200265725ustar00rootroot00000000000000django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_hotp/migrations/0001_initial.py000066400000000000000000000031741524731414200312420ustar00rootroot00000000000000from django.conf import settings from django.db import migrations, models import django_otp.plugins.otp_hotp.models class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( name='HOTPDevice', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('name', models.CharField(help_text='The human-readable name of this device.', max_length=64)), ('confirmed', models.BooleanField(default=True, help_text='Is this device ready for use?')), ('key', models.CharField(default=django_otp.plugins.otp_hotp.models.default_key, help_text='A hex-encoded secret key of up to 40 bytes.', max_length=80, validators=[django_otp.plugins.otp_hotp.models.key_validator])), ('digits', models.PositiveSmallIntegerField(default=6, help_text='The number of digits to expect in a token.', choices=[(6, 6), (8, 8)])), ('tolerance', models.PositiveSmallIntegerField(default=5, help_text='The number of missed tokens to tolerate.')), ('counter', models.BigIntegerField(default=0, help_text='The next counter value to expect.')), ('user', models.ForeignKey(help_text='The user that this device belongs to.', to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), ], options={ 'abstract': False, 'verbose_name': 'HOTP device', }, bases=(models.Model,), ), ] django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_hotp/migrations/0002_auto_20190420_0723.py000066400000000000000000000013611524731414200322120ustar00rootroot00000000000000# Generated by Django 2.2 on 2019-04-20 12:23 from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ ('otp_hotp', '0001_initial'), ] operations = [ migrations.AddField( model_name='hotpdevice', name='throttling_failure_count', field=models.PositiveIntegerField(default=0, help_text='Number of successive failed attempts.'), ), migrations.AddField( model_name='hotpdevice', name='throttling_failure_timestamp', field=models.DateTimeField(blank=True, default=None, help_text='A timestamp of the last failed verification attempt. Null if last attempt succeeded.', null=True), ), ] django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_hotp/migrations/0003_add_timestamps.py000066400000000000000000000015341524731414200326070ustar00rootroot00000000000000# Generated by Django 4.2.7 on 2024-04-08 19:19 from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ ('otp_hotp', '0002_auto_20190420_0723'), ] operations = [ migrations.AddField( model_name='hotpdevice', name='created_at', field=models.DateTimeField( auto_now_add=True, help_text='The date and time when this device was initially created in the system.', null=True, ), ), migrations.AddField( model_name='hotpdevice', name='last_used_at', field=models.DateTimeField( blank=True, help_text='The most recent date and time this device was used.', null=True, ), ), ] django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_hotp/migrations/__init__.py000066400000000000000000000000001524731414200306710ustar00rootroot00000000000000django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_hotp/models.py000066400000000000000000000077541524731414200262700ustar00rootroot00000000000000from base64 import b32encode from binascii import unhexlify from urllib.parse import quote, urlencode from django.conf import settings from django.db import models from django_otp.models import Device, ThrottlingMixin, TimestampMixin from django_otp.oath import hotp from django_otp.util import hex_validator, random_hex def default_key(): return random_hex(20) def key_validator(value): return hex_validator()(value) class HOTPDevice(TimestampMixin, ThrottlingMixin, Device): """ A generic HOTP :class:`~django_otp.models.Device`. The model fields mostly correspond to the arguments to :func:`django_otp.oath.hotp`. They all have sensible defaults, including the key, which is randomly generated. .. attribute:: key *CharField*: A hex-encoded secret key of up to 40 bytes. (Default: 20 random bytes) .. attribute:: digits *PositiveSmallIntegerField*: The number of digits to expect from the token generator (6 or 8). (Default: 6) .. attribute:: tolerance *PositiveSmallIntegerField*: The number of missed tokens to tolerate. (Default: 5) .. attribute:: counter *BigIntegerField*: The next counter value to expect. (Initial: 0) """ key = models.CharField( max_length=80, validators=[key_validator], default=default_key, help_text="A hex-encoded secret key of up to 40 bytes.", ) digits = models.PositiveSmallIntegerField( choices=[(6, 6), (8, 8)], default=6, help_text="The number of digits to expect in a token.", ) tolerance = models.PositiveSmallIntegerField( default=5, help_text="The number of missed tokens to tolerate." ) counter = models.BigIntegerField( default=0, help_text="The next counter value to expect." ) class Meta(Device.Meta): verbose_name = "HOTP device" @property def bin_key(self): """ The secret key as a binary string. """ return unhexlify(self.key.encode()) def verify_token(self, token): verify_allowed, _ = self.verify_is_allowed() if not verify_allowed: return False try: token = int(token) except Exception: verified = False else: key = self.bin_key for counter in range(self.counter, self.counter + self.tolerance + 1): if hotp(key, counter, self.digits) == token: verified = True self.counter = counter + 1 self.throttle_reset(commit=False) self.set_last_used_timestamp(commit=False) self.save() break else: verified = False if not verified: self.throttle_increment(commit=True) return verified def get_throttle_factor(self): return getattr(settings, 'OTP_HOTP_THROTTLE_FACTOR', 1) @property def config_url(self): """ A URL for configuring Google Authenticator or similar. See https://github.com/google/google-authenticator/wiki/Key-Uri-Format. The issuer is taken from :setting:`OTP_HOTP_ISSUER`, if available. """ label = self.user.get_username() params = { 'secret': b32encode(self.bin_key), 'algorithm': 'SHA1', 'digits': self.digits, 'counter': self.counter, } urlencoded_params = urlencode(params) issuer = getattr(settings, 'OTP_HOTP_ISSUER', None) if callable(issuer): issuer = issuer(self) if isinstance(issuer, str) and (issuer != ''): issuer = issuer.replace(':', '') label = '{}:{}'.format(issuer, label) urlencoded_params += '&issuer={}'.format( quote(issuer) ) # encode issuer as per RFC 3986, not quote_plus url = 'otpauth://hotp/{}?{}'.format(quote(label), urlencoded_params) return url django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_hotp/static/000077500000000000000000000000001524731414200257055ustar00rootroot00000000000000django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_hotp/static/otp_hotp/000077500000000000000000000000001524731414200275415ustar00rootroot00000000000000django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_hotp/static/otp_hotp/css/000077500000000000000000000000001524731414200303315ustar00rootroot00000000000000django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_hotp/static/otp_hotp/css/qrcode.css000066400000000000000000000002051524731414200323150ustar00rootroot00000000000000.qrcode-container { text-align: center; } .qrcode-image { background-color: white; } .qrcode-hidden { display: none; } django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_hotp/static/otp_hotp/js/000077500000000000000000000000001524731414200301555ustar00rootroot00000000000000django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_hotp/static/otp_hotp/js/qrcode.js000066400000000000000000000012101524731414200317620ustar00rootroot00000000000000function ready(fn) { if (document.readyState !== 'loading') { fn(); } else { document.addEventListener('DOMContentLoaded', fn); } } ready(function () { var qrCodeImage = document.querySelector('.qrcode-image'); if (!qrCodeImage) { return; } qrCodeImage.addEventListener('error', function () { var qrCode = document.getElementById('qrcode'); var noQrCode = document.getElementById('no-qrcode'); if (qrCode) { qrCode.classList.add('qrcode-hidden'); } if (noQrCode) { noQrCode.classList.remove('qrcode-hidden'); } }); }); django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_hotp/templates/000077500000000000000000000000001524731414200264145ustar00rootroot00000000000000django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_hotp/templates/otp_hotp/000077500000000000000000000000001524731414200302505ustar00rootroot00000000000000django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_hotp/templates/otp_hotp/admin/000077500000000000000000000000001524731414200313405ustar00rootroot00000000000000django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_hotp/templates/otp_hotp/admin/config.html000066400000000000000000000013551524731414200334770ustar00rootroot00000000000000{% extends "admin/base_site.html" %} {% load static %} {% block extrastyle %} {{ block.super }} {% endblock %} {% block content %}

Install segno or qrcode or use the URL below:

{{ device.config_url }}

{% endblock %} django-otp-django-otp-f5775d4/src/django_otp/plugins/otp_hotp/tests.py000066400000000000000000000342731524731414200261430ustar00rootroot00000000000000from datetime import timedelta from urllib.parse import parse_qs, urlsplit from freezegun import freeze_time from django.contrib.admin.sites import AdminSite from django.contrib.auth.models import Permission from django.contrib.contenttypes.models import ContentType from django.core.exceptions import PermissionDenied from django.db import IntegrityError from django.test import RequestFactory from django.test.utils import override_settings from django.urls import reverse from django_otp.forms import OTPAuthenticationForm from django_otp.test_utils import TestCase, ThrottlingTestMixin, TimestampTestMixin from .admin import HOTPDeviceAdmin from .models import HOTPDevice class HOTPDeviceMixin: """ A TestCase helper that gives us a HOTPDevice to work with. """ # The next three tokens tokens = [782373, 313268, 307722] key = 'd2e8a68036f68960b1c30532bb6c56da5934d879' def setUp(self): try: alice = self.create_user('alice', 'password', email='alice@example.com') except IntegrityError: self.skipTest("Unable to create test user.") else: self.device = alice.hotpdevice_set.create( key=self.key, digits=6, tolerance=1, counter=0 ) @override_settings( OTP_HOTP_THROTTLE_FACTOR=0, ) class HOTPTest(HOTPDeviceMixin, TestCase): def test_normal(self): ok = self.device.verify_token(self.tokens[0]) self.assertTrue(ok) self.assertEqual(self.device.counter, 1) def test_normal_drift(self): ok = self.device.verify_token(self.tokens[1]) self.assertTrue(ok) self.assertEqual(self.device.counter, 2) def test_excessive_drift(self): ok = self.device.verify_token(self.tokens[2]) self.assertFalse(ok) self.assertEqual(self.device.counter, 0) def test_bad_value(self): ok = self.device.verify_token(123456) self.assertFalse(ok) self.assertEqual(self.device.counter, 0) def test_config_url_no_issuer(self): with override_settings(OTP_HOTP_ISSUER=None): url = self.device.config_url parsed = urlsplit(url) params = parse_qs(parsed.query) self.assertEqual(parsed.scheme, 'otpauth') self.assertEqual(parsed.netloc, 'hotp') self.assertEqual(parsed.path, '/alice') self.assertIn('secret', params) self.assertNotIn('issuer', params) def test_config_url_issuer(self): with override_settings(OTP_HOTP_ISSUER='example.com'): url = self.device.config_url parsed = urlsplit(url) params = parse_qs(parsed.query) self.assertEqual(parsed.scheme, 'otpauth') self.assertEqual(parsed.netloc, 'hotp') self.assertEqual(parsed.path, '/example.com%3Aalice') self.assertIn('secret', params) self.assertIn('issuer', params) self.assertEqual(params['issuer'][0], 'example.com') def test_config_url_issuer_spaces(self): with override_settings(OTP_HOTP_ISSUER='Very Trustworthy Source'): url = self.device.config_url parsed = urlsplit(url) params = parse_qs(parsed.query) self.assertEqual(parsed.scheme, 'otpauth') self.assertEqual(parsed.netloc, 'hotp') self.assertEqual(parsed.path, '/Very%20Trustworthy%20Source%3Aalice') self.assertIn('secret', params) self.assertIn('issuer', params) self.assertEqual(params['issuer'][0], 'Very Trustworthy Source') def test_config_url_issuer_method(self): with override_settings(OTP_HOTP_ISSUER=lambda d: d.user.email): url = self.device.config_url parsed = urlsplit(url) params = parse_qs(parsed.query) self.assertEqual(parsed.scheme, 'otpauth') self.assertEqual(parsed.netloc, 'hotp') self.assertEqual(parsed.path, '/alice%40example.com%3Aalice') self.assertIn('secret', params) self.assertIn('issuer', params) self.assertEqual(params['issuer'][0], 'alice@example.com') class AuthFormTest(TestCase): """ Test auth form with HOTP tokens """ tokens = HOTPTest.tokens key = HOTPTest.key def setUp(self): try: alice = self.create_user('alice', 'password') except IntegrityError: self.skipTest("Unable to create test user.") else: self.device = alice.hotpdevice_set.create( key=self.key, digits=6, tolerance=1, counter=0 ) def test_no_token(self): data = { 'username': 'alice', 'password': 'password', 'otp_device': self.device.persistent_id, } form = OTPAuthenticationForm(None, data) self.assertFalse(form.is_valid()) self.assertEqual(form.get_user().get_username(), 'alice') def test_bad_token(self): data = { 'username': 'alice', 'password': 'password', 'otp_token': '123456', 'otp_device': self.device.persistent_id, } form = OTPAuthenticationForm(None, data) self.assertFalse(form.is_valid()) def test_good_token(self): data = { 'username': 'alice', 'password': 'password', 'otp_token': self.tokens[0], 'otp_device': self.device.persistent_id, } form = OTPAuthenticationForm(None, data) self.assertTrue(form.is_valid()) def test_attempt_after_fail(self): good_data = { 'username': 'alice', 'password': 'password', 'otp_token': self.tokens[0], 'otp_device': self.device.persistent_id, } bad_data = { 'username': 'alice', 'password': 'password', 'otp_token': '123456', 'otp_device': self.device.persistent_id, } with freeze_time() as frozen_time: form1 = OTPAuthenticationForm(None, bad_data) self.assertFalse(form1.is_valid()) # Should fail even with good data: form2 = OTPAuthenticationForm(None, good_data) self.assertFalse(form2.is_valid()) self.assertIn( 'Verification temporarily disabled because of 1 failed attempt', form2.errors['__all__'][0], ) # Fail again after throttling expired: frozen_time.tick(timedelta(seconds=1.1)) form3 = OTPAuthenticationForm(None, bad_data) self.assertFalse(form3.is_valid()) self.assertIn('Invalid token', form3.errors['__all__'][0]) # Test n=2 error message: form4 = OTPAuthenticationForm(None, bad_data) self.assertFalse(form4.is_valid()) self.assertIn( 'Verification temporarily disabled because of 2 failed attempts', form4.errors['__all__'][0], ) # Pass this time: frozen_time.tick(timedelta(seconds=2.1)) form5 = OTPAuthenticationForm(None, good_data) self.assertTrue(form5.is_valid()) class HOTPAdminTest(TestCase): def setUp(self): """ Create a device at the fourth time step. The current token is 154567. """ try: self.admin = self.create_user( 'admin', 'password', email='admin@example.com', is_staff=True ) except IntegrityError: self.skipTest("Unable to create test user.") else: self.device = self.admin.hotpdevice_set.create( key='d2e8a68036f68960b1c30532bb6c56da5934d879', digits=6, tolerance=1, counter=0, ) self.device_admin = HOTPDeviceAdmin(HOTPDevice, AdminSite()) self.get_request = RequestFactory().get('/') self.get_request.user = self.admin def test_anonymous(self): for suffix in ['config', 'qrcode']: with self.subTest(view=suffix): url = reverse( 'admin:otp_hotp_hotpdevice_' + suffix, kwargs={'pk': self.device.pk} ) response = self.client.get(url) self.assertEqual(response.status_code, 302) def test_unauthorized(self): self.client.login(username='admin', password='password') for suffix in ['config', 'qrcode']: with self.subTest(view=suffix): url = reverse( 'admin:otp_hotp_hotpdevice_' + suffix, kwargs={'pk': self.device.pk} ) response = self.client.get(url) self.assertEqual(response.status_code, 403) def test_view_perm(self): self._add_device_perms('view_hotpdevice') self.client.login(username='admin', password='password') for suffix in ['config', 'qrcode']: with self.subTest(view=suffix): url = reverse( 'admin:otp_hotp_hotpdevice_' + suffix, kwargs={'pk': self.device.pk} ) response = self.client.get(url) self.assertEqual(response.status_code, 200) def test_change_perm(self): self._add_device_perms('change_hotpdevice') self.client.login(username='admin', password='password') for suffix in ['config', 'qrcode']: with self.subTest(view=suffix): url = reverse( 'admin:otp_hotp_hotpdevice_' + suffix, kwargs={'pk': self.device.pk} ) response = self.client.get(url) self.assertEqual(response.status_code, 200) def test_config_page_has_no_inline_style_or_script(self): """ The QR code config page shouldn't rely on inline style attributes,