├── Pipfile ├── Pipfile.lock ├── Procfile ├── README.md ├── accounts ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── templates │ ├── index.html │ ├── login.html │ ├── mfa_auth_base.html │ └── register.html ├── tests.py ├── urls.py ├── utils.py └── views.py ├── db.sqlite3 ├── django_mfa2_example ├── __init__.py ├── asgi.py ├── settings.py ├── urls.py └── wsgi.py ├── manage.py ├── mfa ├── ApproveLogin.py ├── Common.py ├── Email.py ├── FIDO2.py ├── TrustedDevice.py ├── U2F.py ├── __init__.py ├── admin.py ├── apps.py ├── helpers.py ├── middleware.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── static │ └── mfa │ │ ├── css │ │ └── bootstrap-toggle.min.css │ │ └── js │ │ ├── bootstrap-toggle.min.js │ │ ├── cbor.js │ │ ├── qrious.min.js │ │ ├── u2f-api.js │ │ └── ua-parser.min.js ├── templates │ ├── ApproveLogin │ │ └── Add.html │ ├── Email │ │ ├── Add.html │ │ ├── Auth.html │ │ ├── mfa_email_token_template.html │ │ └── recheck.html │ ├── FIDO2 │ │ ├── Add.html │ │ ├── Auth.html │ │ └── recheck.html │ ├── MFA.html │ ├── TOTP │ │ ├── Add.html │ │ ├── Auth.html │ │ └── recheck.html │ ├── TrustedDevices │ │ ├── Add.html │ │ ├── Done.html │ │ ├── email.html │ │ ├── start.html │ │ └── user-agent.html │ ├── U2F │ │ ├── Add.html │ │ ├── Auth.html │ │ └── recheck.html │ ├── mfa_check.html │ ├── modal.html │ └── select_mfa_method.html ├── tests.py ├── totp.py ├── urls.py └── views.py ├── requirements.txt ├── static ├── css │ ├── bootstrap │ │ ├── css │ │ │ ├── bootstrap.min.css │ │ │ └── bootstrap.min.css.map │ │ └── js │ │ │ ├── bootstrap.bundle.min.js │ │ │ ├── bootstrap.bundle.min.js.map │ │ │ └── bootstrap.min.js │ └── custom.css ├── images │ └── logo.png └── js │ └── jquery-3.1.1.min.js └── templates └── base.html /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | django = "*" 8 | whitenoise = "*" 9 | gunicorn = "*" 10 | python-decouple = "*" 11 | psycopg2 = "*" 12 | dj-database-url = "*" 13 | jsonfield = "*" 14 | python-jose = "*" 15 | django-mfa2 = "*" 16 | 17 | [dev-packages] 18 | 19 | [requires] 20 | python_version = "3.8" 21 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "edffc00b245250f40cf97556743a1632bc9ddfe51e3781f11c4245c92802cbe4" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.8" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "asgiref": { 20 | "hashes": [ 21 | "sha256:92906c611ce6c967347bbfea733f13d6313901d54dcca88195eaeb52b2a8e8ee", 22 | "sha256:d1216dfbdfb63826470995d31caed36225dcaf34f182e0fa257a4dd9e86f1b78" 23 | ], 24 | "markers": "python_version >= '3.6'", 25 | "version": "==3.3.4" 26 | }, 27 | "cffi": { 28 | "hashes": [ 29 | "sha256:005a36f41773e148deac64b08f233873a4d0c18b053d37da83f6af4d9087b813", 30 | "sha256:04c468b622ed31d408fea2346bec5bbffba2cc44226302a0de1ade9f5ea3d373", 31 | "sha256:06d7cd1abac2ffd92e65c0609661866709b4b2d82dd15f611e602b9b188b0b69", 32 | "sha256:06db6321b7a68b2bd6df96d08a5adadc1fa0e8f419226e25b2a5fbf6ccc7350f", 33 | "sha256:0857f0ae312d855239a55c81ef453ee8fd24136eaba8e87a2eceba644c0d4c06", 34 | "sha256:0f861a89e0043afec2a51fd177a567005847973be86f709bbb044d7f42fc4e05", 35 | "sha256:1071534bbbf8cbb31b498d5d9db0f274f2f7a865adca4ae429e147ba40f73dea", 36 | "sha256:158d0d15119b4b7ff6b926536763dc0714313aa59e320ddf787502c70c4d4bee", 37 | "sha256:1bf1ac1984eaa7675ca8d5745a8cb87ef7abecb5592178406e55858d411eadc0", 38 | "sha256:1f436816fc868b098b0d63b8920de7d208c90a67212546d02f84fe78a9c26396", 39 | "sha256:24a570cd11895b60829e941f2613a4f79df1a27344cbbb82164ef2e0116f09c7", 40 | "sha256:24ec4ff2c5c0c8f9c6b87d5bb53555bf267e1e6f70e52e5a9740d32861d36b6f", 41 | "sha256:2894f2df484ff56d717bead0a5c2abb6b9d2bf26d6960c4604d5c48bbc30ee73", 42 | "sha256:29314480e958fd8aab22e4a58b355b629c59bf5f2ac2492b61e3dc06d8c7a315", 43 | "sha256:293e7ea41280cb28c6fcaaa0b1aa1f533b8ce060b9e701d78511e1e6c4a1de76", 44 | "sha256:34eff4b97f3d982fb93e2831e6750127d1355a923ebaeeb565407b3d2f8d41a1", 45 | "sha256:35f27e6eb43380fa080dccf676dece30bef72e4a67617ffda586641cd4508d49", 46 | "sha256:3c3f39fa737542161d8b0d680df2ec249334cd70a8f420f71c9304bd83c3cbed", 47 | "sha256:3d3dd4c9e559eb172ecf00a2a7517e97d1e96de2a5e610bd9b68cea3925b4892", 48 | "sha256:43e0b9d9e2c9e5d152946b9c5fe062c151614b262fda2e7b201204de0b99e482", 49 | "sha256:48e1c69bbacfc3d932221851b39d49e81567a4d4aac3b21258d9c24578280058", 50 | "sha256:51182f8927c5af975fece87b1b369f722c570fe169f9880764b1ee3bca8347b5", 51 | "sha256:58e3f59d583d413809d60779492342801d6e82fefb89c86a38e040c16883be53", 52 | "sha256:5de7970188bb46b7bf9858eb6890aad302577a5f6f75091fd7cdd3ef13ef3045", 53 | "sha256:65fa59693c62cf06e45ddbb822165394a288edce9e276647f0046e1ec26920f3", 54 | "sha256:681d07b0d1e3c462dd15585ef5e33cb021321588bebd910124ef4f4fb71aef55", 55 | "sha256:69e395c24fc60aad6bb4fa7e583698ea6cc684648e1ffb7fe85e3c1ca131a7d5", 56 | "sha256:6c97d7350133666fbb5cf4abdc1178c812cb205dc6f41d174a7b0f18fb93337e", 57 | "sha256:6e4714cc64f474e4d6e37cfff31a814b509a35cb17de4fb1999907575684479c", 58 | "sha256:72d8d3ef52c208ee1c7b2e341f7d71c6fd3157138abf1a95166e6165dd5d4369", 59 | "sha256:8ae6299f6c68de06f136f1f9e69458eae58f1dacf10af5c17353eae03aa0d827", 60 | "sha256:8b198cec6c72df5289c05b05b8b0969819783f9418e0409865dac47288d2a053", 61 | "sha256:99cd03ae7988a93dd00bcd9d0b75e1f6c426063d6f03d2f90b89e29b25b82dfa", 62 | "sha256:9cf8022fb8d07a97c178b02327b284521c7708d7c71a9c9c355c178ac4bbd3d4", 63 | "sha256:9de2e279153a443c656f2defd67769e6d1e4163952b3c622dcea5b08a6405322", 64 | "sha256:9e93e79c2551ff263400e1e4be085a1210e12073a31c2011dbbda14bda0c6132", 65 | "sha256:9ff227395193126d82e60319a673a037d5de84633f11279e336f9c0f189ecc62", 66 | "sha256:a465da611f6fa124963b91bf432d960a555563efe4ed1cc403ba5077b15370aa", 67 | "sha256:ad17025d226ee5beec591b52800c11680fca3df50b8b29fe51d882576e039ee0", 68 | "sha256:afb29c1ba2e5a3736f1c301d9d0abe3ec8b86957d04ddfa9d7a6a42b9367e396", 69 | "sha256:b85eb46a81787c50650f2392b9b4ef23e1f126313b9e0e9013b35c15e4288e2e", 70 | "sha256:bb89f306e5da99f4d922728ddcd6f7fcebb3241fc40edebcb7284d7514741991", 71 | "sha256:cbde590d4faaa07c72bf979734738f328d239913ba3e043b1e98fe9a39f8b2b6", 72 | "sha256:cc5a8e069b9ebfa22e26d0e6b97d6f9781302fe7f4f2b8776c3e1daea35f1adc", 73 | "sha256:cd2868886d547469123fadc46eac7ea5253ea7fcb139f12e1dfc2bbd406427d1", 74 | "sha256:d42b11d692e11b6634f7613ad8df5d6d5f8875f5d48939520d351007b3c13406", 75 | "sha256:df5052c5d867c1ea0b311fb7c3cd28b19df469c056f7fdcfe88c7473aa63e333", 76 | "sha256:f2d45f97ab6bb54753eab54fffe75aaf3de4ff2341c9daee1987ee1837636f1d", 77 | "sha256:fd78e5fee591709f32ef6edb9a015b4aa1a5022598e36227500c8f4e02328d9c" 78 | ], 79 | "version": "==1.14.5" 80 | }, 81 | "cryptography": { 82 | "hashes": [ 83 | "sha256:0f1212a66329c80d68aeeb39b8a16d54ef57071bf22ff4e521657b27372e327d", 84 | "sha256:1e056c28420c072c5e3cb36e2b23ee55e260cb04eee08f702e0edfec3fb51959", 85 | "sha256:240f5c21aef0b73f40bb9f78d2caff73186700bf1bc6b94285699aff98cc16c6", 86 | "sha256:26965837447f9c82f1855e0bc8bc4fb910240b6e0d16a664bb722df3b5b06873", 87 | "sha256:37340614f8a5d2fb9aeea67fd159bfe4f5f4ed535b1090ce8ec428b2f15a11f2", 88 | "sha256:3d10de8116d25649631977cb37da6cbdd2d6fa0e0281d014a5b7d337255ca713", 89 | "sha256:3d8427734c781ea5f1b41d6589c293089704d4759e34597dce91014ac125aad1", 90 | "sha256:7ec5d3b029f5fa2b179325908b9cd93db28ab7b85bb6c1db56b10e0b54235177", 91 | "sha256:8e56e16617872b0957d1c9742a3f94b43533447fd78321514abbe7db216aa250", 92 | "sha256:de4e5f7f68220d92b7637fc99847475b59154b7a1b3868fb7385337af54ac9ca", 93 | "sha256:eb8cc2afe8b05acbd84a43905832ec78e7b3873fb124ca190f574dca7389a87d", 94 | "sha256:ee77aa129f481be46f8d92a1a7db57269a2f23052d5f2433b4621bb457081cc9" 95 | ], 96 | "markers": "python_version >= '3.6'", 97 | "version": "==3.4.7" 98 | }, 99 | "dj-database-url": { 100 | "hashes": [ 101 | "sha256:4aeaeb1f573c74835b0686a2b46b85990571159ffc21aa57ecd4d1e1cb334163", 102 | "sha256:851785365761ebe4994a921b433062309eb882fedd318e1b0fcecc607ed02da9" 103 | ], 104 | "index": "pypi", 105 | "version": "==0.5.0" 106 | }, 107 | "django": { 108 | "hashes": [ 109 | "sha256:13ac78dbfd189532cad8f383a27e58e18b3d33f80009ceb476d7fcbfc5dcebd8", 110 | "sha256:7e0a1393d18c16b503663752a8b6790880c5084412618990ce8a81cc908b4962" 111 | ], 112 | "index": "pypi", 113 | "version": "==3.2.3" 114 | }, 115 | "django-mfa2": { 116 | "hashes": [ 117 | "sha256:f13be96323ddb3521ccf77ff792a5c88bce2ae223e09417b55f1e37849b192bb" 118 | ], 119 | "index": "pypi", 120 | "version": "==2.1.2" 121 | }, 122 | "ecdsa": { 123 | "hashes": [ 124 | "sha256:64c613005f13efec6541bb0a33290d0d03c27abab5f15fbab20fb0ee162bdd8e", 125 | "sha256:e108a5fe92c67639abae3260e43561af914e7fd0d27bae6d2ec1312ae7934dfe" 126 | ], 127 | "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", 128 | "version": "==0.14.1" 129 | }, 130 | "fido2": { 131 | "hashes": [ 132 | "sha256:8680ee25238e2307596eb3900a0f8c0d9cc91189146ed8039544f1a3a69dfe6e" 133 | ], 134 | "markers": "python_version not in '3.0, 3.1, 3.2, 3.3' and python_full_version >= '2.7.6'", 135 | "version": "==0.9.1" 136 | }, 137 | "gunicorn": { 138 | "hashes": [ 139 | "sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e", 140 | "sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8" 141 | ], 142 | "index": "pypi", 143 | "version": "==20.1.0" 144 | }, 145 | "jsonfield": { 146 | "hashes": [ 147 | "sha256:7e4e84597de21eeaeeaaa7cc5da08c61c48a9b64d0c446b2d71255d01812887a", 148 | "sha256:df857811587f252b97bafba42e02805e70a398a7a47870bc6358a0308dd689ed" 149 | ], 150 | "index": "pypi", 151 | "version": "==3.1.0" 152 | }, 153 | "jsonlookup": { 154 | "hashes": [ 155 | "sha256:95bb1cd51e04e21c1b5a70ec5faa597b8435cb94da868fbbd5d1550fc7c88432" 156 | ], 157 | "version": "==0.9.0" 158 | }, 159 | "psycopg2": { 160 | "hashes": [ 161 | "sha256:00195b5f6832dbf2876b8bf77f12bdce648224c89c880719c745b90515233301", 162 | "sha256:068115e13c70dc5982dfc00c5d70437fe37c014c808acce119b5448361c03725", 163 | "sha256:26e7fd115a6db75267b325de0fba089b911a4a12ebd3d0b5e7acb7028bc46821", 164 | "sha256:2c93d4d16933fea5bbacbe1aaf8fa8c1348740b2e50b3735d1b0bf8154cbf0f3", 165 | "sha256:56007a226b8e95aa980ada7abdea6b40b75ce62a433bd27cec7a8178d57f4051", 166 | "sha256:56fee7f818d032f802b8eed81ef0c1232b8b42390df189cab9cfa87573fe52c5", 167 | "sha256:6a3d9efb6f36f1fe6aa8dbb5af55e067db802502c55a9defa47c5a1dad41df84", 168 | "sha256:a49833abfdede8985ba3f3ec641f771cca215479f41523e99dace96d5b8cce2a", 169 | "sha256:ad2fe8a37be669082e61fb001c185ffb58867fdbb3e7a6b0b0d2ffe232353a3e", 170 | "sha256:b8cae8b2f022efa1f011cc753adb9cbadfa5a184431d09b273fb49b4167561ad", 171 | "sha256:d160744652e81c80627a909a0e808f3c6653a40af435744de037e3172cf277f5", 172 | "sha256:d5062ae50b222da28253059880a871dc87e099c25cb68acf613d9d227413d6f7", 173 | "sha256:f22ea9b67aea4f4a1718300908a2fb62b3e4276cf00bd829a97ab5894af42ea3", 174 | "sha256:f974c96fca34ae9e4f49839ba6b78addf0346777b46c4da27a7bf54f48d3057d", 175 | "sha256:fb23f6c71107c37fd667cb4ea363ddeb936b348bbd6449278eb92c189699f543" 176 | ], 177 | "index": "pypi", 178 | "version": "==2.8.6" 179 | }, 180 | "pyasn1": { 181 | "hashes": [ 182 | "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359", 183 | "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576", 184 | "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf", 185 | "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7", 186 | "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", 187 | "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00", 188 | "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8", 189 | "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86", 190 | "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12", 191 | "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776", 192 | "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", 193 | "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2", 194 | "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3" 195 | ], 196 | "version": "==0.4.8" 197 | }, 198 | "pycparser": { 199 | "hashes": [ 200 | "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", 201 | "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" 202 | ], 203 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 204 | "version": "==2.20" 205 | }, 206 | "pyotp": { 207 | "hashes": [ 208 | "sha256:9d144de0f8a601d6869abe1409f4a3f75f097c37b50a36a3bf165810a6e23f28", 209 | "sha256:d28ddfd40e0c1b6a6b9da961c7d47a10261fb58f378cb00f05ce88b26df9c432" 210 | ], 211 | "version": "==2.6.0" 212 | }, 213 | "python-decouple": { 214 | "hashes": [ 215 | "sha256:2e5adb0263a4f963b58d7407c4760a2465d464ee212d733e2a2c179e54c08d8f", 216 | "sha256:a8268466e6389a639a20deab9d880faee186eb1eb6a05e54375bdf158d691981" 217 | ], 218 | "index": "pypi", 219 | "version": "==3.4" 220 | }, 221 | "python-jose": { 222 | "hashes": [ 223 | "sha256:4e4192402e100b5fb09de5a8ea6bcc39c36ad4526341c123d401e2561720335b", 224 | "sha256:67d7dfff599df676b04a996520d9be90d6cdb7e6dd10b4c7cacc0c3e2e92f2be" 225 | ], 226 | "index": "pypi", 227 | "version": "==3.2.0" 228 | }, 229 | "python-u2flib-server": { 230 | "hashes": [ 231 | "sha256:b5e1712bf8f703c6fc9bac6643efb2d57e6c9d9f0b9ab0c0df74981b2c349632" 232 | ], 233 | "version": "==5.0.1" 234 | }, 235 | "pytz": { 236 | "hashes": [ 237 | "sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da", 238 | "sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798" 239 | ], 240 | "version": "==2021.1" 241 | }, 242 | "rsa": { 243 | "hashes": [ 244 | "sha256:78f9a9bf4e7be0c5ded4583326e7461e3a3c5aae24073648b4bdfa797d78c9d2", 245 | "sha256:9d689e6ca1b3038bc82bf8d23e944b6b6037bc02301a574935b2dd946e0353b9" 246 | ], 247 | "markers": "python_version >= '3.5' and python_version < '4'", 248 | "version": "==4.7.2" 249 | }, 250 | "simplejson": { 251 | "hashes": [ 252 | "sha256:034550078a11664d77bc1a8364c90bb7eef0e44c2dbb1fd0a4d92e3997088667", 253 | "sha256:05b43d568300c1cd43f95ff4bfcff984bc658aa001be91efb3bb21df9d6288d3", 254 | "sha256:0dd9d9c738cb008bfc0862c9b8fa6743495c03a0ed543884bf92fb7d30f8d043", 255 | "sha256:10fc250c3edea4abc15d930d77274ddb8df4803453dde7ad50c2f5565a18a4bb", 256 | "sha256:2862beabfb9097a745a961426fe7daf66e1714151da8bb9a0c430dde3d59c7c0", 257 | "sha256:292c2e3f53be314cc59853bd20a35bf1f965f3bc121e007ab6fd526ed412a85d", 258 | "sha256:2d3eab2c3fe52007d703a26f71cf649a8c771fcdd949a3ae73041ba6797cfcf8", 259 | "sha256:2e7b57c2c146f8e4dadf84977a83f7ee50da17c8861fd7faf694d55e3274784f", 260 | "sha256:311f5dc2af07361725033b13cc3d0351de3da8bede3397d45650784c3f21fbcf", 261 | "sha256:344e2d920a7f27b4023c087ab539877a1e39ce8e3e90b867e0bfa97829824748", 262 | "sha256:3fabde09af43e0cbdee407555383063f8b45bfb52c361bc5da83fcffdb4fd278", 263 | "sha256:42b8b8dd0799f78e067e2aaae97e60d58a8f63582939af60abce4c48631a0aa4", 264 | "sha256:4b3442249d5e3893b90cb9f72c7d6ce4d2ea144d2c0d9f75b9ae1e5460f3121a", 265 | "sha256:55d65f9cc1b733d85ef95ab11f559cce55c7649a2160da2ac7a078534da676c8", 266 | "sha256:5c659a0efc80aaaba57fcd878855c8534ecb655a28ac8508885c50648e6e659d", 267 | "sha256:72d8a3ffca19a901002d6b068cf746be85747571c6a7ba12cbcf427bfb4ed971", 268 | "sha256:75ecc79f26d99222a084fbdd1ce5aad3ac3a8bd535cd9059528452da38b68841", 269 | "sha256:76ac9605bf2f6d9b56abf6f9da9047a8782574ad3531c82eae774947ae99cc3f", 270 | "sha256:7d276f69bfc8c7ba6c717ba8deaf28f9d3c8450ff0aa8713f5a3280e232be16b", 271 | "sha256:7f10f8ba9c1b1430addc7dd385fc322e221559d3ae49b812aebf57470ce8de45", 272 | "sha256:8042040af86a494a23c189b5aa0ea9433769cc029707833f261a79c98e3375f9", 273 | "sha256:813846738277729d7db71b82176204abc7fdae2f566e2d9fcf874f9b6472e3e6", 274 | "sha256:845a14f6deb124a3bcb98a62def067a67462a000e0508f256f9c18eff5847efc", 275 | "sha256:869a183c8e44bc03be1b2bbcc9ec4338e37fa8557fc506bf6115887c1d3bb956", 276 | "sha256:8acf76443cfb5c949b6e781c154278c059b09ac717d2757a830c869ba000cf8d", 277 | "sha256:8f713ea65958ef40049b6c45c40c206ab363db9591ff5a49d89b448933fa5746", 278 | "sha256:934115642c8ba9659b402c8bdbdedb48651fb94b576e3b3efd1ccb079609b04a", 279 | "sha256:9551f23e09300a9a528f7af20e35c9f79686d46d646152a0c8fc41d2d074d9b0", 280 | "sha256:9a2b7543559f8a1c9ed72724b549d8cc3515da7daf3e79813a15bdc4a769de25", 281 | "sha256:a55c76254d7cf8d4494bc508e7abb993a82a192d0db4552421e5139235604625", 282 | "sha256:ad8f41c2357b73bc9e8606d2fa226233bf4d55d85a8982ecdfd55823a6959995", 283 | "sha256:af4868da7dd53296cd7630687161d53a7ebe2e63814234631445697bd7c29f46", 284 | "sha256:afebfc3dd3520d37056f641969ce320b071bc7a0800639c71877b90d053e087f", 285 | "sha256:b59aa298137ca74a744c1e6e22cfc0bf9dca3a2f41f51bc92eb05695155d905a", 286 | "sha256:bc00d1210567a4cdd215ac6e17dc00cb9893ee521cee701adfd0fa43f7c73139", 287 | "sha256:c1cb29b1fced01f97e6d5631c3edc2dadb424d1f4421dad079cb13fc97acb42f", 288 | "sha256:c94dc64b1a389a416fc4218cd4799aa3756f25940cae33530a4f7f2f54f166da", 289 | "sha256:ceaa28a5bce8a46a130cd223e895080e258a88d51bf6e8de2fc54a6ef7e38c34", 290 | "sha256:cff6453e25204d3369c47b97dd34783ca820611bd334779d22192da23784194b", 291 | "sha256:d0b64409df09edb4c365d95004775c988259efe9be39697d7315c42b7a5e7e94", 292 | "sha256:d4813b30cb62d3b63ccc60dd12f2121780c7a3068db692daeb90f989877aaf04", 293 | "sha256:da3c55cdc66cfc3fffb607db49a42448785ea2732f055ac1549b69dcb392663b", 294 | "sha256:e058c7656c44fb494a11443191e381355388443d543f6fc1a245d5d238544396", 295 | "sha256:fed0f22bf1313ff79c7fc318f7199d6c2f96d4de3234b2f12a1eab350e597c06", 296 | "sha256:ffd4e4877a78c84d693e491b223385e0271278f5f4e1476a4962dca6824ecfeb" 297 | ], 298 | "markers": "python_version >= '2.5' and python_version not in '3.0, 3.1, 3.2, 3.3'", 299 | "version": "==3.17.2" 300 | }, 301 | "six": { 302 | "hashes": [ 303 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 304 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 305 | ], 306 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 307 | "version": "==1.16.0" 308 | }, 309 | "sqlparse": { 310 | "hashes": [ 311 | "sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0", 312 | "sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8" 313 | ], 314 | "markers": "python_version >= '3.5'", 315 | "version": "==0.4.1" 316 | }, 317 | "ua-parser": { 318 | "hashes": [ 319 | "sha256:46ab2e383c01dbd2ab284991b87d624a26a08f72da4d7d413f5bfab8b9036f8a", 320 | "sha256:47b1782ed130d890018d983fac37c2a80799d9e0b9c532e734c67cf70f185033" 321 | ], 322 | "version": "==0.10.0" 323 | }, 324 | "user-agents": { 325 | "hashes": [ 326 | "sha256:a98c4dc72ecbc64812c4534108806fb0a0b3a11ec3fd1eafe807cee5b0a942e7", 327 | "sha256:d36d25178db65308d1458c5fa4ab39c9b2619377010130329f3955e7626ead26" 328 | ], 329 | "version": "==2.2.0" 330 | }, 331 | "whitenoise": { 332 | "hashes": [ 333 | "sha256:05ce0be39ad85740a78750c86a93485c40f08ad8c62a6006de0233765996e5c7", 334 | "sha256:05d00198c777028d72d8b0bbd234db605ef6d60e9410125124002518a48e515d" 335 | ], 336 | "index": "pypi", 337 | "version": "==5.2.0" 338 | } 339 | }, 340 | "develop": {} 341 | } 342 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: gunicorn django_mfa2_example.wsgi --log-file - -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django_mfa2_example 2 | 3 | Fingerprint-based authentication and authorization system in Python (Django). This can be integrated with e-voting systems and other applications that should be very secure. 4 | 5 | A walk-through of this repository can be found on [dev.to](https://dev.to/) in this tutorial-like article [Fingerprint-based authentication and authorization in Python(Django) web applications]( 6 | https://dev.to/sirneij/fingerprint-based-authentication-and-authorization-in-python-django-web-applications-2c6l). 7 | This example application uses [Django-mfa2](https://github.com/mkalioby/django-mfa2) to implement a password-less fingerprint-based authentication and authorization system. It's live and can be accessed [here](https://django-mfa2-example.herokuapp.com/). 8 | ## Run locally 9 | 10 | - clone this report: 11 | ``` 12 | git clone https://github.com/Sirneij/django_mfa2_example.git 13 | ``` 14 | - create and activate virtual environment (I used `pipenv` but you can stick with `venv`, `virtualenv` or `poetry`): 15 | ``` 16 | pipenv shell 17 | pipenv install 18 | ``` 19 | - makemigrations and migrate: 20 | ``` 21 | python manage.py makemigrations 22 | python manage.py migrate 23 | ``` 24 | - optionally, createsuperuser: 25 | ``` 26 | python manage.py createsuperuser 27 | ``` 28 | -------------------------------------------------------------------------------- /accounts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sirneij/django_mfa2_example/d9505213e7e914481f06ea3426a515f5e0d26bc2/accounts/__init__.py -------------------------------------------------------------------------------- /accounts/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import User 3 | from django.contrib.auth.admin import UserAdmin 4 | 5 | class CustomUserAdmin(UserAdmin): 6 | model = User 7 | readonly_fields = ['date_joined', ] 8 | actions = ['activate_users', ] 9 | list_display = ('username','display_name', 'email', 'first_name', 'last_name', 10 | 'is_staff',) 11 | 12 | def get_inline_instances(self, request, obj=None): 13 | if not obj: 14 | return list() 15 | return super(CustomUserAdmin, self).get_inline_instances(request, obj) 16 | 17 | def get_form(self, request, obj=None, **kwargs): 18 | form = super().get_form(request, obj, **kwargs) 19 | is_superuser = request.user.is_superuser 20 | disabled_fields = set() 21 | 22 | if not is_superuser: 23 | disabled_fields |= { 24 | 'username', 25 | 'is_superuser', 26 | } 27 | # Prevent non-superusers from editing their own permissions 28 | if ( 29 | not is_superuser 30 | and obj is not None 31 | and obj == request.user 32 | ): 33 | disabled_fields |= { 34 | 'is_staff', 35 | 'is_superuser', 36 | 'groups', 37 | 'user_permissions', 38 | } 39 | for f in disabled_fields: 40 | if f in form.base_fields: 41 | form.base_fields[f].disabled = True 42 | 43 | return form 44 | 45 | def activate_users(self, request, queryset): 46 | cannot = queryset.filter(is_active=False).update(is_active=True) 47 | self.message_user(request, 'Activated {} users.'.format(cannot)) 48 | activate_users.short_description = 'Activate Users' # type: ignore 49 | 50 | def get_actions(self, request): 51 | actions = super().get_actions(request) 52 | if not request.user.has_perm('auth.change_user'): 53 | del actions['activate_users'] 54 | return actions 55 | 56 | 57 | admin.site.register(User, CustomUserAdmin) 58 | -------------------------------------------------------------------------------- /accounts/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AccountsConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'accounts' 7 | -------------------------------------------------------------------------------- /accounts/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.3 on 2021-05-26 09:05 2 | 3 | import django.contrib.auth.models 4 | import django.contrib.auth.validators 5 | from django.db import migrations, models 6 | import django.utils.timezone 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ('auth', '0012_alter_user_first_name_max_length'), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='User', 20 | fields=[ 21 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('password', models.CharField(max_length=128, verbose_name='password')), 23 | ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), 24 | ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), 25 | ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), 26 | ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), 27 | ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), 28 | ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), 29 | ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), 30 | ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), 31 | ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), 32 | ('display_name', models.CharField(max_length=32)), 33 | ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), 34 | ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), 35 | ], 36 | options={ 37 | 'verbose_name': 'user', 38 | 'verbose_name_plural': 'users', 39 | 'abstract': False, 40 | }, 41 | managers=[ 42 | ('objects', django.contrib.auth.models.UserManager()), 43 | ], 44 | ), 45 | ] 46 | -------------------------------------------------------------------------------- /accounts/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sirneij/django_mfa2_example/d9505213e7e914481f06ea3426a515f5e0d26bc2/accounts/migrations/__init__.py -------------------------------------------------------------------------------- /accounts/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.auth.models import AbstractUser 3 | 4 | class User(AbstractUser): 5 | display_name = models.CharField(max_length=32) 6 | 7 | -------------------------------------------------------------------------------- /accounts/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% load static %} 4 | 5 | {% block title %} {{page_title}} {% endblock title %} 6 | 7 | {% block css %} 8 | 9 | {% endblock css %} 10 | 11 | {% block content %} 12 | 13 |
14 |
15 |
16 |

