pax_global_header00006660000000000000000000000064136175604640014526gustar00rootroot0000000000000052 comment=e351a41da1de8bec2bafd5e740d7a68869f40ca6 django-otp-0.8.1/000077500000000000000000000000001361756046400135765ustar00rootroot00000000000000django-otp-0.8.1/.bumpversion.cfg000066400000000000000000000004041361756046400167040ustar00rootroot00000000000000[bumpversion] current_version = 0.8.1 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] [bumpversion:file:docs/source/conf.py] django-otp-0.8.1/.coveragerc000066400000000000000000000000311361756046400157110ustar00rootroot00000000000000[run] source = django_otpdjango-otp-0.8.1/.gitignore000066400000000000000000000000771361756046400155720ustar00rootroot00000000000000# setuptools /MANIFEST /build/ /dist/ /docs/build/ *.egg-info/ django-otp-0.8.1/.isort.cfg000066400000000000000000000004421361756046400154750ustar00rootroot00000000000000[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-0.8.1/CHANGES.rst000066400000000000000000000232361361756046400154060ustar00rootroot00000000000000v0.8.1 - February 08, 2020 - Admin fix -------------------------------------------------------------------------------- - `#26`_: Display OTP Token field on the login page even when user has not yet authenticated. 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/pulls/17 .. _#18: https://github.com/django-otp/django-otp/pulls/18 .. _#23: https://github.com/django-otp/django-otp/pulls/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-0.8.1/LICENSE000066400000000000000000000024211361756046400146020ustar00rootroot00000000000000Copyright (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-0.8.1/MANIFEST.in000066400000000000000000000004751361756046400153420ustar00rootroot00000000000000include README.rst CHANGES.rst LICENSE recursive-include docs *.rst *.py Makefile prune docs/build recursive-include django_otp/plugins/otp_email/templates * recursive-include django_otp/plugins/otp_hotp/templates * recursive-include django_otp/plugins/otp_totp/templates * recursive-include django_otp/templates * django-otp-0.8.1/Makefile000066400000000000000000000003451361756046400152400ustar00rootroot00000000000000.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-0.8.1/Pipfile000066400000000000000000000005131361756046400151100ustar00rootroot00000000000000[[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 = "*" [packages] [requires] python_version = "3.7" django-otp-0.8.1/Pipfile.lock000066400000000000000000000524701361756046400160500ustar00rootroot00000000000000{ "_meta": { "hash": { "sha256": "64fd58021e939b4ac6e341ca63e72af82894d222dd755627ad356e108b12246e" }, "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:7e06d934a7718bf3975acbf87780ba678957b87c7adc056f13b6215d610695a0", "sha256:ea448f92fc35a0ef4b1508f53a04c4670255a3f33d22a81c8fc9c872036adbe5" ], "version": "==3.2.3" }, "babel": { "hashes": [ "sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38", "sha256:d670ea0b10f8b723672d3a6abeb87b565b244da220d76b4dba1b66269ec152d4" ], "version": "==2.8.0" }, "bleach": { "hashes": [ "sha256:213336e49e102af26d9cde77dd2d0397afabc5a6bf2fed985dc35b5d1e285a16", "sha256:3fdf7f77adcf649c9911387df51254b813185e32b2c6619f690b593a617e19fa" ], "version": "==3.1.0" }, "bumpversion": { "hashes": [ "sha256:6744c873dd7aafc24453d8b6a1a0d6d109faf63cd0cd19cb78fd46e74932c77e", "sha256:6753d9ff3552013e2130f7bc03c1007e24473b4835952679653fb132367bdd57" ], "index": "pypi", "version": "==0.5.3" }, "certifi": { "hashes": [ "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3", "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f" ], "version": "==2019.11.28" }, "chardet": { "hashes": [ "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" ], "version": "==3.0.4" }, "coverage": { "hashes": [ "sha256:0101888bd1592a20ccadae081ba10e8b204d20235d18d05c6f7d5e904a38fc10", "sha256:04b961862334687549eb91cd5178a6fbe977ad365bddc7c60f2227f2f9880cf4", "sha256:1ca43dbd739c0fc30b0a3637a003a0d2c7edc1dd618359d58cc1e211742f8bd1", "sha256:1cbb88b34187bdb841f2599770b7e6ff8e259dc3bb64fc7893acf44998acf5f8", "sha256:232f0b52a5b978288f0bbc282a6c03fe48cd19a04202df44309919c142b3bb9c", "sha256:24bcfa86fd9ce86b73a8368383c39d919c497a06eebb888b6f0c12f13e920b1a", "sha256:25b8f60b5c7da71e64c18888f3067d5b6f1334b9681876b2fb41eea26de881ae", "sha256:2714160a63da18aed9340c70ed514973971ee7e665e6b336917ff4cca81a25b1", "sha256:2ca2cd5264e84b2cafc73f0045437f70c6378c0d7dbcddc9ee3fe192c1e29e5d", "sha256:2cc707fc9aad2592fc686d63ef72dc0031fc98b6fb921d2f5395d9ab84fbc3ef", "sha256:348630edea485f4228233c2f310a598abf8afa5f8c716c02a9698089687b6085", "sha256:40fbfd6b044c9db13aeec1daf5887d322c710d811f944011757526ef6e323fd9", "sha256:46c9c6a1d1190c0b75ec7c0f339088309952b82ae8d67a79ff1319eb4e749b96", "sha256:591506e088901bdc25620c37aec885e82cc896528f28c57e113751e3471fc314", "sha256:5ac71bba1e07eab403b082c4428f868c1c9e26a21041436b4905c4c3d4e49b08", "sha256:5f622f19abda4e934938e24f1d67599249abc201844933a6f01aaa8663094489", "sha256:65bead1ac8c8930cf92a1ccaedcce19a57298547d5d1db5c9d4d068a0675c38b", "sha256:7362a7f829feda10c7265b553455de596b83d1623b3d436b6d3c51c688c57bf6", "sha256:7f2675750c50151f806070ec11258edf4c328340916c53bac0adbc465abd6b1e", "sha256:960d7f42277391e8b1c0b0ae427a214e1b31a1278de6b73f8807b20c2e913bba", "sha256:a50b0888d8a021a3342d36a6086501e30de7d840ab68fca44913e97d14487dc1", "sha256:b7dbc5e8c39ea3ad3db22715f1b5401cd698a621218680c6daf42c2f9d36e205", "sha256:bb3d29df5d07d5399d58a394d0ef50adf303ab4fbf66dfd25b9ef258effcb692", "sha256:c0fff2733f7c2950f58a4fd09b5db257b00c6fec57bf3f68c5bae004d804b407", "sha256:c792d3707a86c01c02607ae74364854220fb3e82735f631cd0a345dea6b4cee5", "sha256:c90bda74e16bcd03861b09b1d37c0a4158feda5d5a036bb2d6e58de6ff65793e", "sha256:cfce79ce41cc1a1dc7fc85bb41eeeb32d34a4cf39a645c717c0550287e30ff06", "sha256:eeafb646f374988c22c8e6da5ab9fb81367ecfe81c70c292623373d2a021b1a1", "sha256:f425f50a6dd807cb9043d15a4fcfba3b5874a54d9587ccbb748899f70dc18c47", "sha256:fcd4459fe35a400b8f416bc57906862693c9f88b66dc925e7f2a933e77f6b18b", "sha256:ff3936dd5feaefb4f91c8c1f50a06c588b5dc69fba4f7d9c79a6617ad80bb7df" ], "index": "pypi", "version": "==5.0.1" }, "django": { "hashes": [ "sha256:4f2c913303be4f874015993420bf0bd8fd2097a9c88e6b49c6a92f9bdd3fb13a", "sha256:8c3575f81e11390893860d97e1e0154c47512f180ea55bd84ce8fa69ba8051ca" ], "index": "pypi", "version": "==3.0.2" }, "django-otp": { "editable": true, "path": "." }, "docutils": { "hashes": [ "sha256:6c4f696463b79f1fb8ba0c594b63840ebd41f059e92b31957c46b74a4599b6d0", "sha256:9e4d7ecfc600058e07ba661411a2b7de2fd0fafa17d1a7f7361cd47b1175c827", "sha256:a2aeea129088da402665e92e0b25b04b073c04b2dce4ab65caaa38b7ce2e1a99" ], "version": "==0.15.2" }, "entrypoints": { "hashes": [ "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", "sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451" ], "version": "==0.3" }, "filelock": { "hashes": [ "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59", "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836" ], "version": "==3.0.12" }, "flake8": { "hashes": [ "sha256:45681a117ecc81e870cbf1262835ae4af5e7a8b08e40b944a8a6e6b895914cfb", "sha256:49356e766643ad15072a789a20915d3c91dc89fd313ccd71802303fd67e4deca" ], "index": "pypi", "version": "==3.7.9" }, "freezegun": { "hashes": [ "sha256:2a4d9c8cd3c04a201e20c313caf8b6338f1cfa4cda43f46a94cc4a9fd13ea5e7", "sha256:edfdf5bc6040969e6ed2e36eafe277963bdc8b7c01daeda96c5c8594576c9390" ], "index": "pypi", "version": "==0.3.12" }, "idna": { "hashes": [ "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" ], "version": "==2.8" }, "imagesize": { "hashes": [ "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1", "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1" ], "version": "==1.2.0" }, "importlib-metadata": { "hashes": [ "sha256:073a852570f92da5f744a3472af1b61e28e9f78ccf0c9117658dc32b15de7b45", "sha256:d95141fbfa7ef2ec65cfd945e2af7e5a6ddbd7c8d9a25e66ff3be8e3daf9f60f" ], "markers": "python_version < '3.8'", "version": "==1.3.0" }, "isort": { "hashes": [ "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1", "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd" ], "index": "pypi", "version": "==4.3.21" }, "jinja2": { "hashes": [ "sha256:74320bb91f31270f9551d46522e33af46a80c3d619f4a4bf42b3164d30b5911f", "sha256:9fe95f19286cfefaa917656583d020be14e7859c6b0252588391e47db34527de" ], "version": "==2.10.3" }, "keyring": { "hashes": [ "sha256:5f5f92327b6c7432bebc18a1b60cb3797d99b08db1f5b919b8187c37a01f1ccc", "sha256:ad84f7fe26ab51731f089eaf1c44ebf4c5fae323653c908888a3a6212ad0bbe7" ], "version": "==21.0.0" }, "markupsafe": { "hashes": [ "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7" ], "version": "==1.1.1" }, "mccabe": { "hashes": [ "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" ], "version": "==0.6.1" }, "more-itertools": { "hashes": [ "sha256:b84b238cce0d9adad5ed87e745778d20a3f8487d0f0cb8b8a586816c7496458d", "sha256:c833ef592a0324bcc6a60e48440da07645063c453880c9477ceb22490aec1564" ], "version": "==8.0.2" }, "packaging": { "hashes": [ "sha256:28b924174df7a2fa32c1953825ff29c61e2f5e082343165438812f00d3a7fc47", "sha256:d9551545c6d761f3def1677baf08ab2a3ca17c56879e70fecba2fc4dde4ed108" ], "version": "==19.2" }, "pkginfo": { "hashes": [ "sha256:7424f2c8511c186cd5424bbf31045b77435b37a8d604990b79d4e70d741148bb", "sha256:a6d9e40ca61ad3ebd0b72fbadd4fba16e4c0e4df0428c041e01e06eb6ee71f32" ], "version": "==1.5.0.1" }, "pluggy": { "hashes": [ "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" ], "version": "==0.13.1" }, "py": { "hashes": [ "sha256:5e27081401262157467ad6e7f851b7aa402c5852dbcb3dae06768434de5752aa", "sha256:c20fdd83a5dbc0af9efd622bee9a5564e278f6380fffcacc43ba6f43db2813b0" ], "version": "==1.8.1" }, "pycodestyle": { "hashes": [ "sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56", "sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c" ], "version": "==2.5.0" }, "pyflakes": { "hashes": [ "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0", "sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2" ], "version": "==2.1.1" }, "pygments": { "hashes": [ "sha256:2a3fe295e54a20164a9df49c75fa58526d3be48e14aceba6d6b1e8ac0bfd6f1b", "sha256:98c8aa5a9f778fcd1026a17361ddaf7330d1b7c62ae97c3bb0ae73e0b9b6b0fe" ], "version": "==2.5.2" }, "pyparsing": { "hashes": [ "sha256:4c830582a84fb022400b85429791bc551f1f4871c33f23e44f353119e92f969f", "sha256:c342dccb5250c08d45fd6f8b4a559613ca603b57498511740e65cd11a2e7dcec" ], "version": "==2.4.6" }, "python-dateutil": { "hashes": [ "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" ], "version": "==2.8.1" }, "pytz": { "hashes": [ "sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d", "sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be" ], "version": "==2019.3" }, "readme-renderer": { "hashes": [ "sha256:bb16f55b259f27f75f640acf5e00cf897845a8b3e4731b5c1a436e4b8529202f", "sha256:c8532b79afc0375a85f10433eca157d6b50f7d6990f337fa498c96cd4bfc203d" ], "version": "==24.0" }, "requests": { "hashes": [ "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31" ], "version": "==2.22.0" }, "requests-toolbelt": { "hashes": [ "sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f", "sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0" ], "version": "==0.9.1" }, "six": { "hashes": [ "sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd", "sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66" ], "version": "==1.13.0" }, "snowballstemmer": { "hashes": [ "sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0", "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52" ], "version": "==2.0.0" }, "sphinx": { "hashes": [ "sha256:298537cb3234578b2d954ff18c5608468229e116a9757af3b831c2b2b4819159", "sha256:e6e766b74f85f37a5f3e0773a1e1be8db3fcb799deb58ca6d18b70b0b44542a5" ], "index": "pypi", "version": "==2.3.1" }, "sphinxcontrib-applehelp": { "hashes": [ "sha256:edaa0ab2b2bc74403149cb0209d6775c96de797dfd5b5e2a71981309efab3897", "sha256:fb8dee85af95e5c30c91f10e7eb3c8967308518e0f7488a2828ef7bc191d0d5d" ], "version": "==1.0.1" }, "sphinxcontrib-devhelp": { "hashes": [ "sha256:6c64b077937330a9128a4da74586e8c2130262f014689b4b89e2d08ee7294a34", "sha256:9512ecb00a2b0821a146736b39f7aeb90759834b07e81e8cc23a9c70bacb9981" ], "version": "==1.0.1" }, "sphinxcontrib-htmlhelp": { "hashes": [ "sha256:4670f99f8951bd78cd4ad2ab962f798f5618b17675c35c5ac3b2132a14ea8422", "sha256:d4fd39a65a625c9df86d7fa8a2d9f3cd8299a3a4b15db63b50aac9e161d8eff7" ], "version": "==1.0.2" }, "sphinxcontrib-jsmath": { "hashes": [ "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8" ], "version": "==1.0.1" }, "sphinxcontrib-qthelp": { "hashes": [ "sha256:513049b93031beb1f57d4daea74068a4feb77aa5630f856fcff2e50de14e9a20", "sha256:79465ce11ae5694ff165becda529a600c754f4bc459778778c7017374d4d406f" ], "version": "==1.0.2" }, "sphinxcontrib-serializinghtml": { "hashes": [ "sha256:c0efb33f8052c04fd7a26c0a07f1678e8512e0faec19f4aa8f2473a8b81d5227", "sha256:db6615af393650bf1151a6cd39120c29abaf93cc60db8c48eb2dddbfdc3a9768" ], "version": "==1.1.3" }, "sqlparse": { "hashes": [ "sha256:40afe6b8d4b1117e7dff5504d7a8ce07d9a1b15aeeade8a2d10f130a834f8177", "sha256:7c3dca29c022744e95b547e867cee89f4fce4373f3549ccd8797d8eb52cdb873" ], "version": "==0.3.0" }, "toml": { "hashes": [ "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c", "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e" ], "version": "==0.10.0" }, "tox": { "hashes": [ "sha256:06ba73b149bf838d5cd25dc30c2dd2671ae5b2757cf98e5c41a35fe449f131b3", "sha256:806d0a9217584558cc93747a945a9d9bff10b141a5287f0c8429a08828a22192" ], "index": "pypi", "version": "==3.14.3" }, "tqdm": { "hashes": [ "sha256:4789ccbb6fc122b5a6a85d512e4e41fc5acad77216533a6f2b8ce51e0f265c23", "sha256:efab950cf7cc1e4d8ee50b2bb9c8e4a89f8307b49e0b2c9cfef3ec4ca26655eb" ], "version": "==4.41.1" }, "twine": { "hashes": [ "sha256:c1af8ca391e43b0a06bbc155f7f67db0bf0d19d284bfc88d1675da497a946124", "sha256:d561a5e511f70275e5a485a6275ff61851c16ffcb3a95a602189161112d9f160" ], "index": "pypi", "version": "==3.1.1" }, "urllib3": { "hashes": [ "sha256:a8a318824cc77d1fd4b2bec2ded92646630d7fe8619497b142c84a9e6f5a7293", "sha256:f3c5fd51747d450d4dcf6f923c81f78f811aab8205fda64b0aba34a4e48b0745" ], "version": "==1.25.7" }, "virtualenv": { "hashes": [ "sha256:0d62c70883c0342d59c11d0ddac0d954d0431321a41ab20851facf2b222598f3", "sha256:55059a7a676e4e19498f1aad09b8313a38fcc0cdbe4fdddc0e9b06946d21b4bb" ], "version": "==16.7.9" }, "webencodings": { "hashes": [ "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923" ], "version": "==0.5.1" }, "wheel": { "hashes": [ "sha256:10c9da68765315ed98850f8e048347c3eb06dd81822dc2ab1d4fde9dc9702646", "sha256:f4da1763d3becf2e2cd92a14a7c920f0f00eca30fdde9ea992c836685b9faf28" ], "index": "pypi", "version": "==0.33.6" }, "zipp": { "hashes": [ "sha256:3718b1cbcd963c7d4c5511a8240812904164b7f381b647143a89d3b98f9bcd8e", "sha256:f06903e9f1f43b12d371004b4ac7b06ab39a44adc747266928ae6debfa7b3335" ], "version": "==0.6.0" } } } django-otp-0.8.1/README.rst000066400000000000000000000042041361756046400152650ustar00rootroot00000000000000django-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. Well-formed pull requests are welcome. Anyone interested in taking over aspects of the project should `contact me `_. .. end-of-doc-include 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. .. _pipenv: https://pipenv.readthedocs.io/en/latest/ .. _flake8: https://pypi.org/project/flake8/ .. _isort: https://pypi.org/project/isort/ django-otp-0.8.1/docs/000077500000000000000000000000001361756046400145265ustar00rootroot00000000000000django-otp-0.8.1/docs/Makefile000066400000000000000000000130751361756046400161740ustar00rootroot00000000000000# 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-0.8.1/docs/ext/000077500000000000000000000000001361756046400153265ustar00rootroot00000000000000django-otp-0.8.1/docs/ext/otpdocs.py000066400000000000000000000003311361756046400173500ustar00rootroot00000000000000""" 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-0.8.1/docs/source/000077500000000000000000000000001361756046400160265ustar00rootroot00000000000000django-otp-0.8.1/docs/source/.spell.utf-8.add000066400000000000000000000015051361756046400206400ustar00rootroot00000000000000backends 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-0.8.1/docs/source/auth.rst000066400000000000000000000204501361756046400175220ustar00rootroot00000000000000Authentication 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 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 ~~~~~~~~~~~~~~~~~ .. autofunction:: django_otp.devices_for_user .. autofunction:: django_otp.user_has_device .. autofunction:: django_otp.match_token .. autofunction:: django_otp.login .. autoclass:: django_otp.models.Device :members: is_interactive, generate_challenge, verify_token, verify_is_allowed .. 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-0.8.1/docs/source/changes.rst000066400000000000000000000000661361756046400201720ustar00rootroot00000000000000Change Log ========== .. include:: ../../CHANGES.rst django-otp-0.8.1/docs/source/conf.py000066400000000000000000000211341361756046400173260ustar00rootroot00000000000000# 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', ] ) django.setup() intersphinx_mapping = { 'python': ('http://docs.python.org/3/', None), 'django': ('https://docs.djangoproject.com/en/1.11/', 'https://docs.djangoproject.com/en/1.11/_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 = '0.8.1' # 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-0.8.1/docs/source/extend.rst000066400000000000000000000044131361756046400200510ustar00rootroot00000000000000Extending 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: .. _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-0.8.1/docs/source/index.rst000066400000000000000000000027051361756046400176730ustar00rootroot00000000000000.. include:: ../../README.rst :end-before: end-of-doc-include 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-0.8.1/docs/source/overview.rst000066400000000000000000000366741361756046400204460ustar00rootroot00000000000000Overview 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 .. _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 .. _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 OTPs 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. 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-0.8.1/manage.py000077500000000000000000000006461361756046400154110ustar00rootroot00000000000000#!/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-0.8.1/readthedocs.yaml000066400000000000000000000001741361756046400167510ustar00rootroot00000000000000version: 2 sphinx: configuration: docs/source/conf.py python: version: 3.7 install: - method: pip path: . django-otp-0.8.1/setup.cfg000066400000000000000000000002321361756046400154140ustar00rootroot00000000000000[metadata] long_description: file: README.rst [flake8] ignore = # line break after binary operator W504 # line too long E501 django-otp-0.8.1/setup.py000077500000000000000000000051011361756046400153100ustar00rootroot00000000000000#!/usr/bin/env python import os import os.path import re from setuptools import find_packages, setup def gen_package_data(pkg_root, paths, prune=[]): """ Generates a value for package_data. pkg_root is the path to the Python package we're generating package_data for. paths is a list of paths relative to pkg_root to add. We'll search these directories recursively, yielding a sequence of '/*' strings to select every nested file for inclusion. The optional third argument is a collection of directory names to prune from the traversal. """ pkg_root = os.path.abspath(pkg_root) # For stripping pkg_root from results. root_re = re.compile(r'^' + re.escape(pkg_root) + r'/*') for path in paths: for dirpath, dirnames, _ in os.walk(os.path.join(pkg_root, path)): dirpath = root_re.sub('', dirpath) yield os.path.join(dirpath, '*') if prune: dirnames[:] = [d for d in dirnames if d not in prune] def find_package_data(*args, **kwargs): return list(gen_package_data(*args, **kwargs)) setup( name='django-otp', version='0.8.1', 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'), package_data={ 'django_otp': find_package_data('src/django_otp', ['templates']), 'django_otp.plugins.otp_email': find_package_data('src/django_otp/plugins/otp_email', ['templates']), 'django_otp.plugins.otp_hotp': find_package_data('src/django_otp/plugins/otp_hotp', ['templates']), 'django_otp.plugins.otp_totp': find_package_data('src/django_otp/plugins/otp_totp', ['templates']), }, zip_safe=False, install_requires=[ 'django >= 1.11', ], extras_require={ 'qrcode': ['qrcode'], }, ) django-otp-0.8.1/src/000077500000000000000000000000001361756046400143655ustar00rootroot00000000000000django-otp-0.8.1/src/django_otp/000077500000000000000000000000001361756046400165115ustar00rootroot00000000000000django-otp-0.8.1/src/django_otp/__init__.py000066400000000000000000000070121361756046400206220ustar00rootroot00000000000000from django.contrib.auth.signals import user_logged_in 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 :func:`~django.contrib.auth.views.login` 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 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 string token: An OTP token to verify. :returns: The device that accepted ``token``, if any. :rtype: :class:`~django_otp.models.Device` or ``None`` """ matches = (d for d in devices_for_user(user) if d.verify_token(token)) return next(matches, None) def devices_for_user(user, confirmed=True): """ 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 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. :rtype: iterable """ if user.is_anonymous: return for model in device_classes(): for device in model.objects.devices_for_user(user, confirmed=confirmed): yield device 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_otp.models import Device from django.apps import apps for config in apps.get_app_configs(): for model in config.get_models(): if issubclass(model, Device): yield model django-otp-0.8.1/src/django_otp/admin.py000066400000000000000000000047441361756046400201640ustar00rootroot00000000000000from 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-0.8.1/src/django_otp/conf.py000066400000000000000000000012501361756046400200060ustar00rootroot00000000000000import 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, } def __init__(self): """ Loads our settings from django.conf.settings, applying defaults for any that are omitted. """ for name, default in self.defaults.items(): value = getattr(django.conf.settings, name, default) setattr(self, name, value) settings = Settings() django-otp-0.8.1/src/django_otp/decorators.py000066400000000000000000000017071361756046400212350ustar00rootroot00000000000000from 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-0.8.1/src/django_otp/forms.py000066400000000000000000000312231361756046400202120ustar00rootroot00000000000000from django import forms from django.contrib.auth.forms import AuthenticationForm 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 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) def _chosen_device(self, user): device_id = self.cleaned_data.get('otp_device') if device_id: device = Device.from_persistent_id(device_id) 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.decoratorrs 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-0.8.1/src/django_otp/middleware.py000066400000000000000000000045441361756046400212070ustar00rootroot00000000000000import 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-0.8.1/src/django_otp/models.py000066400000000000000000000232331361756046400203510ustar00rootroot00000000000000from 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 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): 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): """ Loads a device from its persistent id:: device == Device.from_persistent_id(device.persistent_id) """ 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 = device_cls.objects.filter(id=int(device_id)).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 string token: The OTP token provided by the user. :rtype: bool """ 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, }) 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): raise NotImplementedError() class Meta: abstract = True django-otp-0.8.1/src/django_otp/oath.py000066400000000000000000000126101361756046400200160ustar00rootroot00000000000000from 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-0.8.1/src/django_otp/plugins/000077500000000000000000000000001361756046400201725ustar00rootroot00000000000000django-otp-0.8.1/src/django_otp/plugins/__init__.py000066400000000000000000000000001361756046400222710ustar00rootroot00000000000000django-otp-0.8.1/src/django_otp/plugins/otp_email/000077500000000000000000000000001361756046400221435ustar00rootroot00000000000000django-otp-0.8.1/src/django_otp/plugins/otp_email/__init__.py000066400000000000000000000000001361756046400242420ustar00rootroot00000000000000django-otp-0.8.1/src/django_otp/plugins/otp_email/admin.py000066400000000000000000000012251361756046400236050ustar00rootroot00000000000000from 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': ['key'], }), ] raw_id_fields = ['user'] # Somehow this is getting imported twice, triggering a useless exception. try: admin.site.register(EmailDevice) except AlreadyRegistered: pass django-otp-0.8.1/src/django_otp/plugins/otp_email/conf.py000066400000000000000000000013101361756046400234350ustar00rootroot00000000000000import 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': '', 'OTP_EMAIL_SUBJECT': 'OTP token' } def __init__(self): """ Loads our settings from django.conf.settings, applying defaults for any that are omitted. """ for name, default in self.defaults.items(): value = getattr(django.conf.settings, name, default) setattr(self, name, value) settings = OTPEmailSettings() django-otp-0.8.1/src/django_otp/plugins/otp_email/migrations/000077500000000000000000000000001361756046400243175ustar00rootroot00000000000000django-otp-0.8.1/src/django_otp/plugins/otp_email/migrations/0001_initial.py000066400000000000000000000023051361756046400267620ustar00rootroot00000000000000from 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-0.8.1/src/django_otp/plugins/otp_email/migrations/__init__.py000066400000000000000000000000001361756046400264160ustar00rootroot00000000000000django-otp-0.8.1/src/django_otp/plugins/otp_email/models.py000066400000000000000000000033731361756046400240060ustar00rootroot00000000000000from binascii import unhexlify from django.core.mail import send_mail from django.db import models from django.template.loader import render_to_string from django_otp.models import Device from django_otp.oath import totp from django_otp.util import hex_validator, random_hex from .conf import settings def default_key(): return random_hex(20) def key_validator(value): return hex_validator()(value) class EmailDevice(Device): """ A :class:`~django_otp.models.Device` that delivers a token to the user's registered email address (``user.email``). This is intended for demonstration purposes; if you allow users to reset their passwords via email, then this provides no security benefits. .. attribute:: key *CharField*: A hex-encoded secret key of up to 40 bytes. (Default: 20 random bytes) """ key = models.CharField(max_length=80, validators=[key_validator], default=default_key, help_text='A hex-encoded secret key of up to 20 bytes.') @property def bin_key(self): return unhexlify(self.key.encode()) def generate_challenge(self): token = totp(self.bin_key) body = render_to_string('otp/email/token.txt', {'token': token}) send_mail(settings.OTP_EMAIL_SUBJECT, body, settings.OTP_EMAIL_SENDER, [self.user.email]) message = "sent by email" return message def verify_token(self, token): try: token = int(token) except Exception: verified = False else: verified = any(totp(self.bin_key, drift=drift) == token for drift in [0, -1]) return verified django-otp-0.8.1/src/django_otp/plugins/otp_email/templates/000077500000000000000000000000001361756046400241415ustar00rootroot00000000000000django-otp-0.8.1/src/django_otp/plugins/otp_email/templates/otp/000077500000000000000000000000001361756046400247435ustar00rootroot00000000000000django-otp-0.8.1/src/django_otp/plugins/otp_email/templates/otp/email/000077500000000000000000000000001361756046400260325ustar00rootroot00000000000000django-otp-0.8.1/src/django_otp/plugins/otp_email/templates/otp/email/token.txt000066400000000000000000000000371361756046400277130ustar00rootroot00000000000000{{ token|stringformat:".6d" }} django-otp-0.8.1/src/django_otp/plugins/otp_email/tests.py000066400000000000000000000027371361756046400236700ustar00rootroot00000000000000from 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 from .models import EmailDevice class AuthFormTest(TestCase): def setUp(self): try: alice = self.create_user('alice', 'password') except IntegrityError: self.skipTest("Failed to create user.") else: alice.emaildevice_set.create() if hasattr(alice, 'email'): alice.email = 'alice@example.com' alice.save() else: self.skipTest("User model has no email.") @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) data['otp_token'] = mail.outbox[0].body del data['otp_challenge'] form = OTPAuthenticationForm(None, data) self.assertTrue(form.is_valid()) self.assertIsInstance(form.get_user().otp_device, EmailDevice) django-otp-0.8.1/src/django_otp/plugins/otp_hotp/000077500000000000000000000000001361756046400220265ustar00rootroot00000000000000django-otp-0.8.1/src/django_otp/plugins/otp_hotp/__init__.py000066400000000000000000000000001361756046400241250ustar00rootroot00000000000000django-otp-0.8.1/src/django_otp/plugins/otp_hotp/admin.py000066400000000000000000000057401361756046400234760ustar00rootroot00000000000000from django.conf.urls import url from django.contrib import admin from django.contrib.admin.sites import AlreadyRegistered from django.http import HttpResponse from django.template.response import TemplateResponse from django.urls import reverse from django.utils.html import format_html 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', 'qrcode_link'] fieldsets = [ ('Identity', { 'fields': ['user', 'name', 'confirmed'], }), ('Configuration', { 'fields': ['key', 'digits', 'tolerance'], }), ('State', { 'fields': ['counter'], }), ('Throttling', { 'fields': ['throttling_failure_timestamp', 'throttling_failure_count'], }), (None, { 'fields': ['qrcode_link'], }), ] raw_id_fields = ['user'] readonly_fields = ['qrcode_link'] radio_fields = {'digits': admin.HORIZONTAL} 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 = [ url(r'^(?P\d+)/config/$', self.admin_site.admin_view(self.config_view), name='otp_hotp_hotpdevice_config'), url(r'^(?P\d+)/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): device = HOTPDevice.objects.get(pk=pk) try: context = dict( self.admin_site.each_context(request), device=device, ) except AttributeError: # Older versions don't have each_context(). context = {'device': device} return TemplateResponse(request, 'otp_hotp/admin/config.html', context) def qrcode_view(self, request, pk): device = HOTPDevice.objects.get(pk=pk) 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-0.8.1/src/django_otp/plugins/otp_hotp/migrations/000077500000000000000000000000001361756046400242025ustar00rootroot00000000000000django-otp-0.8.1/src/django_otp/plugins/otp_hotp/migrations/0001_initial.py000066400000000000000000000031741361756046400266520ustar00rootroot00000000000000from 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-0.8.1/src/django_otp/plugins/otp_hotp/migrations/0002_auto_20190420_0723.py000066400000000000000000000013611361756046400276220ustar00rootroot00000000000000# 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-0.8.1/src/django_otp/plugins/otp_hotp/migrations/__init__.py000066400000000000000000000000001361756046400263010ustar00rootroot00000000000000django-otp-0.8.1/src/django_otp/plugins/otp_hotp/models.py000066400000000000000000000074141361756046400236710ustar00rootroot00000000000000from 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-0.8.1/src/django_otp/plugins/otp_hotp/templates/000077500000000000000000000000001361756046400240245ustar00rootroot00000000000000django-otp-0.8.1/src/django_otp/plugins/otp_hotp/templates/otp_hotp/000077500000000000000000000000001361756046400256605ustar00rootroot00000000000000django-otp-0.8.1/src/django_otp/plugins/otp_hotp/templates/otp_hotp/admin/000077500000000000000000000000001361756046400267505ustar00rootroot00000000000000django-otp-0.8.1/src/django_otp/plugins/otp_hotp/templates/otp_hotp/admin/config.html000066400000000000000000000010661361756046400311060ustar00rootroot00000000000000{% extends "admin/base_site.html" %} {% block content %}

