pax_global_header00006660000000000000000000000064141514640270014516gustar00rootroot0000000000000052 comment=99537c1878c54dede0420bc0593824d8afd6b1b0 django-otp-1.1.3/000077500000000000000000000000001415146402700135625ustar00rootroot00000000000000django-otp-1.1.3/.bumpversion.cfg000066400000000000000000000006251415146402700166750ustar00rootroot00000000000000[bumpversion] current_version = 1.1.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:setup.py] search = version='{current_version}' replace = version='{new_version}' [bumpversion:file:docs/source/conf.py] search = release = '{current_version}' replace = release = '{new_version}' django-otp-1.1.3/.coveragerc000066400000000000000000000000641415146402700157030ustar00rootroot00000000000000[run] source = django_otp omit = */migrations/* django-otp-1.1.3/.github/000077500000000000000000000000001415146402700151225ustar00rootroot00000000000000django-otp-1.1.3/.github/workflows/000077500000000000000000000000001415146402700171575ustar00rootroot00000000000000django-otp-1.1.3/.github/workflows/tox.yaml000066400000000000000000000007061415146402700206600ustar00rootroot00000000000000name: Run tox on: pull_request: branches: [ master ] jobs: tox: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Set up Python 3 uses: actions/setup-python@v1 with: python-version: '3.x' - name: Install Dependencies run: | python -m pip install --upgrade pip pip install tox - name: Run Tests run: | tox --skip-missing-interpreters true django-otp-1.1.3/.gitignore000066400000000000000000000000771415146402700155560ustar00rootroot00000000000000# setuptools /MANIFEST /build/ /dist/ /docs/build/ *.egg-info/ django-otp-1.1.3/.isort.cfg000066400000000000000000000004421415146402700154610ustar00rootroot00000000000000[settings] force_sort_within_sections = true line_length = 120 lines_after_imports = 2 multi_line_output = 5 sections = FUTURE,STDLIB,THIRDPARTY,DJANGO,FIRSTPARTY,LOCALFOLDER known_third_party = freezegun known_django = django known_first_party = django_otp skip_glob = **/migrations/*.py django-otp-1.1.3/CHANGES.rst000066400000000000000000000342641415146402700153750ustar00rootroot00000000000000v1.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/pulls/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-1.1.3/LICENSE000066400000000000000000000024211415146402700145660ustar00rootroot00000000000000Copyright (c) 2012, Peter Sagerson All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: - Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. - Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. django-otp-1.1.3/MANIFEST.in000066400000000000000000000005671415146402700153300ustar00rootroot00000000000000include README.rst CHANGES.rst LICENSE recursive-include docs *.rst *.py Makefile prune docs/build recursive-include src/django_otp/locale * recursive-include src/django_otp/plugins/otp_email/templates * recursive-include src/django_otp/plugins/otp_hotp/templates * recursive-include src/django_otp/plugins/otp_totp/templates * recursive-include src/django_otp/templates * django-otp-1.1.3/Makefile000066400000000000000000000003451415146402700152240ustar00rootroot00000000000000.PHONY: full sdist wheel upload clean full: clean sdist wheel sdist: python setup.py sdist wheel: python setup.py bdist_wheel upload: twine upload dist/* clean: -rm -r build -rm -r dist -rm -r src/django_otp.egg-info django-otp-1.1.3/Pipfile000066400000000000000000000005301415146402700150730ustar00rootroot00000000000000[[source]] name = "pypi" url = "https://pypi.org/simple" verify_ssl = true [dev-packages] isort = "*" flake8 = "*" django = "*" setuptools = "*" wheel = "*" twine = "*" sphinx = "*" django-otp = {editable = true,path = "."} freezegun = "*" coverage = "*" tox = "*" bumpversion = "*" qrcode = "*" [packages] [requires] python_version = "3.7" django-otp-1.1.3/Pipfile.lock000066400000000000000000000754641415146402700160440ustar00rootroot00000000000000{ "_meta": { "hash": { "sha256": "fa2a7e4ac77d08feaa86c732e21948a8d6c795677152e1f43c92609b33aa03c2" }, "pipfile-spec": 6, "requires": { "python_version": "3.7" }, "sources": [ { "name": "pypi", "url": "https://pypi.org/simple", "verify_ssl": true } ] }, "default": {}, "develop": { "alabaster": { "hashes": [ "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359", "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02" ], "version": "==0.7.12" }, "asgiref": { "hashes": [ "sha256:4ef1ab46b484e3c706329cedeff284a5d40824200638503f5768edb6de7d58e9", "sha256:ffc141aa908e6f175673e7b1b3b7af4fdb0ecb738fc5c8b88f69f055c2415214" ], "markers": "python_version >= '3.6'", "version": "==3.4.1" }, "babel": { "hashes": [ "sha256:ab49e12b91d937cd11f0b67cb259a57ab4ad2b59ac7a3b41d6c06c0ac5b0def9", "sha256:bc0c176f9f6a994582230df350aa6e05ba2ebe4b3ac317eab29d9be5d2768da0" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.9.1" }, "backports.entry-points-selectable": { "hashes": [ "sha256:7fceed9532a7aa2bd888654a7314f864a3c16a4e710b34a58cfc0f08114c663b", "sha256:914b21a479fde881635f7af5adc7f6e38d6b274be32269070c53b698c60d5386" ], "markers": "python_version >= '2.7'", "version": "==1.1.1" }, "bleach": { "hashes": [ "sha256:0900d8b37eba61a802ee40ac0061f8c2b5dee29c1927dd1d233e075ebf5a71da", "sha256:4d2651ab93271d1129ac9cbc679f524565cc8a1b791909c4a51eac4446a15994" ], "markers": "python_version >= '3.6'", "version": "==4.1.0" }, "bump2version": { "hashes": [ "sha256:37f927ea17cde7ae2d7baf832f8e80ce3777624554a653006c9144f8017fe410", "sha256:762cb2bfad61f4ec8e2bdf452c7c267416f8c70dd9ecb1653fd0bbb01fa936e6" ], "markers": "python_version >= '3.5'", "version": "==1.0.1" }, "bumpversion": { "hashes": [ "sha256:4ba55e4080d373f80177b4dabef146c07ce73c7d1377aabf9d3c3ae1f94584a6", "sha256:4eb3267a38194d09f048a2179980bb4803701969bff2c85fa8f6d1ce050be15e" ], "index": "pypi", "version": "==0.6.0" }, "certifi": { "hashes": [ "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872", "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569" ], "version": "==2021.10.8" }, "charset-normalizer": { "hashes": [ "sha256:735e240d9a8506778cd7a453d97e817e536bb1fc29f4f6961ce297b9c7a917b0", "sha256:83fcdeb225499d6344c8f7f34684c2981270beacc32ede2e669e94f7fa544405" ], "markers": "python_version >= '3'", "version": "==2.0.8" }, "colorama": { "hashes": [ "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b", "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==0.4.4" }, "coverage": { "hashes": [ "sha256:01774a2c2c729619760320270e42cd9e797427ecfddd32c2a7b639cdc481f3c0", "sha256:03b20e52b7d31be571c9c06b74746746d4eb82fc260e594dc662ed48145e9efd", "sha256:0a7726f74ff63f41e95ed3a89fef002916c828bb5fcae83b505b49d81a066884", "sha256:1219d760ccfafc03c0822ae2e06e3b1248a8e6d1a70928966bafc6838d3c9e48", "sha256:13362889b2d46e8d9f97c421539c97c963e34031ab0cb89e8ca83a10cc71ac76", "sha256:174cf9b4bef0db2e8244f82059a5a72bd47e1d40e71c68ab055425172b16b7d0", "sha256:17e6c11038d4ed6e8af1407d9e89a2904d573be29d51515f14262d7f10ef0a64", "sha256:215f8afcc02a24c2d9a10d3790b21054b58d71f4b3c6f055d4bb1b15cecce685", "sha256:22e60a3ca5acba37d1d4a2ee66e051f5b0e1b9ac950b5b0cf4aa5366eda41d47", "sha256:2641f803ee9f95b1f387f3e8f3bf28d83d9b69a39e9911e5bfee832bea75240d", "sha256:276651978c94a8c5672ea60a2656e95a3cce2a3f31e9fb2d5ebd4c215d095840", "sha256:3f7c17209eef285c86f819ff04a6d4cbee9b33ef05cbcaae4c0b4e8e06b3ec8f", "sha256:3feac4084291642165c3a0d9eaebedf19ffa505016c4d3db15bfe235718d4971", "sha256:49dbff64961bc9bdd2289a2bda6a3a5a331964ba5497f694e2cbd540d656dc1c", "sha256:4e547122ca2d244f7c090fe3f4b5a5861255ff66b7ab6d98f44a0222aaf8671a", "sha256:5829192582c0ec8ca4a2532407bc14c2f338d9878a10442f5d03804a95fac9de", "sha256:5d6b09c972ce9200264c35a1d53d43ca55ef61836d9ec60f0d44273a31aa9f17", "sha256:600617008aa82032ddeace2535626d1bc212dfff32b43989539deda63b3f36e4", "sha256:619346d57c7126ae49ac95b11b0dc8e36c1dd49d148477461bb66c8cf13bb521", "sha256:63c424e6f5b4ab1cf1e23a43b12f542b0ec2e54f99ec9f11b75382152981df57", "sha256:6dbc1536e105adda7a6312c778f15aaabe583b0e9a0b0a324990334fd458c94b", "sha256:6e1394d24d5938e561fbeaa0cd3d356207579c28bd1792f25a068743f2d5b282", "sha256:86f2e78b1eff847609b1ca8050c9e1fa3bd44ce755b2ec30e70f2d3ba3844644", "sha256:8bdfe9ff3a4ea37d17f172ac0dff1e1c383aec17a636b9b35906babc9f0f5475", "sha256:8e2c35a4c1f269704e90888e56f794e2d9c0262fb0c1b1c8c4ee44d9b9e77b5d", "sha256:92b8c845527eae547a2a6617d336adc56394050c3ed8a6918683646328fbb6da", "sha256:9365ed5cce5d0cf2c10afc6add145c5037d3148585b8ae0e77cc1efdd6aa2953", "sha256:9a29311bd6429be317c1f3fe4bc06c4c5ee45e2fa61b2a19d4d1d6111cb94af2", "sha256:9a2b5b52be0a8626fcbffd7e689781bf8c2ac01613e77feda93d96184949a98e", "sha256:a4bdeb0a52d1d04123b41d90a4390b096f3ef38eee35e11f0b22c2d031222c6c", "sha256:a9c8c4283e17690ff1a7427123ffb428ad6a52ed720d550e299e8291e33184dc", "sha256:b637c57fdb8be84e91fac60d9325a66a5981f8086c954ea2772efe28425eaf64", "sha256:bf154ba7ee2fd613eb541c2bc03d3d9ac667080a737449d1a3fb342740eb1a74", "sha256:c254b03032d5a06de049ce8bca8338a5185f07fb76600afff3c161e053d88617", "sha256:c332d8f8d448ded473b97fefe4a0983265af21917d8b0cdcb8bb06b2afe632c3", "sha256:c7912d1526299cb04c88288e148c6c87c0df600eca76efd99d84396cfe00ef1d", "sha256:cfd9386c1d6f13b37e05a91a8583e802f8059bebfccde61a418c5808dea6bbfa", "sha256:d5d2033d5db1d58ae2d62f095e1aefb6988af65b4b12cb8987af409587cc0739", "sha256:dca38a21e4423f3edb821292e97cec7ad38086f84313462098568baedf4331f8", "sha256:e2cad8093172b7d1595b4ad66f24270808658e11acf43a8f95b41276162eb5b8", "sha256:e3db840a4dee542e37e09f30859f1612da90e1c5239a6a2498c473183a50e781", "sha256:edcada2e24ed68f019175c2b2af2a8b481d3d084798b8c20d15d34f5c733fa58", "sha256:f467bbb837691ab5a8ca359199d3429a11a01e6dfb3d9dcc676dc035ca93c0a9", "sha256:f506af4f27def639ba45789fa6fde45f9a217da0be05f8910458e4557eed020c", "sha256:f614fc9956d76d8a88a88bb41ddc12709caa755666f580af3a688899721efecd", "sha256:f9afb5b746781fc2abce26193d1c817b7eb0e11459510fba65d2bd77fe161d9e", "sha256:fb8b8ee99b3fffe4fd86f4c81b35a6bf7e4462cba019997af2fe679365db0c49" ], "index": "pypi", "version": "==6.2" }, "distlib": { "hashes": [ "sha256:c8b54e8454e5bf6237cc84c20e8264c3e991e824ef27e8f1e81049867d861e31", "sha256:d982d0751ff6eaaab5e2ec8e691d949ee80eddf01a62eaa96ddb11531fe16b05" ], "version": "==0.3.3" }, "django": { "hashes": [ "sha256:51284300f1522ffcdb07ccbdf676a307c6678659e1284f0618e5a774127a6a08", "sha256:e22c9266da3eec7827737cde57694d7db801fedac938d252bf27377cec06ed1b" ], "index": "pypi", "version": "==3.2.9" }, "django-otp": { "editable": true, "path": "." }, "docutils": { "hashes": [ "sha256:686577d2e4c32380bb50cbb22f575ed742d58168cee37e99117a854bcd88f125", "sha256:cf316c8370a737a022b72b56874f6602acf974a37a9fba42ec2876387549fc61" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==0.17.1" }, "filelock": { "hashes": [ "sha256:2e139a228bcf56dd8b2274a65174d005c4a6b68540ee0bdbb92c76f43f29f7e8", "sha256:93d512b32a23baf4cac44ffd72ccf70732aeff7b8050fcaf6d3ec406d954baf4" ], "markers": "python_version >= '3.6'", "version": "==3.4.0" }, "flake8": { "hashes": [ "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d", "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d" ], "index": "pypi", "version": "==4.0.1" }, "freezegun": { "hashes": [ "sha256:177f9dd59861d871e27a484c3332f35a6e3f5d14626f2bf91be37891f18927f3", "sha256:2ae695f7eb96c62529f03a038461afe3c692db3465e215355e1bb4b0ab408712" ], "index": "pypi", "version": "==1.1.0" }, "idna": { "hashes": [ "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" ], "markers": "python_version >= '3'", "version": "==3.3" }, "imagesize": { "hashes": [ "sha256:1db2f82529e53c3e929e8926a1fa9235aa82d0bd0c580359c67ec31b2fddaa8c", "sha256:cd1750d452385ca327479d45b64d9c7729ecf0b3969a58148298c77092261f9d" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.3.0" }, "importlib-metadata": { "hashes": [ "sha256:057e92c15bc8d9e8109738a48db0ccb31b4d9d5cfbee5a8670879a30be66304b", "sha256:b7e52a1f8dec14a75ea73e0891f3060099ca1d8e6a462a4dff11c3e119ea1b31" ], "markers": "python_version < '3.8'", "version": "==4.2.0" }, "isort": { "hashes": [ "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7", "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951" ], "index": "pypi", "version": "==5.10.1" }, "jinja2": { "hashes": [ "sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8", "sha256:611bb273cd68f3b993fabdc4064fc858c5b47a973cb5aa7999ec1ba405c87cd7" ], "markers": "python_version >= '3.6'", "version": "==3.0.3" }, "keyring": { "hashes": [ "sha256:3dc0f66062a4f8f6f2ce30d6a516e6e623e6c3c2e76864204ceaf64695408f07", "sha256:88f206024295e3c6fb16bb0a60fb4bb7ec1185629dc5a729f12aa7c236d01387" ], "markers": "python_version >= '3.6'", "version": "==23.4.0" }, "markupsafe": { "hashes": [ "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298", "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64", "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b", "sha256:04635854b943835a6ea959e948d19dcd311762c5c0c6e1f0e16ee57022669194", "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567", "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff", "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724", "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74", "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646", "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35", "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6", "sha256:20dca64a3ef2d6e4d5d615a3fd418ad3bde77a47ec8a23d984a12b5b4c74491a", "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6", "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad", "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26", "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38", "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac", "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7", "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6", "sha256:4296f2b1ce8c86a6aea78613c34bb1a672ea0e3de9c6ba08a960efe0b0a09047", "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75", "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f", "sha256:4dc8f9fb58f7364b63fd9f85013b780ef83c11857ae79f2feda41e270468dd9b", "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135", "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8", "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a", "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a", "sha256:5b6d930f030f8ed98e3e6c98ffa0652bdb82601e7a016ec2ab5d7ff23baa78d1", "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9", "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864", "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914", "sha256:6300b8454aa6930a24b9618fbb54b5a68135092bc666f7b06901f897fa5c2fee", "sha256:63f3268ba69ace99cab4e3e3b5840b03340efed0948ab8f78d2fd87ee5442a4f", "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18", "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8", "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2", "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d", "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b", "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b", "sha256:89c687013cb1cd489a0f0ac24febe8c7a666e6e221b783e53ac50ebf68e45d86", "sha256:8d206346619592c6200148b01a2142798c989edcb9c896f9ac9722a99d4e77e6", "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f", "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb", "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833", "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28", "sha256:9f02365d4e99430a12647f09b6cc8bab61a6564363f313126f775eb4f6ef798e", "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415", "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902", "sha256:aca6377c0cb8a8253e493c6b451565ac77e98c2951c45f913e0b52facdcff83f", "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d", "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9", "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d", "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145", "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066", "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c", "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1", "sha256:cdfba22ea2f0029c9261a4bd07e830a8da012291fbe44dc794e488b6c9bb353a", "sha256:d6c7ebd4e944c85e2c3421e612a7057a2f48d478d79e61800d81468a8d842207", "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f", "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53", "sha256:deb993cacb280823246a026e3b2d81c493c53de6acfd5e6bfe31ab3402bb37dd", "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134", "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85", "sha256:f0567c4dc99f264f49fe27da5f735f414c4e7e7dd850cfd8e69f0862d7c74ea9", "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5", "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94", "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509", "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51", "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872" ], "markers": "python_version >= '3.6'", "version": "==2.0.1" }, "mccabe": { "hashes": [ "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" ], "version": "==0.6.1" }, "packaging": { "hashes": [ "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb", "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522" ], "markers": "python_version >= '3.6'", "version": "==21.3" }, "pkginfo": { "hashes": [ "sha256:65175ffa2c807220673a41c371573ac9a1ea1b19ffd5eef916278f428319934f", "sha256:bb55a6c017d50f2faea5153abc7b05a750e7ea7ae2cbb7fb3ad6f1dcf8d40988" ], "version": "==1.8.1" }, "platformdirs": { "hashes": [ "sha256:367a5e80b3d04d2428ffa76d33f124cf11e8fff2acdaa9b43d545f5c7d661ef2", "sha256:8868bbe3c3c80d42f20156f22e7131d2fb321f5bc86a2a345375c6481a67021d" ], "markers": "python_version >= '3.6'", "version": "==2.4.0" }, "pluggy": { "hashes": [ "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159", "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3" ], "markers": "python_version >= '3.6'", "version": "==1.0.0" }, "py": { "hashes": [ "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719", "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==1.11.0" }, "pycodestyle": { "hashes": [ "sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20", "sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.8.0" }, "pyflakes": { "hashes": [ "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c", "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.0" }, "pygments": { "hashes": [ "sha256:b8e67fe6af78f492b3c4b3e2970c0624cbf08beb1e493b2c99b9fa1b67a20380", "sha256:f398865f7eb6874156579fdf36bc840a03cab64d1cde9e93d68f46a425ec52c6" ], "markers": "python_version >= '3.5'", "version": "==2.10.0" }, "pyparsing": { "hashes": [ "sha256:04ff808a5b90911829c55c4e26f75fa5ca8a2f5f36aa3a51f68e27033341d3e4", "sha256:d9bdec0013ef1eb5a84ab39a3b3868911598afa494f5faa038647101504e2b81" ], "markers": "python_version >= '3.6'", "version": "==3.0.6" }, "python-dateutil": { "hashes": [ "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.8.2" }, "pytz": { "hashes": [ "sha256:3672058bc3453457b622aab7a1c3bfd5ab0bdae451512f6cf25f64ed37f5b87c", "sha256:acad2d8b20a1af07d4e4c9d2e9285c5ed9104354062f275f3fcd88dcef4f1326" ], "version": "==2021.3" }, "qrcode": { "hashes": [ "sha256:375a6ff240ca9bd41adc070428b5dfc1dcfbb0f2507f1ac848f6cded38956578" ], "index": "pypi", "version": "==7.3.1" }, "readme-renderer": { "hashes": [ "sha256:3286806450d9961d6e3b5f8a59f77e61503799aca5155c8d8d40359b4e1e1adc", "sha256:8299700d7a910c304072a7601eafada6712a5b011a20139417e1b1e9f04645d8" ], "version": "==30.0" }, "requests": { "hashes": [ "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24", "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", "version": "==2.26.0" }, "requests-toolbelt": { "hashes": [ "sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f", "sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0" ], "version": "==0.9.1" }, "rfc3986": { "hashes": [ "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835", "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97" ], "version": "==1.5.0" }, "setuptools": { "hashes": [ "sha256:b4c634615a0cf5b02cf83c7bedffc8da0ca439f00e79452699454da6fbd4153d", "sha256:feb5ff19b354cde9efd2344ef6d5e79880ce4be643037641b49508bbb850d060" ], "index": "pypi", "version": "==59.4.0" }, "six": { "hashes": [ "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.16.0" }, "snowballstemmer": { "hashes": [ "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1", "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a" ], "version": "==2.2.0" }, "sphinx": { "hashes": [ "sha256:048dac56039a5713f47a554589dc98a442b39226a2b9ed7f82797fcb2fe9253f", "sha256:32a5b3e9a1b176cc25ed048557d4d3d01af635e6b76c5bc7a43b0a34447fbd45" ], "index": "pypi", "version": "==4.3.1" }, "sphinxcontrib-applehelp": { "hashes": [ "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a", "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58" ], "markers": "python_version >= '3.5'", "version": "==1.0.2" }, "sphinxcontrib-devhelp": { "hashes": [ "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e", "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4" ], "markers": "python_version >= '3.5'", "version": "==1.0.2" }, "sphinxcontrib-htmlhelp": { "hashes": [ "sha256:d412243dfb797ae3ec2b59eca0e52dac12e75a241bf0e4eb861e450d06c6ed07", "sha256:f5f8bb2d0d629f398bf47d0d69c07bc13b65f75a81ad9e2f71a63d4b7a2f6db2" ], "markers": "python_version >= '3.6'", "version": "==2.0.0" }, "sphinxcontrib-jsmath": { "hashes": [ "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8" ], "markers": "python_version >= '3.5'", "version": "==1.0.1" }, "sphinxcontrib-qthelp": { "hashes": [ "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72", "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6" ], "markers": "python_version >= '3.5'", "version": "==1.0.3" }, "sphinxcontrib-serializinghtml": { "hashes": [ "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd", "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952" ], "markers": "python_version >= '3.5'", "version": "==1.1.5" }, "sqlparse": { "hashes": [ "sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae", "sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d" ], "markers": "python_version >= '3.5'", "version": "==0.4.2" }, "toml": { "hashes": [ "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" ], "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.10.2" }, "tox": { "hashes": [ "sha256:5e274227a53dc9ef856767c21867377ba395992549f02ce55eb549f9fb9a8d10", "sha256:c30b57fa2477f1fb7c36aa1d83292d5c2336cd0018119e1b1c17340e2c2708ca" ], "index": "pypi", "version": "==3.24.4" }, "tqdm": { "hashes": [ "sha256:8dd278a422499cd6b727e6ae4061c40b48fce8b76d1ccbf5d34fca9b7f925b0c", "sha256:d359de7217506c9851b7869f3708d8ee53ed70a1b8edbba4dbcb47442592920d" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==4.62.3" }, "twine": { "hashes": [ "sha256:4caad5ef4722e127b3749052fcbffaaf71719b19d4fd4973b29c469957adeba2", "sha256:916070f8ecbd1985ebed5dbb02b9bda9a092882a96d7069d542d4fc0bb5c673c" ], "index": "pypi", "version": "==3.6.0" }, "typing-extensions": { "hashes": [ "sha256:2cdf80e4e04866a9b3689a51869016d36db0814d84b8d8a568d22781d45d27ed", "sha256:829704698b22e13ec9eaf959122315eabb370b0884400e9818334d8b677023d9" ], "markers": "python_version < '3.8'", "version": "==4.0.0" }, "urllib3": { "hashes": [ "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece", "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4.0'", "version": "==1.26.7" }, "virtualenv": { "hashes": [ "sha256:4b02e52a624336eece99c96e3ab7111f469c24ba226a53ec474e8e787b365814", "sha256:576d05b46eace16a9c348085f7d0dc8ef28713a2cabaa1cf0aea41e8f12c9218" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==20.10.0" }, "webencodings": { "hashes": [ "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923" ], "version": "==0.5.1" }, "wheel": { "hashes": [ "sha256:21014b2bd93c6d0034b6ba5d35e4eb284340e09d63c59aef6fc14b0f346146fd", "sha256:e2ef7239991699e3355d54f8e968a21bb940a1dbf34a4d226741e64462516fad" ], "index": "pypi", "version": "==0.37.0" }, "zipp": { "hashes": [ "sha256:71c644c5369f4a6e07636f0aa966270449561fcea2e3d6747b8d23efaa9d7832", "sha256:9fe5ea21568a0a70e50f273397638d39b03353731e6cbbb3fd8502a33fec40bc" ], "markers": "python_version >= '3.6'", "version": "==3.6.0" } } } django-otp-1.1.3/README.rst000066400000000000000000000135731415146402700152620ustar00rootroot00000000000000django-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. Anyone interested in taking over aspects of the project should `contact me `_. Well-formed issues and pull requests are welcome, but please see the Contributing section of the README first. .. end-of-doc-intro 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. Development ----------- Development dependencies are defined in the Pipfile; use `pipenv`_ to set up a suitable shell. The tests in tox.ini cover a representative sample of supported Python and Django versions, as well as running `flake8`_ and `isort`_ for linting and style consistency. Please run `tox` before checking in and sending a pull request. Contributing ------------ As mentioned above, this project is stable and mature. Issues and pull requests are welcome for important bugs and improvements. For non-trivial changes, it's often a good idea to start by opening an issue to track the need for a change and then optionally open a pull request with a proposed resolution. Issues and pull requests should also be focused on a single thing. Pull requests that bundle together a bunch of loosely related commits are unlikely to go anywhere. Another good rule of thumb—for any project, but especially a mature one—is to keep changes as simple as possible. In particular, there should be a high bar for adding new dependencies. Although it can't be ruled out, it seems highly unlikely that a new runtime dependency will ever be added. New testing dependencies are more likely, but only if there's no other way to address an important need. If there's a development tool that you'd like to use with this project, the first step is to try to update config files (setup.cfg or similar) to integrate the tool with the existing code. A bit of configuration glue for popular tools should always be safe. If that's not possible, we can consider modifying the code to be compatible with a broader range of tools (without breaking any existing compatibilities). Only as a last resort would a new testing or development tool be incorporated into the project as a dependency. It's also good to remember that writing the code is typically the least part of the work. This is true for software development in general, but especially a small stable project like this. The bulk of the work is in `understanding the problem `_, determining the desired attributes of a solution, researching and evaluating alternatives, writing documentation, designing a testing strategy, etc. Writing the code itself tends to be a minor matter that emerges from that process. .. _pipenv: https://pipenv.readthedocs.io/en/latest/ .. _flake8: https://pypi.org/project/flake8/ .. _isort: https://pypi.org/project/isort/ django-otp-1.1.3/docs/000077500000000000000000000000001415146402700145125ustar00rootroot00000000000000django-otp-1.1.3/docs/Makefile000066400000000000000000000130751415146402700161600ustar00rootroot00000000000000# 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-1.1.3/docs/ext/000077500000000000000000000000001415146402700153125ustar00rootroot00000000000000django-otp-1.1.3/docs/ext/otpdocs.py000066400000000000000000000003311415146402700173340ustar00rootroot00000000000000""" 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-1.1.3/docs/source/000077500000000000000000000000001415146402700160125ustar00rootroot00000000000000django-otp-1.1.3/docs/source/.spell.utf-8.add000066400000000000000000000015051415146402700206240ustar00rootroot00000000000000backends 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-1.1.3/docs/source/auth.rst000066400000000000000000000215521415146402700175120ustar00rootroot00000000000000Authentication 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. 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 See the Django :class:`~django.contrib.admin.AdminSite` documentation for more on installing custom admin sites. If you want to copy the default admin site into an :class:`~django_otp.admin.OTPAdminSite`, we find that the following works well. Note that it relies on a private property, so use this at your own risk:: otp_admin_site = OTPAdminSite(OTPAdminSite.name) for model_cls, model_admin in admin.site._registry.iteritems(): otp_admin_site.register(model_cls, model_admin.__class__) .. 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 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_challenge, verify_token, verify_is_allowed, persistent_id, from_persistent_id .. autoclass:: django_otp.models.SideChannelDevice :members: generate_token, verify_token .. autoclass:: django_otp.models.DeviceManager :members: devices_for_user .. autoclass:: django_otp.models.VerifyNotAllowed 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-1.1.3/docs/source/changes.rst000066400000000000000000000000661415146402700201560ustar00rootroot00000000000000Change Log ========== .. include:: ../../CHANGES.rst django-otp-1.1.3/docs/source/conf.py000066400000000000000000000212021415146402700173060ustar00rootroot00000000000000# 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/2.2/', 'https://docs.djangoproject.com/en/2.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 = '2012, Peter Sagerson' # 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.1.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 = True # 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-1.1.3/docs/source/extend.rst000066400000000000000000000054661415146402700200460ustar00rootroot00000000000000Extending 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: For devices that send a token via a separate channel, like the :class:`~django_otp.plugins.otp_email.models.EmailDevice` example, a generic :class:`~django_otp.models.SideChannelDevice` is provided. This abstract subclass of :class:`~django_otp.models.Device` provides :meth:`~django_otp.models.SideChannelDevice.generate_token` and implements :meth:`~django_otp.models.SideChannelDevice.verify_token` for concrete devices, which then only have to implement :meth:`~django_otp.models.Device.generate_challenge` to actually deliver the token to the user. .. _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-1.1.3/docs/source/index.rst000066400000000000000000000027061415146402700176600ustar00rootroot00000000000000.. include:: ../../README.rst :end-before: .. end-of-doc-intro Contents -------- .. toctree:: :maxdepth: 3 overview auth extend changes License ------- Copyright (c) 2012, Peter Sagerson All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: - Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. - Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. django-otp-1.1.3/docs/source/overview.rst000066400000000000000000000435701415146402700204230ustar00rootroot00000000000000Overview 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]_ 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 the `qrcode`_ package 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 .. _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_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. .. 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_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_TOKEN_VALIDITY **OTP_EMAIL_TOKEN_VALIDITY** Default: ``300`` The maximum number of seconds a token is valid. .. 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. 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-1.1.3/manage.py000077500000000000000000000006461415146402700153750ustar00rootroot00000000000000#!/usr/bin/env python """ Convenience wrapper for the test project: ./manage.py test django_otp It's not really useful for anything else. """ import os import site import sys if __name__ == "__main__": site.addsitedir('test') os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_project.settings') from django.core.management import execute_from_command_line execute_from_command_line(sys.argv) django-otp-1.1.3/readthedocs.yaml000066400000000000000000000001741415146402700167350ustar00rootroot00000000000000version: 2 sphinx: configuration: docs/source/conf.py python: version: 3.7 install: - method: pip path: . django-otp-1.1.3/setup.cfg000066400000000000000000000002321415146402700154000ustar00rootroot00000000000000[metadata] long_description: file: README.rst [flake8] ignore = # line break after binary operator W504 # line too long E501 django-otp-1.1.3/setup.py000077500000000000000000000022341415146402700153000ustar00rootroot00000000000000#!/usr/bin/env python from setuptools import find_packages, setup setup( name='django-otp', version='1.1.3', description="A pluggable framework for adding two-factor authentication to Django using one-time passwords.", license='BSD', author="Peter Sagerson", author_email='psagers@ignorare.net', url='https://github.com/django-otp/django-otp', project_urls={ "Documentation": 'https://django-otp-official.readthedocs.io/', "Source": 'https://github.com/django-otp/django-otp', }, classifiers=[ "Development Status :: 5 - Production/Stable", "Framework :: Django", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Topic :: Security", "Topic :: Software Development :: Libraries :: Python Modules", ], package_dir={'': 'src'}, packages=find_packages(where='src'), include_package_data=True, zip_safe=False, install_requires=[ 'django >= 2.2', ], extras_require={ 'qrcode': ['qrcode'], }, ) django-otp-1.1.3/src/000077500000000000000000000000001415146402700143515ustar00rootroot00000000000000django-otp-1.1.3/src/django_otp/000077500000000000000000000000001415146402700164755ustar00rootroot00000000000000django-otp-1.1.3/src/django_otp/__init__.py000066400000000000000000000117541415146402700206160ustar00rootroot00000000000000from 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. When possible, you should prefer to verify tokens 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): yield model django-otp-1.1.3/src/django_otp/admin.py000066400000000000000000000047441415146402700201500ustar00rootroot00000000000000from django import forms from django.contrib.admin.forms import AdminAuthenticationForm from django.contrib.admin.sites import AdminSite 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' 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-1.1.3/src/django_otp/conf.py000066400000000000000000000011661415146402700200000ustar00rootroot00000000000000import django.conf 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. """ defaults = { '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-1.1.3/src/django_otp/decorators.py000066400000000000000000000017071415146402700212210ustar00rootroot00000000000000from 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-1.1.3/src/django_otp/forms.py000066400000000000000000000322311415146402700201760ustar00rootroot00000000000000from django import forms from django.contrib.auth.forms import AuthenticationForm from django.db import transaction from django.utils.translation import gettext_lazy as _ from django.utils.translation import ngettext_lazy from . import devices_for_user, match_token from .models import Device, VerifyNotAllowed 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"), } 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 not None: 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 else: device = match_token(user, token) if device is None: 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 the one-stop 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 two things happens: - If the user submitted an OTP token, we will enumerate all of the user's OTP devices, asking each one to verify it in turn. If one of them succeeds, then authentication is fully successful and the user is logged in. - If the user did not submit an OTP token or none of user's devices accepted it, then 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; you can use this in place of :class:`~django.contrib.auth.forms.AuthenticationForm` by currying it:: from functools import partial from django.contrib.auth.decorators import login_required from django.contrib.auth.views import login @login_required def verify(request): form_cls = partial(OTPTokenForm, request.user) return login(request, template_name='my_verify_template.html', authentication_form=form_cls) 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-1.1.3/src/django_otp/locale/000077500000000000000000000000001415146402700177345ustar00rootroot00000000000000django-otp-1.1.3/src/django_otp/locale/fr/000077500000000000000000000000001415146402700203435ustar00rootroot00000000000000django-otp-1.1.3/src/django_otp/locale/fr/LC_MESSAGES/000077500000000000000000000000001415146402700221305ustar00rootroot00000000000000django-otp-1.1.3/src/django_otp/locale/fr/LC_MESSAGES/django.mo000066400000000000000000000044331415146402700237330ustar00rootroot00000000000000xy$> ) 5@ `*/?I,.E @M^ n({) 57%]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-1.1.3/src/django_otp/locale/fr/LC_MESSAGES/django.po000066400000000000000000000051541415146402700237370ustar00rootroot00000000000000# 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: 2020-10-02 23:25+0200\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" msgid "Please enter your OTP token." msgstr "Veuillez saisir votre jeton OTP." #, python-brace-format msgid "Error generating challenge: {0}" msgstr "Erreur lors de la production du défi : {0}" msgid "The selected OTP device is not interactive" msgstr "L’appareil OTP sélectionné n’est pas interactif" #, python-brace-format msgid "OTP Challenge: {0}" msgstr "Défi OTP : {0}" msgid "Invalid token. Please make sure you have entered it correctly." msgstr "Jeton non valable. Assurez-vous de bien l’avoir saisi correctement." #, 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." msgid "Verification of the token is currently disabled" msgstr "La vérification du jeton est actuellement désactivée" msgid "Please correct the error below." msgstr "Veuillez corriger l’erreur ci-dessous." msgid "Please correct the errors below." msgstr "Veuillez corriger les erreurs ci-dessous." #, 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 ?" msgid "OTP Device:" msgstr "Appareil OTP :" msgid "OTP Token:" msgstr "Jeton OTP :" msgid "Forgotten your password or username?" msgstr "Mot de passe ou nom d’utilisateur oublié ?" msgid "Log in" msgstr "Se connecter" msgid "Get OTP Challenge" msgstr "Obtenir le défi OTP" django-otp-1.1.3/src/django_otp/middleware.py000066400000000000000000000045441415146402700211730ustar00rootroot00000000000000import functools 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``. """ def __init__(self, get_response=None): self.get_response = get_response def __call__(self, request): user = getattr(request, 'user', None) if user is not None: request.user = SimpleLazyObject(functools.partial(self._verify_user, request, user)) return self.get_response(request) def _verify_user(self, request, user): """ Sets OTP-related fields on an authenticated user. """ user.otp_device = None user.is_verified = functools.partial(is_verified, 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 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] user.otp_device = device return user def _device_from_persistent_id(self, persistent_id): # 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('.') persistent_id = '.'.join((parts[-3], parts[-1])) device = Device.from_persistent_id(persistent_id) return device django-otp-1.1.3/src/django_otp/models.py000066400000000000000000000300531415146402700203330ustar00rootroot00000000000000from datetime import timedelta 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 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. """ device = None try: 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() device = device_set.first() except (ValueError, LookupError): pass return device 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_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. """ 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. Pass 'commit=False' to avoid calling self.save(). :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: Whether to autosave the generated token. """ 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 VerifyNotAllowed: """ 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 need throttling behaviour. Implements exponential back-off. """ # This mixin is not publicly documented, but is used internally to avoid # code duplication. Subclasses must implement get_throttle_factor(), and # must use the verify_is_allowed(), throttle_reset() and # throttle_increment() methods from within their verify_token() method. 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." ) 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 = self.get_throttle_factor() * (2 ** (self.throttling_failure_count - 1)) 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). Pass 'commit=False' to avoid calling self.save(). """ 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). Pass 'commit=False' to avoid calling self.save(). """ 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 raise NotImplementedError() class Meta: abstract = True django-otp-1.1.3/src/django_otp/oath.py000066400000000000000000000126101415146402700200020ustar00rootroot00000000000000from 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-1.1.3/src/django_otp/plugins/000077500000000000000000000000001415146402700201565ustar00rootroot00000000000000django-otp-1.1.3/src/django_otp/plugins/__init__.py000066400000000000000000000000001415146402700222550ustar00rootroot00000000000000django-otp-1.1.3/src/django_otp/plugins/otp_email/000077500000000000000000000000001415146402700221275ustar00rootroot00000000000000django-otp-1.1.3/src/django_otp/plugins/otp_email/__init__.py000066400000000000000000000001671415146402700242440ustar00rootroot00000000000000import django if django.VERSION < (3, 2): default_app_config = 'django_otp.plugins.otp_email.apps.DefaultConfig' django-otp-1.1.3/src/django_otp/plugins/otp_email/admin.py000066400000000000000000000012511415146402700235700ustar00rootroot00000000000000from django.contrib import admin from django.contrib.admin.sites import AlreadyRegistered from .models import EmailDevice class EmailDeviceAdmin(admin.ModelAdmin): """ :class:`~django.contrib.admin.ModelAdmin` for :class:`~django_otp.plugins.otp_email.models.EmailDevice`. """ fieldsets = [ ('Identity', { 'fields': ['user', 'name', 'confirmed'], }), ('Configuration', { 'fields': ['email'], }), ] raw_id_fields = ['user'] # Somehow this is getting imported twice, triggering a useless exception. try: admin.site.register(EmailDevice, EmailDeviceAdmin) except AlreadyRegistered: pass django-otp-1.1.3/src/django_otp/plugins/otp_email/apps.py000066400000000000000000000002441415146402700234440ustar00rootroot00000000000000from django.apps import AppConfig class DefaultConfig(AppConfig): name = 'django_otp.plugins.otp_email' default_auto_field = 'django.db.models.AutoField' django-otp-1.1.3/src/django_otp/plugins/otp_email/conf.py000066400000000000000000000014441415146402700234310ustar00rootroot00000000000000import 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_TOKEN_VALIDITY': 300, 'OTP_EMAIL_THROTTLE_FACTOR': 1, } 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-1.1.3/src/django_otp/plugins/otp_email/migrations/000077500000000000000000000000001415146402700243035ustar00rootroot00000000000000django-otp-1.1.3/src/django_otp/plugins/otp_email/migrations/0001_initial.py000066400000000000000000000023051415146402700267460ustar00rootroot00000000000000from 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,), ), ] django-otp-1.1.3/src/django_otp/plugins/otp_email/migrations/0002_sidechanneldevice_email.py000066400000000000000000000014301415146402700321200ustar00rootroot00000000000000# 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-1.1.3/src/django_otp/plugins/otp_email/migrations/0003_emaildevice_email.py000066400000000000000000000007461415146402700307440ustar00rootroot00000000000000# 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-1.1.3/src/django_otp/plugins/otp_email/migrations/0004_throttling.py000066400000000000000000000014001415146402700275110ustar00rootroot00000000000000# 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), ), ] django-otp-1.1.3/src/django_otp/plugins/otp_email/migrations/__init__.py000066400000000000000000000000001415146402700264020ustar00rootroot00000000000000django-otp-1.1.3/src/django_otp/plugins/otp_email/models.py000066400000000000000000000053451415146402700237730ustar00rootroot00000000000000from 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_otp.models import SideChannelDevice, ThrottlingMixin 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(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 get_throttle_factor(self): return settings.OTP_EMAIL_THROTTLE_FACTOR 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 """ self.generate_token(valid_secs=settings.OTP_EMAIL_TOKEN_VALIDITY) 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) send_mail(settings.OTP_EMAIL_SUBJECT, body, settings.OTP_EMAIL_SENDER, [self.email or self.user.email]) message = "sent by email" return message def verify_token(self, token): verify_allowed, _ = self.verify_is_allowed() if verify_allowed: verified = super().verify_token(token) if verified: self.throttle_reset() else: self.throttle_increment() else: verified = False return verified django-otp-1.1.3/src/django_otp/plugins/otp_email/templates/000077500000000000000000000000001415146402700241255ustar00rootroot00000000000000django-otp-1.1.3/src/django_otp/plugins/otp_email/templates/otp/000077500000000000000000000000001415146402700247275ustar00rootroot00000000000000django-otp-1.1.3/src/django_otp/plugins/otp_email/templates/otp/email/000077500000000000000000000000001415146402700260165ustar00rootroot00000000000000django-otp-1.1.3/src/django_otp/plugins/otp_email/templates/otp/email/token.txt000066400000000000000000000000141415146402700276720ustar00rootroot00000000000000{{ token }} django-otp-1.1.3/src/django_otp/plugins/otp_email/tests.py000066400000000000000000000133511415146402700236460ustar00rootroot00000000000000from 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.tests import TestCase, ThrottlingTestMixin 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': 'otp_email.emaildevice/1', '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", 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_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_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 django-otp-1.1.3/src/django_otp/plugins/otp_hotp/000077500000000000000000000000001415146402700220125ustar00rootroot00000000000000django-otp-1.1.3/src/django_otp/plugins/otp_hotp/__init__.py000066400000000000000000000001661415146402700241260ustar00rootroot00000000000000import django if django.VERSION < (3, 2): default_app_config = 'django_otp.plugins.otp_hotp.apps.DefaultConfig' django-otp-1.1.3/src/django_otp/plugins/otp_hotp/admin.py000066400000000000000000000101711415146402700234540ustar00rootroot00000000000000from django.contrib import admin from django.contrib.admin.sites import AlreadyRegistered 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.conf import settings from .models import HOTPDevice class HOTPDeviceAdmin(admin.ModelAdmin): """ :class:`~django.contrib.admin.ModelAdmin` for :class:`~django_otp.plugins.otp_hotp.models.HOTPDevice`. """ list_display = ['user', 'name', 'confirmed'] raw_id_fields = ['user'] readonly_fields = ['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'], }), ('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 # 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 qrcode_link.short_description = "QR Code" # # 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: import qrcode import qrcode.image.svg img = qrcode.make(device.config_url, image_factory=qrcode.image.svg.SvgImage) response = HttpResponse(content_type='image/svg+xml') img.save(response) except ImportError: response = HttpResponse('', status=503) return response try: admin.site.register(HOTPDevice, HOTPDeviceAdmin) except AlreadyRegistered: # A useless exception from a double import pass django-otp-1.1.3/src/django_otp/plugins/otp_hotp/apps.py000066400000000000000000000002431415146402700233260ustar00rootroot00000000000000from django.apps import AppConfig class DefaultConfig(AppConfig): name = 'django_otp.plugins.otp_hotp' default_auto_field = 'django.db.models.AutoField' django-otp-1.1.3/src/django_otp/plugins/otp_hotp/migrations/000077500000000000000000000000001415146402700241665ustar00rootroot00000000000000django-otp-1.1.3/src/django_otp/plugins/otp_hotp/migrations/0001_initial.py000066400000000000000000000031741415146402700266360ustar00rootroot00000000000000from 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-1.1.3/src/django_otp/plugins/otp_hotp/migrations/0002_auto_20190420_0723.py000066400000000000000000000013611415146402700276060ustar00rootroot00000000000000# 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-1.1.3/src/django_otp/plugins/otp_hotp/migrations/__init__.py000066400000000000000000000000001415146402700262650ustar00rootroot00000000000000django-otp-1.1.3/src/django_otp/plugins/otp_hotp/models.py000066400000000000000000000074141415146402700236550ustar00rootroot00000000000000from 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 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(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.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-1.1.3/src/django_otp/plugins/otp_hotp/templates/000077500000000000000000000000001415146402700240105ustar00rootroot00000000000000django-otp-1.1.3/src/django_otp/plugins/otp_hotp/templates/otp_hotp/000077500000000000000000000000001415146402700256445ustar00rootroot00000000000000django-otp-1.1.3/src/django_otp/plugins/otp_hotp/templates/otp_hotp/admin/000077500000000000000000000000001415146402700267345ustar00rootroot00000000000000django-otp-1.1.3/src/django_otp/plugins/otp_hotp/templates/otp_hotp/admin/config.html000066400000000000000000000011371415146402700310710ustar00rootroot00000000000000{% extends "admin/base_site.html" %} {% block content %}

