mirror of
https://github.com/jwinterm/modoboa.git
synced 2026-10-17 05:41:25 +02:00
Imported to main repo
This commit is contained in:
@@ -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
|
||||
******************
|
||||
|
||||
@@ -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
|
||||
=====
|
||||
|
||||
|
||||
@@ -3,5 +3,17 @@ 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('/confirm_sms_totp/', data)
|
||||
},
|
||||
changePassword (data) {
|
||||
return repository.post('/reset_confirm/', data)
|
||||
},
|
||||
resendSmsTotp (data) {
|
||||
return repository.post('/reset_sms_resend/', data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
outlined
|
||||
/>
|
||||
</validation-provider>
|
||||
<a href="" class="float-right primary--text"><translate>Forgot password?</translate></a>
|
||||
<a @click="recoverPass" class="float-right primary--text"><translate>Forgot password?</translate></a>
|
||||
<v-checkbox
|
||||
v-model="rememberMe"
|
||||
:label="'Remember me'|translate"
|
||||
@@ -70,6 +70,9 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
recoverPass () {
|
||||
this.$router.push({ name: 'PasswordRecoveryForm' })
|
||||
},
|
||||
async authenticate () {
|
||||
const isValid = await this.$refs.observer.validate()
|
||||
if (!isValid) {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<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"></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>
|
||||
<a @click="returnLogin" class="float-right primary--text"><translate>Return to login?</translate></a>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import auth from '@/api/auth'
|
||||
|
||||
export default {
|
||||
data () {
|
||||
return {
|
||||
loading: false,
|
||||
password: '',
|
||||
password_confirmed: '',
|
||||
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' })
|
||||
}
|
||||
}
|
||||
},
|
||||
returnLogin () {
|
||||
this.$router.push({ name: 'Login' })
|
||||
},
|
||||
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
|
||||
auth.changePassword(payload).then(resp => {
|
||||
document.getElementById('password_validation').innerHTML = ''
|
||||
this.loading = false
|
||||
this.returnLogin()
|
||||
}).catch(err => {
|
||||
this.loading = false
|
||||
if (err.response.status === 401) {
|
||||
this.$refs.observer.setErrors({
|
||||
password_confirmed: this.$gettext('Invalid reset token.')
|
||||
})
|
||||
} else if (err.response.status === 404) {
|
||||
this.$refs.observer.setErrors({
|
||||
password_confirmed: this.$gettext('User unknown.')
|
||||
})
|
||||
} else if (err.response.status === 455) {
|
||||
let message = ''
|
||||
err.response.data.forEach(element => {
|
||||
message += this.$gettext(element) + '<br>'
|
||||
})
|
||||
document.getElementById('password_validation').innerHTML = message
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
beforeMount () {
|
||||
this.checkUrl()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#password_validation {
|
||||
color:crimson;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,130 @@
|
||||
<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 showDialogEmail () {
|
||||
const confirm = await this.$refs.dialog_response.open(
|
||||
this.$gettext('Info'),
|
||||
this.$gettext(
|
||||
'Email sent, please check your inbox'
|
||||
),
|
||||
{
|
||||
color: 'info',
|
||||
cancelLabel: this.$gettext('Ok'),
|
||||
agreeLabel: this.$gettext('Return to login')
|
||||
})
|
||||
if (!confirm) {
|
||||
return
|
||||
}
|
||||
this.returnLogin()
|
||||
},
|
||||
async emailFailedToSend () {
|
||||
const confirm = await this.$refs.dialog_response.open(
|
||||
this.$gettext('Error'),
|
||||
this.$gettext(
|
||||
'Failed to send email, please check with you administrator'
|
||||
),
|
||||
{
|
||||
color: 'error',
|
||||
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 === 233) {
|
||||
this.$router.push({ name: 'PasswordRecoverySmsTotpForm' })
|
||||
} else if (resp.status === 210) {
|
||||
this.showDialogEmail()
|
||||
}
|
||||
}).catch(err => {
|
||||
this.loading = false
|
||||
if (err.response.status === 404) {
|
||||
// User not found
|
||||
this.$refs.observer.setErrors({
|
||||
email: this.$gettext('Invalid email')
|
||||
})
|
||||
} else if (err.response.status === 502) {
|
||||
this.emailFailedToSend()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</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 recovert</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="changePassword"
|
||||
:loading="loading"
|
||||
>
|
||||
<translate>Submit</translate>
|
||||
</v-btn>
|
||||
</div>
|
||||
<div>
|
||||
<a @click="resendSms" class="float-left primary--text"><translate>Resend SMS.</translate></a>
|
||||
<a @click="returnLogin" class="float-right primary--text"><translate>Return to login?</translate></a>
|
||||
</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: '',
|
||||
password: '',
|
||||
password_confirmed: '',
|
||||
user_pk: -1,
|
||||
errors: {}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
returnLogin () {
|
||||
this.$router.push({ name: 'Login' })
|
||||
},
|
||||
async userPkError () {
|
||||
const confirm = await this.$refs.confirm.open(
|
||||
this.$gettext('Warning'),
|
||||
this.$gettext(
|
||||
'User seems wrong, return to login or restart reset process?'
|
||||
),
|
||||
{
|
||||
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.resendSmsTotp().then(resp => {
|
||||
this.loading = false
|
||||
// Reset done !
|
||||
}).catch(err => {
|
||||
console.error(err)
|
||||
this.loading = false
|
||||
this.userPkError()
|
||||
})
|
||||
},
|
||||
async checkSmsTotp () {
|
||||
const isValid = await this.$refs.observer.validate()
|
||||
if (!isValid) {
|
||||
return
|
||||
}
|
||||
const payload = {
|
||||
sms_totp: this.sms_totp
|
||||
}
|
||||
this.loading = true
|
||||
auth.checkSmsTotp(payload).then(resp => {
|
||||
this.loading = false
|
||||
if (resp.response.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 === 500) {
|
||||
this.$refs.observer.setErrors({
|
||||
password_confirmed: this.$gettext('Invalid totp, try resend')
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
@@ -1,19 +1,56 @@
|
||||
"""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.validators import validate_email
|
||||
from django.core.exceptions import ValidationError as djangoValidationError
|
||||
|
||||
from django.template import loader
|
||||
|
||||
from django.utils import formats
|
||||
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 _
|
||||
|
||||
import django_otp
|
||||
from rest_framework import serializers
|
||||
|
||||
from modoboa.lib import fields as lib_fields
|
||||
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
|
||||
|
||||
from ... import constants
|
||||
from ... import models
|
||||
from ... import sms_backends
|
||||
from ... import app_settings
|
||||
|
||||
class NoSMSAvailable(Exception):
|
||||
""" Raised when no sms totp is availible for password reset (to try email)."""
|
||||
pass
|
||||
|
||||
|
||||
class UserNotFound(Exception):
|
||||
""" Raised when no valid user has been found (to have a proper 404 http code instead of 400."""
|
||||
pass
|
||||
|
||||
|
||||
class EmailSendingFailure(Exception):
|
||||
""" Raised when a reset email has failed to send."""
|
||||
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 InvalidToken(Exception):
|
||||
""" Raised during password reset if token is not valid."""
|
||||
pass
|
||||
|
||||
|
||||
class CoreGlobalParametersSerializer(serializers.Serializer):
|
||||
@@ -242,3 +279,192 @@ class UserAPITokenSerializer(serializers.Serializer):
|
||||
"""Serializer used by API access routes."""
|
||||
|
||||
token = serializers.CharField()
|
||||
|
||||
|
||||
class EmailPasswordRecoveryInitSerializer(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 UserNotFound()
|
||||
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)
|
||||
try:
|
||||
if send_mail(subject, body, None, [to_email]) == 0:
|
||||
raise EmailSendingFailure()
|
||||
except:
|
||||
raise EmailSendingFailure()
|
||||
|
||||
|
||||
class SMSPasswordRecoveryInitSerializer(serializers.Serializer):
|
||||
|
||||
email = serializers.EmailField()
|
||||
|
||||
def validate(self, data, *args, **kwargs):
|
||||
request = self.context["request"]
|
||||
clean_email = data["email"]
|
||||
|
||||
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)
|
||||
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()
|
||||
request.session["totp_secret"] = secret
|
||||
request.session["user_pk"] = user.pk
|
||||
return data
|
||||
|
||||
|
||||
class PasswordRecoverySmsSerializer(serializers.Serializer):
|
||||
|
||||
sms_totp = serializers.CharField(min_lenght=6, max_length=6)
|
||||
|
||||
def validate(self, data):
|
||||
try:
|
||||
totp_secret = self.context["request"].session["totp_secret"]
|
||||
self.context["request"].session.pop("totp_secret")
|
||||
except KeyError:
|
||||
raise serializers.ValidationError(
|
||||
"totp secret not set in session", 403)
|
||||
|
||||
if not oath.accept_totp(totp_secret, data["sms_totp"]):
|
||||
raise serializers.ValidationError("Wrong totp", 403)
|
||||
|
||||
# Attempt to get user, will raise an error if pk is not valid
|
||||
self.user = User.objects.get(
|
||||
pk=self.context["user_pk"])
|
||||
|
||||
return data
|
||||
|
||||
def save(self):
|
||||
self.context["request"].session.pop("totp_secret")
|
||||
self.context["request"].session.pop("user")
|
||||
token = default_token_generator.make_token(self.user)
|
||||
uid = urlsafe_base64_encode(force_bytes(self.user.pk))
|
||||
self.context["response"] = (token, uid)
|
||||
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 UserNotFound
|
||||
|
||||
if not default_token_generator.check_token(user, data["token"]):
|
||||
raise InvalidToken
|
||||
|
||||
# Check that the password works with set conditions
|
||||
try:
|
||||
password_validation.validate_password(data["new_password1"], user)
|
||||
except djangoValidationError as e:
|
||||
raise PasswordRequirementsFailure(e.error_list)
|
||||
|
||||
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
|
||||
|
||||
|
||||
class PasswordRecoverySmsResendSerializer(serializers.Serializer):
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
try:
|
||||
self.context["session"].user_pk
|
||||
self.context["session"].totp_token
|
||||
return data
|
||||
except KeyError:
|
||||
raise serializers.ValidationError(
|
||||
"No previous reset attempt recorded", 403)
|
||||
|
||||
def save(self):
|
||||
user = User.objects.get(
|
||||
pk=self.context["session"].user_pk)
|
||||
request = self.context["request"]
|
||||
backend = sms_backends.get_active_backend(
|
||||
request.localconfig.parameters)
|
||||
secret = cryptutils.random_hex_key(20)
|
||||
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)]):
|
||||
return False
|
||||
request.session["totp_secret"] = secret
|
||||
request.session["user_pk"] = user.pk
|
||||
|
||||
@@ -5,8 +5,10 @@ from unittest import mock
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
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",
|
||||
@@ -112,6 +114,14 @@ class ParametersAPITestCase(ModoAPITestCase):
|
||||
|
||||
class AccountViewSetTestCase(ModoAPITestCase):
|
||||
|
||||
@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"))
|
||||
|
||||
def test_me(self):
|
||||
url = reverse("v2:account-me")
|
||||
resp = self.client.get(url)
|
||||
@@ -199,6 +209,41 @@ class AccountViewSetTestCase(ModoAPITestCase):
|
||||
resp = self.client.post(url)
|
||||
self.assertEqual(resp.status_code, 201)
|
||||
|
||||
@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)
|
||||
|
||||
# SMS parameter
|
||||
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")
|
||||
account = models.User.objects.get(username="user@test.com")
|
||||
data = {"email": account.email}
|
||||
# Provide a user w/o phone or secondary
|
||||
response = self.client.post(url, data, format="json")
|
||||
self.assertEqual(response.status_code, 404)
|
||||
# Secondary email for reset
|
||||
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, 204)
|
||||
# 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, 233)
|
||||
|
||||
|
||||
class AuthenticationTestCase(ModoAPITestCase):
|
||||
|
||||
|
||||
@@ -21,5 +21,13 @@ 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('confirm_sms_totp/', views.PasswordResetConfirmSmsCodeView.as_view(),
|
||||
name="confirm_sms_totp"),
|
||||
path('reset_sms_resend', views.PasswordResetResendSmsCodeView.as_view(),
|
||||
name='reset_sms_resend'),
|
||||
]
|
||||
|
||||
@@ -10,10 +10,13 @@ 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 .serializers import *
|
||||
|
||||
logger = logging.getLogger("modoboa.auth")
|
||||
|
||||
|
||||
@@ -70,3 +73,87 @@ 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 = EmailPasswordRecoveryInitSerializer(
|
||||
data=request.data, context={'request': request})
|
||||
try:
|
||||
serializer.is_valid(raise_exception=True)
|
||||
except UserNotFound:
|
||||
return response.Response({"no valid user found"}, 404)
|
||||
try:
|
||||
serializer.save()
|
||||
except EmailSendingFailure:
|
||||
return response.Response({"Email failed to send, possibly a misconfiguration?"}, 502)
|
||||
# Email response
|
||||
return response.Response(status=204)
|
||||
|
||||
|
||||
class DefaultPasswordResetView(EmailPasswordResetView):
|
||||
"""
|
||||
Works with PasswordRecoveryForm.vue.
|
||||
First checks if SMS recovery is available, else switch to super (Email recovery [with seconday email]).
|
||||
"""
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""Recover password."""
|
||||
serializer = SMSPasswordRecoveryInitSerializer(
|
||||
data=request.data, context={'request': request})
|
||||
try:
|
||||
serializer.is_valid(raise_exception=True)
|
||||
except NoSMSAvailable:
|
||||
return super().post(request, *args, **kwargs)
|
||||
# SMS response
|
||||
return response.Response(status=233)
|
||||
|
||||
|
||||
class PasswordResetConfirmSmsCodeView(APIView):
|
||||
""" Check SMS Totp code. """
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
serializer = PasswordRecoverySmsSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
|
||||
payload = serializer.context["response"]
|
||||
payload = {"token": payload[0], "id": payload[1]}
|
||||
|
||||
return response.Response(payload, 200)
|
||||
|
||||
|
||||
class PasswordResetConfirmView(APIView):
|
||||
""" Get and set new user password. """
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
serializer = PasswordRecoveryConfirmSerializer(data=request.data)
|
||||
try:
|
||||
serializer.is_valid(raise_exception=True)
|
||||
except (PasswordRequirementsFailure, UserNotFound, InvalidToken) as e:
|
||||
if type(e) == PasswordRequirementsFailure:
|
||||
errors = []
|
||||
for element in e.message_list:
|
||||
errors.append(element)
|
||||
return response.Response(errors, 455)
|
||||
elif type(e) == UserNotFound:
|
||||
return response.Response(status=404)
|
||||
elif type(e) == InvalidToken:
|
||||
return response.Response(status=401)
|
||||
serializer.save()
|
||||
return response.Response(status=200)
|
||||
|
||||
|
||||
class PasswordResetResendSmsCodeView(APIView):
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
serializer = PasswordRecoverySmsResendSerializer(
|
||||
data=request.data, context={"request": request})
|
||||
|
||||
serializer.is_valid(raise_exception=True)
|
||||
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',
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user