{{ device.config_url }}

{% endblock %} django-otp-0.8.1/src/django_otp/plugins/otp_hotp/tests.py000066400000000000000000000206301361756046400235430ustar00rootroot00000000000000from datetime import timedelta from urllib.parse import parse_qs, urlsplit from freezegun import freeze_time from django.db import IntegrityError from django.test.utils import override_settings from django_otp.forms import OTPAuthenticationForm from django_otp.models import VerifyNotAllowed from django_otp.tests import TestCase class HOTPTest(TestCase): # 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 ) 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_delay_imposed_after_fail(self): ok1 = self.device.verify_token(123456) self.assertFalse(ok1) ok2 = self.device.verify_token(self.tokens[0]) self.assertFalse(ok2) def test_delay_after_fail_expires(self): ok1 = self.device.verify_token(123456) self.assertFalse(ok1) with freeze_time() as frozen_time: # With default settings initial delay is 1 second frozen_time.tick(delta=timedelta(seconds=1.1)) ok2 = self.device.verify_token(self.tokens[0]) self.assertTrue(ok2) def test_throttling_failure_count(self): self.assertEqual(self.device.throttling_failure_count, 0) for i in range(0, 5): self.device.verify_token(123456) # 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 self.device.verify_token(123456) 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}) # 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.tokens[0]) verify_is_allowed3, data3 = self.device.verify_is_allowed() self.assertEqual(verify_is_allowed3, True) self.assertEqual(data3, None) 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()) django-otp-0.8.1/src/django_otp/plugins/otp_static/000077500000000000000000000000001361756046400223435ustar00rootroot00000000000000django-otp-0.8.1/src/django_otp/plugins/otp_static/__init__.py000066400000000000000000000000001361756046400244420ustar00rootroot00000000000000django-otp-0.8.1/src/django_otp/plugins/otp_static/admin.py000066400000000000000000000013731361756046400240110ustar00rootroot00000000000000from django.contrib import admin from django.contrib.admin.sites import AlreadyRegistered 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, ] # Somehow this is getting imported twice, triggering a useless exception. try: admin.site.register(StaticDevice, StaticDeviceAdmin) except AlreadyRegistered: pass django-otp-0.8.1/src/django_otp/plugins/otp_static/lib.py000066400000000000000000000012611361756046400234630ustar00rootroot00000000000000from 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-0.8.1/src/django_otp/plugins/otp_static/management/000077500000000000000000000000001361756046400244575ustar00rootroot00000000000000django-otp-0.8.1/src/django_otp/plugins/otp_static/management/__init__.py000066400000000000000000000000001361756046400265560ustar00rootroot00000000000000django-otp-0.8.1/src/django_otp/plugins/otp_static/management/commands/000077500000000000000000000000001361756046400262605ustar00rootroot00000000000000django-otp-0.8.1/src/django_otp/plugins/otp_static/management/commands/__init__.py000066400000000000000000000000001361756046400303570ustar00rootroot00000000000000django-otp-0.8.1/src/django_otp/plugins/otp_static/management/commands/addstatictoken.py000066400000000000000000000020661361756046400316370ustar00rootroot00000000000000from 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-0.8.1/src/django_otp/plugins/otp_static/migrations/000077500000000000000000000000001361756046400245175ustar00rootroot00000000000000django-otp-0.8.1/src/django_otp/plugins/otp_static/migrations/0001_initial.py000066400000000000000000000026441361756046400271700ustar00rootroot00000000000000from 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-0.8.1/src/django_otp/plugins/otp_static/migrations/__init__.py000066400000000000000000000000001361756046400266160ustar00rootroot00000000000000django-otp-0.8.1/src/django_otp/plugins/otp_static/models.py000066400000000000000000000030611361756046400242000ustar00rootroot00000000000000from base64 import b32encode from os import urandom from django.db import models from django_otp.models import Device class StaticDevice(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 verify_token(self, token): try: match = next(self.token_set.filter(token=token).iterator()) match.delete() except StopIteration: 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-0.8.1/src/django_otp/plugins/otp_static/tests.py000066400000000000000000000117321361756046400240630ustar00rootroot00000000000000from django.db import IntegrityError from django_otp.forms import OTPAuthenticationForm from django_otp.tests import TestCase from .lib import add_static_token from .models import StaticDevice 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) django-otp-0.8.1/src/django_otp/plugins/otp_totp/000077500000000000000000000000001361756046400220425ustar00rootroot00000000000000django-otp-0.8.1/src/django_otp/plugins/otp_totp/__init__.py000066400000000000000000000000001361756046400241410ustar00rootroot00000000000000django-otp-0.8.1/src/django_otp/plugins/otp_totp/admin.py000066400000000000000000000057541361756046400235170ustar00rootroot00000000000000from django.conf.urls import url from django.contrib import admin from django.contrib.admin.sites import AlreadyRegistered from django.http import HttpResponse from django.template.response import TemplateResponse from django.urls import reverse from django.utils.html import format_html 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', 'qrcode_link'] fieldsets = [ ('Identity', { 'fields': ['user', 'name', 'confirmed'], }), ('Configuration', { 'fields': ['key', 'step', 't0', 'digits', 'tolerance'], }), ('State', { 'fields': ['drift'], }), ('Throttling', { 'fields': ['throttling_failure_timestamp', 'throttling_failure_count'], }), (None, { 'fields': ['qrcode_link'], }), ] raw_id_fields = ['user'] readonly_fields = ['qrcode_link'] radio_fields = {'digits': admin.HORIZONTAL} 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 = [ url(r'^(?P\d+)/config/$', self.admin_site.admin_view(self.config_view), name='otp_totp_totpdevice_config'), url(r'^(?P\d+)/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): device = TOTPDevice.objects.get(pk=pk) try: context = dict( self.admin_site.each_context(request), device=device, ) except AttributeError: # Older versions don't have each_context(). context = {'device': device} return TemplateResponse(request, 'otp_totp/admin/config.html', context) def qrcode_view(self, request, pk): device = TOTPDevice.objects.get(pk=pk) 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-0.8.1/src/django_otp/plugins/otp_totp/migrations/000077500000000000000000000000001361756046400242165ustar00rootroot00000000000000django-otp-0.8.1/src/django_otp/plugins/otp_totp/migrations/0001_initial.py000066400000000000000000000040761361756046400266700ustar00rootroot00000000000000from 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-0.8.1/src/django_otp/plugins/otp_totp/migrations/0002_auto_20190420_0723.py000066400000000000000000000013611361756046400276360ustar00rootroot00000000000000# 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-0.8.1/src/django_otp/plugins/otp_totp/migrations/__init__.py000066400000000000000000000000001361756046400263150ustar00rootroot00000000000000django-otp-0.8.1/src/django_otp/plugins/otp_totp/models.py000066400000000000000000000120641361756046400237020ustar00rootroot00000000000000from 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 = 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-0.8.1/src/django_otp/plugins/otp_totp/templates/000077500000000000000000000000001361756046400240405ustar00rootroot00000000000000django-otp-0.8.1/src/django_otp/plugins/otp_totp/templates/otp_totp/000077500000000000000000000000001361756046400257105ustar00rootroot00000000000000django-otp-0.8.1/src/django_otp/plugins/otp_totp/templates/otp_totp/admin/000077500000000000000000000000001361756046400270005ustar00rootroot00000000000000django-otp-0.8.1/src/django_otp/plugins/otp_totp/templates/otp_totp/admin/config.html000066400000000000000000000010661361756046400311360ustar00rootroot00000000000000{% extends "admin/base_site.html" %} {% block content %}

