* Updated parameter forms
* Updated layout
* Updated login page
* Added 2FA authentication
This commit is contained in:
Antoine Nguyen
2021-05-29 09:57:35 +02:00
parent 4e7d660b44
commit ea98bed16b
29 changed files with 936 additions and 329 deletions
+1 -3
View File
@@ -1,9 +1,7 @@
<template>
<v-app v-if="!isAuthenticated">
<v-main>
<v-container fluid fill-height>
<router-view />
</v-container>
<router-view />
</v-main>
</v-app>
<v-app v-else>
+4
View File
@@ -5,5 +5,9 @@ const resource = 'account'
export default {
getMe () {
return repository.get(`/${resource}/me/`)
},
verifyTFACode (code) {
const payload = { code }
return repository.post(`${resource}/tfa/verify/`, payload)
}
}
+6 -2
View File
@@ -25,13 +25,17 @@ _axios.interceptors.response.use(
return response
},
function (error) {
if (error.response.status !== 401) {
if (error.response.status === 418) {
router.push({ name: 'TwoFA' })
return Promise.reject(error)
}
if (error.response.status !== 401 || router.currentRoute.path === '/login/') {
return Promise.reject(error)
}
const refreshToken = Cookies.get('refreshToken')
if (error.config.url.endsWith('/token/refresh/') || !refreshToken) {
store.dispatch('auth/logout')
router.push('/login/')
router.push({ name: 'Login' })
return Promise.reject(error)
}
return _axios.post('/token/refresh/', { refresh: refreshToken }).then(resp => {
@@ -20,6 +20,28 @@
<template v-slot:form.options="{ step }">
<domain-options-form :ref="`form_${step}`" :domain="domain" @createAdmin="updateCreateAdmin" />
</template>
<template v-slot:item.with_random_password="{ item }">
<template v-if="item.value">
<v-col cols="12" class="highligth white--text">
<v-row>
<v-col><span>{{ item.key }}</span></v-col>
<v-col class="text-right">{{ item.value|yesno }}</v-col>
</v-row>
<v-row>
<v-col class="text-right py-1">
{{ domain.domain_admin.password }}
<v-btn icon color="white" :title="'Copy to clipboard'|translate" @click="copyPassword">
<v-icon>mdi-clipboard-multiple-outline</v-icon>
</v-btn>
</v-col>
</v-row>
</v-col>
</template>
<template v-else>
<v-col><span class="grey--text">{{ item.key }}</span></v-col>
<v-col class="text-right">{{ item.value|yesno }}</v-col>
</template>
</template>
</creation-form>
</template>
@@ -80,6 +102,7 @@ export default {
if (this.createAdmin) {
result[3].items.push({ key: this.$gettext('Administrator name'), value: this.domain.domain_admin.username })
result[3].items.push({
name: 'with_random_password',
key: this.$gettext('Random password'),
value: this.domain.domain_admin.with_random_password,
type: 'yesno'
@@ -117,6 +140,11 @@ export default {
this.initDomain()
},
methods: {
copyPassword () {
navigator.clipboard.writeText(this.domain.domain_admin.password).then(() => {
bus.$emit('notification', { msg: this.$gettext('Password copied to clipboard') })
})
},
initDomain () {
this.domain = {
name: '',
@@ -154,6 +182,7 @@ export default {
delete data.domain_admin
}
this.$store.dispatch('domains/createDomain', data).then(resp => {
this.$router.push({ name: 'DomainDetail', params: { id: resp.data.pk } })
bus.$emit('notification', { msg: this.$gettext('Domain created') })
this.close()
})
@@ -161,3 +190,9 @@ export default {
}
}
</script>
<style lang="scss">
.highligth {
background-color: #515D78;
}
</style>
@@ -23,6 +23,7 @@
:label="'Random password' | translate"
:disabled="!createAdmin"
:hint="'Generate a random password for the administrator.' | translate"
@change="updatePassword"
persistent-hint
/>
<v-switch v-model="domain.domain_admin.with_mailbox"
@@ -41,6 +42,8 @@
</template>
<script>
import accounts from '@/api/accounts'
export default {
props: ['domain'],
data () {
@@ -49,6 +52,16 @@ export default {
}
},
methods: {
updatePassword (value) {
if (value) {
accounts.getRandomPassword().then(resp => {
this.$set(this.domain.domain_admin, 'password', resp.data.password)
})
} else {
delete this.domain.domain_admin.password
}
},
update (val) {
this.$emit('createAdmin', val)
}
@@ -21,22 +21,28 @@
<account-alias-form v-if="step >= 4" :ref="`form_${step}`" :account="account" />
</template>
<template v-slot:item.random_password="{ item }">
<v-col cols="12" class="highligth white--text">
<v-row>
<v-col><span>{{ item.key }}</span></v-col>
<v-col class="text-right" v-if="item.type === 'yesno'">
{{ item.value|yesno }}
</v-col>
</v-row>
<v-row>
<v-col class="text-right py-1">
{{ account.password }}
<v-btn icon color="white" :title="'Copy to clipboard'|translate" @click="copyPassword">
<v-icon>mdi-clipboard-multiple-outline</v-icon>
</v-btn>
</v-col>
</v-row>
</v-col>
<template v-if="item.value">
<v-col cols="12" class="highligth white--text">
<v-row>
<v-col><span>{{ item.key }}</span></v-col>
<v-col class="text-right" v-if="item.type === 'yesno'">
{{ item.value|yesno }}
</v-col>
</v-row>
<v-row>
<v-col class="text-right py-1">
{{ account.password }}
<v-btn icon color="white" :title="'Copy to clipboard'|translate" @click="copyPassword">
<v-icon>mdi-clipboard-multiple-outline</v-icon>
</v-btn>
</v-col>
</v-row>
</v-col>
</template>
<template v-else>
<v-col><span class="grey--text">{{ item.key }}</span></v-col>
<v-col class="text-right" v-if="item.type === 'yesno'">{{ item.value|yesno }}</v-col>
</template>
</template>
</creation-form>
</template>
+62 -58
View File
@@ -14,62 +14,64 @@
<v-list>
<template v-for="item in mainMenuItems">
<v-list-item
v-if="!item.children"
class="menu-item"
:to="item.to"
link
:key="item.title"
>
<v-list-item-icon v-if="item.icon">
<v-icon>{{ item.icon }}</v-icon>
</v-list-item-icon>
<template v-if="item.roles === undefined || item.roles.indexOf(authUser.role) !== -1">
<v-list-item
v-if="!item.children"
class="menu-item"
:to="item.to"
link
:key="item.title"
:exact="item.exact"
>
<v-list-item-icon v-if="item.icon">
<v-icon>{{ item.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>{{ item.text }}</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-group v-else
color="white"
:key="item.text"
:prepend-icon="item.icon"
no-action>
<template v-slot:activator>
<v-list-item-title>{{ item.text }}</v-list-item-title>
</template>
<v-list-item-content>
<v-list-item-title>{{ item.text }}</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-group v-else
color="white"
:key="item.text"
:prepend-icon="item.icon"
no-action>
<template v-slot:activator>
<v-list-item-title>{{ item.text }}</v-list-item-title>
</template>
<template v-for="subitem in item.children">
<v-list-item v-if="!subitem.children"
:key="subitem.text"
:to="subitem.to"
link>
<v-list-item-content>
<v-list-item-title>{{ subitem.text }}</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-group v-else
:key="subitem.text"
:value="true"
no-action
sub-group>
<template v-slot:activator>
<v-list-item-title>{{ subitem.text }}</v-list-item-title>
</template>
<v-list-item v-for="subsubitem in subitem.children"
:key="subsubitem.text"
:to="subsubitem.to"
<template v-for="subitem in item.children">
<v-list-item v-if="!subitem.children"
:key="subitem.text"
:to="subitem.to"
link>
<v-list-item-title>{{ subsubitem.text }}</v-list-item-title>
<v-list-item-content>
<v-list-item-title>{{ subitem.text }}</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list-group>
</template>
</v-list-group>
<v-list-group v-else
:key="subitem.text"
:value="true"
no-action
sub-group>
<template v-slot:activator>
<v-list-item-title>{{ subitem.text }}</v-list-item-title>
</template>
<v-list-item v-for="subsubitem in subitem.children"
:key="subsubitem.text"
:to="subsubitem.to"
link>
<v-list-item-title>{{ subsubitem.text }}</v-list-item-title>
</v-list-item>
</v-list-group>
</template>
</v-list-group>
</template>
</template>
</v-list>
<template v-slot:append>
<v-menu rounded="lg" offset-y top>
<template v-slot:activator="{ attrs, on }">
<div class="user-box justify-end white--text" v-bind="attrs" v-on="on">
<div class="d-flex user-box justify-center align-center white--text py-2" v-bind="attrs" v-on="on">
<v-avatar size="40" color="primary">
<span class="white--text headline">AN</span>
</v-avatar>
@@ -113,27 +115,33 @@ export default {
mainMenuItems: [
{
text: this.$gettext('Dashboard'),
icon: 'mdi-view-dashboard-outline'
to: { name: 'Dashboard' },
icon: 'mdi-view-dashboard-outline',
exact: true
},
{
text: this.$gettext('Domains'),
to: { name: 'DomainList' },
icon: 'mdi-domain'
icon: 'mdi-domain',
roles: ['DomainAdmins', 'Resellers', 'SuperAdmins']
},
{
text: this.$gettext('Identities'),
to: { name: 'Identities' },
icon: 'mdi-account'
icon: 'mdi-account',
roles: ['DomainAdmins', 'Resellers', 'SuperAdmins']
},
{
icon: 'mdi-history',
text: this.$gettext('Logs'),
to: { name: 'LogList' }
to: { name: 'AuditTrail' },
roles: ['SuperAdmins']
},
{
icon: 'mdi-cog',
text: this.$gettext('Parameters'),
children: []
children: [],
roles: ['SuperAdmins']
}
],
userMenuItems: [
@@ -158,7 +166,7 @@ export default {
methods: {
logout () {
this.$store.dispatch('auth/logout').then(() => {
this.$router.push('/login')
this.$router.push({ name: 'Login' })
})
}
}
@@ -178,10 +186,6 @@ export default {
}
.user-box {
box-sizing: border-box;
border: 1px solid #979797;
width: 100%;
background: rgba(0, 0, 0, 0.25);
padding: 5px;
}
</style>
@@ -0,0 +1,71 @@
<template>
<v-card class="mt-6">
<v-toolbar flat>
<v-spacer></v-spacer>
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
:placeholder="'Search'|translate"
filled
outlined
dense
hide-details
/>
</v-toolbar>
<v-data-table
:headers="headers"
:items="logs"
:search="search"
class="elevation-1"
:options.sync="pagination"
>
<template v-slot:item.level="{ item }">
<v-chip :color="getLevelColor(item.level)" text-color="white" small>
{{ item.level }}
</v-chip>
</template>
</v-data-table>
</v-card>
</template>
<script>
import logs from '@/api/logs'
export default {
data () {
return {
logs: [],
headers: [
{ text: 'Date', value: 'date_created' },
{ text: 'Level', value: 'level' },
{ text: 'Logger', value: 'logger' },
{ text: 'Message', value: 'message' }
],
loading: true,
pagination: {
},
search: ''
}
},
created () {
logs.getAll().then(response => {
this.logs = response.data
this.loading = false
})
},
methods: {
getLevelColor (level) {
if (level === 'INFO') {
return 'primary'
}
if (level === 'WARNING') {
return 'orange'
}
if (level === 'CRITICAL') {
return 'red'
}
return ''
}
}
}
</script>
-72
View File
@@ -1,72 +0,0 @@
<template>
<v-layout>
<v-flex>
<v-toolbar flat color="white">
<v-toolbar-title>Logs</v-toolbar-title>
<v-divider class="mx-2" inset vertical></v-divider>
<v-spacer></v-spacer>
<v-text-field
v-model="search"
append-icon="mdi-magnify"
label="Search"
single-line
hide-details
></v-text-field>
</v-toolbar>
<v-data-table :headers="headers"
:items="logs"
:search="search"
class="elevation-1"
:options.sync="pagination"
>
<template slot="items" slot-scope="props">
<td>{{ props.item.date_created }}</td>
<td>
<v-chip v-if="props.item.level === 'INFO'"
color="primary" text-color="white" small>
{{ props.item.level }}
</v-chip>
<v-chip v-if="props.item.level === 'WARNING'"
color="orange" text-color="white" small>
{{ props.item.level }}
</v-chip>
<v-chip v-if="props.item.level === 'CRITICAL'"
color="red" text-color="white" small>
{{ props.item.level }}
</v-chip>
</td>
<td>{{ props.item.logger }}</td>
<td>{{ props.item.message }}</td>
</template>
</v-data-table>
</v-flex>
</v-layout>
</template>
<script>
import logs from '@/api/logs'
export default {
data () {
return {
logs: [],
headers: [
{ text: 'Date', value: 'date_created' },
{ text: 'Level', value: 'level' },
{ text: 'Logger', value: 'logger' },
{ text: 'Message', value: 'message' }
],
loading: true,
pagination: {
},
search: ''
}
},
created () {
logs.getAll().then(response => {
this.logs = response.data
this.loading = false
})
}
}
</script>
@@ -1,22 +1,28 @@
<template>
<v-layout>
<v-flex>
<v-tabs v-model="active">
<v-tab v-for="(element, index) in displayableElements"
:key="index"
>
{{ element.label }}
</v-tab>
<v-tabs-items v-model="active">
<v-tab-item
v-for="(element, index) in displayableElements"
:key="index"
>
<v-card>
<v-card-text>
<template v-for="(param, index) in displayableParams(element.parameters)">
<div class="my-4" :key="index">
<template v-if="param.widget === 'BooleanField'">
<div>
<v-toolbar flat>
<v-toolbar-title>{{ title }}</v-toolbar-title>
</v-toolbar>
<v-tabs v-model="active">
<v-tab v-for="(element, index) in displayableElements"
:key="index"
>
{{ element.label }}
</v-tab>
<v-tabs-items v-model="active">
<v-tab-item
v-for="(element, index) in displayableElements"
:key="index"
>
<v-card>
<v-card-text>
<template v-for="(param, index) in displayableParams(element.parameters)">
<div class="my-4" :key="index">
<template v-if="param.widget === 'SeparatorField'">
<h2>{{ param.label }}</h2>
</template>
<template v-else-if="param.widget === 'BooleanField'">
<v-switch
v-model="parameters[param.name]"
:label="param.label"
@@ -42,30 +48,30 @@
v-model="parameters[param.name]"
:error="formErrors[param.name] !== undefined"
:error-messages="formErrors[param.name]"
:type="param.widget === 'PasswordField' ? 'password' : 'text'"
v-else
dense
outlined
/>
</template>
</div>
</template>
</v-card-text>
</v-card>
</v-tab-item>
</v-tabs-items>
</v-tabs>
<v-btn fixed
dark
fab
bottom
right
color="green"
@click="save"
>
<v-icon>mdi-content-save</v-icon>
</v-btn>
</v-flex>
</v-layout>
</div>
</template>
</v-card-text>
</v-card>
</v-tab-item>
</v-tabs-items>
</v-tabs>
<v-btn fixed
dark
fab
bottom
right
color="green"
@click="save"
>
<v-icon>mdi-content-save</v-icon>
</v-btn>
</div>
</template>
<script>
@@ -85,6 +91,17 @@ export default {
displayableElements () {
const result = this.structure.filter(element => this.display(element))
return result
},
title () {
let result = this.$gettext('Parameters: ')
if (this.$route.params.app === 'core') {
result += this.$gettext('General')
} else if (this.$route.params.app === 'admin') {
result += this.$gettext('Administration')
} else if (this.$route.params.app === 'limits') {
result += this.$gettext('Limitations')
}
return result
}
},
created () {
@@ -108,11 +125,15 @@ export default {
if (element.display === '') {
return true
}
var [field, value] = element.display.split('=')
if (value === 'true' || value === 'false') {
value = Boolean(value)
}
return this.parameters[field] === value
let result = true
element.display.split('&').forEach(rule => {
var [field, value] = rule.split('=')
if (value === 'true' || value === 'false') {
value = Boolean(value)
}
result &= this.parameters[field] === value
})
return result
},
displayableParams (params) {
return params.filter(param => this.display(param))
@@ -127,3 +148,9 @@ export default {
}
}
</script>
<style scoped>
.v-toolbar {
background-color: #f7f8fa !important;
}
</style>
+41 -15
View File
@@ -4,11 +4,18 @@ import VueRouter from 'vue-router'
import store from '@/store'
import ParametersForm from '@/components/parameters/ParametersForm'
import LogList from '@/components/logs/LogList'
Vue.use(VueRouter)
const routes = [
{
path: '/',
name: 'Dashboard',
component: () => import(/* webpackChunkName: "dashboard" */ '../views/Dashboard.vue'),
meta: {
requiresAuth: true
}
},
{
path: '/login',
name: 'Login',
@@ -17,28 +24,36 @@ const routes = [
// which is lazy-loaded when the route is visited.
component: () => import(/* webpackChunkName: "about" */ '../views/Login.vue')
},
{
path: '/twofa',
name: 'TwoFA',
component: () => import('../views/TwoFA.vue')
},
{
path: '/domains',
name: 'DomainList',
component: () => import('../views/Domains.vue'),
component: () => import('../views/domains/Domains.vue'),
meta: {
requiresAuth: true
requiresAuth: true,
allowedRoles: ['DomainAdmins', 'Resellers', 'SuperAdmins']
}
},
{
path: '/domains/:id',
name: 'DomainDetail',
component: () => import('../views/Domain.vue'),
component: () => import('../views/domains/Domain.vue'),
meta: {
requiresAuth: true
requiresAuth: true,
allowedRoles: ['DomainAdmins', 'Resellers', 'SuperAdmins']
}
},
{
path: '/domains/:id/edit',
name: 'DomainEdit',
component: () => import('../views/DomainEdit.vue'),
component: () => import('../views/domains/DomainEdit.vue'),
meta: {
requiresAuth: true
requiresAuth: true,
allowedRoles: ['DomainAdmins', 'Resellers', 'SuperAdmins']
}
},
{
@@ -46,7 +61,8 @@ const routes = [
name: 'Identities',
component: () => import('../views/identities/Identities.vue'),
meta: {
requiresAuth: true
requiresAuth: true,
allowedRoles: ['DomainAdmins', 'Resellers', 'SuperAdmins']
}
},
{
@@ -54,7 +70,8 @@ const routes = [
name: 'AccountEdit',
component: () => import('../views/identities/AccountEdit.vue'),
meta: {
requiresAuth: true
requiresAuth: true,
allowedRoles: ['DomainAdmins', 'Resellers', 'SuperAdmins']
}
},
{
@@ -62,7 +79,8 @@ const routes = [
name: 'AliasEdit',
component: () => import('../views/identities/AliasEdit.vue'),
meta: {
requiresAuth: true
requiresAuth: true,
allowedRoles: ['DomainAdmins', 'Resellers', 'SuperAdmins']
}
},
{
@@ -70,15 +88,17 @@ const routes = [
name: 'ParametersEdit',
component: ParametersForm,
meta: {
requiresAuth: true
requiresAuth: true,
allowedRoles: ['SuperAdmins']
}
},
{
path: '/logs/',
name: 'LogList',
component: LogList,
name: 'AuditTrail',
component: () => import('../views/logs/AuditTrail.vue'),
meta: {
requiresAuth: true
requiresAuth: true,
allowedRoles: ['SuperAdmins']
}
}
]
@@ -93,8 +113,14 @@ router.beforeEach((to, from, next) => {
if (to.matched.some(record => record.meta.requiresAuth)) {
store.dispatch('auth/initialize').then(() => {
if (!store.getters['auth/isAuthenticated']) {
next('/login/')
next({ name: 'Login' })
} else {
if (to.meta.allowedRoles !== undefined) {
if (to.meta.allowedRoles.indexOf(store.getters['auth/authUser'].role) === -1) {
next({ name: 'Dashboard' })
return
}
}
next()
}
})
+7 -2
View File
@@ -40,8 +40,13 @@ const actions = {
})
},
createDomain ({ commit }, data) {
return domainApi.createDomain(data).then(response => {
commit(ADD_DOMAIN, { domain: response.data })
return new Promise((resolve, reject) => {
domainApi.createDomain(data).then(response => {
commit(ADD_DOMAIN, { domain: response.data })
resolve(response)
}).catch(error => {
reject(error)
})
})
},
updateDomain ({ commit }, data) {
+7
View File
@@ -0,0 +1,7 @@
<template>
<div>
<v-toolbar flat>
<v-toolbar-title><translate>Welcome to Modoboa</translate></v-toolbar-title>
</v-toolbar>
</div>
</template>
+64 -41
View File
@@ -1,58 +1,73 @@
<template>
<v-layout align-center justify-center>
<v-flex xs12 sm8 md4>
<v-card class="elevation-12" @keyup.enter="authenticate">
<v-toolbar dark color="primary">
<v-toolbar-title>Modoboa</v-toolbar-title>
</v-toolbar>
<v-card-text>
<v-text-field label="Utilisateur"
v-model="username"
:error="errors.username !== undefined"
:error-messages="errors.username"
prepend-icon="mdi-account"
>
</v-text-field>
<v-text-field label="Mot de passe"
type="password"
v-model="password"
:error="errors.password !== undefined"
:error-messages="errors.password"
prepend-icon="mdi-lock"
>
</v-text-field>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" text @click="authenticate">
Connect
</v-btn>
</v-card-actions>
</v-card>
</v-flex>
</v-layout>
<v-row style="height: 100%; width: 80%; 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="authenticate">
<span class="primary--text mb-6 text-h4"><translate>Identification</translate></span>
<validation-observer ref="observer">
<validation-provider
v-slot="{ errors }"
rules="required"
>
<label class="m-label"><translate>Username</translate></label>
<v-text-field
v-model="username"
:error-messages="errors"
outlined
/>
</validation-provider>
<validation-provider
vid="password"
v-slot="{ errors }"
rules="required"
>
<label class="m-label"><translate>Password</translate></label>
<v-text-field
type="password"
v-model="password"
:error-messages="errors"
outlined
/>
</validation-provider>
<a href="" class="float-right primary--text"><translate>Forgot password?</translate></a>
<v-checkbox
v-model="rememberMe"
:label="'Remember me'|translate"
/>
</validation-observer>
<div class="d-flex justify-center">
<v-btn class="flex-grow-1" color="primary" large @click="authenticate">
<translate>Connect</translate>
</v-btn>
</div>
</v-col>
</v-row>
</template>
<script>
import Cookies from 'js-cookie'
import { mapGetters } from 'vuex'
import auth from '@/api/auth'
export default {
computed: {
...mapGetters({
endpoints: 'auth/endpoints'
})
},
data () {
return {
rememberMe: false,
username: '',
password: '',
errors: {}
}
},
methods: {
authenticate () {
async authenticate () {
const isValid = await this.$refs.observer.validate()
if (!isValid) {
return
}
const payload = {
username: this.username,
password: this.password
@@ -61,12 +76,20 @@ export default {
Cookies.set('token', resp.data.access, { sameSite: 'strict' })
Cookies.set('refreshToken', resp.data.refresh, { sameSite: 'strict' })
this.$store.dispatch('auth/initialize').then(() => {
this.$router.push('/')
this.$router.push({ name: 'Dashboard' })
})
}).catch(err => {
console.log(err)
if (err.response.status === 401) {
this.$refs.observer.setErrors({
password: this.$gettext('Invalid username and/or password')
})
}
})
}
}
}
</script>
<style>
</style>
+68
View File
@@ -0,0 +1,68 @@
<template>
<v-row style="height: 100%; width: 80%; 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="verifyCode">
<span class="primary--text mb-6 text-h4"><translate>Verification</translate></span>
<validation-observer ref="observer">
<validation-provider
v-slot="{ errors }"
vid="code"
rules="required"
>
<label class="m-label"><translate>Two-factor authentication code</translate></label>
<v-text-field
v-model="code"
prepend-icon="mdi-numeric"
:error-messages="errors"
:hint="'Enter the code from the two-factor app on your mobile device. If you have lost your device, you may enter one of your recovery codes.'|translate"
persistent-hint
dense
outlined
/>
</validation-provider>
</validation-observer>
<div class="d-flex justify-center mt-6">
<v-btn class="flex-grow-1" color="primary" large @click="verifyCode">
<translate>Verify code</translate>
</v-btn>
</div>
</v-col>
</v-row>
</template>
<script>
import Cookies from 'js-cookie'
import account from '@/api/account'
export default {
data () {
return {
code: null,
errors: {}
}
},
methods: {
async verifyCode () {
const isValid = await this.$refs.observer.validate()
if (!isValid) {
return
}
account.verifyTFACode(this.code).then(resp => {
Cookies.set('token', resp.data.access, { sameSite: 'strict' })
Cookies.set('refreshToken', resp.data.refresh, { sameSite: 'strict' })
this.$store.dispatch('auth/initialize').then(() => {
this.$router.push({ name: 'Dashboard' })
})
}).catch(error => {
this.$refs.observer.setErrors(error.response.data)
})
}
}
}
</script>
@@ -57,7 +57,7 @@ export default {
},
data () {
return {
domain: {},
domain: { pk: this.$route.params.id },
showEditForm: false,
showAdminList: false
}
@@ -79,3 +79,9 @@ export default {
}
}
</script>
<style scoped>
.v-toolbar {
background-color: #f7f8fa !important;
}
</style>
+24
View File
@@ -0,0 +1,24 @@
<template>
<div>
<v-toolbar flat>
<v-toolbar-title><translate>Audit trail</translate></v-toolbar-title>
</v-toolbar>
<audit-trail-list />
</div>
</template>
<script>
import AuditTrailList from '@/components/logs/AuditTrailList.vue'
export default {
components: {
AuditTrailList
}
}
</script>
<style scoped>
.v-toolbar {
background-color: #f7f8fa !important;
}
</style>
+1 -1
View File
@@ -194,7 +194,7 @@ class AccountSerializer(serializers.ModelSerializer):
fields = (
"pk", "username", "first_name", "last_name", "is_active",
"master_user", "mailbox", "role", "language", "phone_number",
"secondary_email", "domains",
"secondary_email", "domains"
)
def __init__(self, *args, **kwargs):
+50 -5
View File
@@ -1,5 +1,7 @@
"""Admin API v2 serializers."""
import os
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils.translation import ugettext as _
@@ -13,6 +15,7 @@ from modoboa.core import models as core_models, signals as core_signals
from modoboa.lib import exceptions as lib_exceptions
from modoboa.lib import fields as lib_fields
from modoboa.lib import validators, web_utils
from modoboa.lib.sysutils import exec_cmd
from modoboa.parameters import tools as param_tools
from ... import constants, lib, models
@@ -22,7 +25,7 @@ class CreateDomainAdminSerializer(serializers.Serializer):
"""Sub serializer for domain administrator creation."""
username = serializers.CharField()
with_random_password = serializers.BooleanField(default=False)
password = serializers.CharField(required=False)
with_mailbox = serializers.BooleanField(default=False)
with_aliases = serializers.BooleanField(default=False)
@@ -57,9 +60,8 @@ class DomainSerializer(v1_serializers.DomainSerializer):
core_signals.can_create_object.send(
self.__class__, context=user, klass=models.Mailbox)
da = core_models.User(username=username, email=username, is_active=True)
if domain_admin["with_random_password"]:
password = lib.make_password()
else:
password = domain_admin.get("password")
if password is None:
password = param_tools.get_global_parameter(
"default_password", app="core")
da.set_password(password)
@@ -132,6 +134,48 @@ class AdminGlobalParametersSerializer(serializers.Serializer):
auto_create_domain_and_mailbox = serializers.BooleanField(default=True)
create_alias_on_mbox_rename = serializers.BooleanField(default=False)
def validate_default_domain_quota(self, value):
"""Ensure quota is a positive integer."""
if value < 0:
raise serializers.ValidationError(
_("Must be a positive integer")
)
return value
def validate_default_mailbox_quota(self, value):
"""Ensure quota is a positive integer."""
if value < 0:
raise serializers.ValidationError(
_("Must be a positive integer")
)
return value
def validate_dkim_keys_storage_dir(self, value):
"""Check that directory exists."""
if value:
if not os.path.isdir(value):
raise serializers.ValidationError(
_("Directory not found.")
)
code, output = exec_cmd("which openssl")
if code:
raise serializers.ValidationError(
_("openssl not found, please make sure it is installed.")
)
return value
def validate(self, data):
"""Check MX options."""
condition = (
data.get("enable_mx_checks") and
data.get("domains_must_have_authorized_mx") and
not data.get("valid_mxs"))
if condition:
raise serializers.ValidationError({
"valid_mxs": _("Define at least one authorized network / address")
})
return data
class DomainAdminSerializer(serializers.ModelSerializer):
"""Serializer used for administrator related routes."""
@@ -184,8 +228,9 @@ class MailboxSerializer(serializers.ModelSerializer):
def validate(self, data):
"""Check if quota is required."""
method = self.context["request"].method
if not data.get("use_domain_quota", False):
if "quota" not in data:
if "quota" not in data and method != "PATCH":
raise serializers.ValidationError({
"quota": _("This field is required")
})
+1 -1
View File
@@ -137,7 +137,7 @@ class AccountViewSet(v1_viewsets.AccountViewSet):
filterset_class = AccountFilterSet
def get_serializer_class(self):
if self.action in ["create", "validate"]:
if self.action in ["create", "validate", "update", "partial_update"]:
return serializers.WritableAccountSerializer
if self.action == "delete":
return serializers.DeleteAccountSerializer
+82 -10
View File
@@ -5,12 +5,14 @@ from django.utils.translation import ugettext_lazy, ugettext as _
from django.contrib.auth import password_validation
import django_otp
from rest_framework import serializers
from modoboa.lib import fields as lib_fields
from ... import constants
from ... import models
from ... import sms_backends
class CoreGlobalParametersSerializer(serializers.Serializer):
@@ -34,16 +36,39 @@ class CoreGlobalParametersSerializer(serializers.Serializer):
default="sha512crypt"
)
rounds_number = serializers.IntegerField(default=70000)
update_scheme = serializers.BooleanField(default=True)
default_password = serializers.CharField(default="password")
random_password_length = serializers.IntegerField(min_value=8, default=8)
update_password_url = serializers.URLField(required=False)
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)
# LDAP settings
ldap_server_address = serializers.CharField(default="localhost")
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_secured = serializers.ChoiceField(
choices=constants.LDAP_SECURE_MODES,
default="none"
)
ldap_is_active_directory = serializers.BooleanField(default=False)
ldap_admin_groups = serializers.CharField(
default="", required=False, allow_blank=True)
ldap_group_type = serializers.ChoiceField(
default="posixgroup",
choices=constants.LDAP_GROUP_TYPES
)
ldap_groups_search_base = serializers.CharField(
default="", required=False, allow_blank=True)
ldap_password_attribute = serializers.CharField(default="userPassword")
# LDAP auth settings
ldap_auth_method = serializers.ChoiceField(
choices=constants.LDAP_AUTH_METHODS,
default="searchbind",
@@ -59,16 +84,24 @@ class CoreGlobalParametersSerializer(serializers.Serializer):
allow_blank=True)
ldap_user_dn_template = serializers.CharField(
default="", required=False, allow_blank=True)
ldap_password_attribute = serializers.CharField(default="userPassword")
ldap_is_active_directory = serializers.BooleanField(default=False)
ldap_admin_groups = serializers.CharField(
default="", required=False, allow_blank=True)
ldap_group_type = serializers.ChoiceField(
default="posixgroup",
choices=constants.LDAP_GROUP_TYPES
# LDAP sync settings
ldap_sync_bind_dn = serializers.CharField(required=False)
ldap_sync_bind_password = serializers.CharField(required=False)
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)
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(
default="/etc/dovecot/dovecot-modoboa.conf", required=False
)
ldap_groups_search_base = serializers.CharField(
default="", required=False, allow_blank=True)
# Dashboard settings
rss_feed_url = serializers.URLField(allow_blank=True)
@@ -81,6 +114,8 @@ class CoreGlobalParametersSerializer(serializers.Serializer):
# API settings
enable_api_communication = serializers.BooleanField(default=True)
check_new_versions = serializers.BooleanField(default=True)
send_new_versions_email = serializers.BooleanField(default=False)
new_versions_email_rcpt = lib_fields.DRFEmailFieldUTF8(required=False)
send_statistics = serializers.BooleanField(default=True)
# Misc settings
@@ -98,6 +133,20 @@ class CoreGlobalParametersSerializer(serializers.Serializer):
raise serializers.ValidationError(_("Invalid syntax"))
return value
def validate_ldap_sync_account_dn_template(self, value):
try:
value % {"user": "toto"}
except (KeyError, ValueError):
raise serializers.ValidationError(_("Invalid syntax"))
return value
def validate_ldap_search_filter(self, value):
try:
value % {"user": "toto"}
except (KeyError, ValueError, TypeError):
raise serializers.ValidationError(_("Invalid syntax"))
return value
def validate_rounds_number(self, value):
if value < 1000 or value > 999999999:
raise serializers.ValidationError(_("Invalid rounds number"))
@@ -114,13 +163,24 @@ class CoreGlobalParametersSerializer(serializers.Serializer):
Depending on 'ldap_auth_method' value, we check for different
required parameters.
"""
errors = {}
if data["sms_password_recovery"]:
provider = data.get("sms_provider")
if provider:
sms_settings = sms_backends.get_backend_settings(provider)
if sms_settings:
for name in sms_settings.keys():
if not data.get(name):
errors[name] = _("This field is required")
else:
errors["sms_provider"] = _("This field is required")
if data["authentication_type"] != "ldap":
return data
if data["ldap_auth_method"] == "searchbind":
required_fields = ["ldap_search_base", "ldap_search_filter"]
else:
required_fields = ["ldap_user_dn_template"]
errors = {}
for f in required_fields:
if data.get(f, u"") == u"":
errors[f] = _("This field is required")
@@ -140,3 +200,15 @@ class LogSerializer(serializers.ModelSerializer):
def get_date_created(self, log) -> str:
return formats.date_format(log.date_created, "SHORT_DATETIME_FORMAT")
class VerifyTFACodeSerializer(serializers.Serializer):
"""Serializer used to verify 2FA code validity."""
code = serializers.CharField()
def validate_code(self, value):
device = django_otp.match_token(self.context["user"], value)
if device is None:
raise serializers.ValidationError(_("This code is invalid"))
return device
+18
View File
@@ -1,11 +1,14 @@
"""Core API v2 viewsets."""
from drf_spectacular.utils import extend_schema
from rest_framework import permissions, response, viewsets
from rest_framework.decorators import action
from rest_framework_simplejwt.tokens import RefreshToken
from modoboa.admin.api.v1 import serializers as admin_v1_serializers
from modoboa.core.api.v1 import viewsets as core_v1_viewsets
from ... import constants
from ... import models
from . import serializers
@@ -14,11 +17,26 @@ class AccountViewSet(core_v1_viewsets.AccountViewSet):
"""Account viewset."""
@action(methods=["get"], detail=False)
@extend_schema(responses=admin_v1_serializers.AccountSerializer)
def me(self, request):
"""Return information about connected user."""
serializer = admin_v1_serializers.AccountSerializer(request.user)
return response.Response(serializer.data)
@action(methods=["post"], detail=False, url_path="tfa/verify")
def tfa_verify_code(self, request):
"""Verify given code validity."""
serializer = serializers.VerifyTFACodeSerializer(
data=request.data, context={"user": request.user})
serializer.is_valid(raise_exception=True)
refresh = RefreshToken.for_user(request.user)
refresh[constants.TFA_DEVICE_TOKEN_KEY] = (
serializer.validated_data["code"].persistent_id)
return response.Response({
"refresh": str(refresh),
"access": str(refresh.access_token)
})
class LogViewSet(viewsets.ReadOnlyModelViewSet):
"""Log viewset."""
+222 -53
View File
@@ -737,6 +737,13 @@ GLOBAL_PARAMETERS_STRUCT = collections.OrderedDict([
"inclusive."
)
}),
("update_scheme", {
"label": ugettext_lazy("Update password scheme at login"),
"help_text": ugettext_lazy(
"Update user password at login to use the default "
"password scheme"
)
}),
("default_password", {
"label": ugettext_lazy("Default password"),
"help_text": ugettext_lazy(
@@ -746,12 +753,41 @@ GLOBAL_PARAMETERS_STRUCT = collections.OrderedDict([
"label": ugettext_lazy("Random password length"),
"help_text": ugettext_lazy(
"Length of randomly generated passwords.")
})
}),
("update_password_url", {
"label": ugettext_lazy("Update password service URL"),
"help_text": ugettext_lazy(
"The URL of an external page where users will be able"
" to update their password. It applies only to non local"
" users, ie. those automatically created after a successful"
" external authentication (LDAP, SMTP)."
)
}),
("password_recovery_msg", {
"label": ugettext_lazy("Password recovery announcement"),
"help_text": ugettext_lazy(
"A temporary message that will be displayed on the "
"reset password page."
)
}),
("sms_password_recovery", {
"label": ugettext_lazy("Enable password recovery by SMS"),
"help_text": ugettext_lazy(
"Enable password recovery by SMS for users who filled "
"a phone number."
)
}),
("sms_provider", {
"label": ugettext_lazy("SMS provider"),
"display": "sms_password_recovery=true",
"help_text": ugettext_lazy(
"Choose a provider to send password recovery SMS"
)
}),
])
}),
("ldap", {
"label": _("LDAP"),
"display": "authentication_type=ldap",
"params": collections.OrderedDict([
("ldap_server_address", {
"label": ugettext_lazy("Server address"),
@@ -763,61 +799,32 @@ GLOBAL_PARAMETERS_STRUCT = collections.OrderedDict([
"help_text": ugettext_lazy(
"The TCP port number used by the LDAP server")
}),
("ldap_enable_secondary_server", {
"label": ugettext_lazy("Enable secondary server (fallback)"),
"help_text": ugettext_lazy(
"Enable a secondary LDAP server which will be used "
"if the primary one fails"
)
}),
("ldap_secondary_server_address", {
"label": ugettext_lazy("Secondary server address"),
"display": "ldap_enable_secondary_server=true",
"help_text": ugettext_lazy(
"The IP address or the DNS name of the seondary LDAP server"
)
}),
("ldap_secondary_server_port", {
"label": ugettext_lazy("Secondary server port"),
"display": "ldap_enable_secondary_server=true",
"help_text": ugettext_lazy(
"The TCP port number used by the LDAP secondary server"
)
}),
("ldap_secured", {
"label": ugettext_lazy("Use a secured connection"),
"help_text": ugettext_lazy(
"Use an SSL/STARTTLS connection to access the LDAP server")
}),
("ldap_auth_method", {
"label": ugettext_lazy("Authentication method"),
"help_text": ugettext_lazy(
"Choose the authentication method to use"),
}),
("ldap_bind_dn", {
"label": ugettext_lazy("Bind DN"),
"help_text": ugettext_lazy(
"The distinguished name to use when binding to the LDAP "
"server. Leave empty for an anonymous bind"
),
"display": "ldap_auth_method=searchbind"
}),
("ldap_bind_password", {
"label": ugettext_lazy("Bind password"),
"help_text": ugettext_lazy(
"The password to use when binding to the LDAP server "
"(with 'Bind DN')"
),
"display": "ldap_auth_method=searchbind"
}),
("ldap_search_base", {
"label": ugettext_lazy("Users search base"),
"help_text": ugettext_lazy(
"The distinguished name of the search base used to find "
"users"
),
"display": "ldap_auth_method=searchbind"
}),
("ldap_search_filter", {
"label": ugettext_lazy("Search filter"),
"help_text": ugettext_lazy(
"An optional filter string (e.g. '(objectClass=person)'). "
"In order to be valid, it must be enclosed in parentheses."
),
"display": "ldap_auth_method=searchbind"
}),
("ldap_user_dn_template", {
"label": ugettext_lazy("User DN template"),
"help_text": ugettext_lazy(
"The template used to construct a user's DN. It should "
"contain one placeholder (ie. %(user)s)"
),
"display": "ldap_auth_method=directbind"
}),
("ldap_password_attribute", {
"label": ugettext_lazy("Password attribute"),
"help_text": ugettext_lazy(
"The attribute used to store user passwords"),
}),
("ldap_is_active_directory", {
"label": ugettext_lazy("Active Directory"),
"help_text": ugettext_lazy(
@@ -843,7 +850,154 @@ GLOBAL_PARAMETERS_STRUCT = collections.OrderedDict([
"The distinguished name of the search base used to find "
"groups"
)
})
}),
("ldap_password_attribute", {
"label": ugettext_lazy("Password attribute"),
"help_text": ugettext_lazy(
"The attribute used to store user passwords"),
}),
("ldap_auth_sep", {
"label": ugettext_lazy("LDAP authentication settings"),
"display": "authentication_type=ldap",
"separator": True
}),
("ldap_auth_method", {
"label": ugettext_lazy("Authentication method"),
"display": "authentication_type=ldap",
"help_text": ugettext_lazy(
"Choose the authentication method to use"),
}),
("ldap_bind_dn", {
"label": ugettext_lazy("Bind DN"),
"help_text": ugettext_lazy(
"The distinguished name to use when binding to the LDAP "
"server. Leave empty for an anonymous bind"
),
"display": "authentication_type=ldap&ldap_auth_method=searchbind"
}),
("ldap_bind_password", {
"label": ugettext_lazy("Bind password"),
"help_text": ugettext_lazy(
"The password to use when binding to the LDAP server "
"(with 'Bind DN')"
),
"display": "authentication_type=ldap&ldap_auth_method=searchbind",
"password": True
}),
("ldap_search_base", {
"label": ugettext_lazy("Users search base"),
"help_text": ugettext_lazy(
"The distinguished name of the search base used to find "
"users"
),
"display": "authentication_type=ldap&ldap_auth_method=searchbind"
}),
("ldap_search_filter", {
"label": ugettext_lazy("Search filter"),
"help_text": ugettext_lazy(
"An optional filter string (e.g. '(objectClass=person)'). "
"In order to be valid, it must be enclosed in parentheses."
),
"display": "authentication_type=ldap&ldap_auth_method=searchbind"
}),
("ldap_user_dn_template", {
"label": ugettext_lazy("User DN template"),
"help_text": ugettext_lazy(
"The template used to construct a user's DN. It should "
"contain one placeholder (ie. %(user)s)"
),
"display": "authentication_type=ldap&ldap_auth_method=directbind"
}),
("ldap_sync_sep", {
"label": ugettext_lazy("LDAP synchronization settings"),
"separator": True
}),
("ldap_sync_bind_dn", {
"label": ugettext_lazy("Bind DN"),
"help_text": ugettext_lazy(
"The distinguished name to use when binding to the LDAP server. "
"Leave empty for an anonymous bind"
)
}),
("ldap_sync_bind_password", {
"label": ugettext_lazy("Bind password"),
"help_text": ugettext_lazy(
"The password to use when binding to the LDAP server "
"(with 'Bind DN')"
),
"password": True
}),
("ldap_enable_sync", {
"label": ugettext_lazy("Enable export to LDAP"),
"help_text": ugettext_lazy(
"Enable automatic synchronization between local database "
"and LDAP directory"
)
}),
("ldap_sync_delete_remote_account", {
"label": ugettext_lazy(
"Delete remote LDAP account when local account is deleted"),
"help_text": ugettext_lazy(
"Delete remote LDAP account when local account is deleted, "
"otherwise it will be disabled."
),
"display": "ldap_enable_sync=true"
}),
("ldap_sync_account_dn_template", {
"label": ugettext_lazy("Account DN template"),
"help_text": ugettext_lazy(
"The template used to construct an account's DN. It should "
"contain one placeholder (ie. %(user)s)"
),
"display": "ldap_enable_sync=true"
}),
("ldap_enable_import", {
"label": ugettext_lazy("Enable import from LDAP"),
"help_text": ugettext_lazy(
"Enable account synchronization from LDAP directory to "
"local database"
)
}),
("ldap_import_search_base", {
"label": ugettext_lazy("Users search base"),
"help_text": ugettext_lazy(
"The distinguished name of the search base used to find "
"users"
),
"display": "ldap_enable_import=true"
}),
("ldap_import_search_filter", {
"label": ugettext_lazy("Search filter"),
"help_text": ugettext_lazy(
"An optional filter string (e.g. '(objectClass=person)'). "
"In order to be valid, it must be enclosed in parentheses."
),
"display": "ldap_enable_import=true"
}),
("ldap_import_username_attr", {
"label": ugettext_lazy("Username attribute"),
"help_text": ugettext_lazy(
"The name of the LDAP attribute where the username can be "
"found."
),
"display": "ldap_enable_import=true"
}),
("ldap_dovecot_sync", {
"label": ugettext_lazy("Enable Dovecot LDAP sync"),
"help_text": ugettext_lazy(
"LDAP authentication settings will be applied to Dovecot "
"configuration."
)
}),
("ldap_dovecot_conf_file", {
"label": ugettext_lazy("Dovecot LDAP config file"),
"help_text": ugettext_lazy(
"Location of the configuration file which contains "
"Dovecot LDAP settings."
),
"display": "ldap_dovecot_sync=true"
}),
])
}),
("dashboard", {
@@ -889,6 +1043,21 @@ GLOBAL_PARAMETERS_STRUCT = collections.OrderedDict([
"help_text": ugettext_lazy(
"Automatically checks if a newer version is available")
}),
("send_new_versions_email", {
"label": ugettext_lazy(
"Send an email when new versions are found"),
"display": "check_new_versions=true",
"help_text": ugettext_lazy(
"Send an email to notify admins about new versions"
)
}),
("new_versions_email_rcpt", {
"label": ugettext_lazy("Recipient"),
"display": "check_new_versions=true",
"help_text": ugettext_lazy(
"Recipient of new versions notification emails."
)
}),
("send_statistics", {
"label": ugettext_lazy("Send statistics"),
"display": "enable_api_communication=true",
+2
View File
@@ -110,3 +110,5 @@ SMS_BACKENDS = [
if settings.DEBUG:
SMS_BACKENDS.insert(1, ("dummy", ugettext_lazy("Dummy")))
TFA_DEVICE_TOKEN_KEY = "otp_device_id"
+46
View File
@@ -0,0 +1,46 @@
"""Custom authentication for DRF."""
from django.urls import reverse
from django.utils.translation import ugettext_lazy as _
from django_otp.models import Device
from rest_framework import status
from rest_framework.exceptions import APIException
from rest_framework_simplejwt.authentication import JWTAuthentication
from . import constants
class NotVerified(APIException):
status_code = status.HTTP_418_IM_A_TEAPOT
default_detail = _('Missing 2FA verification.')
default_code = 'not_verified'
class JWTAuthenticationWith2FA(JWTAuthentication):
"""Add 2FA support."""
def verify_user(self, request, user):
header = self.get_header(request)
raw_token = self.get_raw_token(header)
validated_token = self.get_validated_token(raw_token)
url_exceptions = (
reverse("v2:account-tfa-verify-code"),
)
if request.path in url_exceptions:
return
if constants.TFA_DEVICE_TOKEN_KEY in validated_token:
device_id = validated_token[constants.TFA_DEVICE_TOKEN_KEY]
device = Device.from_persistent_id(device_id)
if device is not None and device.user_id == user.pk:
return
raise NotVerified
def authenticate(self, request):
result = super().authenticate(request)
if result is None:
return None
user, token = result
if user.tfa_enabled:
self.verify_user(request, user)
return user, token
+13 -7
View File
@@ -120,19 +120,25 @@ class Registry(object):
"parameters": []
}
for name, config in list(sconfig["params"].items()):
field = serializer.fields[name]
data = {
"name": name,
"label": config["label"],
"help_text": config.get("help_text", ""),
"display": config.get("display", ""),
"widget": field.__class__.__name__,
}
if data["widget"] == "ChoiceField":
data["choices"] = [
{"text": value, "value": key}
for key, value in field.choices.items()
]
if config.get("separator"):
data["widget"] = "SeparatorField"
else:
field = serializer.fields[name]
if config.get("password"):
data["widget"] = "PasswordField"
else:
data["widget"] = field.__class__.__name__
if data["widget"] == "ChoiceField":
data["choices"] = [
{"text": value, "value": key}
for key, value in field.choices.items()
]
item["parameters"].append(data)
result.append(item)
return result