Cover

17 | 32 |
33 |
34 | 35 |
36 | {% if request.user.is_authenticated %} 37 | 38 |

You are logged in!!!!

39 |

40 | You have successfully used Django-mfa2 biometric authentication to log 41 | in. Hurray! 42 |

43 |

44 | Learn more 49 |

50 | {% else %} 51 | 52 |

Cover your page.

53 |

54 | Cover is a one-page template for building simple and beautiful home 55 | pages. Download, edit the text, and add your own fullscreen background 56 | photo to make it your own. 57 |

58 |

59 | Learn more 64 |

65 | {% endif %} 66 |
67 | 68 | 75 |
76 | 77 | 78 | {% endblock content %} 79 | -------------------------------------------------------------------------------- /accounts/templates/login.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% load static %} 4 | 5 | {% block css %} 6 | 60 | {% endblock css %} 61 | 62 | 63 | {% block title %} {{page_title}} {% endblock title %} 64 | 65 | 66 | {% block content %} 67 | 68 |
69 |
70 | {% csrf_token %} 71 | 78 |

Login

79 | 80 | {% if err %} 81 |

{{err}}

82 | {% endif %} 83 | 84 |
85 | 92 | 93 | 94 |
95 | 96 | 97 |

© 2021

98 |
99 |
100 | 101 | {% endblock content %} 102 | -------------------------------------------------------------------------------- /accounts/templates/mfa_auth_base.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | -------------------------------------------------------------------------------- /accounts/templates/register.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | 4 | {% block title %} {{page_title}} {% endblock title %} 5 | 6 | 7 | {% load static %} 8 | 9 | {% block css %} 10 | 64 | {% endblock css %} 65 | 66 | {% block content %} 67 | 68 |
69 |
70 | {% csrf_token %} 71 | 78 |