{{ device.config_url }}

{% endblock %} django-otp-0.8.1/src/django_otp/plugins/otp_totp/tests.py000066400000000000000000000147221361756046400235640ustar00rootroot00000000000000from datetime import timedelta from time import time from urllib.parse import parse_qs, urlsplit from freezegun import freeze_time from django.db import IntegrityError from django.test.utils import override_settings from django_otp.models import VerifyNotAllowed from django_otp.tests import TestCase @override_settings(OTP_TOTP_SYNC=False) class TOTPTest(TestCase): # 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 ) def test_default_key(self): device = self.alice.totpdevice_set.create() # Make sure we can decode the key. device.bin_key @override_settings(OTP_TOTP_THROTTLE_FACTOR=0) def test_single(self): results = [self.device.verify_token(token) for token in self.tokens] self.assertEqual(results, [False] * 3 + [True] + [False] * 6) @override_settings(OTP_TOTP_THROTTLE_FACTOR=0) 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) @override_settings(OTP_TOTP_THROTTLE_FACTOR=0) 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_delay_imposed_after_fail(self): verified1 = self.device.verify_token(0) self.assertFalse(verified1) verified2 = self.device.verify_token(self.tokens[3]) self.assertFalse(verified2) def test_delay_after_fail_expires(self): verified1 = self.device.verify_token(0) 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.tokens[3]) 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(0) # 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 self.device.verify_token(0) 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}) # 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.tokens[3]) verify_is_allowed3, data3 = self.device.verify_is_allowed() self.assertEqual(verify_is_allowed3, True) self.assertEqual(data3, None) 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') django-otp-0.8.1/src/django_otp/templates/000077500000000000000000000000001361756046400205075ustar00rootroot00000000000000django-otp-0.8.1/src/django_otp/templates/otp/000077500000000000000000000000001361756046400213115ustar00rootroot00000000000000django-otp-0.8.1/src/django_otp/templates/otp/admin111/000077500000000000000000000000001361756046400226245ustar00rootroot00000000000000django-otp-0.8.1/src/django_otp/templates/otp/admin111/login.html000066400000000000000000000054441361756046400246310ustar00rootroot00000000000000{% 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 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 with username=request.user.username 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-0.8.1/src/django_otp/tests.py000066400000000000000000000150441361756046400202310ustar00rootroot00000000000000from doctest import DocTestSuite import pickle import unittest from django.contrib.auth import get_user_model from django.db import IntegrityError from django.test import RequestFactory from django.test import TestCase as DjangoTestCase from django.urls import reverse from django_otp import DEVICE_ID_SESSION_KEY, oath, util from django_otp.middleware import OTPMiddleware def load_tests(loader, tests, pattern): suite = unittest.TestSuite() suite.addTests(tests) suite.addTest(DocTestSuite(util)) suite.addTest(DocTestSuite(oath)) return suite class TestCase(DjangoTestCase): """ 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 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('admin:login')) self.assertContains(response, 'Username:') self.assertContains(response, 'Password:') self.assertNotContains(response, 'OTP Device:') self.assertContains(response, 'OTP Token:') response = self.client.post(reverse('admin: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('admin: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()) django-otp-0.8.1/src/django_otp/util.py000066400000000000000000000036211361756046400200420ustar00rootroot00000000000000from binascii import unhexlify from os import urandom 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() django-otp-0.8.1/src/django_otp/views.py000066400000000000000000000032371361756046400202250ustar00rootroot00000000000000from 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-0.8.1/test/000077500000000000000000000000001361756046400145555ustar00rootroot00000000000000django-otp-0.8.1/test/test_project/000077500000000000000000000000001361756046400172625ustar00rootroot00000000000000django-otp-0.8.1/test/test_project/__init__.py000066400000000000000000000000001361756046400213610ustar00rootroot00000000000000django-otp-0.8.1/test/test_project/backends.py000066400000000000000000000002031361756046400214010ustar00rootroot00000000000000class DummyBackend: def authenticate(self, request): return None def get_user(self, user_id): return None django-otp-0.8.1/test/test_project/settings.py000066400000000000000000000034621361756046400215010ustar00rootroot00000000000000# django-otp test project from os.path import abspath, dirname, join def project_path(path): return abspath(join(dirname(__file__), path)) DEBUG = True DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': ':memory:', } } INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', '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' django-otp-0.8.1/test/test_project/templates/000077500000000000000000000000001361756046400212605ustar00rootroot00000000000000django-otp-0.8.1/test/test_project/templates/registration/000077500000000000000000000000001361756046400237725ustar00rootroot00000000000000django-otp-0.8.1/test/test_project/templates/registration/logged_out.html000066400000000000000000000000001361756046400267760ustar00rootroot00000000000000django-otp-0.8.1/test/test_project/urls.py000066400000000000000000000014061361756046400206220ustar00rootroot00000000000000from django.conf.urls import url from django.contrib import admin import django.contrib.auth.views from django.http import HttpResponse 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 = [ url(r'^$', HomeView.as_view()), url(r'^login/$', django_otp.views.LoginView.as_view()), url(r'^logout/$', django.contrib.auth.views.LogoutView.as_view()), url(r'^admin/', otp_admin_site.urls), ] django-otp-0.8.1/tox.ini000066400000000000000000000014571361756046400151200ustar00rootroot00000000000000[tox] envlist = static py{37}-django111 py{35,36}-django22 py{37,38}-django30 coverage [testenv] setenv = PYTHONPATH = {env:PYTHONPATH:}{:}{toxinidir}/test DJANGO_SETTINGS_MODULE = test_project.settings deps = freezegun django111: Django==1.11.* django22: Django==2.2.* django30: Django==3.0.* commands = {envbindir}/django-admin test django_otp [testenv:static] basepython = python3.7 deps = flake8 isort skip_install = true commands = {envbindir}/flake8 src {envbindir}/isort -c -rc src [testenv:coverage] basepython = python3.7 deps = {[testenv]deps} coverage commands = {envbindir}/coverage erase {envbindir}/coverage run {envbindir}/django-admin test django_otp {envbindir}/coverage report