mirror of
https://github.com/jwinterm/modoboa.git
synced 2026-10-17 05:41:25 +02:00
Improved login process
This commit is contained in:
@@ -75,8 +75,7 @@ class CoreGlobalParametersSerializer(serializers.Serializer):
|
||||
default_password = serializers.CharField(default="ChangeMe1!")
|
||||
random_password_length = serializers.IntegerField(min_value=8, default=8)
|
||||
update_password_url = serializers.URLField(required=False, allow_blank=True)
|
||||
password_recovery_msg = serializers.CharField(
|
||||
required=False, allow_blank=True)
|
||||
password_recovery_msg = serializers.CharField(required=False, allow_blank=True)
|
||||
sms_password_recovery = serializers.BooleanField(default=False)
|
||||
sms_provider = serializers.ChoiceField(
|
||||
choices=constants.SMS_BACKENDS, required=False, allow_null=True
|
||||
@@ -87,8 +86,7 @@ class CoreGlobalParametersSerializer(serializers.Serializer):
|
||||
ldap_server_port = serializers.IntegerField(default=389)
|
||||
ldap_enable_secondary_server = serializers.BooleanField(default=False)
|
||||
ldap_secondary_server_address = serializers.CharField(required=False)
|
||||
ldap_secondary_server_port = serializers.IntegerField(
|
||||
default=389, required=False)
|
||||
ldap_secondary_server_port = serializers.IntegerField(default=389, required=False)
|
||||
ldap_secured = serializers.ChoiceField(
|
||||
choices=constants.LDAP_SECURE_MODES, default="none"
|
||||
)
|
||||
@@ -109,8 +107,7 @@ class CoreGlobalParametersSerializer(serializers.Serializer):
|
||||
choices=constants.LDAP_AUTH_METHODS,
|
||||
default="searchbind",
|
||||
)
|
||||
ldap_bind_dn = serializers.CharField(
|
||||
default="", required=False, allow_blank=True)
|
||||
ldap_bind_dn = serializers.CharField(default="", required=False, allow_blank=True)
|
||||
ldap_bind_password = serializers.CharField(
|
||||
default="", required=False, allow_blank=True
|
||||
)
|
||||
@@ -126,18 +123,15 @@ class CoreGlobalParametersSerializer(serializers.Serializer):
|
||||
|
||||
# LDAP sync settings
|
||||
ldap_sync_bind_dn = serializers.CharField(required=False, allow_blank=True)
|
||||
ldap_sync_bind_password = serializers.CharField(
|
||||
required=False, allow_blank=True)
|
||||
ldap_sync_bind_password = serializers.CharField(required=False, allow_blank=True)
|
||||
ldap_enable_sync = serializers.BooleanField(default=False)
|
||||
ldap_sync_delete_remote_account = serializers.BooleanField(default=False)
|
||||
ldap_sync_account_dn_template = serializers.CharField(
|
||||
required=False, allow_blank=True
|
||||
)
|
||||
ldap_enable_import = serializers.BooleanField(default=False)
|
||||
ldap_import_search_base = serializers.CharField(
|
||||
required=False, allow_blank=True)
|
||||
ldap_import_search_filter = serializers.CharField(
|
||||
default="(cn=*)", required=False)
|
||||
ldap_import_search_base = serializers.CharField(required=False, allow_blank=True)
|
||||
ldap_import_search_filter = serializers.CharField(default="(cn=*)", required=False)
|
||||
ldap_import_username_attr = serializers.CharField(default="cn")
|
||||
ldap_dovecot_sync = serializers.BooleanField(default=False)
|
||||
ldap_dovecot_conf_file = serializers.CharField(
|
||||
@@ -151,8 +145,7 @@ class CoreGlobalParametersSerializer(serializers.Serializer):
|
||||
hide_features_widget = serializers.BooleanField(default=False)
|
||||
|
||||
# Notification settings
|
||||
sender_address = lib_fields.DRFEmailFieldUTF8(
|
||||
default="noreply@yourdomain.test")
|
||||
sender_address = lib_fields.DRFEmailFieldUTF8(default="noreply@yourdomain.test")
|
||||
|
||||
# API settings
|
||||
enable_api_communication = serializers.BooleanField(default=True)
|
||||
@@ -265,6 +258,7 @@ class FidoRegistrationSerializer(serializers.Serializer):
|
||||
|
||||
class FidoAuthenticationSerializer(serializers.Serializer):
|
||||
"""Serializer used to finish the fido key authentication."""
|
||||
|
||||
authenticatorAttachment = serializers.CharField()
|
||||
clientExtensionResults = serializers.JSONField()
|
||||
id = serializers.CharField()
|
||||
@@ -381,8 +375,7 @@ class PasswordRecoverySmsSerializer(serializers.Serializer):
|
||||
request = self.context["request"]
|
||||
clean_email = data["email"]
|
||||
|
||||
user = User.objects.filter(
|
||||
email__iexact=clean_email, is_active=True).first()
|
||||
user = User.objects.filter(email__iexact=clean_email, is_active=True).first()
|
||||
if user is None:
|
||||
raise CustomValidationError(
|
||||
{"type": "sms", "reason": "No valid user found."}, 404
|
||||
@@ -402,8 +395,7 @@ class PasswordRecoverySmsSerializer(serializers.Serializer):
|
||||
user = self.context["user"]
|
||||
if not user:
|
||||
raise NoSMSAvailable()
|
||||
backend = sms_backends.get_active_backend(
|
||||
request.localconfig.parameters)
|
||||
backend = sms_backends.get_active_backend(request.localconfig.parameters)
|
||||
secret = cryptutils.random_hex_key(20)
|
||||
send_sms_code(secret, backend, user)
|
||||
request.session["totp_secret"] = secret
|
||||
@@ -466,8 +458,7 @@ class PasswordRecoverySmsResendSerializer(serializers.Serializer):
|
||||
backend = sms_backends.get_active_backend(
|
||||
self.context["request"].localconfig.parameters
|
||||
)
|
||||
send_sms_code(self.context["totp_secret"],
|
||||
backend, self.context["user"])
|
||||
send_sms_code(self.context["totp_secret"], backend, self.context["user"])
|
||||
return True
|
||||
|
||||
|
||||
@@ -496,8 +487,7 @@ class PasswordRecoveryConfirmSerializer(serializers.Serializer):
|
||||
data["new_password1"] == ""
|
||||
or data["new_password1"] != data["new_password2"]
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
"Password is empty or does not match")
|
||||
raise serializers.ValidationError("Password is empty or does not match")
|
||||
|
||||
user = self.get_user(data["id"])
|
||||
|
||||
|
||||
@@ -64,8 +64,7 @@ class AccountViewSet(core_v1_viewsets.AccountViewSet):
|
||||
def manage_api_token(self, request):
|
||||
"""Manage API token."""
|
||||
if not request.user.is_superuser:
|
||||
raise PermissionDenied(
|
||||
"Only super administrators can have API tokens")
|
||||
raise PermissionDenied("Only super administrators can have API tokens")
|
||||
if request.method == "DELETE":
|
||||
Token.objects.filter(user=request.user).delete()
|
||||
return response.Response(status=204)
|
||||
@@ -171,12 +170,14 @@ class LanguageViewSet(GetThrottleViewsetMixin, viewsets.ViewSet):
|
||||
return response.Response(languages)
|
||||
|
||||
|
||||
class FIDOViewSet(GetThrottleViewsetMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet):
|
||||
"""Fido management viewset. """
|
||||
class FIDOViewSet(
|
||||
GetThrottleViewsetMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""Fido management viewset."""
|
||||
|
||||
permission_classes = (permissions.IsAuthenticated,)
|
||||
serializer_class = serializers.FIDOSerializer
|
||||
@@ -193,32 +194,17 @@ class FIDOViewSet(GetThrottleViewsetMixin,
|
||||
@action(methods=["post"], detail=False, url_path="registration/end")
|
||||
def registration_end(self, request):
|
||||
"""An Api View to complete the registration process of a fido key."""
|
||||
serializer = serializers.FidoRegistrationSerializer(
|
||||
data=request.data)
|
||||
serializer = serializers.FidoRegistrationSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
credential_data = f2_auth.end_registration(request.data,
|
||||
request.session.pop(
|
||||
"fido2_state"),
|
||||
request.user.id)
|
||||
models.UserFidoKeys.objects.create(name=request.data["name"],
|
||||
credential_data=credential_data,
|
||||
user=request.user)
|
||||
credential_data = f2_auth.end_registration(
|
||||
request.data, request.session.pop("fido2_state"), request.user.id
|
||||
)
|
||||
models.UserFidoKeys.objects.create(
|
||||
name=request.data["name"],
|
||||
credential_data=credential_data,
|
||||
user=request.user,
|
||||
)
|
||||
if not request.user.tfa_enabled:
|
||||
request.user.tfa_enabled = True
|
||||
request.user.save()
|
||||
return response.Response(status=204)
|
||||
|
||||
@action(methods=["post"], detail=False, url_path="authenticate/begin")
|
||||
def authenticate_begin(self, request):
|
||||
"""An API View to start the authentication process."""
|
||||
options = f2_auth.begin_authentication(request)
|
||||
return response.Response(dict(options))
|
||||
|
||||
@action(methods=["post"], detail=False, url_path="authenticate/end")
|
||||
def authenticate_end(self, request):
|
||||
"""An API View to finish the authentication process."""
|
||||
serializer = serializers.FidoAuthenticationSerializer(
|
||||
data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
result, detail = f2_auth.end_authentication(request)
|
||||
return response.Response({"success": True})
|
||||
|
||||
@@ -114,5 +114,7 @@ if settings.DEBUG:
|
||||
SMS_BACKENDS.insert(1, ("dummy", gettext_lazy("Dummy")))
|
||||
|
||||
TFA_DEVICE_TOKEN_KEY = "otp_device_id"
|
||||
TFA_PRE_VERIFY_USER_PK = "tfa_pre_verify_user_pk"
|
||||
TFA_PRE_VERIFY_USER_BACKEND = "tfa_pre_verify_user_backend"
|
||||
|
||||
DOVEADM_PASS_SCHEME_ALARM = "doveadm_password_scheme_fail"
|
||||
|
||||
+27
-25
@@ -1,11 +1,16 @@
|
||||
|
||||
from .models import UserFidoKeys
|
||||
|
||||
from fido2.webauthn import PublicKeyCredentialRpEntity, PublicKeyCredentialUserEntity, AttestedCredentialData, UserVerificationRequirement
|
||||
from fido2.webauthn import (
|
||||
PublicKeyCredentialRpEntity,
|
||||
PublicKeyCredentialUserEntity,
|
||||
AttestedCredentialData,
|
||||
UserVerificationRequirement,
|
||||
)
|
||||
from fido2.server import Fido2Server
|
||||
from fido2.utils import websafe_decode, websafe_encode
|
||||
import fido2.features
|
||||
import datetime
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from modoboa.core.models import User, UserFidoKeys
|
||||
|
||||
|
||||
def set_json_mapping():
|
||||
@@ -21,7 +26,10 @@ def create_fido2_server():
|
||||
|
||||
|
||||
def get_creds_from_user(user_id):
|
||||
return [AttestedCredentialData(websafe_decode(uf.credential_data)) for uf in UserFidoKeys.objects.filter(user=user_id)]
|
||||
return [
|
||||
AttestedCredentialData(websafe_decode(uf.credential_data))
|
||||
for uf in UserFidoKeys.objects.filter(user=user_id)
|
||||
]
|
||||
|
||||
|
||||
def begin_registration(request):
|
||||
@@ -30,49 +38,43 @@ def begin_registration(request):
|
||||
options, state = server.register_begin(
|
||||
PublicKeyCredentialUserEntity(
|
||||
id=request.user.pk,
|
||||
name=request.user.username.encode('utf-8'),
|
||||
name=request.user.username.encode("utf-8"),
|
||||
display_name=request.user.username,
|
||||
),
|
||||
get_creds_from_user(request.user.pk),
|
||||
user_verification=UserVerificationRequirement.DISCOURAGED,
|
||||
extensions={"credentialProtectionPolicy": "userVerificationOptional"}
|
||||
extensions={"credentialProtectionPolicy": "userVerificationOptional"},
|
||||
)
|
||||
request.session['fido2_state'] = state
|
||||
request.session["fido2_state"] = state
|
||||
return options
|
||||
|
||||
|
||||
def end_registration(data, fido2_state, user_id):
|
||||
set_json_mapping()
|
||||
server = create_fido2_server()
|
||||
auth_data = server.register_complete(
|
||||
fido2_state,
|
||||
data
|
||||
)
|
||||
auth_data = server.register_complete(fido2_state, data)
|
||||
return websafe_encode(auth_data.credential_data)
|
||||
|
||||
|
||||
def begin_authentication(request):
|
||||
def begin_authentication(user_id: int):
|
||||
set_json_mapping()
|
||||
server = create_fido2_server()
|
||||
options, state = server.authenticate_begin(
|
||||
get_creds_from_user(request.user.id))
|
||||
request.session['fido_state'] = state
|
||||
return options
|
||||
return server.authenticate_begin(get_creds_from_user(user_id))
|
||||
|
||||
|
||||
def end_authentication(request):
|
||||
def end_authentication(user: User, state: str, data: dict):
|
||||
set_json_mapping()
|
||||
server = create_fido2_server()
|
||||
credentials = get_creds_from_user(request.user.id)
|
||||
# FIXME: review the next function...
|
||||
credentials = get_creds_from_user(user.pk)
|
||||
result = server.authenticate_complete(
|
||||
request.session.pop("fido_state"),
|
||||
state,
|
||||
credentials,
|
||||
request.data,
|
||||
data,
|
||||
)
|
||||
for key in UserFidoKeys.objects.filter(user=request.user.id):
|
||||
for key in user.userfidokeys_set.all():
|
||||
cred = AttestedCredentialData(websafe_decode(key.credential_data))
|
||||
if cred.credential_id == result.credential_id:
|
||||
key.last_used = datetime.datetime.now()
|
||||
key.last_used = timezone.now()
|
||||
key.use_count += 1
|
||||
key.save()
|
||||
return True, ""
|
||||
|
||||
+1
-10
@@ -14,17 +14,8 @@ from modoboa.lib.form_utils import UserKwargModelFormMixin
|
||||
from modoboa.parameters import tools as param_tools
|
||||
|
||||
|
||||
class LoginForm(forms.Form):
|
||||
"""User login form."""
|
||||
class AuthenticationForm(auth_forms.AuthenticationForm):
|
||||
|
||||
username = forms.CharField(
|
||||
label=gettext_lazy("Username"),
|
||||
widget=forms.TextInput(attrs={"class": "form-control"}),
|
||||
)
|
||||
password = forms.CharField(
|
||||
label=gettext_lazy("Password"),
|
||||
widget=forms.PasswordInput(attrs={"class": "form-control"}),
|
||||
)
|
||||
rememberme = forms.BooleanField(initial=False, required=False)
|
||||
|
||||
|
||||
|
||||
+11
-1
@@ -10,13 +10,23 @@ app_name = "core"
|
||||
urlpatterns = [
|
||||
path("", views.RootDispatchView.as_view(), name="root"),
|
||||
path("dashboard/", views.DashboardView.as_view(), name="dashboard"),
|
||||
path("accounts/login/", views.dologin, name="login"),
|
||||
path("accounts/login/", views.LoginView.as_view(), name="login"),
|
||||
path("accounts/logout/", views.dologout, name="logout"),
|
||||
path(
|
||||
"accounts/2fa_verify/",
|
||||
views.TwoFactorCodeVerifyView.as_view(),
|
||||
name="2fa_verify",
|
||||
),
|
||||
path(
|
||||
"accounts/fido/authenticate/begin",
|
||||
views.FidoAuthenticationBeginView.as_view(),
|
||||
name="fido_auth_begin",
|
||||
),
|
||||
path(
|
||||
"accounts/fido/authenticate/end",
|
||||
views.FidoAuthenticationEndView.as_view(),
|
||||
name="fido_auth_end",
|
||||
),
|
||||
path("core/", views.viewsettings, name="index"),
|
||||
path("core/parameters/", views.parameters, name="parameters"),
|
||||
path("core/info/", views.information, name="information"),
|
||||
|
||||
@@ -10,11 +10,13 @@ from .admin import (
|
||||
)
|
||||
from .auth import (
|
||||
PasswordResetView,
|
||||
dologin,
|
||||
LoginView,
|
||||
dologout,
|
||||
VerifySMSCodeView,
|
||||
ResendSMSCodeView,
|
||||
TwoFactorCodeVerifyView,
|
||||
FidoAuthenticationBeginView,
|
||||
FidoAuthenticationEndView,
|
||||
)
|
||||
from .base import RootDispatchView
|
||||
from .dashboard import DashboardView
|
||||
@@ -22,15 +24,17 @@ from .user import api_access, index, preferences, profile, security
|
||||
|
||||
__all__ = [
|
||||
"DashboardView",
|
||||
"LoginView",
|
||||
"PasswordResetView",
|
||||
"ResendSMSCodeView",
|
||||
"RootDispatchView",
|
||||
"VerifySMSCodeView",
|
||||
"api_access",
|
||||
"check_top_notifications",
|
||||
"dologin",
|
||||
"dologout",
|
||||
"TwoFactorCodeVerifyView",
|
||||
"FidoAuthenticationBeginView",
|
||||
"FidoAuthenticationEndView",
|
||||
"index",
|
||||
"information",
|
||||
"logs",
|
||||
|
||||
+153
-116
@@ -1,12 +1,13 @@
|
||||
"""Core authentication views."""
|
||||
|
||||
from functools import cached_property
|
||||
import logging
|
||||
|
||||
import oath
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponse, HttpResponseRedirect, Http404, JsonResponse
|
||||
from django.template.loader import render_to_string
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import HttpResponseRedirect, Http404, JsonResponse
|
||||
from django.urls import reverse
|
||||
from django.utils import translation
|
||||
from django.utils.encoding import force_bytes
|
||||
@@ -14,22 +15,21 @@ from django.utils.html import escape
|
||||
from django.utils.http import urlsafe_base64_encode
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views import generic
|
||||
from django.views.decorators.cache import never_cache
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from django.contrib.auth import authenticate, login, logout, views as auth_views
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.auth import load_backend, login, logout
|
||||
from django.contrib.auth.tokens import default_token_generator
|
||||
import django.contrib.auth.views as auth_views
|
||||
|
||||
import django_otp
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from modoboa.core import forms
|
||||
from modoboa.core import constants, fido2_auth, forms, models
|
||||
from modoboa.core.api.v2 import serializers
|
||||
from modoboa.core.password_hashers import get_password_hasher
|
||||
from modoboa.lib import cryptutils
|
||||
from modoboa.lib.views import UserFormKwargsMixin
|
||||
from modoboa.parameters import tools as param_tools
|
||||
|
||||
from .. import models
|
||||
from .. import sms_backends
|
||||
from .. import signals
|
||||
from .base import find_nextlocation
|
||||
@@ -37,104 +37,107 @@ from .base import find_nextlocation
|
||||
logger = logging.getLogger("modoboa.auth")
|
||||
|
||||
|
||||
def dologin(request):
|
||||
"""Try to authenticate."""
|
||||
error = None
|
||||
if request.method == "POST":
|
||||
form = forms.LoginForm(request.POST)
|
||||
if form.is_valid():
|
||||
logger = logging.getLogger("modoboa.auth")
|
||||
user = authenticate(
|
||||
username=form.cleaned_data["username"],
|
||||
password=form.cleaned_data["password"],
|
||||
class LoginViewMixin:
|
||||
|
||||
@cached_property
|
||||
def logger(self):
|
||||
return logging.getLogger("modoboa.auth")
|
||||
|
||||
def get_user(self):
|
||||
try:
|
||||
user_id = self.request.session[constants.TFA_PRE_VERIFY_USER_PK]
|
||||
backend_path = self.request.session[constants.TFA_PRE_VERIFY_USER_BACKEND]
|
||||
assert backend_path in settings.AUTHENTICATION_BACKENDS
|
||||
backend = load_backend(backend_path)
|
||||
user = backend.get_user(user_id)
|
||||
if user is not None:
|
||||
user.backend = backend_path
|
||||
return user
|
||||
except (KeyError, AssertionError):
|
||||
return None
|
||||
|
||||
def login(self, user, rememberme):
|
||||
login(self.request, user)
|
||||
if not rememberme:
|
||||
self.request.session.set_expiry(0)
|
||||
|
||||
translation.activate(user.language)
|
||||
|
||||
self.logger.info(_("User '%s' successfully logged in") % user.username)
|
||||
response = HttpResponseRedirect(find_nextlocation(self.request, user))
|
||||
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, user.language)
|
||||
return response
|
||||
|
||||
|
||||
class LoginView(LoginViewMixin, auth_views.LoginView):
|
||||
"""Login view with 2FA support."""
|
||||
|
||||
form_class = forms.AuthenticationForm
|
||||
template_name = "registration/login.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
announcements = signals.get_announcements.send(
|
||||
sender="login", location="loginpage"
|
||||
)
|
||||
announcements = [announcement[1] for announcement in announcements]
|
||||
return super().get_context_data(announcements=announcements, **kwargs)
|
||||
|
||||
def check_password_hash(self, user, form):
|
||||
condition = user.is_local and param_tools.get_global_parameter(
|
||||
"update_scheme", raise_exception=False
|
||||
)
|
||||
if not condition:
|
||||
return
|
||||
# check if password scheme is correct
|
||||
scheme = param_tools.get_global_parameter(
|
||||
"password_scheme", raise_exception=False
|
||||
)
|
||||
# use SHA512CRYPT as default fallback
|
||||
if scheme is None:
|
||||
pwhash = get_password_hasher("sha512crypt")()
|
||||
else:
|
||||
pwhash = get_password_hasher(scheme)()
|
||||
if not user.password.startswith(pwhash.scheme):
|
||||
logging.info(
|
||||
_("Password scheme mismatch. Updating %s password"),
|
||||
user.username,
|
||||
)
|
||||
if user and user.is_active:
|
||||
condition = user.is_local and param_tools.get_global_parameter(
|
||||
"update_scheme", raise_exception=False
|
||||
)
|
||||
if condition:
|
||||
# check if password scheme is correct
|
||||
scheme = param_tools.get_global_parameter(
|
||||
"password_scheme", raise_exception=False
|
||||
)
|
||||
# use SHA512CRYPT as default fallback
|
||||
if scheme is None:
|
||||
pwhash = get_password_hasher("sha512crypt")()
|
||||
else:
|
||||
pwhash = get_password_hasher(scheme)()
|
||||
if not user.password.startswith(pwhash.scheme):
|
||||
logging.info(
|
||||
_("Password scheme mismatch. Updating %s password"),
|
||||
user.username,
|
||||
)
|
||||
user.set_password(form.cleaned_data["password"])
|
||||
user.save()
|
||||
if pwhash.needs_rehash(user.password):
|
||||
logging.info(
|
||||
_(
|
||||
"Password hash parameter missmatch. "
|
||||
"Updating %s password"
|
||||
),
|
||||
user.username,
|
||||
)
|
||||
user.set_password(form.cleaned_data["password"])
|
||||
user.save()
|
||||
|
||||
login(request, user)
|
||||
if not form.cleaned_data["rememberme"]:
|
||||
request.session.set_expiry(0)
|
||||
|
||||
translation.activate(request.user.language)
|
||||
|
||||
logger.info(_("User '%s' successfully logged in") %
|
||||
user.username)
|
||||
signals.user_login.send(
|
||||
sender="dologin",
|
||||
username=form.cleaned_data["username"],
|
||||
password=form.cleaned_data["password"],
|
||||
)
|
||||
response = HttpResponseRedirect(
|
||||
find_nextlocation(request, user))
|
||||
response.set_cookie(
|
||||
settings.LANGUAGE_COOKIE_NAME, request.user.language
|
||||
)
|
||||
return response
|
||||
|
||||
error = _("Your username and password didn't match. Please try again.")
|
||||
logger.warning(
|
||||
"Failed connection attempt from '%(addr)s' as user '%(user)s'"
|
||||
% {
|
||||
"addr": request.META["REMOTE_ADDR"],
|
||||
"user": escape(form.cleaned_data["username"]),
|
||||
}
|
||||
user.set_password(form.cleaned_data["password"])
|
||||
user.save()
|
||||
if pwhash.needs_rehash(user.password):
|
||||
logging.info(
|
||||
_("Password hash parameter missmatch. " "Updating %s password"),
|
||||
user.username,
|
||||
)
|
||||
user.set_password(form.cleaned_data["password"])
|
||||
user.save()
|
||||
|
||||
nextlocation = request.POST.get("next", "")
|
||||
httpcode = 401
|
||||
else:
|
||||
form = forms.LoginForm()
|
||||
nextlocation = request.GET.get("next", "")
|
||||
httpcode = 200
|
||||
def form_valid(self, form):
|
||||
user = form.get_user()
|
||||
self.check_password_hash(user, form)
|
||||
# FIXME: remove ASAP
|
||||
signals.user_login.send(
|
||||
sender="dologin",
|
||||
username=user.username,
|
||||
password=form.cleaned_data["password"],
|
||||
)
|
||||
if user.tfa_enabled:
|
||||
self.request.session[constants.TFA_PRE_VERIFY_USER_PK] = user.pk
|
||||
self.request.session[constants.TFA_PRE_VERIFY_USER_BACKEND] = user.backend
|
||||
self.request.session["rememberme"] = form.cleaned_data["rememberme"]
|
||||
return HttpResponseRedirect(reverse("core:2fa_verify"))
|
||||
return self.login(user, form.cleaned_data["rememberme"])
|
||||
|
||||
announcements = signals.get_announcements.send(
|
||||
sender="login", location="loginpage")
|
||||
announcements = [announcement[1] for announcement in announcements]
|
||||
return HttpResponse(
|
||||
render_to_string(
|
||||
"registration/login.html",
|
||||
{
|
||||
"form": form,
|
||||
"error": error,
|
||||
"next": nextlocation,
|
||||
"annoucements": announcements,
|
||||
},
|
||||
request,
|
||||
),
|
||||
status=httpcode,
|
||||
)
|
||||
|
||||
|
||||
dologin = never_cache(dologin)
|
||||
def form_invalid(self, form):
|
||||
# FIXME: should we return a 401 as before ?
|
||||
self.logger.warning(
|
||||
"Failed connection attempt from '%(addr)s' as user '%(user)s'"
|
||||
% {
|
||||
"addr": self.request.META["REMOTE_ADDR"],
|
||||
"user": escape(form.cleaned_data["username"]),
|
||||
}
|
||||
)
|
||||
return super().form_invalid(form)
|
||||
|
||||
|
||||
@require_http_methods(["POST"])
|
||||
@@ -157,8 +160,7 @@ class PasswordResetView(auth_views.PasswordResetView):
|
||||
|
||||
def setup(self, request, *args, **kwargs):
|
||||
super().setup(request, *args, **kwargs)
|
||||
self.from_email = request.localconfig.parameters.get_value(
|
||||
"sender_address")
|
||||
self.from_email = request.localconfig.parameters.get_value("sender_address")
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Include help text."""
|
||||
@@ -181,8 +183,7 @@ class PasswordResetView(auth_views.PasswordResetView):
|
||||
if not user:
|
||||
# Fallback to email
|
||||
return super().form_valid(form)
|
||||
backend = sms_backends.get_active_backend(
|
||||
self.request.localconfig.parameters)
|
||||
backend = sms_backends.get_active_backend(self.request.localconfig.parameters)
|
||||
secret = cryptutils.random_hex_key(20)
|
||||
code = oath.totp(secret)
|
||||
text = _(
|
||||
@@ -232,12 +233,10 @@ class ResendSMSCodeView(generic.View):
|
||||
if not sms_password_recovery:
|
||||
raise Http404
|
||||
try:
|
||||
user = models.User._default_manager.get(
|
||||
pk=self.request.session["user_pk"])
|
||||
user = models.User._default_manager.get(pk=self.request.session["user_pk"])
|
||||
except KeyError:
|
||||
raise Http404
|
||||
backend = sms_backends.get_active_backend(
|
||||
self.request.localconfig.parameters)
|
||||
backend = sms_backends.get_active_backend(self.request.localconfig.parameters)
|
||||
secret = cryptutils.random_hex_key(20)
|
||||
code = oath.totp(secret)
|
||||
text = _(
|
||||
@@ -251,24 +250,62 @@ class ResendSMSCodeView(generic.View):
|
||||
return JsonResponse({"status": "ok"})
|
||||
|
||||
|
||||
class TwoFactorCodeVerifyView(
|
||||
LoginRequiredMixin, UserFormKwargsMixin, generic.FormView
|
||||
):
|
||||
class TwoFactorCodeVerifyView(LoginViewMixin, generic.FormView):
|
||||
"""View to verify a 2FA code after login."""
|
||||
|
||||
form_class = forms.Verify2FACodeForm
|
||||
template_name = "registration/twofactor_code_verify.html"
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
self.user = self.get_user()
|
||||
if not self.user:
|
||||
return HttpResponseRedirect(reverse("core:login"))
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs.update({"user": self.user})
|
||||
return kwargs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
has_totp_device = django_otp.user_has_device(self.request.user)
|
||||
has_totp_device = django_otp.user_has_device(self.user)
|
||||
context["totp_device"] = has_totp_device
|
||||
has_webauthn_device = models.UserFidoKeys.objects.filter(
|
||||
user=self.request.user).exists()
|
||||
user=self.user
|
||||
).exists()
|
||||
context["webauthn_device"] = has_webauthn_device
|
||||
return context
|
||||
|
||||
def form_valid(self, form):
|
||||
"""Login user."""
|
||||
response = self.login(self.user, self.request.session.pop("rememberme", False))
|
||||
django_otp.login(self.request, form.cleaned_data["tfa_code"])
|
||||
return HttpResponseRedirect(find_nextlocation(self.request, self.request.user))
|
||||
return response
|
||||
|
||||
|
||||
class FidoAuthenticationBeginView(generic.View):
|
||||
"""FIDO authentication process, begining."""
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
user_id = request.session.get(constants.TFA_PRE_VERIFY_USER_PK)
|
||||
if not user_id:
|
||||
raise PermissionDenied
|
||||
options, state = fido2_auth.begin_authentication(user_id)
|
||||
request.session["fido_state"] = state
|
||||
return JsonResponse(dict(options))
|
||||
|
||||
|
||||
class FidoAuthenticationEndView(LoginViewMixin, APIView):
|
||||
"""FIDO authentication process, end."""
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
user = self.get_user()
|
||||
fido_state = request.session.pop("fido_state", None)
|
||||
if not user or not fido_state:
|
||||
raise PermissionDenied
|
||||
serializer = serializers.FidoAuthenticationSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
fido2_auth.end_authentication(user, fido_state, serializer.validated_data)
|
||||
response = self.login(user, self.request.session.pop("rememberme", False))
|
||||
return JsonResponse({"next": response.url})
|
||||
|
||||
@@ -128,6 +128,10 @@ h2 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.mt {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
flex-direction: column;
|
||||
|
||||
@@ -7,106 +7,107 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block main_content %}
|
||||
<div class="row">
|
||||
<div class="col-sm-offset-4 col-sm-6">
|
||||
<div id="methodSelection">
|
||||
{% if totp_device %}
|
||||
<button onclick="showTOTP()">Use TOTP/backup code</button>
|
||||
{% endif %}
|
||||
{% if webauthn_device %}
|
||||
<button onclick="showWebAuthn()">Use Webautn device</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div id="totp_form" style="display:none;">
|
||||
|
||||
<form method="post" action="{% url 'core:2fa_verify' %}" class="login-form">
|
||||
{% csrf_token %}
|
||||
<div class="form-group{% if form.tfa_code.errors %} has-error{% endif %}">
|
||||
<label class="control-label">Two-Factor authentication code</label>
|
||||
<input id="id_tfa_code" name="tfa_code" class="form-control">
|
||||
<span class="help-block">
|
||||
{{ form.tfa_code.errors }}
|
||||
</span>
|
||||
<p class="help-block">
|
||||
<div class="login-box">
|
||||
<h2>{% translate "Verification" %}</h2>
|
||||
<div id="methodSelection">
|
||||
{% if totp_device %}
|
||||
<button class="btn" onclick="showTOTP()">{% translate "Use TOTP/backup code" %}</button>
|
||||
{% endif %}
|
||||
{% if webauthn_device %}
|
||||
<button class="mt btn" onclick="showWebAuthn()">{% translate "Use Webauthn device" %}</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div id="totp_form" style="display:none;">
|
||||
<form method="post" action="{% url 'core:2fa_verify' %}">
|
||||
{% csrf_token %}
|
||||
<div class="input-group{% if form.tfa_code.errors %} has-error{% endif %}">
|
||||
<label>{% translate "One-time password" %}</label>
|
||||
<input id="id_tfa_code" name="tfa_code">
|
||||
<span class="help-block">
|
||||
{{ form.tfa_code.errors }}
|
||||
</span>
|
||||
<p class="help-block">
|
||||
{% blocktranslate trimmed %}
|
||||
Enter the code from the two-factor app on your mobile
|
||||
device. If you've lost your device, you may enter one of your
|
||||
recovery codes.
|
||||
</p>
|
||||
</div>
|
||||
<button class="btn btn-primary col-sm-12" type="submit">Verify code</button>
|
||||
</form>
|
||||
<button onclick="backToSelection()">Back</button>
|
||||
</div>
|
||||
<div id="webautn_form" style="display:none;">
|
||||
<button onclick="useWebAuthn()">Log using Webauthn</button>
|
||||
<button onclick="backToSelection()">Back</button>
|
||||
</div>
|
||||
{% endblocktranslate %}
|
||||
</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit">Verify code</button>
|
||||
</form>
|
||||
<button class="mt" onclick="backToSelection()">Back</button>
|
||||
</div>
|
||||
<div id="webautn_form" style="display:none;">
|
||||
<button class="btn" onclick="useWebAuthn()">Log using Webauthn</button>
|
||||
<button class="mt" onclick="backToSelection()">Back</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script type="module" defer>
|
||||
import {
|
||||
get,
|
||||
parseRequestOptionsFromJSON,
|
||||
} from "{% static 'js/webauthn-json.browser-ponyfill.js' %}"
|
||||
import {
|
||||
get,
|
||||
parseRequestOptionsFromJSON,
|
||||
} from "{% static 'js/webauthn-json.browser-ponyfill.js' %}"
|
||||
|
||||
function showTOTP() {
|
||||
document.getElementById("totp_form").style.display = 'block'
|
||||
document.getElementById("methodSelection").style.display = 'none'
|
||||
function showTOTP() {
|
||||
document.getElementById("totp_form").style.display = 'block'
|
||||
document.getElementById("methodSelection").style.display = 'none'
|
||||
}
|
||||
window.showTOTP = showTOTP
|
||||
|
||||
function backToSelection() {
|
||||
document.getElementById("totp_form").style.display = 'none'
|
||||
document.getElementById("webautn_form").style.display = 'none'
|
||||
document.getElementById("methodSelection").style.display = 'block'
|
||||
}
|
||||
window.backToSelection = backToSelection
|
||||
|
||||
function showWebAuthn() {
|
||||
document.getElementById("webautn_form").style.display = 'block'
|
||||
document.getElementById("methodSelection").style.display = 'none'
|
||||
}
|
||||
window.showWebAuthn = showWebAuthn
|
||||
|
||||
async function useWebAuthn() {
|
||||
const csrf = getCookie('csrftoken')
|
||||
const request = await fetch("{% url 'core:fido_auth_begin' %}", {
|
||||
method: 'POST',
|
||||
headers: { "X-CSRFToken": csrf, 'Content-Type': 'application/json' }
|
||||
})
|
||||
if (!request.ok) {
|
||||
throw new Error('No credential available to authenticate!')
|
||||
}
|
||||
window.showTOTP = showTOTP
|
||||
let json = await request.json()
|
||||
let options = parseRequestOptionsFromJSON(json)
|
||||
|
||||
function backToSelection() {
|
||||
document.getElementById("totp_form").style.display = 'none'
|
||||
document.getElementById("webautn_form").style.display = 'none'
|
||||
document.getElementById("methodSelection").style.display = 'block'
|
||||
}
|
||||
window.backToSelection = backToSelection
|
||||
let response = await get(options);
|
||||
let result = await fetch("{% url 'core:fido_auth_end' %}", {
|
||||
method: 'POST',
|
||||
headers: { "X-CSRFToken": csrf, 'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(response),
|
||||
})
|
||||
json = await result.json()
|
||||
location.href = json.next
|
||||
}
|
||||
window.useWebAuthn = useWebAuthn
|
||||
|
||||
function showWebAuthn() {
|
||||
document.getElementById("webautn_form").style.display = 'block'
|
||||
document.getElementById("methodSelection").style.display = 'none'
|
||||
}
|
||||
window.showWebAuthn = showWebAuthn
|
||||
|
||||
async function useWebAuthn() {
|
||||
const csrf = getCookie('csrftoken')
|
||||
console.log(csrf)
|
||||
const request = await fetch("{% url 'v2:fido-authenticate-begin' %}", {
|
||||
method: 'POST',
|
||||
headers: { "X-CSRFToken": csrf, 'Content-Type': 'application/json' }
|
||||
})
|
||||
if(!request.ok) {
|
||||
throw new Error('No credential available to authenticate!')
|
||||
function getCookie(cname) {
|
||||
const name = cname + "="
|
||||
const decodedCookie = decodeURIComponent(document.cookie)
|
||||
const ca = decodedCookie.split(';')
|
||||
for(let i = 0; i <ca.length; i++) {
|
||||
let c = ca[i]
|
||||
while (c.charAt(0) == ' ') {
|
||||
c = c.substring(1)
|
||||
}
|
||||
let json = await request.json()
|
||||
let options = parseRequestOptionsFromJSON(json)
|
||||
|
||||
let response = await get(options);
|
||||
let result = await fetch("{% url 'v2:fido-authenticate-end' %}", {
|
||||
method: 'POST',
|
||||
headers: { "X-CSRFToken": csrf, 'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(response),
|
||||
})
|
||||
}
|
||||
window.useWebAuthn = useWebAuthn
|
||||
|
||||
function getCookie(cname) {
|
||||
const name = cname + "="
|
||||
const decodedCookie = decodeURIComponent(document.cookie)
|
||||
const ca = decodedCookie.split(';')
|
||||
for(let i = 0; i <ca.length; i++) {
|
||||
let c = ca[i]
|
||||
while (c.charAt(0) == ' ') {
|
||||
c = c.substring(1)
|
||||
}
|
||||
if (c.indexOf(name) == 0) {
|
||||
return c.substring(name.length, c.length)
|
||||
}
|
||||
if (c.indexOf(name) == 0) {
|
||||
return c.substring(name.length, c.length)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return ""
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -122,8 +122,8 @@ MIDDLEWARE = (
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django_otp.middleware.OTPMiddleware",
|
||||
"modoboa.core.middleware.TwoFAMiddleware",
|
||||
# "django_otp.middleware.OTPMiddleware",
|
||||
# "modoboa.core.middleware.TwoFAMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"modoboa.core.middleware.LocalConfigMiddleware",
|
||||
|
||||
Reference in New Issue
Block a user