Register

79 | 80 | {% if error %} 81 |

{{error}}

82 | {% endif %} 83 | 84 |
85 | 92 | 93 | 94 |
95 |
96 | 103 | 104 | 105 |
106 | 107 | 112 |

© 2021

113 |
114 |
115 | 116 | {% endblock content %} 117 | -------------------------------------------------------------------------------- /accounts/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /accounts/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from . import views 3 | from django.contrib.auth import views as auth_views 4 | 5 | app_name = 'accounts' 6 | 7 | urlpatterns = [ 8 | path('', views.index, name='index'), 9 | path('login/', views.login, name='login'), 10 | path('register/', views.register, name='register'), 11 | path('logout/', auth_views.LogoutView.as_view(), name='logout'), 12 | ] 13 | -------------------------------------------------------------------------------- /accounts/utils.py: -------------------------------------------------------------------------------- 1 | import six 2 | 3 | USERNAME_MAX_LENGTH = 32 4 | DISPLAY_NAME_MAX_LENGTH = 65 5 | 6 | def validate_username(username): 7 | if not isinstance(username, six.string_types): 8 | return False 9 | 10 | if len(username) > USERNAME_MAX_LENGTH: 11 | return False 12 | 13 | if not username.isalnum(): 14 | return False 15 | 16 | if not username.lower().startswith("cpe"): 17 | return False 18 | 19 | return True 20 | 21 | 22 | def validate_display_name(display_name): 23 | if not isinstance(display_name, six.string_types): 24 | return False 25 | 26 | if len(display_name) > DISPLAY_NAME_MAX_LENGTH: 27 | return False 28 | 29 | if not display_name.replace(' ', '').isalnum(): 30 | return False 31 | 32 | return True -------------------------------------------------------------------------------- /accounts/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render, redirect 2 | from django.urls import reverse 3 | from django.contrib import auth 4 | from django.utils import timezone 5 | from django.conf import settings 6 | from .models import User 7 | from . import utils 8 | 9 | def login_user_in(request, username): 10 | user=User.objects.get(username=username) 11 | user.backend='django.contrib.auth.backends.ModelBackend' 12 | auth.login(request, user) 13 | if "redirect" in request.POST: 14 | return redirect(request.POST["redirect"]) 15 | else: 16 | return redirect(reverse('accounts:index')) 17 | 18 | def login(request): 19 | if request.method == "POST": 20 | username = request.POST.get('username').replace('/', '') 21 | user = User.objects.filter(username=username).first() 22 | err="" 23 | if user is not None: 24 | if user.is_active: 25 | if "mfa" in settings.INSTALLED_APPS: 26 | from mfa.helpers import has_mfa 27 | res = has_mfa(request,username=username) 28 | if res: return res 29 | return login_user_in(request, username) 30 | else: 31 | err="This student is NOT activated yet." 32 | else: 33 | err="No student with such matriculation number exists." 34 | return render(request, 'login.html', {"err":err}) 35 | else: 36 | return render(request, 'login.html') 37 | 38 | def register(request): 39 | if request.method == "POST": 40 | error = '' 41 | username = request.POST.get('username').replace('/', '') 42 | display_name = request.POST.get('display-name') 43 | if not utils.validate_username(username): 44 | error = 'Invalid matriculation number' 45 | return render(request, 'register.html', context = {'page_title': "Register", 'error': error}) 46 | if not utils.validate_display_name(display_name): 47 | error = 'Invalid display name' 48 | return render(request, 'register.html', context = {'page_title': "Register", 'error': error}) 49 | if User.objects.filter(username=username).exists(): 50 | error = 'Student already exists.' 51 | return render(request, 'register.html', context = {'page_title': "Register", 'error': error}) 52 | else: 53 | u = User.objects.create(first_name = display_name, password='none', is_superuser=False, username=username, last_name='', display_name=display_name, email='none', is_staff=False, is_active=True,date_joined=timezone.now()) 54 | u.backend = 'django.contrib.auth.backends.ModelBackend' 55 | auth.login(request,u) 56 | return redirect(reverse('start_fido2')) 57 | else: 58 | return render(request, 'register.html', context = {'page_title': "Register"}) 59 | 60 | def index(request): 61 | return render(request, 'index.html', {"page_title": "Welcome home"}) -------------------------------------------------------------------------------- /db.sqlite3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sirneij/django_mfa2_example/d9505213e7e914481f06ea3426a515f5e0d26bc2/db.sqlite3 -------------------------------------------------------------------------------- /django_mfa2_example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sirneij/django_mfa2_example/d9505213e7e914481f06ea3426a515f5e0d26bc2/django_mfa2_example/__init__.py -------------------------------------------------------------------------------- /django_mfa2_example/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for django_mfa2_example project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_mfa2_example.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /django_mfa2_example/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for django_mfa2_example project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.2.3. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.2/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | from decouple import config 15 | 16 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 17 | BASE_DIR = Path(__file__).resolve().parent.parent 18 | 19 | 20 | # Quick-start development settings - unsuitable for production 21 | # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ 22 | 23 | # SECURITY WARNING: keep the secret key used in production secret! 24 | SECRET_KEY = config('SECRET_KEY', default='django-insecure-k+*^qwc1ud*t5uvsc3@j2&(3567-x+6p)4$rx7gpt$gi@&rx5d') 25 | 26 | # SECURITY WARNING: don't run with debug turned on in production! 27 | DEBUG = config('DEBUG', default=True, cast=bool) 28 | 29 | ALLOWED_HOSTS = ['*'] 30 | 31 | 32 | # Application definition 33 | 34 | INSTALLED_APPS = [ 35 | 'django.contrib.admin', 36 | 'django.contrib.auth', 37 | 'django.contrib.contenttypes', 38 | 'django.contrib.sessions', 39 | 'django.contrib.messages', 40 | 'django.contrib.staticfiles', 41 | 'mfa', 42 | 43 | 'accounts.apps.AccountsConfig', 44 | ] 45 | 46 | MIDDLEWARE = [ 47 | 'django.middleware.security.SecurityMiddleware', 48 | 'django.contrib.sessions.middleware.SessionMiddleware', 49 | 'django.middleware.common.CommonMiddleware', 50 | 'django.middleware.csrf.CsrfViewMiddleware', 51 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 52 | 'django.contrib.messages.middleware.MessageMiddleware', 53 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 54 | 'whitenoise.middleware.WhiteNoiseMiddleware', 55 | ] 56 | 57 | ROOT_URLCONF = 'django_mfa2_example.urls' 58 | 59 | TEMPLATES = [ 60 | { 61 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 62 | 'DIRS': [BASE_DIR / 'templates'], 63 | 'APP_DIRS': True, 64 | 'OPTIONS': { 65 | 'context_processors': [ 66 | 'django.template.context_processors.debug', 67 | 'django.template.context_processors.request', 68 | 'django.contrib.auth.context_processors.auth', 69 | 'django.contrib.messages.context_processors.messages', 70 | ], 71 | }, 72 | }, 73 | ] 74 | 75 | WSGI_APPLICATION = 'django_mfa2_example.wsgi.application' 76 | 77 | 78 | # Database 79 | # https://docs.djangoproject.com/en/3.2/ref/settings/#databases 80 | 81 | DATABASES = { 82 | 'default': { 83 | 'ENGINE': 'django.db.backends.sqlite3', 84 | 'NAME': BASE_DIR / 'db.sqlite3', 85 | } 86 | } 87 | 88 | 89 | # Password validation 90 | # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators 91 | 92 | AUTH_PASSWORD_VALIDATORS = [ 93 | { 94 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 95 | }, 96 | { 97 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 98 | }, 99 | { 100 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 101 | }, 102 | { 103 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 104 | }, 105 | ] 106 | 107 | 108 | # Internationalization 109 | # https://docs.djangoproject.com/en/3.2/topics/i18n/ 110 | 111 | LANGUAGE_CODE = 'en-us' 112 | 113 | TIME_ZONE = 'UTC' 114 | 115 | USE_I18N = True 116 | 117 | USE_L10N = True 118 | 119 | USE_TZ = True 120 | 121 | 122 | # Static files (CSS, JavaScript, Images) 123 | # https://docs.djangoproject.com/en/3.2/howto/static-files/ 124 | 125 | STATIC_URL = '/static/' 126 | STATICFILES_DIRS = (BASE_DIR / 'static',) 127 | STATIC_ROOT = BASE_DIR / 'staticfiles' 128 | 129 | 130 | # Default primary key field type 131 | # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field 132 | 133 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 134 | AUTH_USER_MODEL = 'accounts.User' 135 | 136 | LOGOUT_REDIRECT_URL='accounts:index' 137 | 138 | 139 | 140 | MFA_UNALLOWED_METHODS=() # Methods that shouldn't be allowed for the user 141 | MFA_LOGIN_CALLBACK="accounts.views.login_user_in" # A function that should be called by username to login the user in session 142 | MFA_RECHECK=True # Allow random rechecking of the user 143 | MFA_RECHECK_MIN=10 # Minimum interval in seconds 144 | MFA_RECHECK_MAX=30 # Maximum in seconds 145 | MFA_QUICKLOGIN=True 146 | 147 | TOKEN_ISSUER_NAME="django_mfa2_example" #TOTP Issuer name 148 | 149 | if DEBUG: 150 | U2F_APPID="https://localhost" #URL For U2F 151 | FIDO_SERVER_ID=u"localhost" # Server rp id for FIDO2, it the full domain of your project 152 | else: 153 | U2F_APPID="https://django-mfa2-example.herokuapp.com" #URL For U2F 154 | FIDO_SERVER_ID=u"django-mfa2-example.herokuapp.com" # Server rp id for FIDO2, it the full domain of your project 155 | 156 | FIDO_SERVER_NAME=u"django_mfa2_example" 157 | 158 | import dj_database_url 159 | db_from_env = dj_database_url.config(conn_max_age=500) 160 | DATABASES['default'].update(db_from_env) 161 | -------------------------------------------------------------------------------- /django_mfa2_example/urls.py: -------------------------------------------------------------------------------- 1 | """django_mfa2_example URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.2/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.conf import settings 18 | from django.conf.urls.static import static 19 | from django.urls import path, include 20 | import mfa 21 | import mfa.TrustedDevice 22 | 23 | urlpatterns = [ 24 | path('admin/', admin.site.urls), 25 | path('mfa/', include('mfa.urls')), 26 | path('devices/add/', mfa.TrustedDevice.add,name="mfa_add_new_trusted_device"), 27 | path("", include('accounts.urls', namespace='accounts')) 28 | ] 29 | 30 | if settings.DEBUG: 31 | urlpatterns += static(settings.STATIC_URL, 32 | document_root=settings.STATIC_ROOT) -------------------------------------------------------------------------------- /django_mfa2_example/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for django_mfa2_example project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_mfa2_example.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_mfa2_example.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /mfa/ApproveLogin.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sirneij/django_mfa2_example/d9505213e7e914481f06ea3426a515f5e0d26bc2/mfa/ApproveLogin.py -------------------------------------------------------------------------------- /mfa/Common.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.mail import EmailMessage 3 | 4 | def send(to,subject,body): 5 | from_email_address = settings.EMAIL_HOST_USER 6 | if '@' not in from_email_address: 7 | from_email_address = settings.DEFAULT_FROM_EMAIL 8 | From = "%s <%s>" % (settings.EMAIL_FROM, from_email_address) 9 | email = EmailMessage(subject,body,From,to) 10 | email.content_subtype = "html" 11 | return email.send(False) -------------------------------------------------------------------------------- /mfa/Email.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | from django.views.decorators.cache import never_cache 3 | from django.template.context_processors import csrf 4 | import datetime,random 5 | from random import randint 6 | from .models import * 7 | #from django.template.context import RequestContext 8 | from .views import login 9 | from .Common import send 10 | def sendEmail(request,username,secret): 11 | from django.contrib.auth import get_user_model 12 | User = get_user_model() 13 | key = getattr(User, 'USERNAME_FIELD', 'username') 14 | kwargs = {key: username} 15 | user = User.objects.get(**kwargs) 16 | res=render(request,"mfa_email_token_template.html",{"request":request,"user":user,'otp':secret}) 17 | return send([user.email],"OTP", res.content.decode()) 18 | 19 | @never_cache 20 | def start(request): 21 | context = csrf(request) 22 | if request.method == "POST": 23 | if request.session["email_secret"] == request.POST["otp"]: 24 | uk=User_Keys() 25 | uk.username=request.user.username 26 | uk.key_type="Email" 27 | uk.enabled=1 28 | uk.save() 29 | from django.http import HttpResponseRedirect 30 | try: 31 | from django.core.urlresolvers import reverse 32 | except: 33 | from django.urls import reverse 34 | return HttpResponseRedirect(reverse('mfa_home')) 35 | context["invalid"] = True 36 | else: 37 | request.session["email_secret"] = str(randint(0,100000)) 38 | if sendEmail(request, request.user.username, request.session["email_secret"]): 39 | context["sent"] = True 40 | return render(request,"Email/Add.html", context) 41 | @never_cache 42 | def auth(request): 43 | context=csrf(request) 44 | if request.method=="POST": 45 | if request.session["email_secret"]==request.POST["otp"].strip(): 46 | uk = User_Keys.objects.get(username=request.session["base_username"], key_type="Email") 47 | mfa = {"verified": True, "method": "Email","id":uk.id} 48 | if getattr(settings, "MFA_RECHECK", False): 49 | mfa["next_check"] = datetime.datetime.timestamp(datetime.datetime.now() + datetime.timedelta( 50 | seconds = random.randint(settings.MFA_RECHECK_MIN, settings.MFA_RECHECK_MAX))) 51 | request.session["mfa"] = mfa 52 | 53 | from django.utils import timezone 54 | uk.last_used=timezone.now() 55 | uk.save() 56 | return login(request) 57 | context["invalid"]=True 58 | else: 59 | request.session["email_secret"] = str(randint(0, 100000)) 60 | if sendEmail(request, request.session["base_username"], request.session["email_secret"]): 61 | context["sent"] = True 62 | return render(request,"Email/Auth.html", context) 63 | -------------------------------------------------------------------------------- /mfa/FIDO2.py: -------------------------------------------------------------------------------- 1 | from fido2.client import ClientData 2 | from fido2.server import Fido2Server, PublicKeyCredentialRpEntity 3 | from fido2.ctap2 import AttestationObject, AuthenticatorData 4 | from django.template.context_processors import csrf 5 | from django.views.decorators.csrf import csrf_exempt 6 | from django.shortcuts import render 7 | # from django.template.context import RequestContext 8 | import simplejson 9 | from fido2 import cbor 10 | from django.http import HttpResponse 11 | from django.conf import settings 12 | from .models import * 13 | from fido2.utils import websafe_decode, websafe_encode 14 | from fido2.ctap2 import AttestedCredentialData 15 | from .views import login, reset_cookie 16 | import datetime 17 | from django.utils import timezone 18 | 19 | 20 | def recheck(request): 21 | context = csrf(request) 22 | context["mode"] = "recheck" 23 | request.session["mfa_recheck"] = True 24 | return render(request, "FIDO2/recheck.html", context) 25 | 26 | 27 | def getServer(): 28 | rp = PublicKeyCredentialRpEntity(settings.FIDO_SERVER_ID, settings.FIDO_SERVER_NAME) 29 | return Fido2Server(rp) 30 | 31 | 32 | def begin_registeration(request): 33 | server = getServer() 34 | registration_data, state = server.register_begin({ 35 | u'id': request.user.username.encode("utf8"), 36 | u'name': (request.user.first_name + " " + request.user.last_name), 37 | u'displayName': request.user.username, 38 | }, getUserCredentials(request.user.username)) 39 | request.session['fido_state'] = state 40 | 41 | return HttpResponse(cbor.encode(registration_data), content_type = 'application/octet-stream') 42 | 43 | 44 | @csrf_exempt 45 | def complete_reg(request): 46 | try: 47 | data = cbor.decode(request.body) 48 | 49 | client_data = ClientData(data['clientDataJSON']) 50 | att_obj = AttestationObject((data['attestationObject'])) 51 | server = getServer() 52 | auth_data = server.register_complete( 53 | request.session['fido_state'], 54 | client_data, 55 | att_obj 56 | ) 57 | encoded = websafe_encode(auth_data.credential_data) 58 | uk = User_Keys() 59 | uk.username = request.user.username 60 | uk.properties = {"device": encoded, "type": att_obj.fmt, } 61 | uk.owned_by_enterprise = getattr(settings, "MFA_OWNED_BY_ENTERPRISE", False) 62 | uk.key_type = "FIDO2" 63 | uk.save() 64 | return HttpResponse(simplejson.dumps({'status': 'OK'})) 65 | except Exception as exp: 66 | try: 67 | from raven.contrib.django.raven_compat.models import client 68 | client.captureException() 69 | except: 70 | pass 71 | return HttpResponse(simplejson.dumps({'status': 'ERR', "message": "Error on server, please try again later"})) 72 | 73 | 74 | def start(request): 75 | context = csrf(request) 76 | return render(request, "FIDO2/Add.html", context) 77 | 78 | 79 | def getUserCredentials(username): 80 | credentials = [] 81 | for uk in User_Keys.objects.filter(username = username, key_type = "FIDO2"): 82 | credentials.append(AttestedCredentialData(websafe_decode(uk.properties["device"]))) 83 | return credentials 84 | 85 | 86 | def auth(request): 87 | context = csrf(request) 88 | return render(request, "FIDO2/Auth.html", context) 89 | 90 | 91 | def authenticate_begin(request): 92 | server = getServer() 93 | credentials = getUserCredentials(request.session.get("base_username", request.user.username)) 94 | auth_data, state = server.authenticate_begin(credentials) 95 | request.session['fido_state'] = state 96 | return HttpResponse(cbor.encode(auth_data), content_type = "application/octet-stream") 97 | 98 | 99 | @csrf_exempt 100 | def authenticate_complete(request): 101 | try: 102 | credentials = [] 103 | username = request.session.get("base_username", request.user.username) 104 | server = getServer() 105 | credentials = getUserCredentials(username) 106 | data = cbor.decode(request.body) 107 | credential_id = data['credentialId'] 108 | client_data = ClientData(data['clientDataJSON']) 109 | auth_data = AuthenticatorData(data['authenticatorData']) 110 | signature = data['signature'] 111 | try: 112 | cred = server.authenticate_complete( 113 | request.session.pop('fido_state'), 114 | credentials, 115 | credential_id, 116 | client_data, 117 | auth_data, 118 | signature 119 | ) 120 | except ValueError: 121 | return HttpResponse(simplejson.dumps({'status': "ERR", 122 | "message": "Wrong challenge received, make sure that this is your security and try again."}), 123 | content_type = "application/json") 124 | except Exception as excep: 125 | try: 126 | from raven.contrib.django.raven_compat.models import client 127 | client.captureException() 128 | except: 129 | pass 130 | return HttpResponse(simplejson.dumps({'status': "ERR", 131 | "message": excep.message}), 132 | content_type = "application/json") 133 | 134 | if request.session.get("mfa_recheck", False): 135 | import time 136 | request.session["mfa"]["rechecked_at"] = time.time() 137 | return HttpResponse(simplejson.dumps({'status': "OK"}), 138 | content_type = "application/json") 139 | else: 140 | import random 141 | keys = User_Keys.objects.filter(username = username, key_type = "FIDO2", enabled = 1) 142 | for k in keys: 143 | if AttestedCredentialData(websafe_decode(k.properties["device"])).credential_id == cred.credential_id: 144 | k.last_used = timezone.now() 145 | k.save() 146 | mfa = {"verified": True, "method": "FIDO2", 'id': k.id} 147 | if getattr(settings, "MFA_RECHECK", False): 148 | mfa["next_check"] = datetime.datetime.timestamp((datetime.datetime.now() + datetime.timedelta( 149 | seconds = random.randint(settings.MFA_RECHECK_MIN, settings.MFA_RECHECK_MAX)))) 150 | request.session["mfa"] = mfa 151 | try: 152 | authenticated = request.user.is_authenticated 153 | except: 154 | authenticated = request.user.is_authenticated() 155 | if not authenticated: 156 | res = login(request) 157 | if not "location" in res: return reset_cookie(request) 158 | return HttpResponse(simplejson.dumps({'status': "OK", "redirect": res["location"]}), 159 | content_type = "application/json") 160 | return HttpResponse(simplejson.dumps({'status': "OK"}), 161 | content_type = "application/json") 162 | except Exception as exp: 163 | return HttpResponse(simplejson.dumps({'status': "ERR", "message": exp.message}), 164 | content_type = "application/json") 165 | -------------------------------------------------------------------------------- /mfa/TrustedDevice.py: -------------------------------------------------------------------------------- 1 | import string 2 | import random 3 | from django.shortcuts import render 4 | from django.http import HttpResponse 5 | from django.template.context import RequestContext 6 | from django.template.context_processors import csrf 7 | from .models import * 8 | import user_agents 9 | from django.utils import timezone 10 | 11 | def id_generator(size=6, chars=string.ascii_uppercase + string.digits): 12 | x=''.join(random.choice(chars) for _ in range(size)) 13 | if not User_Keys.objects.filter(properties__shas="$.key="+x).exists(): return x 14 | else: return id_generator(size,chars) 15 | 16 | def getUserAgent(request): 17 | id=id=request.session.get("td_id",None) 18 | if id: 19 | tk=User_Keys.objects.get(id=id) 20 | if tk.properties.get("user_agent","")!="": 21 | ua = user_agents.parse(tk.properties["user_agent"]) 22 | res = render(None, "TrustedDevices/user-agent.html", context={"ua":ua}) 23 | return HttpResponse(res) 24 | return HttpResponse("") 25 | 26 | def trust_device(request): 27 | tk = User_Keys.objects.get(id=request.session["td_id"]) 28 | tk.properties["status"]="trusted" 29 | tk.save() 30 | del request.session["td_id"] 31 | return HttpResponse("OK") 32 | 33 | def checkTrusted(request): 34 | res = "" 35 | id=request.session.get("td_id","") 36 | if id!="": 37 | try: 38 | tk = User_Keys.objects.get(id=id) 39 | if tk.properties["status"] == "trusted": res = "OK" 40 | except: 41 | pass 42 | return HttpResponse(res) 43 | 44 | def getCookie(request): 45 | tk = User_Keys.objects.get(id=request.session["td_id"]) 46 | 47 | if tk.properties["status"] == "trusted": 48 | context={"added":True} 49 | response = render(request,"TrustedDevices/Done.html", context) 50 | from datetime import datetime, timedelta 51 | expires = datetime.now() + timedelta(days=180) 52 | tk.expires=expires 53 | tk.save() 54 | response.set_cookie("deviceid", tk.properties["signature"], expires=expires) 55 | return response 56 | 57 | def add(request): 58 | context=csrf(request) 59 | if request.method=="GET": 60 | return render(request,"TrustedDevices/Add.html",context) 61 | else: 62 | key=request.POST["key"].replace("-","").replace(" ","").upper() 63 | context["username"] = request.POST["username"] 64 | context["key"] = request.POST["key"] 65 | trusted_keys=User_Keys.objects.filter(username=request.POST["username"],properties__has="$.key="+key) 66 | cookie=False 67 | if trusted_keys.exists(): 68 | tk=trusted_keys[0] 69 | request.session["td_id"]=tk.id 70 | ua=request.META['HTTP_USER_AGENT'] 71 | agent=user_agents.parse(ua) 72 | if agent.is_pc: 73 | context["invalid"]="This is a PC, it can't used as a trusted device." 74 | else: 75 | tk.properties["user_agent"]=ua 76 | tk.save() 77 | context["success"]=True 78 | # tk.properties["user_agent"]=ua 79 | # tk.save() 80 | # context["success"]=True 81 | 82 | else: 83 | context["invalid"]="The username or key is wrong, please check and try again." 84 | 85 | return render(request,"TrustedDevices/Add.html", context) 86 | 87 | def start(request): 88 | if User_Keys.objects.filter(username=request.user.username,key_type="Trusted Device").count()>= 2: 89 | return render(request,"TrustedDevices/start.html",{"not_allowed":True}) 90 | td=None 91 | if not request.session.get("td_id",None): 92 | td=User_Keys() 93 | td.username=request.user.username 94 | td.properties={"key":id_generator(),"status":"adding"} 95 | td.key_type="Trusted Device" 96 | td.save() 97 | request.session["td_id"]=td.id 98 | try: 99 | if td==None: td=User_Keys.objects.get(id=request.session["td_id"]) 100 | context={"key":td.properties["key"]} 101 | except: 102 | del request.session["td_id"] 103 | return start(request) 104 | return render(request,"TrustedDevices/start.html",context) 105 | 106 | def send_email(request): 107 | body=render(request,"TrustedDevices/email.html",{}).content 108 | from .Common import send 109 | e=request.user.email 110 | if e=="": 111 | e=request.session.get("user",{}).get("email","") 112 | if e=="": 113 | res = "User has no email on the system." 114 | elif send([e],"Add Trusted Device Link",body): 115 | res="Sent Successfully" 116 | else: 117 | res="Error occured, please try again later." 118 | return HttpResponse(res) 119 | 120 | 121 | def verify(request): 122 | if request.COOKIES.get('deviceid',None): 123 | from jose import jwt 124 | json= jwt.decode(request.COOKIES.get('deviceid'),settings.SECRET_KEY) 125 | if json["username"].lower()== request.session['base_username'].lower(): 126 | try: 127 | uk = User_Keys.objects.get(username=request.POST["username"].lower(), properties__has="$.key=" + json["key"]) 128 | if uk.enabled and uk.properties["status"] == "trusted": 129 | uk.last_used=timezone.now() 130 | uk.save() 131 | request.session["mfa"] = {"verified": True, "method": "Trusted Device","id":uk.id} 132 | return True 133 | except: 134 | return False 135 | return False 136 | -------------------------------------------------------------------------------- /mfa/U2F.py: -------------------------------------------------------------------------------- 1 | 2 | from u2flib_server.u2f import (begin_registration, begin_authentication, 3 | complete_registration, complete_authentication) 4 | from cryptography import x509 5 | from cryptography.hazmat.backends import default_backend 6 | from cryptography.hazmat.primitives.serialization import Encoding 7 | from django.shortcuts import render 8 | import simplejson 9 | #from django.template.context import RequestContext 10 | from django.template.context_processors import csrf 11 | from django.conf import settings 12 | from django.http import HttpResponse 13 | from .models import * 14 | from .views import login 15 | import datetime 16 | from django.utils import timezone 17 | 18 | def recheck(request): 19 | context = csrf(request) 20 | context["mode"]="recheck" 21 | s = sign(request.user.username) 22 | request.session["_u2f_challenge_"] = s[0] 23 | context["token"] = s[1] 24 | request.session["mfa_recheck"]=True 25 | return render(request,"U2F/recheck.html", context) 26 | 27 | def process_recheck(request): 28 | x=validate(request,request.user.username) 29 | if x==True: 30 | import time 31 | request.session["mfa"]["rechecked_at"] = time.time() 32 | return HttpResponse(simplejson.dumps({"recheck":True}),content_type="application/json") 33 | return x 34 | 35 | def check_errors(request, data): 36 | if "errorCode" in data: 37 | if data["errorCode"] == 0: return True 38 | if data["errorCode"] == 4: 39 | return HttpResponse("Invalid Security Key") 40 | if data["errorCode"] == 1: 41 | return auth(request) 42 | return True 43 | def validate(request,username): 44 | import datetime, random 45 | 46 | data = simplejson.loads(request.POST["response"]) 47 | 48 | res= check_errors(request,data) 49 | if res!=True: 50 | return res 51 | 52 | challenge = request.session.pop('_u2f_challenge_') 53 | device, c, t = complete_authentication(challenge, data, [settings.U2F_APPID]) 54 | 55 | key=User_Keys.objects.get(username=username,properties__shas="$.device.publicKey=%s"%device["publicKey"]) 56 | key.last_used=timezone.now() 57 | key.save() 58 | mfa = {"verified": True, "method": "U2F","id":key.id} 59 | if getattr(settings, "MFA_RECHECK", False): 60 | mfa["next_check"] = datetime.datetime.timestamp((datetime.datetime.now() 61 | + datetime.timedelta( 62 | seconds=random.randint(settings.MFA_RECHECK_MIN, settings.MFA_RECHECK_MAX)))) 63 | request.session["mfa"] = mfa 64 | return True 65 | 66 | def auth(request): 67 | context=csrf(request) 68 | s=sign(request.session["base_username"]) 69 | request.session["_u2f_challenge_"]=s[0] 70 | context["token"]=s[1] 71 | 72 | return render(request,"U2F/Auth.html") 73 | 74 | def start(request): 75 | enroll = begin_registration(settings.U2F_APPID, []) 76 | request.session['_u2f_enroll_'] = enroll.json 77 | context=csrf(request) 78 | context["token"]=simplejson.dumps(enroll.data_for_client) 79 | return render(request,"U2F/Add.html",context) 80 | 81 | 82 | def bind(request): 83 | import hashlib 84 | enroll = request.session['_u2f_enroll_'] 85 | data=simplejson.loads(request.POST["response"]) 86 | device, cert = complete_registration(enroll, data, [settings.U2F_APPID]) 87 | cert = x509.load_der_x509_certificate(cert, default_backend()) 88 | cert_hash=hashlib.md5(cert.public_bytes(Encoding.PEM)).hexdigest() 89 | q=User_Keys.objects.filter(key_type="U2F", properties__icontains= cert_hash) 90 | if q.exists(): 91 | return HttpResponse("This key is registered before, it can't be registered again.") 92 | User_Keys.objects.filter(username=request.user.username,key_type="U2F").delete() 93 | uk = User_Keys() 94 | uk.username = request.user.username 95 | uk.owned_by_enterprise = getattr(settings, "MFA_OWNED_BY_ENTERPRISE", False) 96 | uk.properties = {"device":simplejson.loads(device.json),"cert":cert_hash} 97 | uk.key_type = "U2F" 98 | uk.save() 99 | return HttpResponse("OK") 100 | 101 | def sign(username): 102 | u2f_devices=[d.properties["device"] for d in User_Keys.objects.filter(username=username,key_type="U2F")] 103 | challenge = begin_authentication(settings.U2F_APPID, u2f_devices) 104 | return [challenge.json,simplejson.dumps(challenge.data_for_client)] 105 | 106 | def verify(request): 107 | x= validate(request,request.session["base_username"]) 108 | if x==True: 109 | return login(request) 110 | else: return x 111 | -------------------------------------------------------------------------------- /mfa/__init__.py: -------------------------------------------------------------------------------- 1 | __version__="2.1.2" 2 | -------------------------------------------------------------------------------- /mfa/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from mfa.models import User_Keys 3 | 4 | 5 | @admin.register(User_Keys) 6 | class User_KeysAdmin(admin.ModelAdmin): 7 | list_display = ('username', 'added_on', 'key_type', 'owned_by_enterprise',) 8 | -------------------------------------------------------------------------------- /mfa/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | class myAppNameConfig(AppConfig): 3 | name = 'mfa' 4 | verbose_name = 'A Much Better Name' -------------------------------------------------------------------------------- /mfa/helpers.py: -------------------------------------------------------------------------------- 1 | import pyotp 2 | from .models import * 3 | from . import TrustedDevice, U2F, FIDO2, totp 4 | import simplejson 5 | from django.shortcuts import HttpResponse 6 | from mfa.views import verify,goto 7 | def has_mfa(request,username): 8 | if User_Keys.objects.filter(username=username,enabled=1).count()>0: 9 | return verify(request, username) 10 | return False 11 | 12 | def is_mfa(request,ignore_methods=[]): 13 | if request.session.get("mfa",{}).get("verified",False): 14 | if not request.session.get("mfa",{}).get("method",None) in ignore_methods: 15 | return True 16 | return False 17 | 18 | def recheck(request): 19 | method=request.session.get("mfa",{}).get("method",None) 20 | if not method: 21 | return HttpResponse(simplejson.dumps({"res":False}),content_type="application/json") 22 | if method=="Trusted Device": 23 | return HttpResponse(simplejson.dumps({"res":TrustedDevice.verify(request)}),content_type="application/json") 24 | elif method=="U2F": 25 | return HttpResponse(simplejson.dumps({"html": U2F.recheck(request).content}), content_type="application/json") 26 | elif method == "FIDO2": 27 | return HttpResponse(simplejson.dumps({"html": FIDO2.recheck(request).content}), content_type="application/json") 28 | elif method=="TOTP": 29 | return HttpResponse(simplejson.dumps({"html": totp.recheck(request).content}), content_type="application/json") 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /mfa/middleware.py: -------------------------------------------------------------------------------- 1 | import time 2 | from django.http import HttpResponseRedirect 3 | from django.core.urlresolvers import reverse 4 | from django.conf import settings 5 | def process(request): 6 | next_check=request.session.get('mfa',{}).get("next_check",False) 7 | if not next_check: return None 8 | now=int(time.time()) 9 | if now >= next_check: 10 | method=request.session["mfa"]["method"] 11 | path = request.META["PATH_INFO"] 12 | return HttpResponseRedirect(reverse(method+"_auth")+"?next=%s"%(settings.BASE_URL + path).replace("//", "/")) 13 | return None -------------------------------------------------------------------------------- /mfa/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.3 on 2021-05-26 09:05 2 | 3 | from django.db import migrations, models 4 | import jsonfield.fields 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='User_Keys', 17 | fields=[ 18 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('username', models.CharField(max_length=50)), 20 | ('properties', jsonfield.fields.JSONField(null=True)), 21 | ('added_on', models.DateTimeField(auto_now_add=True)), 22 | ('key_type', models.CharField(default='TOTP', max_length=25)), 23 | ('enabled', models.BooleanField(default=True)), 24 | ('expires', models.DateTimeField(blank=True, default=None, null=True)), 25 | ('last_used', models.DateTimeField(blank=True, default=None, null=True)), 26 | ('owned_by_enterprise', models.BooleanField(blank=True, default=None, null=True)), 27 | ], 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /mfa/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sirneij/django_mfa2_example/d9505213e7e914481f06ea3426a515f5e0d26bc2/mfa/migrations/__init__.py -------------------------------------------------------------------------------- /mfa/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from jsonfield import JSONField 3 | from jose import jwt 4 | from django.conf import settings 5 | from jsonLookup import shasLookup, hasLookup 6 | JSONField.register_lookup(shasLookup) 7 | JSONField.register_lookup(hasLookup) 8 | 9 | 10 | class User_Keys(models.Model): 11 | username=models.CharField(max_length = 50) 12 | properties=JSONField(null = True) 13 | added_on=models.DateTimeField(auto_now_add = True) 14 | key_type=models.CharField(max_length = 25,default = "TOTP") 15 | enabled=models.BooleanField(default=True) 16 | expires=models.DateTimeField(null=True,default=None,blank=True) 17 | last_used=models.DateTimeField(null=True,default=None,blank=True) 18 | owned_by_enterprise=models.BooleanField(default=None,null=True,blank=True) 19 | 20 | def save(self, force_insert=False, force_update=False, using=None, update_fields=None): 21 | if self.key_type == "Trusted Device" and self.properties.get("signature","") == "": 22 | self.properties["signature"]= jwt.encode({"username": self.username, "key": self.properties["key"]}, settings.SECRET_KEY) 23 | super(User_Keys, self).save(force_insert=force_insert, force_update=force_update, using=using, update_fields=update_fields) 24 | 25 | def __unicode__(self): 26 | return "%s -- %s"%(self.username,self.key_type) 27 | 28 | def __str__(self): 29 | return self.__unicode__() 30 | 31 | class Meta: 32 | app_label='mfa' 33 | -------------------------------------------------------------------------------- /mfa/static/mfa/css/bootstrap-toggle.min.css: -------------------------------------------------------------------------------- 1 | /*! ======================================================================== 2 | * Bootstrap Toggle: bootstrap-toggle.css v2.2.0 3 | * http://www.bootstraptoggle.com 4 | * ======================================================================== 5 | * Copyright 2014 Min Hur, The New York Times Company 6 | * Licensed under MIT 7 | * ======================================================================== */ 8 | .checkbox label .toggle,.checkbox-inline .toggle{margin-left:-20px;margin-right:5px} 9 | .toggle{position:relative;overflow:hidden} 10 | .toggle input[type=checkbox]{display:none} 11 | .toggle-group{position:absolute;width:200%;top:0;bottom:0;left:0;transition:left .35s;-webkit-transition:left .35s;-moz-user-select:none;-webkit-user-select:none} 12 | .toggle.off .toggle-group{left:-100%} 13 | .toggle-on{position:absolute;top:0;bottom:0;left:0;right:50%;margin:0;border:0;border-radius:0} 14 | .toggle-off{position:absolute;top:0;bottom:0;left:50%;right:0;margin:0;border:0;border-radius:0} 15 | .toggle-handle{position:relative;margin:0 auto;padding-top:0;padding-bottom:0;height:100%;width:0;border-width:0 1px} 16 | .toggle.btn{min-width:59px;min-height:34px} 17 | .toggle-on.btn{padding-right:24px} 18 | .toggle-off.btn{padding-left:24px} 19 | .toggle.btn-lg{min-width:79px;min-height:45px} 20 | .toggle-on.btn-lg{padding-right:31px} 21 | .toggle-off.btn-lg{padding-left:31px} 22 | .toggle-handle.btn-lg{width:40px} 23 | .toggle.btn-sm{min-width:50px;min-height:30px} 24 | .toggle-on.btn-sm{padding-right:20px} 25 | .toggle-off.btn-sm{padding-left:20px} 26 | .toggle.btn-xs{min-width:35px;min-height:22px} 27 | .toggle-on.btn-xs{padding-right:12px} 28 | .toggle-off.btn-xs{padding-left:12px} -------------------------------------------------------------------------------- /mfa/static/mfa/js/bootstrap-toggle.min.js: -------------------------------------------------------------------------------- 1 | /*! ======================================================================== 2 | * Bootstrap Toggle: bootstrap-toggle.js v2.2.0 3 | * http://www.bootstraptoggle.com 4 | * ======================================================================== 5 | * Copyright 2014 Min Hur, The New York Times Company 6 | * Licensed under MIT 7 | * ======================================================================== */ 8 | +function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.toggle"),f="object"==typeof b&&b;e||d.data("bs.toggle",e=new c(this,f)),"string"==typeof b&&e[b]&&e[b]()})}var c=function(b,c){this.$element=a(b),this.options=a.extend({},this.defaults(),c),this.render()};c.VERSION="2.2.0",c.DEFAULTS={on:"On",off:"Off",onstyle:"primary",offstyle:"default",size:"normal",style:"",width:null,height:null},c.prototype.defaults=function(){return{on:this.$element.attr("data-on")||c.DEFAULTS.on,off:this.$element.attr("data-off")||c.DEFAULTS.off,onstyle:this.$element.attr("data-onstyle")||c.DEFAULTS.onstyle,offstyle:this.$element.attr("data-offstyle")||c.DEFAULTS.offstyle,size:this.$element.attr("data-size")||c.DEFAULTS.size,style:this.$element.attr("data-style")||c.DEFAULTS.style,width:this.$element.attr("data-width")||c.DEFAULTS.width,height:this.$element.attr("data-height")||c.DEFAULTS.height}},c.prototype.render=function(){this._onstyle="btn-"+this.options.onstyle,this._offstyle="btn-"+this.options.offstyle;var b="large"===this.options.size?"btn-lg":"small"===this.options.size?"btn-sm":"mini"===this.options.size?"btn-xs":"",c=a('