diff --git a/requirements.in b/requirements.in
index bc115a755..5a99bb696 100644
--- a/requirements.in
+++ b/requirements.in
@@ -3,6 +3,8 @@ djangorestframework
djangorestframework-jsonp
django-filter
django-modeltranslation
+django-two-factor-auth
+django-two-factor-auth[phonenumbers]
flake8
requests
requests_cache
diff --git a/requirements.txt b/requirements.txt
index 130ee0fbb..32912bbcb 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -40,10 +40,14 @@ django==5.1
# django-cors-headers
# django-extensions
# django-filter
+ # django-formtools
# django-js-asset
# django-modeltranslation
# django-munigeo
+ # django-otp
+ # django-phonenumber-field
# django-polymorphic
+ # django-two-factor-auth
# djangorestframework
# drf-spectacular
django-cors-headers==4.4.0
@@ -54,6 +58,8 @@ django-extensions==3.2.3
# via -r requirements.in
django-filter==24.3
# via -r requirements.in
+django-formtools==2.5.1
+ # via django-two-factor-auth
django-js-asset==2.2.0
# via django-mptt
django-modeltranslation==0.19.7
@@ -66,8 +72,14 @@ django-mptt==0.16.0
# django-munigeo
django-munigeo @ git+https://github.com/City-of-Helsinki/django-munigeo@v0.2.86
# via -r requirements.in
+django-otp==1.5.2
+ # via django-two-factor-auth
+django-phonenumber-field==8.0.0
+ # via django-two-factor-auth
django-polymorphic==3.1.0
# via -r requirements.in
+django-two-factor-auth[phonenumbers]==1.17.0
+ # via -r requirements.in
djangorestframework==3.15.2
# via
# -r requirements.in
@@ -125,6 +137,8 @@ pathspec==0.12.1
# via black
pep8-naming==0.14.1
# via -r requirements.in
+phonenumbers==8.13.43
+ # via django-two-factor-auth
pip-tools==7.4.1
# via -r requirements.in
platformdirs==4.2.2
@@ -139,6 +153,8 @@ pycodestyle==2.12.1
# via flake8
pyflakes==3.2.0
# via flake8
+pypng==0.20220715.0
+ # via qrcode
pyproject-hooks==1.1.0
# via
# build
@@ -159,6 +175,8 @@ pyyaml==6.0.2
# via
# django-munigeo
# drf-spectacular
+qrcode==7.4.2
+ # via django-two-factor-auth
referencing==0.35.1
# via
# jsonschema
@@ -202,6 +220,7 @@ typing-extensions==4.12.2
# black
# cattrs
# django-modeltranslation
+ # qrcode
tzdata==2024.1
# via -r requirements.in
uritemplate==4.1.1
diff --git a/services/templates/two_factor/_base.html b/services/templates/two_factor/_base.html
new file mode 100644
index 000000000..cd525901b
--- /dev/null
+++ b/services/templates/two_factor/_base.html
@@ -0,0 +1,30 @@
+
+
+
+ {% block title %}{% endblock %}
+
+
+
+
+
+{% block extra_media %}{% endblock %}
+
+
+ {% block content_wrapper %}
+
+ {% block content %}{% endblock %}
+
+ {% endblock %}
+
+
diff --git a/smbackend/settings.py b/smbackend/settings.py
index 51d7389ee..1e2b67547 100644
--- a/smbackend/settings.py
+++ b/smbackend/settings.py
@@ -45,6 +45,13 @@
SEARCH_LOG_LEVEL=(str, "INFO"),
GEO_SEARCH_LOCATION=(str, ""),
GEO_SEARCH_API_KEY=(str, ""),
+ EMAIL_USE_TLS=(bool, True),
+ EMAIL_HOST=(str, None),
+ EMAIL_PORT=(int, None),
+ EMAIL_TIMEOUT=(int, None),
+ EMAIL_HOST_USER=(str, None),
+ EMAIL_HOST_PASSWORD=(str, None),
+ OTP_EMAIL_SENDER=(str, None),
)
env_path = BASE_DIR / ".env"
@@ -60,6 +67,13 @@
DJANGO_LOG_LEVEL = env("DJANGO_LOG_LEVEL")
IMPORT_LOG_LEVEL = env("IMPORT_LOG_LEVEL")
SEARCH_LOG_LEVEL = env("SEARCH_LOG_LEVEL")
+EMAIL_USE_TLS = env("EMAIL_USE_TLS")
+EMAIL_HOST = env("EMAIL_HOST")
+EMAIL_PORT = env("EMAIL_PORT")
+EMAIL_TIMEOUT = env("EMAIL_TIMEOUT")
+EMAIL_HOST_USER = env("EMAIL_HOST_USER")
+EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD")
+OTP_EMAIL_SENDER = env("OTP_EMAIL_SENDER")
# Application definition
INSTALLED_APPS = [
@@ -82,6 +96,13 @@
"services.apps.ServicesConfig",
"observations",
"drf_spectacular",
+ # Two-factor authentication
+ "django_otp",
+ "django_otp.plugins.otp_static",
+ "django_otp.plugins.otp_totp",
+ "django_otp.plugins.otp_email",
+ "two_factor",
+ "two_factor.plugins.email",
]
if env("ADDITIONAL_INSTALLED_APPS"):
@@ -100,6 +121,7 @@
"django.contrib.sessions.middleware.SessionMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
+ "django_otp.middleware.OTPMiddleware",
]
if env("ADDITIONAL_MIDDLEWARE"):
@@ -252,6 +274,7 @@ def gettext(s):
},
"handlers": {
"console": {
+ "level": "DEBUG",
"class": "logging.StreamHandler",
"formatter": "timestamped_named",
},
@@ -260,6 +283,10 @@ def gettext(s):
"django": {"handlers": ["console"], "level": DJANGO_LOG_LEVEL},
"services.search": {"handlers": ["console"], "level": SEARCH_LOG_LEVEL},
"services.management": {"handlers": ["console"], "level": IMPORT_LOG_LEVEL},
+ "two_factor": {
+ "handlers": ["console"],
+ "level": "INFO",
+ },
},
}
@@ -287,3 +314,12 @@ def gettext(s):
"VERSION": None,
"SERVE_INCLUDE_SCHEMA": False,
}
+
+# Two-factor authentication settings
+LOGIN_URL = "two_factor:login"
+OTP_EMAIL_SUBJECT = "Palvelukartta - kirjautumistunniste"
+
+if DEBUG:
+ EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
+else:
+ EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
diff --git a/smbackend/urls.py b/smbackend/urls.py
index bf0879025..56586ae7b 100644
--- a/smbackend/urls.py
+++ b/smbackend/urls.py
@@ -9,6 +9,7 @@
)
from munigeo.api import all_views as munigeo_views
from rest_framework import routers
+from two_factor.urls import urlpatterns as tf_urls
from observations.api import views as observations_views
from observations.views import obtain_auth_token
@@ -77,4 +78,5 @@ def readiness(*args, **kwargs):
"schema/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"
),
re_path(r"", include(shortcutter_urls)),
+ re_path("", include(tf_urls)),
]