From 0e8380ffd83d9648e177990e889cd63e8f98b686 Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Tue, 24 Aug 2021 21:17:55 +0200 Subject: [PATCH 1/9] Update docker image --- docker/django | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/django b/docker/django index 5b9b7e0..340cee6 100644 --- a/docker/django +++ b/docker/django @@ -1,4 +1,4 @@ -FROM python:3.7-buster +FROM python:3.9-slim RUN pip install poetry From 25e0bc11972184f6a01e7a5353edc8680c40c697 Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Tue, 24 Aug 2021 21:41:21 +0200 Subject: [PATCH 2/9] Add two factor dependency --- poetry.lock | 118 +++++++++++++++++++++++++++++++++++- pyproject.toml | 1 + src/newsreader/conf/base.py | 6 ++ src/newsreader/urls.py | 2 + 4 files changed, 126 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index d098bd6..f812065 100644 --- a/poetry.lock +++ b/poetry.lock @@ -192,7 +192,7 @@ importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} name = "colorama" version = "0.4.4" description = "Cross-platform colored terminal text." -category = "dev" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" @@ -298,6 +298,17 @@ python-versions = "*" [package.dependencies] six = ">=1.2" +[[package]] +name = "django-formtools" +version = "2.3" +description = "A set of high-level abstractions for Django forms" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +Django = ">=2.2" + [[package]] name = "django-ipware" version = "4.0.0" @@ -306,6 +317,35 @@ category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +[[package]] +name = "django-otp" +version = "1.0.6" +description = "A pluggable framework for adding two-factor authentication to Django using one-time passwords." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +django = ">=2.2" + +[package.extras] +qrcode = ["qrcode"] + +[[package]] +name = "django-phonenumber-field" +version = "5.2.0" +description = "An international phone number field for django models." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +Django = ">=2.2" + +[package.extras] +phonenumbers = ["phonenumbers (>=7.0.2)"] +phonenumberslite = ["phonenumberslite (>=7.0.2)"] + [[package]] name = "django-registration-redux" version = "2.9" @@ -329,6 +369,29 @@ pytz = "*" [package.extras] rest_framework = ["djangorestframework (>=3.0.0)"] +[[package]] +name = "django-two-factor-auth" +version = "1.13.1" +description = "Complete Two-Factor Authentication for Django" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +Django = ">=2.2" +django-formtools = "*" +django-otp = ">=0.8.0" +django-phonenumber-field = ">=1.1.0,<6" +phonenumberslite = {version = ">=7.0.9,<8.99", optional = true, markers = "extra == \"phonenumberslite\""} +qrcode = ">=4.0.0,<6.99" + +[package.extras] +call = ["twilio (>=6.0)"] +phonenumbers = ["phonenumbers (>=7.0.9,<8.99)"] +phonenumberslite = ["phonenumberslite (>=7.0.9,<8.99)"] +sms = ["twilio (>=6.0)"] +yubikey = ["django-otp-yubikey"] + [[package]] name = "djangorestframework" version = "3.12.4" @@ -575,6 +638,14 @@ python-versions = ">=3.6" [package.dependencies] pyparsing = ">=2.0.2" +[[package]] +name = "phonenumberslite" +version = "8.12.31" +description = "Python version of Google's common library for parsing, formatting, storing and validating international phone numbers." +category = "main" +optional = false +python-versions = "*" + [[package]] name = "psycopg2-binary" version = "2.9.1" @@ -655,6 +726,24 @@ category = "main" optional = false python-versions = "*" +[[package]] +name = "qrcode" +version = "6.1" +description = "QR Code image generator" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} +six = "*" + +[package.extras] +dev = ["tox", "pytest", "mock"] +maintainer = ["zest.releaser"] +pil = ["pillow"] +test = ["pytest", "pytest-cov", "mock"] + [[package]] name = "requests" version = "2.26.0" @@ -1007,10 +1096,22 @@ django-extensions = [ {file = "django-extensions-2.2.9.tar.gz", hash = "sha256:2f81b618ba4d1b0e58603e25012e5c74f88a4b706e0022a3b21f24f0322a6ce6"}, {file = "django_extensions-2.2.9-py2.py3-none-any.whl", hash = "sha256:b19182d101a441fe001c5753553a901e2ef3ff60e8fbbe38881eb4a61fdd17c4"}, ] +django-formtools = [ + {file = "django-formtools-2.3.tar.gz", hash = "sha256:9663b6eca64777b68d6d4142efad8597fe9a685924673b25aa8a1dcff4db00c3"}, + {file = "django_formtools-2.3-py3-none-any.whl", hash = "sha256:4699937e19ee041d803943714fe0c1c7ad4cab802600eb64bbf4cdd0a1bfe7d9"}, +] django-ipware = [ {file = "django-ipware-4.0.0.tar.gz", hash = "sha256:1294f916f3b3475e40e1b0ec1bd320aa2397978eae672721c81cbc2ed517e9ee"}, {file = "django_ipware-4.0.0-py2.py3-none-any.whl", hash = "sha256:116bd0d7940f09bf7ffd465943992e23d87e772a9d6c0d3a57b74040589a383b"}, ] +django-otp = [ + {file = "django-otp-1.0.6.tar.gz", hash = "sha256:0d56dd2a7fbb6ee6e54557e036ca64add0bd3596f471794bad673b7637d5e935"}, + {file = "django_otp-1.0.6-py3-none-any.whl", hash = "sha256:01b5888f0bde5125e139433aacb947e52d5c406fa56c9db43c3e8d75b5c323c4"}, +] +django-phonenumber-field = [ + {file = "django-phonenumber-field-5.2.0.tar.gz", hash = "sha256:52b2e5970133ec5ab701218b802f7ab237229854dc95fd239b7e9e77dc43731d"}, + {file = "django_phonenumber_field-5.2.0-py3-none-any.whl", hash = "sha256:5547fb2b2cc690a306ba77a5038419afc8fa8298a486fb7895008e9067cc7e75"}, +] django-registration-redux = [ {file = "django-registration-redux-2.9.tar.gz", hash = "sha256:e3d123354a1b8cbfa005d60f1ebb89ae8541f3eaffd6174d9f2aff529b57e430"}, {file = "django_registration_redux-2.9-py2.py3-none-any.whl", hash = "sha256:e94b8a945e1cbfa9ec6c32b549597270405328d4e26651985d287d0211120691"}, @@ -1019,6 +1120,10 @@ django-timezone-field = [ {file = "django-timezone-field-4.2.1.tar.gz", hash = "sha256:97780cde658daa5094ae515bb55ca97c1352928ab554041207ad515dee3fe971"}, {file = "django_timezone_field-4.2.1-py3-none-any.whl", hash = "sha256:6dc782e31036a58da35b553bd00c70f112d794700025270d8a6a4c1d2e5b26c6"}, ] +django-two-factor-auth = [ + {file = "django-two-factor-auth-1.13.1.tar.gz", hash = "sha256:a20e03d256fd9fd668988545f052cedcc47e5a981888562e5e27d0bb83deae89"}, + {file = "django_two_factor_auth-1.13.1-py2.py3-none-any.whl", hash = "sha256:d270d4288731233621a9462a89a8dfed2dcb86fa354125c816a89772d55f9e29"}, +] djangorestframework = [ {file = "djangorestframework-3.12.4-py3-none-any.whl", hash = "sha256:6d1d59f623a5ad0509fe0d6bfe93cbdfe17b8116ebc8eda86d45f6e16e819aaf"}, {file = "djangorestframework-3.12.4.tar.gz", hash = "sha256:f747949a8ddac876e879190df194b925c177cdeb725a099db1460872f7c0a7f2"}, @@ -1159,6 +1264,10 @@ packaging = [ {file = "packaging-21.0-py3-none-any.whl", hash = "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14"}, {file = "packaging-21.0.tar.gz", hash = "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7"}, ] +phonenumberslite = [ + {file = "phonenumberslite-8.12.31-py2.py3-none-any.whl", hash = "sha256:c593d2716dee6726f30d8e13c2fabf4b6d15551adfeb6a424c893c65686fb829"}, + {file = "phonenumberslite-8.12.31.tar.gz", hash = "sha256:19ba2c15b0926707e670e58faafe80957344db4bae1479d74fa4ec34b3d8632a"}, +] psycopg2-binary = [ {file = "psycopg2-binary-2.9.1.tar.gz", hash = "sha256:b0221ca5a9837e040ebf61f48899926b5783668b7807419e4adae8175a31f773"}, {file = "psycopg2_binary-2.9.1-cp36-cp36m-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:c250a7ec489b652c892e4f0a5d122cc14c3780f9f643e1a326754aedf82d9a76"}, @@ -1217,6 +1326,10 @@ pytz = [ {file = "pytz-2021.1-py2.py3-none-any.whl", hash = "sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798"}, {file = "pytz-2021.1.tar.gz", hash = "sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da"}, ] +qrcode = [ + {file = "qrcode-6.1-py2.py3-none-any.whl", hash = "sha256:3996ee560fc39532910603704c82980ff6d4d5d629f9c3f25f34174ce8606cf5"}, + {file = "qrcode-6.1.tar.gz", hash = "sha256:505253854f607f2abf4d16092c61d4e9d511a3b4392e60bff957a68592b04369"}, +] requests = [ {file = "requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"}, {file = "requests-2.26.0.tar.gz", hash = "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"}, @@ -1260,6 +1373,9 @@ sentry-sdk = [ sgmllib3k = [ {file = "sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9"}, ] +sgmllib3k = [ + {file = "sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9"}, +] six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, diff --git a/pyproject.toml b/pyproject.toml index 6b3757f..bcaed9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ python-dotenv = "^0.12.0" sentry-sdk = {version = "^1.0.0", optional = true} ftfy = "^5.8" requests_oauthlib = "^1.3.0" +django-two-factor-auth = {extras = ["phonenumberslite"], version = "^1.13.1"} [tool.poetry.extras] sentry = ["sentry_sdk"] diff --git a/src/newsreader/conf/base.py b/src/newsreader/conf/base.py index cd51218..c1f3b16 100644 --- a/src/newsreader/conf/base.py +++ b/src/newsreader/conf/base.py @@ -42,6 +42,10 @@ INSTALLED_APPS = [ "django_celery_beat", "registration", "axes", + "django_otp", + "django_otp.plugins.otp_static", + "django_otp.plugins.otp_totp", + "two_factor", # app modules "newsreader.accounts", "newsreader.utils", @@ -61,6 +65,7 @@ MIDDLEWARE = [ "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", "axes.middleware.AxesMiddleware", @@ -182,6 +187,7 @@ AUTH_PASSWORD_VALIDATORS = [ # Authentication user model AUTH_USER_MODEL = "accounts.User" +LOGIN_URL = "two_factor:login" LOGIN_REDIRECT_URL = "/" # Internationalization diff --git a/src/newsreader/urls.py b/src/newsreader/urls.py index 0779b29..c80ba2f 100644 --- a/src/newsreader/urls.py +++ b/src/newsreader/urls.py @@ -5,6 +5,7 @@ from django.urls import include, path from drf_yasg import openapi from drf_yasg.views import get_schema_view +from two_factor.urls import urlpatterns as two_factor_urls from newsreader.accounts.urls import urlpatterns as login_urls from newsreader.news.core.views import NewsView @@ -18,6 +19,7 @@ schema_info = openapi.Info(title="Newsreader API", default_version="v1") schema_view = get_schema_view(schema_info, patterns=api_patterns) urlpatterns = [ + path("", include(two_factor_urls)), path("", login_required(NewsView.as_view()), name="index"), path("", include((news_patterns, "news"))), path("", include((api_patterns, "api"))), From 9b38b05a111820cf983f4aa080dc4979e20e7f31 Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Tue, 24 Aug 2021 21:51:32 +0200 Subject: [PATCH 3/9] Update settings --- src/newsreader/conf/dev.py | 2 ++ src/newsreader/conf/docker.py | 2 ++ src/newsreader/conf/sentry.py | 0 src/newsreader/urls.py | 2 +- 4 files changed, 5 insertions(+), 1 deletion(-) delete mode 100644 src/newsreader/conf/sentry.py diff --git a/src/newsreader/conf/dev.py b/src/newsreader/conf/dev.py index 9372350..abda195 100644 --- a/src/newsreader/conf/dev.py +++ b/src/newsreader/conf/dev.py @@ -2,6 +2,8 @@ from .base import * # isort:skip from .version import get_current_version +LOGGING.update({"loggers": {"two_factor": {"handlers": ["console"], "level": "INFO"}}}) + SECRET_KEY = "mv4&5#+)-=abz3^&1r^nk_ca6y54--p(4n4cg%z*g&rb64j%wl" MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"] diff --git a/src/newsreader/conf/docker.py b/src/newsreader/conf/docker.py index 9b62961..878f319 100644 --- a/src/newsreader/conf/docker.py +++ b/src/newsreader/conf/docker.py @@ -2,6 +2,8 @@ from .base import * # isort:skip from .version import get_current_version +LOGGING.update({"loggers": {"two_factor": {"handlers": ["console"], "level": "INFO"}}}) + SECRET_KEY = "=q(ztyo)b6noom#a164g&s9vcj1aawa^g#ing_ir99=_zl4g&$" INSTALLED_APPS += ["debug_toolbar", "django_extensions"] diff --git a/src/newsreader/conf/sentry.py b/src/newsreader/conf/sentry.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/newsreader/urls.py b/src/newsreader/urls.py index c80ba2f..916042a 100644 --- a/src/newsreader/urls.py +++ b/src/newsreader/urls.py @@ -19,7 +19,7 @@ schema_info = openapi.Info(title="Newsreader API", default_version="v1") schema_view = get_schema_view(schema_info, patterns=api_patterns) urlpatterns = [ - path("", include(two_factor_urls)), + path("", include(two_factor_urls)), # TODO enforce two factor auth path("", login_required(NewsView.as_view()), name="index"), path("", include((news_patterns, "news"))), path("", include((api_patterns, "api"))), From 7504acced272f6c316bc80b4f9f0a3bee681ce07 Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Sun, 29 Aug 2021 21:48:19 +0200 Subject: [PATCH 4/9] Enforce admin two factor auth --- src/newsreader/urls.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/newsreader/urls.py b/src/newsreader/urls.py index 916042a..63879a6 100644 --- a/src/newsreader/urls.py +++ b/src/newsreader/urls.py @@ -5,6 +5,7 @@ from django.urls import include, path from drf_yasg import openapi from drf_yasg.views import get_schema_view +from two_factor.admin import AdminSiteOTPRequired from two_factor.urls import urlpatterns as two_factor_urls from newsreader.accounts.urls import urlpatterns as login_urls @@ -18,8 +19,10 @@ api_patterns = [path("api/", include((news_endpoints, "news")))] schema_info = openapi.Info(title="Newsreader API", default_version="v1") schema_view = get_schema_view(schema_info, patterns=api_patterns) +admin.site.__class__ = AdminSiteOTPRequired + urlpatterns = [ - path("", include(two_factor_urls)), # TODO enforce two factor auth + path("", include(two_factor_urls)), path("", login_required(NewsView.as_view()), name="index"), path("", include((news_patterns, "news"))), path("", include((api_patterns, "api"))), From 1087ce4a3c9bdc95977c70e1906e41d655eb1f7a Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Sun, 29 Aug 2021 22:43:11 +0200 Subject: [PATCH 5/9] Start login template refactor --- .../accounts/components/login-form.html | 73 +++++++++++++++++-- .../templates/accounts/views/login.html | 2 +- src/newsreader/accounts/views/auth.py | 4 +- 3 files changed, 71 insertions(+), 8 deletions(-) diff --git a/src/newsreader/accounts/templates/accounts/components/login-form.html b/src/newsreader/accounts/templates/accounts/components/login-form.html index 87dceb9..3bfd952 100644 --- a/src/newsreader/accounts/templates/accounts/components/login-form.html +++ b/src/newsreader/accounts/templates/accounts/components/login-form.html @@ -1,17 +1,78 @@ {% extends "components/form/form.html" %} {% load i18n %} +{# TODO incorporate formtools wizard #} +{# TODO add support for other devices and backup tokens #} +{# see two_factor/templates/two_factor/core/login.html #} + +{% block intro %} +
+ {% if wizard.steps.current == 'auth' %} +

{% blocktrans %}Enter your credentials.{% endblocktrans %}

+ {% elif wizard.steps.current == 'token' %} + {% if device.method == 'call' %} +

+ {% blocktrans trimmed %} + We are calling your phone right now, please enter the digits you hear. + {% endblocktrans %} +

+ {% elif device.method == 'sms' %} +

+ {% blocktrans trimmed %} + We sent you a text message, please enter the tokens we sent. + {% endblocktrans %} +

+ {% else %} +

+ {% blocktrans trimmed %} + Please enter the tokens generated by your token generator. + {% endblocktrans %} +

+ {% endif %} + {% elif wizard.steps.current == 'backup' %} +

+ {% blocktrans trimmed %} + Use this form for entering backup tokens for logging in. + These tokens have been generated for you to print and keep safe. Please + enter one of these backup tokens to login to your account. + {% endblocktrans %} +

+ {% endif %} +
+{% endblock intro %} + +{# TODO test this #} +{% block fields %} + {{ wizard.management_form }} + {{ block.super }} +{% endblock fields %} + {% block actions %}
- {% include "components/form/cancel-button.html" %} + {% if cancel_url %} + {% include "components/form/cancel-button.html" %} + {% endif %} + + {% if wizard.steps.prev %} + + {% else %} + + {% endif %} + {% include "components/form/confirm-button.html" %}
-
- - {% trans "I forgot my password" %} - -
+ {% if wizard.steps.index == wizard.steps.first %} +
+ + {% trans "I forgot my password" %} + +
+ {% endif %}
{% endblock actions %} diff --git a/src/newsreader/accounts/templates/accounts/views/login.html b/src/newsreader/accounts/templates/accounts/views/login.html index b4c391d..4e1f283 100644 --- a/src/newsreader/accounts/templates/accounts/views/login.html +++ b/src/newsreader/accounts/templates/accounts/views/login.html @@ -2,6 +2,6 @@ {% block content %}
- {% include "accounts/components/login-form.html" with form=form title="Login" confirm_text="Login" %} + {% include "accounts/components/login-form.html" with title="Login" confirm_text="Next" %}
{% endblock %} diff --git a/src/newsreader/accounts/views/auth.py b/src/newsreader/accounts/views/auth.py index 0663768..c478e26 100644 --- a/src/newsreader/accounts/views/auth.py +++ b/src/newsreader/accounts/views/auth.py @@ -1,8 +1,10 @@ from django.contrib.auth import views as django_views from django.urls import reverse_lazy +from two_factor import views as two_factor_views -class LoginView(django_views.LoginView): + +class LoginView(two_factor_views.LoginView): template_name = "accounts/views/login.html" success_url = reverse_lazy("index") From ef7c2fef1bc0040d8aad43ab4bd041858f42f90d Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Sat, 25 Sep 2021 09:54:06 +0200 Subject: [PATCH 6/9] Move 2 factor urls --- .../accounts/components/login-form.html | 4 +-- src/newsreader/accounts/urls.py | 32 +++++++++++++++++++ src/newsreader/accounts/views/auth.py | 10 ++++-- src/newsreader/conf/base.py | 3 +- src/newsreader/urls.py | 2 -- 5 files changed, 42 insertions(+), 9 deletions(-) diff --git a/src/newsreader/accounts/templates/accounts/components/login-form.html b/src/newsreader/accounts/templates/accounts/components/login-form.html index 3bfd952..71f16ae 100644 --- a/src/newsreader/accounts/templates/accounts/components/login-form.html +++ b/src/newsreader/accounts/templates/accounts/components/login-form.html @@ -7,9 +7,7 @@ {% block intro %}
- {% if wizard.steps.current == 'auth' %} -

{% blocktrans %}Enter your credentials.{% endblocktrans %}

- {% elif wizard.steps.current == 'token' %} + {% if wizard.steps.current == 'token' %} {% if device.method == 'call' %}

{% blocktrans trimmed %} diff --git a/src/newsreader/accounts/urls.py b/src/newsreader/accounts/urls.py index 0eaee5c..c5b9944 100644 --- a/src/newsreader/accounts/urls.py +++ b/src/newsreader/accounts/urls.py @@ -1,6 +1,18 @@ from django.contrib.auth.decorators import login_required from django.urls import include, path +from two_factor.views import ( + BackupTokensView, + DisableView, + LoginView, + PhoneDeleteView, + PhoneSetupView, + ProfileView, + QRGeneratorView, + SetupCompleteView, + SetupView, +) + from newsreader.accounts.views import ( ActivationCompleteView, ActivationResendView, @@ -67,8 +79,28 @@ settings_patterns = [ path("", login_required(SettingsView.as_view()), name="home"), ] +two_factor = [ + path("accounts/setup/", SetupView.as_view(), name="setup"), + path("accounts/qrcode/", QRGeneratorView.as_view(), name="qr"), + path( + "accounts/setup/complete/", SetupCompleteView.as_view(), name="setup_complete" + ), + path("accounts/backup/tokens/", BackupTokensView.as_view(), name="backup_tokens"), + path( + "accounts/backup/phone/register/", PhoneSetupView.as_view(), name="phone_create" + ), + path( + "accounts/backup/phone/unregister//", + PhoneDeleteView.as_view(), + name="phone_delete", + ), + path("accounts/profile/", ProfileView.as_view(), name="profile"), + path("accounts/disable/", DisableView.as_view(), name="disable"), +] + urlpatterns = [ # Auth + path("", include((two_factor, "two_factor"))), path("login/", LoginView.as_view(), name="login"), path("logout/", LogoutView.as_view(), name="logout"), # Register diff --git a/src/newsreader/accounts/views/auth.py b/src/newsreader/accounts/views/auth.py index c478e26..8ec2895 100644 --- a/src/newsreader/accounts/views/auth.py +++ b/src/newsreader/accounts/views/auth.py @@ -1,12 +1,16 @@ from django.contrib.auth import views as django_views from django.urls import reverse_lazy -from two_factor import views as two_factor_views +from two_factor.views.core import LoginView as TwoFactorLoginView -class LoginView(two_factor_views.LoginView): +class LoginView(TwoFactorLoginView): + redirect_authenticated_user = True template_name = "accounts/views/login.html" - success_url = reverse_lazy("index") + + def post(self, *args, **kwargs): + print(self.request.POST) + return super().post(*args, **kwargs) class LogoutView(django_views.LogoutView): diff --git a/src/newsreader/conf/base.py b/src/newsreader/conf/base.py index c1f3b16..99f5b44 100644 --- a/src/newsreader/conf/base.py +++ b/src/newsreader/conf/base.py @@ -187,7 +187,7 @@ AUTH_PASSWORD_VALIDATORS = [ # Authentication user model AUTH_USER_MODEL = "accounts.User" -LOGIN_URL = "two_factor:login" +LOGIN_URL = "accounts:login" LOGIN_REDIRECT_URL = "/" # Internationalization @@ -259,6 +259,7 @@ SWAGGER_SETTINGS = { # https://docs.celeryproject.org/en/stable/userguide/configuration.html CELERY_WORKER_HIJACK_ROOT_LOGGER = False +# Registration REGISTRATION_OPEN = True REGISTRATION_AUTO_LOGIN = True ACCOUNT_ACTIVATION_DAYS = 7 diff --git a/src/newsreader/urls.py b/src/newsreader/urls.py index 63879a6..e416d5d 100644 --- a/src/newsreader/urls.py +++ b/src/newsreader/urls.py @@ -6,7 +6,6 @@ from django.urls import include, path from drf_yasg import openapi from drf_yasg.views import get_schema_view from two_factor.admin import AdminSiteOTPRequired -from two_factor.urls import urlpatterns as two_factor_urls from newsreader.accounts.urls import urlpatterns as login_urls from newsreader.news.core.views import NewsView @@ -22,7 +21,6 @@ schema_view = get_schema_view(schema_info, patterns=api_patterns) admin.site.__class__ = AdminSiteOTPRequired urlpatterns = [ - path("", include(two_factor_urls)), path("", login_required(NewsView.as_view()), name="index"), path("", include((news_patterns, "news"))), path("", include((api_patterns, "api"))), From f08d542803f541cc19aa3166f6695ea78fa7e686 Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Sat, 25 Sep 2021 14:16:11 +0200 Subject: [PATCH 7/9] Fix requirements conflict --- poetry.lock | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index f812065..b01bd16 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1373,9 +1373,7 @@ sentry-sdk = [ sgmllib3k = [ {file = "sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9"}, ] -sgmllib3k = [ - {file = "sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9"}, -] + six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, From 79bff5505ed7b4eb590427b2c18037c7a9fae42f Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Sat, 25 Sep 2021 14:16:30 +0200 Subject: [PATCH 8/9] Two factor tidbits - Redirect users who don't have a device setup - Add SetupView --- src/newsreader/accounts/urls.py | 2 +- src/newsreader/accounts/views/__init__.py | 2 +- src/newsreader/accounts/views/auth.py | 19 ++++++++++++++++--- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/newsreader/accounts/urls.py b/src/newsreader/accounts/urls.py index c5b9944..5784b7f 100644 --- a/src/newsreader/accounts/urls.py +++ b/src/newsreader/accounts/urls.py @@ -10,7 +10,6 @@ from two_factor.views import ( ProfileView, QRGeneratorView, SetupCompleteView, - SetupView, ) from newsreader.accounts.views import ( @@ -33,6 +32,7 @@ from newsreader.accounts.views import ( RegistrationCompleteView, RegistrationView, SettingsView, + SetupView, TwitterAuthRedirectView, TwitterRevokeRedirectView, TwitterTemplateView, diff --git a/src/newsreader/accounts/views/__init__.py b/src/newsreader/accounts/views/__init__.py index 3be2b81..5ff5c61 100644 --- a/src/newsreader/accounts/views/__init__.py +++ b/src/newsreader/accounts/views/__init__.py @@ -1,4 +1,4 @@ -from newsreader.accounts.views.auth import LoginView, LogoutView +from newsreader.accounts.views.auth import LoginView, LogoutView, SetupView from newsreader.accounts.views.favicon import FaviconRedirectView from newsreader.accounts.views.integrations import ( IntegrationsView, diff --git a/src/newsreader/accounts/views/auth.py b/src/newsreader/accounts/views/auth.py index 8ec2895..addea11 100644 --- a/src/newsreader/accounts/views/auth.py +++ b/src/newsreader/accounts/views/auth.py @@ -1,17 +1,30 @@ from django.contrib.auth import views as django_views +from django.shortcuts import redirect from django.urls import reverse_lazy from two_factor.views.core import LoginView as TwoFactorLoginView +from two_factor.views.core import SetupView as TwoFactorSetupView class LoginView(TwoFactorLoginView): redirect_authenticated_user = True template_name = "accounts/views/login.html" - def post(self, *args, **kwargs): - print(self.request.POST) - return super().post(*args, **kwargs) + def done(self, form_list, **kwargs): + response = super().done(form_list, **kwargs) + + user = self.get_user() + + if not user.phonedevice_set.exists(): + return redirect("accounts:two_factor:setup") + + return response class LogoutView(django_views.LogoutView): next_page = reverse_lazy("accounts:login") + + +class SetupView(TwoFactorSetupView): + success_url = "accounts:two_factor:setup_complete" + qrcode_url = "accounts:two_factor:qr" From 4e52a4e867ad3ebf92e8fd918357adea63877cda Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Thu, 7 Oct 2021 21:17:39 +0200 Subject: [PATCH 9/9] Use otp_required permission decorator --- src/newsreader/accounts/permissions.py | 8 +++++++- src/newsreader/accounts/urls.py | 23 +++++++++++----------- src/newsreader/conf/base.py | 2 +- src/newsreader/news/collection/urls.py | 27 +++++++++++++------------- src/newsreader/news/core/endpoints.py | 4 ---- src/newsreader/news/core/urls.py | 10 +++++----- src/newsreader/urls.py | 4 ++-- 7 files changed, 40 insertions(+), 38 deletions(-) diff --git a/src/newsreader/accounts/permissions.py b/src/newsreader/accounts/permissions.py index 2c6cf25..8e19735 100644 --- a/src/newsreader/accounts/permissions.py +++ b/src/newsreader/accounts/permissions.py @@ -1,4 +1,4 @@ -from rest_framework.permissions import BasePermission +from rest_framework.permissions import BasePermission, IsAuthenticated class IsOwner(BasePermission): @@ -21,3 +21,9 @@ class IsPostOwner(BasePermission): return bool(is_category_user and is_rule_user) return is_rule_user + + +class TwoFactorAuthenticated(IsAuthenticated): + def has_permission(self, request, view): + is_authenticated = super().has_permission(request, view) + return is_authenticated and request.user.is_verified() diff --git a/src/newsreader/accounts/urls.py b/src/newsreader/accounts/urls.py index 5784b7f..9962509 100644 --- a/src/newsreader/accounts/urls.py +++ b/src/newsreader/accounts/urls.py @@ -1,6 +1,6 @@ -from django.contrib.auth.decorators import login_required from django.urls import include, path +from django_otp.decorators import otp_required from two_factor.views import ( BackupTokensView, DisableView, @@ -43,42 +43,43 @@ settings_patterns = [ # Integrations path( "integrations/reddit/callback/", - login_required(RedditTemplateView.as_view()), + otp_required(RedditTemplateView.as_view()), name="reddit-template", ), path( "integrations/reddit/refresh/", - login_required(RedditTokenRedirectView.as_view()), + otp_required(RedditTokenRedirectView.as_view()), name="reddit-refresh", ), path( "integrations/reddit/revoke/", - login_required(RedditRevokeRedirectView.as_view()), + otp_required(RedditRevokeRedirectView.as_view()), name="reddit-revoke", ), path( "integrations/twitter/auth/", - login_required(TwitterAuthRedirectView.as_view()), + otp_required(TwitterAuthRedirectView.as_view()), name="twitter-auth", ), path( "integrations/twitter/callback/", - login_required(TwitterTemplateView.as_view()), + otp_required(TwitterTemplateView.as_view()), name="twitter-template", ), path( "integrations/twitter/revoke/", - login_required(TwitterRevokeRedirectView.as_view()), + otp_required(TwitterRevokeRedirectView.as_view()), name="twitter-revoke", ), path( - "integrations/", login_required(IntegrationsView.as_view()), name="integrations" + "integrations/", otp_required(IntegrationsView.as_view()), name="integrations" ), # Misc - path("favicon/", login_required(FaviconRedirectView.as_view()), name="favicon"), - path("", login_required(SettingsView.as_view()), name="home"), + path("favicon/", otp_required(FaviconRedirectView.as_view()), name="favicon"), + path("", otp_required(SettingsView.as_view()), name="home"), ] +# permissions are handled through the views itself two_factor = [ path("accounts/setup/", SetupView.as_view(), name="setup"), path("accounts/qrcode/", QRGeneratorView.as_view(), name="qr"), @@ -140,7 +141,7 @@ urlpatterns = [ ), path( "password-change/", - login_required(PasswordChangeView.as_view()), + otp_required(PasswordChangeView.as_view()), name="password-change", ), # Settings diff --git a/src/newsreader/conf/base.py b/src/newsreader/conf/base.py index 99f5b44..5d7db19 100644 --- a/src/newsreader/conf/base.py +++ b/src/newsreader/conf/base.py @@ -243,7 +243,7 @@ REST_FRAMEWORK = { "rest_framework.authentication.SessionAuthentication", ), "DEFAULT_PERMISSION_CLASSES": ( - "rest_framework.permissions.IsAuthenticated", + "newsreader.accounts.permissions.TwoFactorAuthenticated", "newsreader.accounts.permissions.IsOwner", ), "DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",), diff --git a/src/newsreader/news/collection/urls.py b/src/newsreader/news/collection/urls.py index e5276cb..8ffd1c2 100644 --- a/src/newsreader/news/collection/urls.py +++ b/src/newsreader/news/collection/urls.py @@ -1,6 +1,7 @@ -from django.contrib.auth.decorators import login_required from django.urls import path +from django_otp.decorators import otp_required + from newsreader.news.collection.endpoints import ( DetailRuleView, NestedRuleView, @@ -29,48 +30,46 @@ endpoints = [ urlpatterns = [ # Feeds - path( - "feeds//", login_required(FeedUpdateView.as_view()), name="feed-update" - ), - path("feeds/create/", login_required(FeedCreateView.as_view()), name="feed-create"), + path("feeds//", otp_required(FeedUpdateView.as_view()), name="feed-update"), + path("feeds/create/", otp_required(FeedCreateView.as_view()), name="feed-create"), # Generic rules - path("rules/", login_required(CollectionRuleListView.as_view()), name="rules"), + path("rules/", otp_required(CollectionRuleListView.as_view()), name="rules"), path( "rules/delete/", - login_required(CollectionRuleBulkDeleteView.as_view()), + otp_required(CollectionRuleBulkDeleteView.as_view()), name="rules-delete", ), path( "rules/enable/", - login_required(CollectionRuleBulkEnableView.as_view()), + otp_required(CollectionRuleBulkEnableView.as_view()), name="rules-enable", ), path( "rules/disable/", - login_required(CollectionRuleBulkDisableView.as_view()), + otp_required(CollectionRuleBulkDisableView.as_view()), name="rules-disable", ), - path("rules/import/", login_required(OPMLImportView.as_view()), name="import"), + path("rules/import/", otp_required(OPMLImportView.as_view()), name="import"), # Reddit path( "subreddits/create/", - login_required(SubRedditCreateView.as_view()), + otp_required(SubRedditCreateView.as_view()), name="subreddit-create", ), path( "subreddits//", - login_required(SubRedditUpdateView.as_view()), + otp_required(SubRedditUpdateView.as_view()), name="subreddit-update", ), # Twitter path( "twitter/timelines/create/", - login_required(TwitterTimelineCreateView.as_view()), + otp_required(TwitterTimelineCreateView.as_view()), name="twitter-timeline-create", ), path( "twitter/timelines//", - login_required(TwitterTimelineUpdateView.as_view()), + otp_required(TwitterTimelineUpdateView.as_view()), name="twitter-timeline-update", ), ] diff --git a/src/newsreader/news/core/endpoints.py b/src/newsreader/news/core/endpoints.py index b224024..3cace4a 100644 --- a/src/newsreader/news/core/endpoints.py +++ b/src/newsreader/news/core/endpoints.py @@ -7,10 +7,8 @@ from rest_framework.generics import ( RetrieveUpdateDestroyAPIView, get_object_or_404, ) -from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from newsreader.accounts.permissions import IsPostOwner from newsreader.core.pagination import CursorPagination from newsreader.news.collection.serializers import RuleSerializer from newsreader.news.core.filters import ReadFilter, SavedFilter @@ -21,7 +19,6 @@ from newsreader.news.core.serializers import CategorySerializer, PostSerializer class ListPostView(ListAPIView): queryset = Post.objects.all() serializer_class = PostSerializer - permission_classes = (IsAuthenticated, IsPostOwner) pagination_class = CursorPagination filter_backends = [ReadFilter, SavedFilter] @@ -29,7 +26,6 @@ class ListPostView(ListAPIView): class DetailPostView(RetrieveUpdateAPIView): queryset = Post.objects.all() serializer_class = PostSerializer - permission_classes = (IsAuthenticated, IsPostOwner) class ListCategoryView(ListAPIView): diff --git a/src/newsreader/news/core/urls.py b/src/newsreader/news/core/urls.py index 8096cf8..2d6c78a 100644 --- a/src/newsreader/news/core/urls.py +++ b/src/newsreader/news/core/urls.py @@ -1,6 +1,7 @@ -from django.contrib.auth.decorators import login_required from django.urls import path +from django_otp.decorators import otp_required + from newsreader.news.core.endpoints import ( CategoryReadView, DetailCategoryView, @@ -14,20 +15,19 @@ from newsreader.news.core.views import ( CategoryCreateView, CategoryListView, CategoryUpdateView, - NewsView, ) urlpatterns = [ - path("categories/", login_required(CategoryListView.as_view()), name="categories"), + path("categories/", otp_required(CategoryListView.as_view()), name="categories"), path( "categories//", - login_required(CategoryUpdateView.as_view()), + otp_required(CategoryUpdateView.as_view()), name="category-update", ), path( "categories/create/", - login_required(CategoryCreateView.as_view()), + otp_required(CategoryCreateView.as_view()), name="category-create", ), ] diff --git a/src/newsreader/urls.py b/src/newsreader/urls.py index e416d5d..6069607 100644 --- a/src/newsreader/urls.py +++ b/src/newsreader/urls.py @@ -1,8 +1,8 @@ from django.conf import settings from django.contrib import admin -from django.contrib.auth.decorators import login_required from django.urls import include, path +from django_otp.decorators import otp_required from drf_yasg import openapi from drf_yasg.views import get_schema_view from two_factor.admin import AdminSiteOTPRequired @@ -21,7 +21,7 @@ schema_view = get_schema_view(schema_info, patterns=api_patterns) admin.site.__class__ = AdminSiteOTPRequired urlpatterns = [ - path("", login_required(NewsView.as_view()), name="index"), + path("", otp_required(NewsView.as_view()), name="index"), path("", include((news_patterns, "news"))), path("", include((api_patterns, "api"))), path("accounts/", include((login_urls, "accounts")), name="accounts"),