{{ device.config_url }}

{% endblock %} django-otp-1.1.3/src/django_otp/plugins/otp_hotp/tests.py000066400000000000000000000314711415146402700235340ustar00rootroot00000000000000from 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.tests import TestCase, ThrottlingTestMixin 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) @override_settings(OTP_ADMIN_HIDE_SENSITIVE_DATA=True) def test_sensitive_information_hidden_while_adding_device(self): fields = self._get_fields(device=None) self.assertIn('key', fields) self.assertNotIn('qrcode_link', fields) @override_settings(OTP_ADMIN_HIDE_SENSITIVE_DATA=True) def test_sensitive_information_hidden_while_changing_device(self): fields = self._get_fields(device=self.device) self.assertNotIn('key', fields) self.assertNotIn('qrcode_link', fields) @override_settings(OTP_ADMIN_HIDE_SENSITIVE_DATA=False) def test_sensitive_information_shown_while_adding_device(self): fields = self._get_fields(device=None) self.assertIn('key', fields) self.assertNotIn('qrcode_link', fields) @override_settings(OTP_ADMIN_HIDE_SENSITIVE_DATA=False) def test_sensitive_information_shown_while_changing_device(self): fields = self._get_fields(device=self.device) self.assertIn('key', fields) self.assertIn('qrcode_link', fields) @override_settings(OTP_ADMIN_HIDE_SENSITIVE_DATA=True) def test_list_display_when_sensitive_information_hidden(self): self.assertEqual( self.device_admin.get_list_display(self.get_request), ['user', 'name', 'confirmed'], ) @override_settings(OTP_ADMIN_HIDE_SENSITIVE_DATA=False) def test_list_display_when_sensitive_information_shown(self): self.assertEqual( self.device_admin.get_list_display(self.get_request), ['user', 'name', 'confirmed', 'qrcode_link'], ) @override_settings(OTP_ADMIN_HIDE_SENSITIVE_DATA=True) def test_config_view_when_sensitive_information_hidden(self): self._add_device_perms('change_hotpdevice') with self.assertRaises(PermissionDenied): self.device_admin.config_view(self.get_request, self.device.id) @override_settings(OTP_ADMIN_HIDE_SENSITIVE_DATA=False) def test_config_view_when_sensitive_information_shown(self): self._add_device_perms('change_hotpdevice') response = self.device_admin.config_view(self.get_request, self.device.id) self.assertEqual(response.status_code, 200) @override_settings(OTP_ADMIN_HIDE_SENSITIVE_DATA=True) def test_qrcode_view_when_sensitive_information_hidden(self): self._add_device_perms('change_hotpdevice') with self.assertRaises(PermissionDenied): self.device_admin.qrcode_view(self.get_request, self.device.id) @override_settings(OTP_ADMIN_HIDE_SENSITIVE_DATA=False) def test_qrcode_view_when_sensitive_information_shown(self): self._add_device_perms('change_hotpdevice') response = self.device_admin.qrcode_view(self.get_request, self.device.id) self.assertEqual(response.status_code, 200) # # Helpers # def _add_device_perms(self, *codenames): ct = ContentType.objects.get_for_model(HOTPDevice) perms = [ Permission.objects.get(content_type=ct, codename=codename) for codename in codenames ] self.admin.user_permissions.add(*perms) def _get_fields(self, device): return { field for fieldset in self.device_admin.get_fieldsets(self.get_request, obj=device) for field in fieldset[1]['fields'] } @override_settings( OTP_HOTP_THROTTLE_FACTOR=1, ) class ThrottlingTestCase(HOTPDeviceMixin, ThrottlingTestMixin, TestCase): def valid_token(self): return self.tokens[0] def invalid_token(self): return -1 django-otp-1.1.3/src/django_otp/plugins/otp_static/000077500000000000000000000000001415146402700223275ustar00rootroot00000000000000django-otp-1.1.3/src/django_otp/plugins/otp_static/__init__.py000066400000000000000000000001701415146402700244360ustar00rootroot00000000000000import django if django.VERSION < (3, 2): default_app_config = 'django_otp.plugins.otp_static.apps.DefaultConfig' django-otp-1.1.3/src/django_otp/plugins/otp_static/admin.py000066400000000000000000000017441415146402700237770ustar00rootroot00000000000000from django.contrib import admin from django.contrib.admin.sites import AlreadyRegistered from django_otp.conf import settings from .models import StaticDevice, StaticToken class StaticTokenInline(admin.TabularInline): model = StaticToken extra = 0 class StaticDeviceAdmin(admin.ModelAdmin): """ :class:`~django.contrib.admin.ModelAdmin` for :class:`~django_otp.plugins.otp_static.models.StaticDevice`. """ fieldsets = [ ('Identity', { 'fields': ['user', 'name', 'confirmed'], }), ] raw_id_fields = ['user'] inlines = [ StaticTokenInline, ] def get_inline_instances(self, request, obj=None): if settings.OTP_ADMIN_HIDE_SENSITIVE_DATA and obj: return [] return super().get_inline_instances(request, obj) # Somehow this is getting imported twice, triggering a useless exception. try: admin.site.register(StaticDevice, StaticDeviceAdmin) except AlreadyRegistered: pass django-otp-1.1.3/src/django_otp/plugins/otp_static/apps.py000066400000000000000000000002451415146402700236450ustar00rootroot00000000000000from django.apps import AppConfig class DefaultConfig(AppConfig): name = 'django_otp.plugins.otp_static' default_auto_field = 'django.db.models.AutoField' django-otp-1.1.3/src/django_otp/plugins/otp_static/lib.py000066400000000000000000000012611415146402700234470ustar00rootroot00000000000000from django.contrib.auth import get_user_model from .models import StaticDevice, StaticToken def add_static_token(username, token=None): """ Adds a random static token to the identified user. This is the implementation for the management command of a similar name. Returns the StaticToken object created. """ user = get_user_model().objects.get_by_natural_key(username) device = next(StaticDevice.objects.filter(user=user).iterator(), None) if device is None: device = StaticDevice.objects.create(user=user, name='Backup Code') if token is None: token = StaticToken.random_token() return device.token_set.create(token=token) django-otp-1.1.3/src/django_otp/plugins/otp_static/management/000077500000000000000000000000001415146402700244435ustar00rootroot00000000000000django-otp-1.1.3/src/django_otp/plugins/otp_static/management/__init__.py000066400000000000000000000000001415146402700265420ustar00rootroot00000000000000django-otp-1.1.3/src/django_otp/plugins/otp_static/management/commands/000077500000000000000000000000001415146402700262445ustar00rootroot00000000000000django-otp-1.1.3/src/django_otp/plugins/otp_static/management/commands/__init__.py000066400000000000000000000000001415146402700303430ustar00rootroot00000000000000django-otp-1.1.3/src/django_otp/plugins/otp_static/management/commands/addstatictoken.py000066400000000000000000000020661415146402700316230ustar00rootroot00000000000000from textwrap import fill from django.core.management.base import BaseCommand, CommandError from django.utils.encoding import force_str from django_otp.plugins.otp_static.lib import add_static_token, get_user_model class Command(BaseCommand): help = fill('Adds a single static OTP token to the given user. ' 'The token will be added to an arbitrary static device ' 'attached to the user, creating one if necessary.', width=78) def add_arguments(self, parser): parser.add_argument('-t', '--token', dest='token', help='The token to add. If omitted, one will be randomly generated.') parser.add_argument('username', help='The user to which the token will be assigned.') def handle(self, *args, **options): username = options['username'] try: statictoken = add_static_token(username, options.get('token')) except get_user_model().DoesNotExist: raise CommandError('User "{0}" does not exist.'.format(username)) self.stdout.write(force_str(statictoken.token)) django-otp-1.1.3/src/django_otp/plugins/otp_static/migrations/000077500000000000000000000000001415146402700245035ustar00rootroot00000000000000django-otp-1.1.3/src/django_otp/plugins/otp_static/migrations/0001_initial.py000066400000000000000000000026441415146402700271540ustar00rootroot00000000000000from django.conf import settings from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( name='StaticDevice', 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?')), ('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,), ), migrations.CreateModel( name='StaticToken', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('token', models.CharField(max_length=16, db_index=True)), ('device', models.ForeignKey(related_name='token_set', to='otp_static.StaticDevice', on_delete=models.CASCADE)), ], options={ }, bases=(models.Model,), ), ] django-otp-1.1.3/src/django_otp/plugins/otp_static/migrations/0002_throttling.py000066400000000000000000000013711415146402700277160ustar00rootroot00000000000000# Generated by Django 3.0.5 on 2020-04-16 13:41 from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ ('otp_static', '0001_initial'), ] operations = [ migrations.AddField( model_name='staticdevice', name='throttling_failure_count', field=models.PositiveIntegerField(default=0, help_text='Number of successive failed attempts.'), ), migrations.AddField( model_name='staticdevice', 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-1.1.3/src/django_otp/plugins/otp_static/migrations/__init__.py000066400000000000000000000000001415146402700266020ustar00rootroot00000000000000django-otp-1.1.3/src/django_otp/plugins/otp_static/models.py000066400000000000000000000036161415146402700241720ustar00rootroot00000000000000from base64 import b32encode from os import urandom from django.conf import settings from django.db import models from django_otp.models import Device, ThrottlingMixin class StaticDevice(ThrottlingMixin, Device): """ A static :class:`~django_otp.models.Device` simply consists of random tokens shared by the database and the user. These are frequently used as emergency tokens in case a user's normal device is lost or unavailable. They can be consumed in any order; each token will be removed from the database as soon as it is used. This model has no fields of its own, but serves as a container for :class:`StaticToken` objects. .. attribute:: token_set The RelatedManager for our tokens. """ def get_throttle_factor(self): return getattr(settings, 'OTP_STATIC_THROTTLE_FACTOR', 1) def verify_token(self, token): verify_allowed, _ = self.verify_is_allowed() if verify_allowed: match = self.token_set.filter(token=token).first() if match is not None: match.delete() self.throttle_reset() else: self.throttle_increment() else: match = None return (match is not None) class StaticToken(models.Model): """ A single token belonging to a :class:`StaticDevice`. .. attribute:: device *ForeignKey*: A foreign key to :class:`StaticDevice`. .. attribute:: token *CharField*: A random string up to 16 characters. """ device = models.ForeignKey(StaticDevice, related_name='token_set', on_delete=models.CASCADE) token = models.CharField(max_length=16, db_index=True) @staticmethod def random_token(): """ Returns a new random string that can be used as a static token. :rtype: bytes """ return b32encode(urandom(5)).decode('utf-8').lower() django-otp-1.1.3/src/django_otp/plugins/otp_static/tests.py000066400000000000000000000174721415146402700240560ustar00rootroot00000000000000from django.contrib.admin import AdminSite from django.contrib.auth.models import Permission from django.contrib.contenttypes.models import ContentType from django.db import IntegrityError from django.test import RequestFactory from django.test.utils import override_settings from django_otp.forms import OTPAuthenticationForm from django_otp.tests import TestCase, ThrottlingTestMixin from .admin import StaticDeviceAdmin, StaticTokenInline from .lib import add_static_token from .models import StaticDevice, StaticToken class DeviceTest(TestCase): """ A few generic tests to get us started. """ def setUp(self): try: self.user = self.create_user('alice', 'password') except Exception: self.skipTest("Unable to create the test user.") def test_str(self): device = StaticDevice.objects.create(user=self.user, name="Device") str(device) def test_str_unpopulated(self): device = StaticDevice() str(device) class LibTest(TestCase): """ Test miscellaneous library functions. """ def setUp(self): try: self.user = self.create_user('alice', 'password') except Exception: self.skipTest("Unable to create the test user.") def test_add_static_token(self): statictoken = add_static_token('alice') self.assertEqual(statictoken.device.user, self.user) self.assertEqual(self.user.staticdevice_set.count(), 1) def test_add_static_token_existing_device(self): self.user.staticdevice_set.create(name='Test') statictoken = add_static_token('alice') self.assertEqual(statictoken.device.user, self.user) self.assertEqual(self.user.staticdevice_set.count(), 1) self.assertEqual(statictoken.device.name, 'Test') def test_add_static_token_no_user(self): with self.assertRaises(self.User.DoesNotExist): add_static_token('bogus') def test_add_static_token_specific(self): statictoken = add_static_token('alice', 'token') self.assertEqual(statictoken.token, 'token') class AuthFormTest(TestCase): """ Test the auth form with static tokens. We try to honor custom user models, but if we can't create users, we'll skip the tests. """ def setUp(self): for device_id, username in enumerate(['alice', 'bob']): try: user = self.create_user(username, 'password') except IntegrityError: self.skipTest("Unable to create a test user.") else: device = user.staticdevice_set.create(id=device_id + 1) device.token_set.create(token=username + '1') device.token_set.create(token=username + '1') device.token_set.create(token=username + '2') def test_empty(self): data = {} form = OTPAuthenticationForm(None, data) self.assertFalse(form.is_valid()) self.assertEqual(form.get_user(), None) def test_bad_password(self): data = { 'username': 'alice', 'password': 'bogus', } form = OTPAuthenticationForm(None, data) self.assertFalse(form.is_valid()) self.assertEqual(list(form.errors.keys()), ['__all__']) def test_no_token(self): data = { 'username': 'alice', 'password': 'password', } form = OTPAuthenticationForm(None, data) self.assertFalse(form.is_valid()) self.assertEqual(form.get_user().get_username(), 'alice') def test_passive_token(self): data = { 'username': 'alice', 'password': 'password', 'otp_token': 'alice1', } form = OTPAuthenticationForm(None, data) self.assertTrue(form.is_valid()) alice = form.get_user() self.assertEqual(alice.get_username(), 'alice') self.assertIsInstance(alice.otp_device, StaticDevice) self.assertEqual(alice.otp_device.token_set.count(), 2) def test_spoofed_device(self): data = { 'username': 'alice', 'password': 'password', 'otp_device': 'otp_static.staticdevice/2', 'otp_token': 'bob1', } form = OTPAuthenticationForm(None, data) self.assertFalse(form.is_valid()) alice = form.get_user() self.assertEqual(alice.get_username(), 'alice') self.assertIsNone(alice.otp_device) def test_specific_device_fail(self): data = { 'username': 'alice', 'password': 'password', 'otp_device': 'otp_email.staticdevice/1', 'otp_token': 'bogus', } form = OTPAuthenticationForm(None, data) self.assertFalse(form.is_valid()) alice = form.get_user() self.assertEqual(alice.get_username(), 'alice') self.assertIsNone(alice.otp_device) def test_specific_device(self): data = { 'username': 'alice', 'password': 'password', 'otp_device': 'otp_static.staticdevice/1', 'otp_token': 'alice1', } form = OTPAuthenticationForm(None, data) self.assertTrue(form.is_valid()) alice = form.get_user() self.assertEqual(alice.get_username(), 'alice') self.assertIsNotNone(alice.otp_device) @override_settings( OTP_STATIC_THROTTLE_FACTOR=1, ) class ThrottlingTestCase(ThrottlingTestMixin, TestCase): def setUp(self): try: user = self.create_user('alice', 'password') except IntegrityError: self.skipTest("Unable to create a test user.") else: self.device = user.staticdevice_set.create() self.device.token_set.create(token='valid1') self.device.token_set.create(token='valid2') self.device.token_set.create(token='valid3') def valid_token(self): return self.device.token_set.first().token def invalid_token(self): return 'bogus' class StaticDeviceAdminTest(TestCase): def setUp(self): 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.staticdevice_set.create() self.device_admin = StaticDeviceAdmin(StaticDevice, AdminSite()) self.get_request = RequestFactory().get('/') self.get_request.user = self.admin @override_settings(OTP_ADMIN_HIDE_SENSITIVE_DATA=True) def test_inline_instances_when_sensitive_information_hidden(self): self._add_device_perms('change_statictoken') instances = self.device_admin.get_inline_instances(self.get_request, obj=None) self.assertIsInstance(instances, list) self.assertEqual(len(instances), 1) self.assertIsInstance(instances[0], StaticTokenInline) instances = self.device_admin.get_inline_instances(self.get_request, obj=self.device) self.assertEqual(instances, []) @override_settings(OTP_ADMIN_HIDE_SENSITIVE_DATA=False) def test_inline_instances_when_sensitive_information_shown(self): self._add_device_perms('change_statictoken') for obj in (None, self.device): instances = self.device_admin.get_inline_instances(self.get_request, obj=obj) self.assertIsInstance(instances, list) self.assertEqual(len(instances), 1) # # Helpers # def _add_device_perms(self, *codenames): ct = ContentType.objects.get_for_models(StaticDevice, StaticToken) perms = [ Permission.objects.get(content_type__in=ct.values(), codename=codename) for codename in codenames ] self.admin.user_permissions.add(*perms) django-otp-1.1.3/src/django_otp/plugins/otp_totp/000077500000000000000000000000001415146402700220265ustar00rootroot00000000000000django-otp-1.1.3/src/django_otp/plugins/otp_totp/__init__.py000066400000000000000000000001661415146402700241420ustar00rootroot00000000000000import django if django.VERSION < (3, 2): default_app_config = 'django_otp.plugins.otp_totp.apps.DefaultConfig' django-otp-1.1.3/src/django_otp/plugins/otp_totp/admin.py000066400000000000000000000102231415146402700234660ustar00rootroot00000000000000from django.contrib import admin from django.contrib.admin.sites import AlreadyRegistered 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.conf import settings from .models import TOTPDevice class TOTPDeviceAdmin(admin.ModelAdmin): """ :class:`~django.contrib.admin.ModelAdmin` for :class:`~django_otp.plugins.otp_totp.models.TOTPDevice`. """ list_display = ['user', 'name', 'confirmed'] raw_id_fields = ['user'] readonly_fields = ['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 = ['step', 't0', 'digits', 'tolerance'] else: configuration_fields = ['key', 'step', 't0', 'digits', 'tolerance'] fieldsets = [ ('Identity', { 'fields': ['user', 'name', 'confirmed'], }), ('Configuration', { 'fields': configuration_fields, }), ('State', { 'fields': ['drift'], }), ('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 # def qrcode_link(self, device): try: href = reverse('admin:otp_totp_totpdevice_config', kwargs={'pk': device.pk}) link = format_html('qrcode', href) except Exception: link = '' return link qrcode_link.short_description = "QR Code" # # Custom views # def get_urls(self): urls = [ path('/config/', self.admin_site.admin_view(self.config_view), name='otp_totp_totpdevice_config'), path('/qrcode/', self.admin_site.admin_view(self.qrcode_view), name='otp_totp_totpdevice_qrcode'), ] + super().get_urls() return urls def config_view(self, request, pk): if settings.OTP_ADMIN_HIDE_SENSITIVE_DATA: raise PermissionDenied() device = TOTPDevice.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_totp/admin/config.html', context) def qrcode_view(self, request, pk): if settings.OTP_ADMIN_HIDE_SENSITIVE_DATA: raise PermissionDenied() device = TOTPDevice.objects.get(pk=pk) if not self.has_view_or_change_permission(request, device): raise PermissionDenied() try: import qrcode import qrcode.image.svg img = qrcode.make(device.config_url, image_factory=qrcode.image.svg.SvgImage) response = HttpResponse(content_type='image/svg+xml') img.save(response) except ImportError: response = HttpResponse('', status=503) return response try: admin.site.register(TOTPDevice, TOTPDeviceAdmin) except AlreadyRegistered: # A useless exception from a double import pass django-otp-1.1.3/src/django_otp/plugins/otp_totp/apps.py000066400000000000000000000002431415146402700233420ustar00rootroot00000000000000from django.apps import AppConfig class DefaultConfig(AppConfig): name = 'django_otp.plugins.otp_totp' default_auto_field = 'django.db.models.AutoField' django-otp-1.1.3/src/django_otp/plugins/otp_totp/migrations/000077500000000000000000000000001415146402700242025ustar00rootroot00000000000000django-otp-1.1.3/src/django_otp/plugins/otp_totp/migrations/0001_initial.py000066400000000000000000000040761415146402700266540ustar00rootroot00000000000000from django.conf import settings from django.db import migrations, models import django_otp.plugins.otp_totp.models class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( name='TOTPDevice', 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_totp.models.default_key, help_text='A hex-encoded secret key of up to 40 bytes.', max_length=80, validators=[django_otp.plugins.otp_totp.models.key_validator])), ('step', models.PositiveSmallIntegerField(default=30, help_text='The time step in seconds.')), ('t0', models.BigIntegerField(default=0, help_text='The Unix time at which to begin counting steps.')), ('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=1, help_text='The number of time steps in the past or future to allow.')), ('drift', models.SmallIntegerField(default=0, help_text='The number of time steps the prover is known to deviate from our clock.')), ('last_t', models.BigIntegerField(default=-1, help_text='The t value of the latest verified token. The next token must be at a higher time step.')), ('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': 'TOTP device', }, bases=(models.Model,), ), ] django-otp-1.1.3/src/django_otp/plugins/otp_totp/migrations/0002_auto_20190420_0723.py000066400000000000000000000013611415146402700276220ustar00rootroot00000000000000# Generated by Django 2.2 on 2019-04-20 12:23 from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ ('otp_totp', '0001_initial'), ] operations = [ migrations.AddField( model_name='totpdevice', name='throttling_failure_count', field=models.PositiveIntegerField(default=0, help_text='Number of successive failed attempts.'), ), migrations.AddField( model_name='totpdevice', 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-1.1.3/src/django_otp/plugins/otp_totp/migrations/__init__.py000066400000000000000000000000001415146402700263010ustar00rootroot00000000000000django-otp-1.1.3/src/django_otp/plugins/otp_totp/models.py000066400000000000000000000120711415146402700236640ustar00rootroot00000000000000from base64 import b32encode from binascii import unhexlify import time from urllib.parse import quote, urlencode from django.conf import settings from django.db import models from django_otp.models import Device, ThrottlingMixin from django_otp.oath import TOTP 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 TOTPDevice(ThrottlingMixin, Device): """ A generic TOTP :class:`~django_otp.models.Device`. The model fields mostly correspond to the arguments to :func:`django_otp.oath.totp`. 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:: step *PositiveSmallIntegerField*: The time step in seconds. (Default: 30) .. attribute:: t0 *BigIntegerField*: The Unix time at which to begin counting steps. (Default: 0) .. attribute:: digits *PositiveSmallIntegerField*: The number of digits to expect in a token (6 or 8). (Default: 6) .. attribute:: tolerance *PositiveSmallIntegerField*: The number of time steps in the past or future to allow. For example, if this is 1, we'll accept any of three tokens: the current one, the previous one, and the next one. (Default: 1) .. attribute:: drift *SmallIntegerField*: The number of time steps the prover is known to deviate from our clock. If :setting:`OTP_TOTP_SYNC` is ``True``, we'll update this any time we match a token that is not the current one. (Default: 0) .. attribute:: last_t *BigIntegerField*: The time step of the last verified token. To avoid verifying the same token twice, this will be updated on each successful verification. Only tokens at a higher time step will be verified subsequently. (Default: -1) """ key = models.CharField(max_length=80, validators=[key_validator], default=default_key, help_text="A hex-encoded secret key of up to 40 bytes.") step = models.PositiveSmallIntegerField(default=30, help_text="The time step in seconds.") t0 = models.BigIntegerField(default=0, help_text="The Unix time at which to begin counting steps.") 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=1, help_text="The number of time steps in the past or future to allow.") drift = models.SmallIntegerField(default=0, help_text="The number of time steps the prover is known to deviate from our clock.") last_t = models.BigIntegerField(default=-1, help_text="The t value of the latest verified token. The next token must be at a higher time step.") class Meta(Device.Meta): verbose_name = "TOTP device" @property def bin_key(self): """ The secret key as a binary string. """ return unhexlify(self.key.encode()) def verify_token(self, token): OTP_TOTP_SYNC = getattr(settings, 'OTP_TOTP_SYNC', True) 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 totp = TOTP(key, self.step, self.t0, self.digits, self.drift) totp.time = time.time() verified = totp.verify(token, self.tolerance, self.last_t + 1) if verified: self.last_t = totp.t() if OTP_TOTP_SYNC: self.drift = totp.drift self.throttle_reset(commit=False) self.save() if not verified: self.throttle_increment(commit=True) return verified def get_throttle_factor(self): return getattr(settings, 'OTP_TOTP_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_TOTP_ISSUER`, if available. """ label = str(self.user.get_username()) params = { 'secret': b32encode(self.bin_key), 'algorithm': 'SHA1', 'digits': self.digits, 'period': self.step, } urlencoded_params = urlencode(params) issuer = getattr(settings, 'OTP_TOTP_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://totp/{}?{}'.format(quote(label), urlencoded_params) return url django-otp-1.1.3/src/django_otp/plugins/otp_totp/templates/000077500000000000000000000000001415146402700240245ustar00rootroot00000000000000django-otp-1.1.3/src/django_otp/plugins/otp_totp/templates/otp_totp/000077500000000000000000000000001415146402700256745ustar00rootroot00000000000000django-otp-1.1.3/src/django_otp/plugins/otp_totp/templates/otp_totp/admin/000077500000000000000000000000001415146402700267645ustar00rootroot00000000000000django-otp-1.1.3/src/django_otp/plugins/otp_totp/templates/otp_totp/admin/config.html000066400000000000000000000011371415146402700311210ustar00rootroot00000000000000{% extends "admin/base_site.html" %} {% block content %}

