Merge pull request #2706 from modoboa/password-recovery-new-admin

Password recovery on new-admin
This commit is contained in:
Antoine Nguyen
2023-01-11 12:18:57 +01:00
committed by GitHub
20 changed files with 1040 additions and 21 deletions
+15
View File
@@ -183,6 +183,8 @@ To work properly, the system user which runs modoboa (``www-data``,
``apache``, whatever) must have write access to this directory.
.. _customization:
*************
Customization
*************
@@ -202,6 +204,19 @@ relative URL of your logo under ``MEDIA_URL``. For example::
Then copy your logo file into the directory indicated by
``MEDIA_ROOT``.
Custom new admin sub-url
========================
You can edit the new admin sub-url (by default it is `/new-admin`).
To do so, open the :file:`settings.py` file and add (or uncomment)
``NEW_ADMIN_URL`` variable. This variable must contain a relative url
without initial and last `'/'`. For example::
NEW_ADMIN_URL = 'test_admin'
You will need to change you web server accordingly and rebuild the frontend.
******************
Host configuration
******************
+35
View File
@@ -91,6 +91,41 @@ Rebuild Virtual Environment
Specific instructions
*********************
2.0.4
=====
* Add ``'modoboa.core.context_processors.new_admin_url',`` at the end of `context_processors` list:
.. sourcecode:: python
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.template.context_processors.i18n',
'django.template.context_processors.media',
'django.template.context_processors.static',
'django.template.context_processors.tz',
'django.contrib.messages.context_processors.messages',
'modoboa.core.context_processors.top_notifications',
'modoboa.core.context_processors.new_admin_url',
],
'debug': False,
},
},
]
* You now have the possibility to customize the url of the new-admin interface.
To do so please head up to :ref:`the custom configuration chapter <customization>` (advanced user).
2.0.3
=====
+9
View File
@@ -3,5 +3,14 @@ import repository from './repository'
export default {
requestToken (data) {
return repository.post('/token/', data)
},
recoverPassword (data) {
return repository.post('/password_reset/', data)
},
checkSmsTotp (data) {
return repository.post('/sms_totp/', data)
},
changePassword (data) {
return repository.post('/reset_confirm/', data)
}
}
+5 -1
View File
@@ -1,6 +1,6 @@
import Vue from 'vue'
import { ValidationObserver, ValidationProvider, extend } from 'vee-validate'
import { confirmed, numeric, required } from 'vee-validate/dist/rules'
import { confirmed, email, numeric, required } from 'vee-validate/dist/rules'
import { translate } from 'vue-gettext'
import account from '@/api/account'
@@ -19,6 +19,10 @@ extend('required', {
...required,
message: () => $gettext('This field is required')
})
extend('email', {
...email,
message: () => $gettext('Specify an email address')
})
// Add custom rules here
extend('validPassword', {
+15
View File
@@ -172,6 +172,21 @@ const routes = [
requiresAuth: true,
layout: 'user'
}
},
{
path: '/password_recovery',
name: 'PasswordRecoveryForm',
component: () => import('../views/user/PasswordRecoveryForm.vue')
},
{
path: '/password_recovery/confirm/:id?/:token?/',
name: 'PasswordRecoveryChangeForm',
component: () => import('../views/user/PasswordRecoveryChangeForm.vue')
},
{
path: '/password_recovery/sms_confirm',
name: 'PasswordRecoverySms',
component: () => import('../views/user/PasswordRecoverySmsTotpForm.vue')
}
]
+3 -5
View File
@@ -34,7 +34,9 @@
outlined
/>
</validation-provider>
<a href="" class="float-right primary--text"><translate>Forgot password?</translate></a>
<router-link :to="{ name: 'PasswordRecoveryForm' }" class="float-right primary--text">
<translate>Forgot password?</translate>
</router-link>
<v-checkbox
v-model="rememberMe"
:label="'Remember me'|translate"
@@ -98,7 +100,3 @@ export default {
}
}
</script>
<style>
</style>
@@ -0,0 +1,137 @@
<template>
<v-row style="height: 100%; margin: 0 auto">
<v-col cols="0" sm="6" class="primary" style="height: 100%">
<v-img
src="../../assets/Modoboa_RVB-BLANC-SANS.png"
max-width="300"
class="mt-10 ml-8"
/>
</v-col>
<v-col cols="12" sm="6" class="d-flex flex-column justify-center pa-10" @keyup.enter="changePassword">
<span class="primary--text mb-6 text-h4"><translate>Forgot password?</translate></span>
<validation-observer ref="observer">
<validation-provider
vid="password"
v-slot="{ errors }"
rules="required"
>
<p id="password_validation">{{ password_validation_error }}</p>
<label class="m-label"><translate>Change password</translate></label>
<v-text-field
v-model="password"
:error-messages="errors"
type="password"
outlined
/>
</validation-provider>
<validation-provider
vid="password_confirm"
v-slot="{ errors }"
rules="required|samePassword:password"
>
<v-text-field
v-model="password_confirmed"
:error-messages="errors"
type="password"
outlined
/>
</validation-provider>
</validation-observer>
<div class="d-flex justify-center">
<v-btn
class="flex-grow-1"
color="primary"
large
@click="changePassword"
:loading="loading"
>
<translate>Submit</translate>
</v-btn>
</div>
<div>
<router-link :to="{ name: 'Login' }" class="float-right primary--text">
<translate>Return to login</translate>
</router-link>
</div>
</v-col>
</v-row>
</template>
<script>
import auth from '@/api/auth'
export default {
data () {
return {
loading: false,
password: '',
password_confirmed: '',
password_validation_error: '',
errors: {},
token: '',
id: ''
}
},
methods: {
checkUrl: function () {
if (this.$route.params.token === undefined) {
if (this.token === '' || this.id === '') {
this.$router.push({ name: 'PasswordRecoveryForm' })
}
} else {
const decodedId = atob(this.$route.params.id)
if (!/^\d+$/.test(decodedId)) {
console.error('Received ID is invalid')
this.$router.push({ name: 'PasswordRecoveryForm' })
} else {
this.id = this.$route.params.id
this.token = this.$route.params.token
this.$router.push({ name: 'PasswordRecoveryChangeForm' })
}
}
},
async changePassword () {
const isValid = await this.$refs.observer.validate()
if (!isValid) {
return
}
const payload = {
new_password1: this.password,
new_password2: this.password_confirmed,
token: this.token,
id: this.id
}
this.loading = true
this.password_validation_error = ''
auth.changePassword(payload).then(resp => {
if (resp.status === 200) {
this.loading = false
this.returnLogin()
}
}).catch(err => {
let message = ''
this.loading = false
if (err.response.status === 403) {
message = this.$gettext('Invalid reset token.')
} else if (err.response.status === 404) {
message = this.$gettext('User unknown.')
} else if (err.response.status === 400 && err.response.data.type === 'password_requirement') {
err.response.data.errors.forEach(element => {
message += this.$gettext(element) + '<br>'
})
}
this.password_validation_error = message
})
}
},
beforeMount () {
this.checkUrl()
}
}
</script>
<style>
#password_validation {
color:crimson;
}
</style>
@@ -0,0 +1,121 @@
<template>
<v-row style="height: 100%; margin: 0 auto">
<v-col cols="0" sm="6" class="primary" style="height: 100%">
<v-img
src="../../assets/Modoboa_RVB-BLANC-SANS.png"
max-width="300"
class="mt-10 ml-8"
/>
</v-col>
<v-col cols="12" sm="6" class="d-flex flex-column justify-center pa-10" @keyup.enter="recoverPassword">
<span class="primary--text mb-6 text-h4"><translate>Forgot password?</translate></span>
<validation-observer ref="observer">
<validation-provider
vid="email"
v-slot="{ errors }"
rules="required|email"
>
<label class="m-label"><translate>Please fill-in your primary email address</translate></label>
<v-text-field
v-model="email"
:error-messages="errors"
outlined
/>
</validation-provider>
</validation-observer>
<div class="d-flex justify-center">
<v-btn
class="flex-grow-1"
color="primary"
large
@click="recoverPassword"
:loading="loading"
>
<translate>Submit</translate>
</v-btn>
</div>
<div>
<a @click="returnLogin" class="float-right primary--text"><translate>Return to login?</translate></a>
</div>
</v-col>
<confirm-dialog ref="dialog_response">
</confirm-dialog>
</v-row>
</template>
<script>
import auth from '@/api/auth'
import ConfirmDialog from '@/components/layout/ConfirmDialog'
export default {
components: {
ConfirmDialog
},
data () {
return {
loading: false,
email: '',
errors: {}
}
},
methods: {
returnLogin () {
this.$router.push({ name: 'Login' })
},
async showDialog (title, body, error = false) {
let color = 'info'
if (error) {
color = 'error'
}
const confirm = await this.$refs.dialog_response.open(
this.$gettext(title),
this.$gettext(
body
),
{
color: color,
cancelLabel: this.$gettext('Ok'),
agreeLabel: this.$gettext('Return to login')
})
if (!confirm) {
return
}
this.returnLogin()
},
async recoverPassword () {
const isValid = await this.$refs.observer.validate()
if (!isValid) {
return
}
const payload = {
email: this.email
}
this.loading = true
auth.recoverPassword(payload).then(resp => {
this.loading = false
if (resp.status === 200) {
if (resp.data.type === 'sms') {
this.$router.push({ name: 'PasswordRecoverySmsTotpForm' })
} else if (resp.data.type === 'email') {
this.showDialog('Info', 'Email sent, please check your inbox')
}
}
}).catch(err => {
this.loading = false
if (err.response.status === 404) {
if (err.response.data.type === 'sms' || err.response.data.type === 'email') {
this.$refs.observer.setErrors({
email: this.$gettext(err.response.data.reason)
})
}
} else if (err.response.status === 503 && err.response.data.type === 'email') {
this.showDialog('Error', err.response.data.reason, true)
}
})
}
}
}
</script>
<style>
</style>
@@ -0,0 +1,127 @@
<template>
<v-row style="height: 100%; margin: 0 auto">
<v-col cols="0" sm="6" class="primary" style="height: 100%">
<v-img
src="../../assets/Modoboa_RVB-BLANC-SANS.png"
max-width="300"
class="mt-10 ml-8"
/>
</v-col>
<v-col cols="12" sm="6" class="d-flex flex-column justify-center pa-10" @keyup.enter="changePassword">
<confirm-dialog ref="confirm_wrong_user">
</confirm-dialog>
<span class="primary--text mb-6 text-h4"><translate>Password recovery</translate></span>
<validation-observer ref="observer">
<validation-provider
vid="sms_totp"
v-slot="{ errors }"
rules="required|length:6"
>
<label class="m-label"><translate>Enter the code you've just received by SMS</translate></label>
<v-text-field
v-model="sms_totp"
:error-messages="errors"
type="totp"
outlined
/>
</validation-provider>
</validation-observer>
<div class="d-flex justify-center">
<v-btn
class="flex-grow-1"
color="primary"
large
@click="checkSmsTotp"
:loading="loading"
>
<translate>Submit</translate>
</v-btn>
</div>
<div>
<a @click="resendSms" class="float-left primary--text"><translate>Resend SMS.</translate></a>
<router-link :to="{ name: 'Login' }" class="float-right primary--text"><translate>Return to login</translate></router-link>
</div>
</v-col>
</v-row>
</template>
<script>
import auth from '@/api/auth'
import ConfirmDialog from '@/components/layout/ConfirmDialog'
export default {
components: {
ConfirmDialog
},
data () {
return {
loading: false,
sms_totp: '',
errors: {}
}
},
methods: {
async showErrorDialog (body) {
const confirm = await this.$refs.confirm.open(
this.$gettext('Error'),
this.$gettext(body),
{
color: 'error',
cancelLabel: this.$gettext('Return to login'),
agreeLabel: this.$gettext('Retry reset')
}
)
if (!confirm) {
this.returnLogin()
} else {
this.$router.push({ name: 'PasswordRecoveryForm' })
}
},
async resendSms () {
auth.checkSmsTotp({ type: 'resend' }).then(resp => {
this.loading = false
if (resp.status === 200) {
this.$refs.observer.setErrors({
password_confirmed: this.$gettext('TOTP resent.')
})
}
}).catch(err => {
if (err.response.status === 400) {
this.loading = false
this.showErrorDialog(this.$t('User seems wrong, return to login or restart reset the process?'))
}
})
},
async checkSmsTotp () {
const isValid = await this.$refs.observer.validate()
if (!isValid) {
return
}
const payload = {
type: 'confirm',
sms_totp: this.sms_totp
}
this.loading = true
auth.checkSmsTotp(payload).then(resp => {
this.loading = false
if (resp.status === 200) {
// Code is good, redirecting to change password form.
this.$router.push({ name: 'PasswordRecoveryChangeForm', data: resp.response.data })
}
}).catch(err => {
this.loading = false
if (err.response.status === 403 && 'reason' in err.response.data) {
this.showErrorDialog(err.response.data.reason)
} else if (err.response.status === 400 && 'reason' in err.response.data) {
this.$refs.observer.setErrors({
sms_totp: this.$gettext(err.response.data.reason)
})
}
})
}
}
}
</script>
<style>
</style>
+235 -9
View File
@@ -1,19 +1,55 @@
"""Core API v2 serializers."""
import django_otp
import oath
from django.utils import formats
from django.utils.translation import ugettext_lazy, ugettext as _
from django.db.models import Q
from django.contrib.auth import password_validation
from django.contrib.sites.shortcuts import get_current_site
from django.contrib.auth.tokens import default_token_generator
from django.core.mail import send_mail
from django.core.exceptions import ValidationError as djangoValidationError
import django_otp
from rest_framework import serializers
from django.template import loader
from modoboa.lib import fields as lib_fields
from django.utils import formats
from django.utils.encoding import force_text
from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode
from django.utils.encoding import force_bytes
from django.utils.translation import ugettext_lazy, ugettext as _
from ... import constants
from ... import models
from ... import sms_backends
from ... import app_settings
from rest_framework import serializers, status
from rest_framework.exceptions import APIException
from modoboa.core import constants, models, sms_backends, app_settings, context_processors
from modoboa.core.models import User
from modoboa.lib import fields as lib_fields, cryptutils
class CustomValidationError(APIException):
status_code = status.HTTP_400_BAD_REQUEST
default_detail = 'A server error occurred.'
def __init__(self, detail, status_code):
if status_code is not None:
self.status_code = status_code
if detail is not None:
self.detail = detail
else:
self.detail = {'detail': force_text(self.default_detail)}
class NoSMSAvailable(Exception):
""" Raised when no sms totp is available for password reset (to try email)."""
pass
class PasswordRequirementsFailure(Exception):
""" Raised when django is not happy with password provided."""
def __init__(self, message_list, *args: object) -> None:
super().__init__(*args)
self.message_list = message_list
class CoreGlobalParametersSerializer(serializers.Serializer):
@@ -242,3 +278,193 @@ class UserAPITokenSerializer(serializers.Serializer):
"""Serializer used by API access routes."""
token = serializers.CharField()
class PasswordRecoveryEmailSerializer(serializers.Serializer):
"""Serializer used by password recovery route."""
email = serializers.EmailField()
def validate(self, data):
clean_email = data["email"]
self.context["user"] = (User.objects.filter(
email__iexact=clean_email, is_active=True)
.exclude(Q(secondary_email__isnull=True) | Q(secondary_email=""))
).first()
if self.context["user"] is None:
raise CustomValidationError({"type": "email", "reason": "No valid user found."}, 404)
return data
def save(self):
user = self.context["user"]
to_email = user.secondary_email
current_site = get_current_site(self.context["request"])
site_name = current_site.name
domain = current_site.domain
context = {
'email': to_email,
'domain': domain,
'site_name': site_name,
'uid': urlsafe_base64_encode(force_bytes(user.pk)),
'user': user,
'token': default_token_generator.make_token(user),
'protocol': 'https',
}
context.update(context_processors.new_admin_url())
subject = loader.render_to_string(
"registration/password_reset_subject.txt", context)
# Email subject *must not* contain newlines
subject = ''.join(subject.splitlines())
body = loader.render_to_string(
"registration/password_reset_email_v2.html", context)
send_mail(subject, body, None, [to_email], fail_silently=False)
def send_sms_code(secret, backend, user):
""" Send a sms containing a TOTP code for password reset. """
code = oath.totp(secret)
text = _(
"Please use the following code to recover your Modoboa password: {}"
.format(code)
)
if not backend.send(text, [str(user.phone_number)]):
raise NoSMSAvailable()
class PasswordRecoverySmsSerializer(serializers.Serializer):
email = serializers.EmailField()
def validate(self, data, *args, **kwargs):
request = self.context["request"]
clean_email = data["email"]
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)
self.context["user"] = (
User.objects.filter(
email__iexact=clean_email, is_active=True)
.exclude(Q(phone_number__isnull=True) | Q(phone_number=""))
).first()
if self.context["user"] is None:
raise NoSMSAvailable()
if not request.localconfig.parameters.get_value("sms_password_recovery"):
raise NoSMSAvailable()
user = self.context["user"]
if not user:
raise NoSMSAvailable()
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
request.session["user_pk"] = user.pk
return data
class PasswordRecoverySmsConfirmSerializer(serializers.Serializer):
sms_totp = serializers.CharField(min_length=6, max_length=6)
def validate(self, data):
try:
self.context["totp_secret"] = self.context["request"].session["totp_secret"]
except KeyError:
raise CustomValidationError(
{"reason": "totp secret not set in session"}, 403)
if not oath.accept_totp(self.context["totp_secret"], data["sms_totp"])[0]:
raise CustomValidationError({"reason": "Wrong totp"}, 400)
# Attempt to get user, will raise an error if pk is not valid
self.context["user"] = User.objects.filter(
pk=self.context["request"].session["user_pk"]).first()
if self.context["user"] is None:
raise CustomValidationError({"reason": "Invalid user"}, 400)
return data
def save(self):
self.context["request"].session.pop("totp_secret")
user_pk = self.context["request"].session.pop("user_pk")
token = default_token_generator.make_token(self.context["user"])
uid = urlsafe_base64_encode(force_bytes(user_pk))
self.context["response"] = (token, uid)
return True
class PasswordRecoverySmsResendSerializer(serializers.Serializer):
def validate(self, data):
try:
self.context["totp_secret"] = self.context["request"].session["totp_secret"]
except KeyError:
raise CustomValidationError(
{"reason": "totp secret not set in session"}, status.HTTP_403_FORBIDDEN)
# Attempt to get user, will raise an error if pk is not valid
self.context["user"] = User.objects.filter(
pk=self.context["request"].session["user_pk"]).first()
if self.context["user"] is None:
raise CustomValidationError({"reason": "Invalid user"}, 400)
return data
def save(self):
backend = sms_backends.get_active_backend(
self.context["request"].localconfig.parameters)
send_sms_code(self.context["totp_secret"], backend, self.context["user"])
return True
class PasswordRecoveryConfirmSerializer(serializers.Serializer):
new_password1 = serializers.CharField()
new_password2 = serializers.CharField()
token = serializers.CharField()
id = serializers.CharField()
def get_user(self, user_id):
try:
# urlsafe_base64_decode() decodes to bytestring
uid = urlsafe_base64_decode(user_id).decode()
user = User.objects.get(pk=uid)
except (TypeError, ValueError, OverflowError, User.DoesNotExist):
user = None
return user
def validate(self, data):
# Validate password
if data["new_password1"] == "" or data["new_password1"] != data["new_password2"]:
raise serializers.ValidationError(
"Password is empty or does not match")
user = self.get_user(data["id"])
if user is None:
raise CustomValidationError({"reason": "User not found."}, 404)
if not default_token_generator.check_token(user, data["token"]):
raise CustomValidationError({"reason": "Wrong reset token"}, 403)
# Check that the password works with set conditions
try:
password_validation.validate_password(data["new_password1"], user)
except djangoValidationError as e:
raise PasswordRequirementsFailure(e.messages)
self.context["user"] = user
return super().validate(data)
def save(self):
user = self.context["user"]
user.set_password(self.validated_data["new_password1"])
user.save()
return True
+199 -3
View File
@@ -1,12 +1,17 @@
"""Core API related tests."""
import copy
import oath
from unittest import mock
from django.urls import reverse
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from modoboa.admin import factories
from modoboa.core import models
from modoboa.lib.tests import ModoAPITestCase
from rest_framework.authtoken.models import Token
CORE_SETTINGS = {
"authentication_type": "local",
@@ -119,11 +124,11 @@ class AccountViewSetTestCase(ModoAPITestCase):
me = resp.json()
self.assertEqual(me["username"], "admin")
def test_me_password(self):
def test_me_password(self, password_ko="Toto1234", password_ok="password"):
url = reverse("v2:account-check-me-password")
resp = self.client.post(url, {"password": "Toto1234"}, format="json")
resp = self.client.post(url, {"password": password_ko}, format="json")
self.assertEqual(resp.status_code, 400)
resp = self.client.post(url, {"password": "password"}, format="json")
resp = self.client.post(url, {"password": password_ok}, format="json")
self.assertEqual(resp.status_code, 200)
@mock.patch("django_otp.match_token")
@@ -200,6 +205,197 @@ class AccountViewSetTestCase(ModoAPITestCase):
self.assertEqual(resp.status_code, 201)
class PasswordResetTestCase(AccountViewSetTestCase):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.id = 0
self.sms_token = 0
@classmethod
def setUpTestData(cls): # NOQA:N802
"""Create test data."""
super().setUpTestData()
factories.populate_database()
cls.da_token = Token.objects.create(
user=models.User.objects.get(username="admin@test.com"))
@mock.patch("ovh.Client.get")
@mock.patch("ovh.Client.post")
def test_reset_password(self, client_post, client_get):
url = reverse("v2:password_reset")
# No payload provided
response = self.client.post(url)
self.assertEqual(response.status_code, 400)
# Invalid user
data = {"email": "doesntexist@whoami.com"}
response = self.client.post(url, data, format="json")
self.assertEqual(response.status_code, 404)
self.assertEqual(response.json()["type"], "sms")
account = models.User.objects.get(username="user@test.com")
# Email reset test
# No secondary email
data = {"email": account.email}
response = self.client.post(url, data, format="json")
self.assertEqual(response.status_code, 404)
self.assertEqual(response.json()["type"], "email")
# With secondary email
account.secondary_email = "toto@test.com"
account.is_active = True
account.save()
response = self.client.post(url, data, format="json")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["type"], "email")
# SMS reset test
self.set_global_parameters({
"sms_password_recovery": True,
"sms_provider": "ovh",
"sms_ovh_application_key": "key",
"sms_ovh_application_secret": "secret",
"sms_ovh_consumer_key": "consumer"
}, app="core")
# Phone number
account.phone_number = "+33612345678"
account.save()
client_get.return_value = ["service"]
client_post.return_value = {"totalCreditsRemoved": 1}
response = self.client.post(url, data, format="json")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["type"], "sms")
@mock.patch("ovh.Client.get")
@mock.patch("ovh.Client.post")
def test_password_reset_sms_totp(self, client_post, client_get):
"""Test reset password by SMS."""
url_create_sms = reverse("v2:password_reset")
url_sms_totp = reverse("v2:sms_totp")
# Prepare account
account = models.User.objects.get(username="user@test.com")
account.phone_number = "+33612345678"
account.secondary_email = "toto@test.com"
account.is_active = True
account.save()
# Send sms
client_get.return_value = ["service"]
client_post.return_value = {"totalCreditsRemoved": 1}
self.set_global_parameters({
"sms_password_recovery": True,
"sms_provider": "ovh",
"sms_ovh_application_key": "key",
"sms_ovh_application_secret": "secret",
"sms_ovh_consumer_key": "consumer"
})
self.client.logout()
self.create_session()
data = {"email": account.email}
response = self.client.post(url_create_sms, data, format="json")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["type"], "sms")
self.assertIn("totp_secret", self.client.session)
# Get the secret
session = self.client.session
secret = session["totp_secret"]
# Fail to provide type
data = {"sms_totp": "123456"}
response = self.client.post(url_sms_totp, data, format="json")
self.assertEqual(response.status_code, 400)
# Resend sms
data = {"type": "resend"}
response = self.client.post(url_sms_totp, data, format="json")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["type"], "resend")
# False totp code
data = {"sms_totp": "123456", "type": "confirm"}
response = self.client.post(url_sms_totp, data, format="json")
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["reason"], "Wrong totp")
# Good totp code
data = {"sms_totp": oath.totp(secret), "type": "confirm"}
response = self.client.post(url_sms_totp, data, format="json")
self.assertEqual(response.status_code, 200)
self.assertIn("token", response.json())
self.assertIn("id", response.json())
@mock.patch("ovh.Client.get")
@mock.patch("ovh.Client.post")
def test_password_change(self, client_post, client_get):
url = reverse("v2:password_reset_confirm_v2")
url_create_sms = reverse("v2:password_reset")
url_sms_totp = reverse("v2:sms_totp")
client_get.return_value = ["service"]
client_post.return_value = {"totalCreditsRemoved": 1}
self.set_global_parameters({
"sms_password_recovery": True,
"sms_provider": "ovh",
"sms_ovh_application_key": "key",
"sms_ovh_application_secret": "secret",
"sms_ovh_consumer_key": "consumer"
})
# Prepare account
account = models.User.objects.get(username="user@test.com")
account.phone_number = "+33612345678"
account.secondary_email = "toto@test.com"
account.is_active = True
account.save()
# Send SMS
self.create_session()
data = {"email": account.email}
response = self.client.post(url_create_sms, data, format="json")
self.assertEqual(response.status_code, 200)
session = self.client.session
secret = session["totp_secret"]
# Get token and ID
data = {"sms_totp": oath.totp(secret), "type": "confirm"}
response = self.client.post(url_sms_totp, data, format="json")
self.assertEqual(response.status_code, 200)
id_ok = response.json()["id"]
token_ok = response.json()["token"]
# Password differs
data = {"new_password1": "123456", "new_password2": "123457", "token": token_ok, "id": id_ok}
response = self.client.post(url, data, format="json")
self.assertEqual(response.status_code, 400)
# Wrong ID
id_ko = urlsafe_base64_encode(force_bytes(-1))
data = {"new_password1": "123456", "new_password2": "123456", "token": token_ok, "id": id_ko}
response = self.client.post(url, data, format="json")
self.assertEqual(response.status_code, 404)
# Wrong Token
token_ko = "123456"
data = {"new_password1": "123456", "new_password2": "123456", "token": token_ko, "id": id_ok}
response = self.client.post(url, data, format="json")
self.assertEqual(response.status_code, 403)
# Failed password requirements
data = {"new_password1": "123456", "new_password2": "123456", "token": token_ok, "id": id_ok}
response = self.client.post(url, data, format="json")
self.assertEqual(response.json()["type"], "password_requirement")
# All good
data = {"new_password1": "MyHardenedPass1!", "new_password2": "MyHardenedPass1!", "token": token_ok,
"id": id_ok}
response = self.client.post(url, data, format="json")
self.assertEqual(response.status_code, 200)
# TODO: See why user doesn't update it's password --> self.test_me_password(password_ok="MyHardenedPass1!")
class AuthenticationTestCase(ModoAPITestCase):
@mock.patch("django_otp.match_token")
+7 -1
View File
@@ -21,5 +21,11 @@ urlpatterns += [
path('token/', views.TokenObtainPairView.as_view(),
name="token_obtain_pair"),
path('token/refresh/', TokenRefreshView.as_view(),
name='token_refresh'),
name="token_refresh"),
path('password_reset/', views.DefaultPasswordResetView.as_view(),
name="password_reset"),
path('reset_confirm/', views.PasswordResetConfirmView.as_view(),
name="password_reset_confirm_v2"),
path('sms_totp/', views.PasswordResetSmsTOTP.as_view(),
name="sms_totp"),
]
+87
View File
@@ -4,16 +4,22 @@ import logging
from django.utils.html import escape
from django.utils.translation import ugettext as _
from django.utils.datastructures import MultiValueDictKeyError
from django.contrib.auth import login
from rest_framework import response, status
from rest_framework_simplejwt import views as jwt_views
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
from rest_framework.views import APIView
from modoboa.core.password_hashers import get_password_hasher
from modoboa.parameters import tools as param_tools
from smtplib import SMTPException
from . import serializers
logger = logging.getLogger("modoboa.auth")
@@ -70,3 +76,84 @@ class TokenObtainPairView(jwt_views.TokenObtainPairView):
return response.Response(
serializer.validated_data, status=status.HTTP_200_OK)
class EmailPasswordResetView(APIView):
"""
An Api View which provides a method to request a password reset token based on an e-mail address.
"""
def post(self, request, *args, **kwargs):
serializer = serializers.PasswordRecoveryEmailSerializer(
data=request.data, context={'request': request})
serializer.is_valid(raise_exception=True)
try:
serializer.save()
except SMTPException:
return response.Response({
"type": "email",
"reason": "Error while sending the email. Please contact an administrator."
}, 503)
# Email response
return response.Response({"type": "email"}, 200)
class DefaultPasswordResetView(EmailPasswordResetView):
"""
Works with PasswordRecoveryForm.vue.
First checks if SMS recovery is available, else switch to super (Email recovery [with secondary email]).
"""
def post(self, request, *args, **kwargs):
"""Recover password."""
serializer = serializers.PasswordRecoverySmsSerializer(
data=request.data, context={'request': request})
try:
serializer.is_valid(raise_exception=True)
except serializers.NoSMSAvailable:
return super().post(request, *args, **kwargs)
# SMS response
return response.Response({"type": "sms"}, 200)
class PasswordResetSmsTOTP(APIView):
""" Check SMS Totp code. """
def post(self, request, *args, **kwargs):
try:
if request.data["type"] == "confirm":
klass = serializers.PasswordRecoverySmsConfirmSerializer
elif request.data["type"] == "resend":
klass = serializers.PasswordRecoverySmsResendSerializer
serializer = klass(data=request.data, context={'request': request})
except (MultiValueDictKeyError, KeyError):
return response.Response({"reason": "No type provided."}, 400)
serializer.is_valid(raise_exception=True)
serializer.save()
payload = {"type": "resend"}
if request.data["type"] == "confirm":
serializer_response = serializer.context["response"]
payload.update({
"token": serializer_response[0],
"id": serializer_response[1],
"type": "confirm"
})
return response.Response(payload, 200)
class PasswordResetConfirmView(APIView):
""" Get and set new user password. """
def post(self, request, *args, **kwargs):
serializer = serializers.PasswordRecoveryConfirmSerializer(data=request.data)
try:
serializer.is_valid(raise_exception=True)
except serializers.PasswordRequirementsFailure as e:
data = {"type": "password_requirement"}
errors = []
for element in e.message_list:
errors.append(element)
data.update({"errors": errors})
return response.Response(data, 400)
serializer.save()
return response.Response(status=200)
@@ -141,6 +141,7 @@ TEMPLATES = [
'django.template.context_processors.tz',
'django.contrib.messages.context_processors.messages',
'modoboa.core.context_processors.top_notifications',
'modoboa.core.context_processors.new_admin_url',
],
'debug': {{ devmode }},
},
@@ -202,6 +203,9 @@ REST_FRAMEWORK = {
}
# Uncomment if you need a custom sub url for new_admin interface
# NEW_ADMIN_URL = 'new_admin'
SPECTACULAR_SETTINGS = {
'SCHEMA_PATH_PREFIX': r'/api/v[0-9]',
'TITLE': 'Modoboa API',
+8
View File
@@ -1,6 +1,7 @@
"""Custom context processors."""
from functools import reduce
from django.conf import settings
from . import signals
@@ -19,3 +20,10 @@ def top_notifications(request):
"notifications_check_interval": interval * 1000,
"top_notifications": notifications
}
def new_admin_url(*args):
"""A context processor to include new admin url."""
return {
"new_admin": getattr(settings, "NEW_ADMIN_URL", "new_admin")
}
+1 -1
View File
@@ -8,7 +8,7 @@
{% if user.is_admin %}
<div class="alert alert-warning text-center">
<strong>Want to check the new admin interface?</strong>
<a class="btn btn-default" style="margin-left: 20px;" href="/new-admin" target="_blank">Go to Modoboa v2</a>
<a class="btn btn-default" style="margin-left: 20px;" href="/{{ new_admin }}" target="_blank">Go to Modoboa v2</a>
</div>
{% endif %}
+10
View File
@@ -2,9 +2,12 @@
import socket
from django.conf import settings
from django.core import management
from django.test import TestCase
from importlib import import_module
from rest_framework.authtoken.models import Token
from rest_framework.test import APITestCase
@@ -99,3 +102,10 @@ class ModoAPITestCase(ParametersMixin, APITestCase):
"""Setup."""
super(ModoAPITestCase, self).setUp()
self.client.credentials(HTTP_AUTHORIZATION="Token " + self.token.key)
def create_session(self):
"""Enable session storage across requests."""
session_engine = import_module(settings.SESSION_ENGINE)
store = session_engine.SessionStore()
store.save()
self.client.cookies[settings.SESSION_COOKIE_NAME] = store.session_key
+1 -1
View File
@@ -53,7 +53,7 @@
<li class="divider"></li>
{% if user.is_admin %}
<li>
<a name="newadmin" href="/new-admin" target="_blank">
<a name="newadmin" href="/{{ new_admin }}" target="_blank">
{% trans "New admin" %}
</a>
</li>
@@ -0,0 +1,17 @@
{% load i18n %}
{% autoescape off %}
{% blocktrans with username=user.get_username trimmed %}
To initiate the password reset process for your {{ username }} Modoboa account, click the link below:
{% endblocktrans %}
{{ protocol }}://{{ domain }}/{{ new_admin }}/password_recovery/confirm/{{ uid }}/{{ token }}
{% blocktrans trimmed %}
If clicking the link above doesn't work, please copy and paste the URL in a new browser window instead.
{% endblocktrans %}
{% blocktrans trimmed %}
Sincerely, The Modoboa Team.
{% endblocktrans %}
{% endautoescape %}
+4
View File
@@ -147,6 +147,7 @@ TEMPLATES = [
'django.template.context_processors.tz',
'django.contrib.messages.context_processors.messages',
'modoboa.core.context_processors.top_notifications',
'modoboa.core.context_processors.new_admin_url'
],
'debug': DEBUG,
},
@@ -194,6 +195,9 @@ REST_FRAMEWORK = {
'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.NamespaceVersioning',
}
# Uncomment if you need a custom sub url for new_admin interface
# NEW_ADMIN_URL = 'new_admin'
SPECTACULAR_SETTINGS = {
'SCHEMA_PATH_PREFIX': r'/api/v[0-9]',
'TITLE': 'Modoboa API',