{{ device.config_url }}

{% endblock %} django-otp-1.1.3/src/django_otp/plugins/otp_totp/tests.py000066400000000000000000000252231415146402700235460ustar00rootroot00000000000000from time import time from urllib.parse import parse_qs, urlsplit 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.tests import TestCase, ThrottlingTestMixin from .admin import TOTPDeviceAdmin from .models import TOTPDevice class TOTPDeviceMixin: """ A TestCase helper that gives us a TOTPDevice to work with. """ # The next ten tokens tokens = [179225, 656163, 839400, 154567, 346912, 471576, 45675, 101397, 491039, 784503] def setUp(self): """ Create a device at the fourth time step. The current token is 154567. """ try: self.alice = self.create_user( 'alice', 'password', email='alice@example.com') except IntegrityError: self.skipTest("Unable to create the test user.") else: self.device = self.alice.totpdevice_set.create( key='2a2bbba1092ffdd25a328ad1a0a5f5d61d7aacc4', step=30, t0=int(time() - (30 * 3)), digits=6, tolerance=0, drift=0 ) @override_settings( OTP_TOTP_SYNC=False, OTP_TOTP_THROTTLE_FACTOR=0, ) class TOTPTest(TOTPDeviceMixin, TestCase): def test_default_key(self): device = self.alice.totpdevice_set.create() # Make sure we can decode the key. device.bin_key def test_single(self): results = [self.device.verify_token(token) for token in self.tokens] self.assertEqual(results, [False] * 3 + [True] + [False] * 6) def test_tolerance(self): self.device.tolerance = 1 results = [self.device.verify_token(token) for token in self.tokens] self.assertEqual(results, [False] * 2 + [True] * 3 + [False] * 5) def test_drift(self): self.device.tolerance = 1 self.device.drift = -1 results = [self.device.verify_token(token) for token in self.tokens] self.assertEqual(results, [False] * 1 + [True] * 3 + [False] * 6) def test_sync_drift(self): self.device.tolerance = 2 with self.settings(OTP_TOTP_SYNC=True): ok = self.device.verify_token(self.tokens[5]) self.assertTrue(ok) self.assertEqual(self.device.drift, 2) def test_no_reuse(self): verified1 = self.device.verify_token(self.tokens[3]) verified2 = self.device.verify_token(self.tokens[3]) self.assertEqual(self.device.last_t, 3) self.assertTrue(verified1) self.assertFalse(verified2) def test_config_url(self): with override_settings(OTP_TOTP_ISSUER=None): url = self.device.config_url parsed = urlsplit(url) params = parse_qs(parsed.query) self.assertEqual(parsed.scheme, 'otpauth') self.assertEqual(parsed.netloc, 'totp') self.assertEqual(parsed.path, '/alice') self.assertIn('secret', params) self.assertNotIn('issuer', params) def test_config_url_issuer(self): with override_settings(OTP_TOTP_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, 'totp') 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_TOTP_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, 'totp') 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_TOTP_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, 'totp') 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 TOTPAdminTest(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 the test user.") else: self.device = self.admin.totpdevice_set.create( key='2a2bbba1092ffdd25a328ad1a0a5f5d61d7aacc4', step=30, t0=int(time() - (30 * 3)), digits=6, tolerance=0, drift=0 ) self.device_admin = TOTPDeviceAdmin(TOTPDevice, 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_totp_totpdevice_' + 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_totp_totpdevice_' + 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_totpdevice') self.client.login(username='admin', password='password') for suffix in ['config', 'qrcode']: with self.subTest(view=suffix): url = reverse('admin:otp_totp_totpdevice_' + 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_totpdevice') self.client.login(username='admin', password='password') for suffix in ['config', 'qrcode']: with self.subTest(view=suffix): url = reverse('admin:otp_totp_totpdevice_' + suffix, kwargs={'pk': self.device.pk}) response = self.client.get(url) self.assertEqual(response.status_code, 200) @override_settings(OTP_ADMIN_HIDE_SENSITIVE_DATA=True) def test_sensitive_information_hidden_while_adding_device(self): fields = self._get_fields(device=None) self.assertIn('key', fields) self.assertNotIn('qrcode_link', fields) @override_settings(OTP_ADMIN_HIDE_SENSITIVE_DATA=True) def test_sensitive_information_hidden_while_changing_device(self): fields = self._get_fields(device=self.device) self.assertNotIn('key', fields) self.assertNotIn('qrcode_link', fields) @override_settings(OTP_ADMIN_HIDE_SENSITIVE_DATA=False) def test_sensitive_information_shown_while_adding_device(self): fields = self._get_fields(device=None) self.assertIn('key', fields) self.assertNotIn('qrcode_link', fields) @override_settings(OTP_ADMIN_HIDE_SENSITIVE_DATA=False) def test_sensitive_information_shown_while_changing_device(self): fields = self._get_fields(device=self.device) self.assertIn('key', fields) self.assertIn('qrcode_link', fields) @override_settings(OTP_ADMIN_HIDE_SENSITIVE_DATA=True) def test_list_display_when_sensitive_information_hidden(self): self.assertEqual( self.device_admin.get_list_display(self.get_request), ['user', 'name', 'confirmed'], ) @override_settings(OTP_ADMIN_HIDE_SENSITIVE_DATA=False) def test_list_display_when_sensitive_information_shown(self): self.assertEqual( self.device_admin.get_list_display(self.get_request), ['user', 'name', 'confirmed', 'qrcode_link'], ) @override_settings(OTP_ADMIN_HIDE_SENSITIVE_DATA=True) def test_config_view_when_sensitive_information_hidden(self): self._add_device_perms('change_totpdevice') with self.assertRaises(PermissionDenied): self.device_admin.config_view(self.get_request, self.device.id) @override_settings(OTP_ADMIN_HIDE_SENSITIVE_DATA=False) def test_config_view_when_sensitive_information_shown(self): self._add_device_perms('change_totpdevice') response = self.device_admin.config_view(self.get_request, self.device.id) self.assertEqual(response.status_code, 200) @override_settings(OTP_ADMIN_HIDE_SENSITIVE_DATA=True) def test_qrcode_view_when_sensitive_information_hidden(self): self._add_device_perms('change_totpdevice') with self.assertRaises(PermissionDenied): self.device_admin.qrcode_view(self.get_request, self.device.id) @override_settings(OTP_ADMIN_HIDE_SENSITIVE_DATA=False) def test_qrcode_view_when_sensitive_information_shown(self): self._add_device_perms('change_totpdevice') response = self.device_admin.qrcode_view(self.get_request, self.device.id) self.assertEqual(response.status_code, 200) # # Helpers # def _add_device_perms(self, *codenames): ct = ContentType.objects.get_for_model(TOTPDevice) perms = [ Permission.objects.get(content_type=ct, codename=codename) for codename in codenames ] self.admin.user_permissions.add(*perms) def _get_fields(self, device): return { field for fieldset in self.device_admin.get_fieldsets(self.get_request, obj=device) for field in fieldset[1]['fields'] } @override_settings( OTP_TOTP_THROTTLE_FACTOR=1, ) class ThrottlingTestCase(TOTPDeviceMixin, ThrottlingTestMixin, TestCase): def valid_token(self): return self.tokens[3] def invalid_token(self): return -1 django-otp-1.1.3/src/django_otp/templates/000077500000000000000000000000001415146402700204735ustar00rootroot00000000000000django-otp-1.1.3/src/django_otp/templates/otp/000077500000000000000000000000001415146402700212755ustar00rootroot00000000000000django-otp-1.1.3/src/django_otp/templates/otp/admin111/000077500000000000000000000000001415146402700226105ustar00rootroot00000000000000django-otp-1.1.3/src/django_otp/templates/otp/admin111/login.html000066400000000000000000000054471415146402700246200ustar00rootroot00000000000000{% extends "admin/base_site.html" %} {% load i18n static %} {% block extrastyle %} {{ block.super }} {{ form.media }} {% endblock %} {% block bodyclass %}{{ block.super }} login{% endblock %} {% block usertools %}{% endblock %} {% block nav-global %}{% endblock %} {% block content_title %}{% endblock %} {% block breadcrumbs %}{% endblock %} {% block nav-sidebar %}{% endblock %} {% block content %} {% if form.errors and not form.non_field_errors %}

{% if form.errors.items|length == 1 %}{% trans "Please correct the error below." %}{% else %}{% trans "Please correct the errors below." %}{% endif %}

{% endif %} {% if form.non_field_errors %} {% for error in form.non_field_errors %}

{{ error }}

{% endfor %} {% endif %}
{% if user.is_authenticated %}

{% blocktrans trimmed %} You are authenticated as {{ username }}, but are not authorized to access this page. Would you like to login to a different account? {% endblocktrans %}

{% endif %}
{% csrf_token %}
{{ 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 }}
{% endif %}
{{ form.otp_token.errors }} {{ form.otp_token }}
{% url 'admin_password_reset' as password_reset_url %} {% if password_reset_url %} {% endif %}
{% if form.get_user %} {% endif %}
{% endblock %} django-otp-1.1.3/src/django_otp/tests.py000066400000000000000000000354251415146402700202220ustar00rootroot00000000000000from datetime import timedelta from doctest import DocTestSuite import pickle from threading import Thread import unittest from freezegun import freeze_time from django.contrib.auth import get_user_model from django.contrib.auth.models import AnonymousUser from django.db import IntegrityError, connection from django.test import RequestFactory from django.test import TestCase as DjangoTestCase from django.test import TransactionTestCase as DjangoTransactionTestCase from django.test import skipUnlessDBFeature from django.test.utils import override_settings from django.urls import reverse from django.utils import timezone from django_otp import DEVICE_ID_SESSION_KEY, match_token, oath, user_has_device, util, verify_token from django_otp.forms import OTPTokenForm from django_otp.middleware import OTPMiddleware from django_otp.models import VerifyNotAllowed from django_otp.plugins.otp_static.models import StaticDevice def load_tests(loader, tests, pattern): suite = unittest.TestSuite() suite.addTests(tests) suite.addTest(DocTestSuite(util)) suite.addTest(DocTestSuite(oath)) return suite class TestThread(Thread): "Django testing quirk: threads have to close their DB connections." def run(self): super().run() connection.close() class OTPTestCaseMixin: """ Utilities for dealing with custom user models. """ @classmethod def setUpClass(cls): super().setUpClass() cls.User = get_user_model() cls.USERNAME_FIELD = cls.User.USERNAME_FIELD def create_user(self, username, password, **kwargs): """ Try to create a user, honoring the custom user model, if any. This may raise an exception if the user model is too exotic for our purposes. """ return self.User.objects.create_user(username, password=password, **kwargs) class TestCase(OTPTestCaseMixin, DjangoTestCase): pass class TransactionTestCase(OTPTestCaseMixin, DjangoTransactionTestCase): pass class ThrottlingTestMixin: """ Generic tests for throttled devices. Any concrete device implementation that uses throttling should define a TestCase subclass that includes this as a base class. This will help verify a correct integration of ThrottlingMixin. Subclasses are responsible for populating self.device with a device to test as well as implementing methods to generate tokens to test with. """ def setUp(self): self.device = None def valid_token(self): """ Returns a valid token to pass to our device under test. """ raise NotImplementedError() def invalid_token(self): """ Returns an invalid token to pass to our device under test. """ raise NotImplementedError() # # Tests # def test_delay_imposed_after_fail(self): verified1 = self.device.verify_token(self.invalid_token()) self.assertFalse(verified1) verified2 = self.device.verify_token(self.valid_token()) self.assertFalse(verified2) def test_delay_after_fail_expires(self): verified1 = self.device.verify_token(self.invalid_token()) self.assertFalse(verified1) with freeze_time() as frozen_time: # With default settings initial delay is 1 second frozen_time.tick(delta=timedelta(seconds=1.1)) verified2 = self.device.verify_token(self.valid_token()) self.assertTrue(verified2) def test_throttling_failure_count(self): self.assertEqual(self.device.throttling_failure_count, 0) for i in range(0, 5): self.device.verify_token(self.invalid_token()) # Only the first attempt will increase throttling_failure_count, # the others will all be within 1 second of first # and therefore not count as attempts. self.assertEqual(self.device.throttling_failure_count, 1) def test_verify_is_allowed(self): # Initially should be allowed verify_is_allowed1, data1 = self.device.verify_is_allowed() self.assertEqual(verify_is_allowed1, True) self.assertEqual(data1, None) # After failure, verify is not allowed with freeze_time(): self.device.verify_token(self.invalid_token()) verify_is_allowed2, data2 = self.device.verify_is_allowed() self.assertEqual(verify_is_allowed2, False) self.assertEqual(data2, {'reason': VerifyNotAllowed.N_FAILED_ATTEMPTS, 'failure_count': 1, 'locked_until': timezone.now() + timezone.timedelta(seconds=1) }) # After a successful attempt, should be allowed again with freeze_time() as frozen_time: frozen_time.tick(delta=timedelta(seconds=1.1)) self.device.verify_token(self.valid_token()) verify_is_allowed3, data3 = self.device.verify_is_allowed() self.assertEqual(verify_is_allowed3, True) self.assertEqual(data3, None) @override_settings(OTP_STATIC_THROTTLE_FACTOR=0) class APITestCase(TestCase): def setUp(self): try: self.alice = self.create_user('alice', 'password') self.bob = self.create_user('bob', 'password') except IntegrityError: self.skipTest("Unable to create a test user.") else: device = self.alice.staticdevice_set.create() device.token_set.create(token='alice') def test_user_has_device(self): with self.subTest(user='anonymous'): self.assertFalse(user_has_device(AnonymousUser())) with self.subTest(user='alice'): self.assertTrue(user_has_device(self.alice)) with self.subTest(user='bob'): self.assertFalse(user_has_device(self.bob)) def test_verify_token(self): device = self.alice.staticdevice_set.first() verified = verify_token(self.alice, device.persistent_id, 'bogus') self.assertIsNone(verified) verified = verify_token(self.alice, device.persistent_id, 'alice') self.assertIsNotNone(verified) def test_match_token(self): verified = match_token(self.alice, 'bogus') self.assertIsNone(verified) verified = match_token(self.alice, 'alice') self.assertEqual(verified, self.alice.staticdevice_set.first()) class OTPMiddlewareTestCase(TestCase): def setUp(self): self.factory = RequestFactory() try: self.alice = self.create_user('alice', 'password') self.bob = self.create_user('bob', 'password') except IntegrityError: self.skipTest("Unable to create a test user.") else: for user in [self.alice, self.bob]: device = user.staticdevice_set.create() device.token_set.create(token=user.get_username()) self.middleware = OTPMiddleware(lambda r: None) def test_verified(self): request = self.factory.get('/') request.user = self.alice device = self.alice.staticdevice_set.get() request.session = { DEVICE_ID_SESSION_KEY: device.persistent_id } self.middleware(request) self.assertTrue(request.user.is_verified()) def test_verified_legacy_device_id(self): request = self.factory.get('/') request.user = self.alice device = self.alice.staticdevice_set.get() request.session = { DEVICE_ID_SESSION_KEY: '{}.{}/{}'.format( device.__module__, device.__class__.__name__, device.id ) } self.middleware(request) self.assertTrue(request.user.is_verified()) def test_unverified(self): request = self.factory.get('/') request.user = self.alice request.session = {} self.middleware(request) self.assertFalse(request.user.is_verified()) def test_no_device(self): request = self.factory.get('/') request.user = self.alice request.session = { DEVICE_ID_SESSION_KEY: 'otp_static.staticdevice/0', } self.middleware(request) self.assertFalse(request.user.is_verified()) def test_no_model(self): request = self.factory.get('/') request.user = self.alice request.session = { DEVICE_ID_SESSION_KEY: 'otp_bogus.bogusdevice/0', } self.middleware(request) self.assertFalse(request.user.is_verified()) def test_wrong_user(self): request = self.factory.get('/') request.user = self.alice device = self.bob.staticdevice_set.get() request.session = { DEVICE_ID_SESSION_KEY: device.persistent_id } self.middleware(request) self.assertFalse(request.user.is_verified()) def test_pickling(self): request = self.factory.get('/') request.user = self.alice device = self.alice.staticdevice_set.get() request.session = { DEVICE_ID_SESSION_KEY: device.persistent_id } self.middleware(request) # Should not raise an exception. pickle.dumps(request.user) class LoginViewTestCase(TestCase): def setUp(self): try: self.alice = self.create_user('alice', 'password') self.bob = self.create_user('bob', 'password', is_staff=True) except IntegrityError: self.skipTest("Unable to create a test user.") else: for user in [self.alice, self.bob]: device = user.staticdevice_set.create() device.token_set.create(token=user.get_username()) def test_admin_login_template(self): response = self.client.get(reverse('otpadmin:login')) self.assertContains(response, 'Username:') self.assertContains(response, 'Password:') self.assertNotContains(response, 'OTP Device:') self.assertContains(response, 'OTP Token:') response = self.client.post(reverse('otpadmin:login'), data={ 'username': self.bob.get_username(), 'password': 'password', }) self.assertContains(response, 'Username:') self.assertContains(response, 'Password:') self.assertContains(response, 'OTP Device:') self.assertContains(response, 'OTP Token:') device = self.bob.staticdevice_set.get() token = device.token_set.get() response = self.client.post(reverse('otpadmin:login'), data={ 'username': self.bob.get_username(), 'password': 'password', 'otp_device': device.persistent_id, 'otp_token': token.token, 'next': '/', }) self.assertRedirects(response, '/') def test_authenticate(self): device = self.alice.staticdevice_set.get() token = device.token_set.get() params = { 'username': self.alice.get_username(), 'password': 'password', 'otp_device': device.persistent_id, 'otp_token': token.token, 'next': '/', } response = self.client.post('/login/', params) self.assertRedirects(response, '/') response = self.client.get('/') self.assertContains(response, self.alice.get_username()) def test_verify(self): device = self.alice.staticdevice_set.get() token = device.token_set.get() params = { 'otp_device': device.persistent_id, 'otp_token': token.token, 'next': '/', } self.client.login(username=self.alice.get_username(), password='password') response = self.client.post('/login/', params) self.assertRedirects(response, '/') response = self.client.get('/') self.assertContains(response, self.alice.get_username()) @skipUnlessDBFeature('has_select_for_update') @override_settings(OTP_STATIC_THROTTLE_FACTOR=0) class ConcurrencyTestCase(TransactionTestCase): def setUp(self): try: self.alice = self.create_user('alice', 'password') self.bob = self.create_user('bob', 'password') except IntegrityError: self.skipTest("Unable to create a test user.") else: for user in [self.alice, self.bob]: device = user.staticdevice_set.create() device.token_set.create(token='valid') def test_verify_token(self): class VerifyThread(Thread): def __init__(self, user, device_id, token): super().__init__() self.user = user self.device_id = device_id self.token = token self.verified = None def run(self): self.verified = verify_token(self.user, self.device_id, self.token) connection.close() device = self.alice.staticdevice_set.get() threads = [VerifyThread(device.user, device.persistent_id, 'valid') for _ in range(10)] for thread in threads: thread.start() for thread in threads: thread.join() self.assertEqual(sum(1 for t in threads if t.verified is not None), 1) def test_match_token(self): class VerifyThread(Thread): def __init__(self, user, token): super().__init__() self.user = user self.token = token self.verified = None def run(self): self.verified = match_token(self.user, self.token) connection.close() threads = [VerifyThread(self.alice, 'valid') for _ in range(10)] for thread in threads: thread.start() for thread in threads: thread.join() self.assertEqual(sum(1 for t in threads if t.verified is not None), 1) def test_concurrent_throttle_count(self): self._test_throttling_concurrency(thread_count=10, expected_failures=10) @override_settings(OTP_STATIC_THROTTLE_FACTOR=1) def test_serialized_throttling(self): # After the first failure, verification will be skipped and the count # will not be incremented. self._test_throttling_concurrency(thread_count=10, expected_failures=1) def _test_throttling_concurrency(self, thread_count, expected_failures): forms = ( OTPTokenForm(device.user, None, {'otp_device': device.persistent_id, 'otp_token': 'bogus'}) for _ in range(thread_count) for device in StaticDevice.objects.all() ) threads = [TestThread(target=form.is_valid) for form in forms] for thread in threads: thread.start() for thread in threads: thread.join() for device in StaticDevice.objects.all(): with self.subTest(user=device.user.get_username()): self.assertEqual(device.throttling_failure_count, expected_failures) django-otp-1.1.3/src/django_otp/util.py000066400000000000000000000045651415146402700200360ustar00rootroot00000000000000from binascii import unhexlify from os import urandom import random import string from django.core.exceptions import ValidationError def hex_validator(length=0): """ Returns a function to be used as a model validator for a hex-encoded CharField. This is useful for secret keys of all kinds:: def key_validator(value): return hex_validator(20)(value) key = models.CharField(max_length=40, validators=[key_validator], help_text='A hex-encoded 20-byte secret key') :param int length: If greater than 0, validation will fail unless the decoded value is exactly this number of bytes. :rtype: function >>> hex_validator()('0123456789abcdef') >>> hex_validator(8)(b'0123456789abcdef') >>> hex_validator()('phlebotinum') # doctest: +IGNORE_EXCEPTION_DETAIL Traceback (most recent call last): ... ValidationError: ['phlebotinum is not valid hex-encoded data.'] >>> hex_validator(9)('0123456789abcdef') # doctest: +IGNORE_EXCEPTION_DETAIL Traceback (most recent call last): ... ValidationError: ['0123456789abcdef does not represent exactly 9 bytes.'] """ def _validator(value): try: if isinstance(value, str): value = value.encode() unhexlify(value) except Exception: raise ValidationError('{0} is not valid hex-encoded data.'.format(value)) if (length > 0) and (len(value) != length * 2): raise ValidationError('{0} does not represent exactly {1} bytes.'.format(value, length)) return _validator def random_hex(length=20): """ Returns a string of random bytes encoded as hex. This uses :func:`os.urandom`, so it should be suitable for generating cryptographic keys. :param int length: The number of (decoded) bytes to return. :returns: A string of hex digits. :rtype: str """ return urandom(length).hex() def random_number_token(length=6): """ Returns a string of random digits encoded as string. :param int length: The number of digits to return. :returns: A string of decimal digits. :rtype: str """ rand = random.SystemRandom() if hasattr(rand, 'choices'): digits = rand.choices(string.digits, k=length) else: digits = (rand.choice(string.digits) for i in range(length)) return ''.join(digits) django-otp-1.1.3/src/django_otp/views.py000066400000000000000000000032371415146402700202110ustar00rootroot00000000000000from functools import partial from django.contrib.auth import BACKEND_SESSION_KEY from django.contrib.auth import views as auth_views from django.utils.functional import cached_property from django_otp.forms import OTPAuthenticationForm, OTPTokenForm class LoginView(auth_views.LoginView): """ This is a replacement for :class:`django.contrib.auth.views.LoginView` that requires two-factor authentication. It's slightly clever: if the user is already authenticated but not verified, it will only ask the user for their OTP token. If the user is anonymous or is already verified by an OTP device, it will use the full username/password/token form. In order to use this, you must supply a template that is compatible with both :class:`~django_otp.forms.OTPAuthenticationForm` and :class:`~django_otp.forms.OTPTokenForm`. This is a good view for :setting:`OTP_LOGIN_URL`. """ otp_authentication_form = OTPAuthenticationForm otp_token_form = OTPTokenForm @cached_property def authentication_form(self): user = self.request.user if user.is_anonymous or user.is_verified(): form = self.otp_authentication_form else: form = partial(self.otp_token_form, user) return form def form_valid(self, form): # OTPTokenForm does not call authenticate(), so we may need to populate # user.backend ourselves to keep login() happy. user = form.get_user() if not hasattr(user, 'backend'): user.backend = self.request.session[BACKEND_SESSION_KEY] return super().form_valid(form) # Backwards compatibility. login = LoginView.as_view() django-otp-1.1.3/test/000077500000000000000000000000001415146402700145415ustar00rootroot00000000000000django-otp-1.1.3/test/test_project/000077500000000000000000000000001415146402700172465ustar00rootroot00000000000000django-otp-1.1.3/test/test_project/.gitignore000066400000000000000000000000121415146402700212270ustar00rootroot00000000000000*.sqlite3 django-otp-1.1.3/test/test_project/__init__.py000066400000000000000000000000001415146402700213450ustar00rootroot00000000000000django-otp-1.1.3/test/test_project/backends.py000066400000000000000000000002031415146402700213650ustar00rootroot00000000000000class DummyBackend: def authenticate(self, request): return None def get_user(self, user_id): return None django-otp-1.1.3/test/test_project/settings.py000066400000000000000000000046521415146402700214670ustar00rootroot00000000000000# django-otp test project import os from os.path import abspath, dirname, join from django.core.exceptions import ImproperlyConfigured def project_path(path): return abspath(join(dirname(__file__), path)) DEBUG = True backend = os.getenv('DB_BACKEND', 'sqlite3') if backend == 'sqlite3': DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': project_path('db.sqlite3'), } } elif backend == 'postgresql': # SQLite lacks some features necessary for advanced concurrency tests. DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql', 'NAME': 'django_otp_test', 'USER': 'postgres', } } else: raise ImproperlyConfigured("Unrecognized value for DB_BACKEND: {}".format(backend)) INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'django_otp', 'django_otp.plugins.otp_email', 'django_otp.plugins.otp_hotp', 'django_otp.plugins.otp_static', 'django_otp.plugins.otp_totp', ] MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django_otp.middleware.OTPMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] AUTHENTICATION_BACKENDS = [ 'django.contrib.auth.backends.ModelBackend', 'test_project.backends.DummyBackend', ] TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', 'APP_DIRS': True, 'DIRS': [ project_path('templates'), ], 'OPTIONS': { 'context_processors': [ 'django.template.context_processors.debug', 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', ], }, }, ] SECRET_KEY = 'PWuluw4x48GkT7JDPzlDQsBJC8pjIIiqodW9MuMYcU315YEkGJL41i5qooJsg3Tt' ROOT_URLCONF = 'test_project.urls' STATIC_URL = '/static/' USE_TZ = True django-otp-1.1.3/test/test_project/templates/000077500000000000000000000000001415146402700212445ustar00rootroot00000000000000django-otp-1.1.3/test/test_project/templates/otp/000077500000000000000000000000001415146402700220465ustar00rootroot00000000000000django-otp-1.1.3/test/test_project/templates/otp/email/000077500000000000000000000000001415146402700231355ustar00rootroot00000000000000django-otp-1.1.3/test/test_project/templates/otp/email/custom.txt000066400000000000000000000000331415146402700252040ustar00rootroot00000000000000Test template 3: {{token}} django-otp-1.1.3/test/test_project/templates/otp/email/token.txt000066400000000000000000000000331415146402700250120ustar00rootroot00000000000000Test template 1: {{token}} django-otp-1.1.3/test/test_project/templates/registration/000077500000000000000000000000001415146402700237565ustar00rootroot00000000000000django-otp-1.1.3/test/test_project/templates/registration/logged_out.html000066400000000000000000000000001415146402700267620ustar00rootroot00000000000000django-otp-1.1.3/test/test_project/urls.py000066400000000000000000000014451415146402700206110ustar00rootroot00000000000000from django.contrib import admin import django.contrib.auth.views from django.http import HttpResponse from django.urls import path from django.views.generic.base import View import django_otp.views from django_otp.admin import OTPAdminSite otp_admin_site = OTPAdminSite(OTPAdminSite.name) for model_cls, model_admin in admin.site._registry.items(): otp_admin_site.register(model_cls, model_admin.__class__) class HomeView(View): def get(self, request, *args, **kwargs): return HttpResponse(request.user.get_username()) urlpatterns = [ path('', HomeView.as_view()), path('login/', django_otp.views.LoginView.as_view()), path('logout/', django.contrib.auth.views.LogoutView.as_view()), path('admin/', admin.site.urls), path('otpadmin/', otp_admin_site.urls), ] django-otp-1.1.3/tox.ini000066400000000000000000000020361415146402700150760ustar00rootroot00000000000000[tox] envlist = static py{3,36}-django22 py{3,39}-django32 coverage [testenv] setenv = PYTHONPATH = {env:PYTHONPATH:}{:}{toxinidir}/test PYTHONWARNINGS = default DJANGO_SETTINGS_MODULE = test_project.settings deps = qrcode freezegun django22: Django==2.2.* django32: Django==3.2.* commands = {envpython} -m django test django_otp [testenv:static] basepython = python3 deps = flake8 isort==5.* skip_install = true commands = flake8 src isort --check src [testenv:coverage] basepython = python3 deps = {[testenv]deps} coverage commands = coverage run -m django test django_otp coverage report # This runs the tests against a local PostgreSQL server in its default # wide-open configuration (username 'postgres', no credentials). The other # environments will skip the concurrency tests, as SQLite doesn't support row # locking. [testenv:postgresql] setenv = {[testenv]setenv} DB_BACKEND = postgresql deps = {[testenv]deps} psycopg2