New user profile section

WIP
This commit is contained in:
Antoine Nguyen
2021-12-26 11:03:05 +01:00
parent 9065b7a567
commit 10cc5e82d6
21 changed files with 740 additions and 94 deletions
+16
View File
@@ -6,8 +6,24 @@ export default {
getMe () {
return repository.get(`/${resource}/me/`)
},
checkPassword (value) {
return repository.post(`/${resource}/me/password/`, { password: value })
},
verifyTFACode (code) {
const payload = { code }
return repository.post(`${resource}/tfa/verify/`, payload)
},
startTFASetup () {
return repository.post(`${resource}/tfa/setup/`)
},
getQRCodeForTFASetup () {
return repository.get(`${resource}/tfa/setup/qr_code/`)
},
finalizeTFASetup (pinCode) {
const payload = { pin_code: pinCode }
return repository.post(`${resource}/tfa/setup/check/`, payload)
},
disableTFA () {
return repository.post(`${resource}/tfa/disable/`)
}
}
+9
View File
@@ -0,0 +1,9 @@
import repository from './repository'
const resource = 'languages'
export default {
getAll () {
return repository.get(`/${resource}/`)
}
}
@@ -31,6 +31,19 @@ mail.{{ domain.name }}. IN A <strong>[<translate>IP address of your Modoboa serv
@ IN TXT "v=spf1 mx ~all"</pre>
</v-alert>
<v-alert
v-if="domain.enable_dkim && domain.dkim_public_key"
border="left"
colored-border
color="primary lighten-3"
elevation="2"
dense
>
<div class="title">DKIM</div>
<pre>
{{ domain.dkim_key_selector }}._domain_key.{{ domain.name }}. IN TXT (
{{ splitKey(`v=DKIM1;k=rsa;p=${domain.dkim_public_key}`) }}</pre>
</v-alert>
<v-alert
border="left"
colored-border
@@ -50,8 +63,8 @@ _dmarc.{{ domain.name }}. IN TXT "v=DMARC1; p=quarantine; pct=100;"</pre>
dense
>
<div class="title">Auto configuration</div>
<pre>
autoconfig.{{ domain.name }}. IN CNAME <strong>[<translate>hostname of your automx server</translate>]</strong>
<pre>
autoconfig.{{ domain.name }}. IN CNAME <strong>[<translate>hostname of your automx server</translate>]</strong>
autodiscover.{{ domain.name }}. IN CNAME <strong>[<translate>hostname of your automx server</translate>]</strong></pre>
</v-alert>
</v-card-text>
@@ -70,6 +83,19 @@ export default {
methods: {
close () {
this.$emit('close')
},
splitKey (value) {
let key = value
const result = []
while (key.length > 0) {
if (key.length > 74) {
result.push(` "${key.substring(0, 74)}"`)
key = key.substring(74)
} else {
result.push(` "${key}"`)
}
}
return result.join('\n')
}
}
}
@@ -139,7 +139,7 @@ export default {
methods: {
async save () {
if (this.$refs.generalForm !== undefined) {
const valid = await this.$refs.generalForm.$refs.observer.validate()
const valid = await this.$refs.generalForm.validateForm()
if (!valid) {
return
}
@@ -28,51 +28,13 @@
outlined
dense
/>
<div class="d-flex align-center">
<v-switch
v-model="account.random_password"
:label="'Random password'|translate"
@change="updatePassword"
dense
/>
<template v-if="account.pk && account.random_password">
<v-alert style="background-color: #515D78" class="ml-6" dense>
<span class="white--text mr-4">{{ account.password }}</span>
<v-btn small color="white" icon :title="'Copy to clipboard'|translate" @click="copyPassword">
<v-icon>mdi-clipboard-multiple-outline</v-icon>
</v-btn>
</v-alert>
</template>
</div>
<validation-provider
v-if="!account.random_password"
v-slot="{ errors }"
:rules="passwordRules"
vid="password"
>
<label class="m-label">{{ $gettext('Password') }}</label>
<v-text-field
v-model="account.password"
:error-messages="errors"
outlined
type="password"
dense
/>
</validation-provider>
<validation-provider
v-if="!account.random_password"
v-slot="{ errors }"
:rules="passwordConfirmationRules"
>
<label class="m-label">{{ $gettext('Confirmation') }}</label>
<v-text-field
v-model="account.password_confirmation"
:error-messages="errors"
outlined
type="password"
dense
/>
</validation-provider>
<account-password-form
ref="passwordForm"
v-model="account.password"
:account="account"
/>
<v-switch
v-model="account.is_active"
:label="'Enabled' | translate"
@@ -82,22 +44,16 @@
</template>
<script>
import { bus } from '@/main'
import accounts from '@/api/accounts'
import AccountPasswordForm from './AccountPasswordForm'
import EmailField from '@/components/tools/EmailField'
export default {
components: {
AccountPasswordForm,
EmailField
},
props: ['account'],
computed: {
passwordRules () {
return (this.account.pk) ? '' : 'required'
},
passwordConfirmationRules () {
return (this.account.password) ? 'required' : ''
},
usernamePlaceholder () {
if (this.account.role === 'SimpleUsers') {
return this.$gettext('Enter an email address')
@@ -109,25 +65,8 @@ export default {
}
},
methods: {
copyPassword () {
navigator.clipboard.writeText(this.account.password).then(() => {
bus.$emit('notification', { msg: this.$gettext('Password copied to clipboard') })
})
},
updatePassword (value) {
if (value) {
accounts.getRandomPassword().then(resp => {
this.$set(this.account, 'password', resp.data.password)
})
} else {
this.$set(this.account, 'password', null)
this.$set(this.account, 'password_confirmation', null)
}
}
},
mounted () {
if (this.account.random_password) {
this.updatePassword(true)
async validateForm () {
return await this.$refs.observer.validate() && await this.$refs.passwordForm.validate()
}
}
}
@@ -0,0 +1,127 @@
<template>
<div>
<validation-observer ref="observer">
<validation-provider
v-if="withPasswordCheck"
v-slot="{ errors }"
rules="required|validPassword"
mode="lazy"
>
<label class="m-label">{{ $gettext('Current password') }}</label>
<v-text-field
v-model="form.currentPassword"
:error-messages="errors"
outlined
type="password"
dense
/>
</validation-provider>
<div class="d-flex align-center">
<v-switch
v-model="form.random_password"
:label="'Random password'|translate"
@change="updatePassword"
dense
/>
<template v-if="form.random_password">
<v-alert style="background-color: #515D78" class="ml-6" dense>
<span class="white--text mr-4">{{ form.password }}</span>
<v-btn small color="white" icon :title="'Copy to clipboard'|translate" @click="copyPassword">
<v-icon>mdi-clipboard-multiple-outline</v-icon>
</v-btn>
</v-alert>
</template>
</div>
<validation-provider
v-if="!form.random_password"
v-slot="{ errors }"
:rules="passwordRules"
vid="password"
>
<label class="m-label">{{ $gettext('Password') }}</label>
<v-text-field
:value="value"
:error-messages="errors"
outlined
type="password"
dense
@input="(value) => $emit('input', value)"
/>
</validation-provider>
<validation-provider
v-if="!form.random_password"
v-slot="{ errors }"
:rules="passwordConfirmationRules"
vid="confirmation"
>
<label class="m-label">{{ $gettext('Confirmation') }}</label>
<v-text-field
v-model="form.password_confirmation"
:error-messages="errors"
outlined
type="password"
dense
/>
</validation-provider>
</validation-observer>
</div>
</template>
<script>
import { bus } from '@/main'
import accounts from '@/api/accounts'
export default {
props: {
value: String,
withPasswordCheck: {
type: Boolean,
default: false
},
account: {
type: Object,
required: false
}
},
data () {
return {
form: {}
}
},
computed: {
passwordRules () {
return (this.account && this.account.pk) ? '' : 'required'
},
passwordConfirmationRules () {
return (this.value) ? 'required|samePassword:password' : ''
}
},
methods: {
copyPassword () {
navigator.clipboard.writeText(this.form.password).then(() => {
bus.$emit('notification', { msg: this.$gettext('Password copied to clipboard') })
})
},
updatePassword (value) {
if (value) {
accounts.getRandomPassword().then(resp => {
this.$set(this.form, 'password', resp.data.password)
this.$emit('input', this.form.password)
})
} else {
this.$set(this.form, 'password', null)
this.$set(this.form, 'password_confirmation', null)
}
},
async validate () {
return await this.$refs.observer.validate()
}
},
mounted () {
if (this.account && this.account.random_password) {
this.updatePassword(true)
}
}
}
</script>
+52 -2
View File
@@ -3,7 +3,7 @@
v-model="drawer"
:mini-variant.sync="mini"
permanent
color="primary"
:color="mainColor"
app
dark
>
@@ -11,6 +11,8 @@
<v-img
src="../../assets/Modoboa_RVB-BLANC-SANS.png"
max-width="190"
class="logo"
@click="$router.push('/')"
/>
<v-btn
icon
@@ -21,7 +23,7 @@
</div>
<v-list>
<template v-for="item in mainMenuItems">
<template v-for="item in menuItems">
<template v-if="item.roles === undefined || item.roles.indexOf(authUser.role) !== -1">
<v-list-item
v-if="!item.children"
@@ -93,6 +95,7 @@
<v-list-item
v-for="item in userMenuItems"
:key="item.text"
:to="item.to"
@click="item.click"
link
>
@@ -135,6 +138,18 @@ export default {
return (this.authUser.first_name || this.authUser.last_name)
? `${this.authUser.first_name} ${this.authUser.last_name}`
: this.authUser.username
},
menuItems () {
if (this.$route.meta.layout === 'user') {
return this.userSettingsMenuItems
}
return this.mainMenuItems
},
mainColor () {
if (this.$route.meta.layout === 'user') {
return 'grey'
}
return 'primary'
}
},
data () {
@@ -183,8 +198,39 @@ export default {
roles: ['SuperAdmins']
}
],
userSettingsMenuItems: [
{
text: this.$gettext('Profile'),
to: { name: 'UserProfile' },
icon: 'mdi-account-circle-outline',
exact: true
},
{
text: this.$gettext('Security'),
to: { name: 'UserSecurity' },
icon: 'mdi-lock-outline',
exact: true
},
{
text: this.$gettext('Preferences'),
to: '',
icon: 'mdi-tune',
exact: true
},
{
text: this.$gettext('Forward'),
to: '',
icon: 'mdi-forward',
exact: true
}
],
mini: false,
userMenuItems: [
{
text: this.$gettext('Profile'),
icon: 'mdi-account-circle-outline',
to: { name: 'UserProfile' }
},
{
text: this.$gettext('Logout'),
icon: 'mdi-logout',
@@ -228,4 +274,8 @@ export default {
.user-box {
background: rgba(0, 0, 0, 0.25);
}
.logo {
cursor: pointer;
}
</style>
@@ -0,0 +1,133 @@
<template>
<v-expansion-panels
v-model="panel"
flat
>
<v-expansion-panel>
<v-expansion-panel-header><translate>General settings</translate></v-expansion-panel-header>
<v-expansion-panel-content>
<label class="m-label">{{ $gettext('First name') }}</label>
<v-text-field
v-model="form.first_name"
outlined
dense
/>
<label class="m-label">{{ $gettext('Last name') }}</label>
<v-text-field
v-model="form.last_name"
outlined
dense
/>
<label class="m-label">{{ $gettext('Language') }}</label>
<v-autocomplete
v-model="form.language"
:items="languages"
item-text="label"
item-value="code"
outlined
dense
/>
<label class="m-label">{{ $gettext('Phone number') }}</label>
<v-text-field
v-model="form.phone_number"
:label="'Phone number' | translate"
outlined
dense
/>
<label class="m-label">{{ $gettext('Secondary email') }}</label>
<v-text-field
v-model="form.secondary_email"
outlined
dense
/>
<v-btn
color="success"
@click="updateGeneralSettings"
>
<translate>Update settings</translate>
</v-btn>
</v-expansion-panel-content>
</v-expansion-panel>
<v-expansion-panel>
<v-expansion-panel-header><translate>Password</translate></v-expansion-panel-header>
<v-expansion-panel-content>
<account-password-form
ref="passwordForm"
v-model="password"
with-password-check
/>
<v-btn
color="success"
@click="updatePassword"
>
<translate>Update Password</translate>
</v-btn>
</v-expansion-panel-content>
</v-expansion-panel>
</v-expansion-panels>
</template>
<script>
import accounts from '@/api/accounts'
import { bus } from '@/main'
import languages from '@/api/languages'
import AccountPasswordForm from '@/components/identities/AccountPasswordForm'
export default {
props: {
account: Object
},
components: {
AccountPasswordForm
},
data () {
return {
form: {},
languages: [],
panel: 0,
password: null
}
},
methods: {
initFormFromAccount (account) {
this.form = {
first_name: account.first_name,
last_name: account.last_name,
language: account.language,
phone_number: account.phone_number,
secondary_email: account.secondary_email
}
},
updateGeneralSettings () {
accounts.patch(this.account.pk, this.form).then(resp => {
bus.$emit('notification', { msg: this.$gettext('Profile updated') })
})
},
async updatePassword () {
const valid = await this.$refs.passwordForm.validate()
if (!valid) {
return
}
accounts.patch(this.account.pk, { password: this.password }).then(resp => {
bus.$emit('notification', { msg: this.$gettext('Password updated') })
this.$refs.passwordObserver.reset()
})
}
},
mounted () {
this.initFormFromAccount(this.account)
languages.getAll().then(resp => {
this.languages = resp.data
})
},
watch: {
account (value) {
if (value) {
this.initFormFromAccount(value)
} else {
this.form = {}
}
}
}
}
</script>
@@ -0,0 +1,135 @@
<template>
<v-card>
<v-card-title>
<span class="text-subtitle-1"><translate>Two factor authentication</translate></span>
</v-card-title>
<v-card-text>
<template v-if="qrCode">
<v-row>
<v-col cols="4">
<div v-html="qrCode"></div>
</v-col>
<v-col cols="6">
<v-alert color="grey lighten-2">
<translate>
Install a soft token authenticator like FreeOTP or Google
Authenticator from your application repository and use that
app to scan this QR code.
</translate>
</v-alert>
<label class="m-label"><translate>Pin code</translate></label>
<validation-observer ref="observer">
<validation-provider
v-slot="{ errors }"
rules="required"
>
<v-text-field
v-model="pinCode"
outlined
:error-messages="errors"
/>
</validation-provider>
</validation-observer>
<v-btn color="primary" @click="finalizeTFASetup">
<translate>Register</translate>
</v-btn>
</v-col>
</v-row>
</template>
<template v-else-if="tokens.length">
<v-alert color="success">
<translate>
Congratulations! Two-Factor Authentication is now enabled for your account.
</translate>
</v-alert>
<p>
<translate>
The following recovery codes can be used one time each to
let you regain access to your account, in case you lose your
phone for example. Make sure to save them in a safe place,
otherwise you won't be able to access your account anymore.
</translate>
</p>
<ul>
<li v-for="token in tokens" :key="token">
{{ token }}
</li>
</ul>
</template>
<template v-else-if="account.tfa_enabled">
<v-alert color="grey lighten-2">
Two-Factor Authentication is enabled for your account.
</v-alert>
<v-btn color="error" @click="disableTFA" class="mr-2">
<translate>Disable 2FA</translate>
</v-btn>
<v-btn @click="resetRecoveryCodes">
<translate>Reset recovery codes</translate>
</v-btn>
</template>
<template v-else>
<translate tag="p" class="my-4">
Two-Factor Authentication (2FA) is not yet activated for your account. Enabling this feature will increase your account's security.
</translate>
<v-btn color="success" @click="startTFASetup">
<translate>Enable 2FA</translate>
</v-btn>
</template>
</v-card-text>
</v-card>
</template>
<script>
import account from '@/api/account'
import Cookies from 'js-cookie'
export default {
props: {
account: Object
},
data () {
return {
pinCode: null,
qrCode: null,
tokens: []
}
},
methods: {
getQRCode () {
account.getQRCodeForTFASetup().then(resp => {
this.qrCode = resp.data
})
},
startTFASetup () {
account.startTFASetup().then(resp => {
this.getQRCode()
})
},
async finalizeTFASetup () {
const valid = await this.$refs.observer.validate()
if (!valid) {
return
}
const resp = await account.finalizeTFASetup(this.pinCode)
this.qrCode = null
this.tokens = resp.data.tokens
Cookies.set('token', resp.data.access, { sameSite: 'strict' })
Cookies.set('refreshToken', resp.data.refresh, { sameSite: 'strict' })
this.$store.dispatch('auth/initialize')
},
disableTFA () {
account.disableTFA().then(resp => {
this.$store.dispatch('auth/fetchUser')
})
},
resetRecoveryCodes () {
}
},
created () {
if (!this.account.tfa_enabled) {
this.getQRCode()
}
}
}
</script>
+17 -1
View File
@@ -1,11 +1,16 @@
import Vue from 'vue'
import { ValidationObserver, ValidationProvider, extend } from 'vee-validate'
import { numeric, required } from 'vee-validate/dist/rules'
import { confirmed, numeric, required } from 'vee-validate/dist/rules'
import { translate } from 'vue-gettext'
import account from '@/api/account'
const { gettext: $gettext } = translate
// Declare builtin rules here
extend('samePassword', {
...confirmed,
message: () => $gettext('Passwords mismatch')
})
extend('numeric', {
...numeric,
message: () => $gettext('This field must be a valid number')
@@ -16,6 +21,17 @@ extend('required', {
})
// Add custom rules here
extend('validPassword', {
validate: async (value) => {
try {
await account.checkPassword(value)
return true
} catch (error) {
return false
}
},
message: $gettext('Invalid password')
})
// Register components globally
Vue.component('ValidationObserver', ValidationObserver)
+18
View File
@@ -109,6 +109,24 @@ const routes = [
requiresAuth: true,
allowedRoles: ['DomainAdmins', 'SuperAdmins']
}
},
{
path: '/user/profile',
name: 'UserProfile',
component: () => import('../views/user/Profile.vue'),
meta: {
requiresAuth: true,
layout: 'user'
}
},
{
path: '/user/security',
name: 'UserSecurity',
component: () => import('../views/user/Security.vue'),
meta: {
requiresAuth: true,
layout: 'user'
}
}
]
+7 -11
View File
@@ -9,12 +9,6 @@ function setupAxios (token) {
repository.defaults.headers.post['Content-Type'] = 'application/json'
}
function fetchUser (commit) {
return account.getMe().then(resp => {
commit('SET_AUTH_USER', { authUser: resp.data, isAuthenticated: true })
})
}
const state = () => ({
authUser: {},
isAuthenticated: false
@@ -38,16 +32,18 @@ const mutations = {
}
const actions = {
initialize ({ commit, state }) {
if (state.isAuthenticated) {
return
}
fetchUser ({ commit }) {
return account.getMe().then(resp => {
commit('SET_AUTH_USER', { authUser: resp.data, isAuthenticated: true })
})
},
initialize ({ commit, dispatch, state }) {
const token = Cookies.get('token')
if (!token) {
return
}
setupAxios(token)
return fetchUser(commit)
return dispatch('fetchUser')
},
logout ({ commit }) {
return new Promise((resolve, reject) => {
+32
View File
@@ -0,0 +1,32 @@
<template>
<div>
<v-toolbar flat>
<v-toolbar-title><translate>Profile</translate></v-toolbar-title>
</v-toolbar>
<div class="pa-4">
<profile-form :account="authUser" />
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import ProfileForm from '@/components/user/ProfileForm'
export default {
components: {
ProfileForm
},
computed: {
...mapGetters({
authUser: 'auth/authUser'
})
}
}
</script>
<style scoped>
.v-toolbar {
background-color: #f7f8fa !important;
}
</style>
+32
View File
@@ -0,0 +1,32 @@
<template>
<div>
<v-toolbar flat>
<v-toolbar-title><translate>Security</translate></v-toolbar-title>
</v-toolbar>
<div class="pa-4">
<two-factor-auth-form :account="authUser" />
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import TwoFactorAuthForm from '@/components/user/TwoFactorAuthForm'
export default {
components: {
TwoFactorAuthForm
},
computed: {
...mapGetters({
authUser: 'auth/authUser'
})
}
}
</script>
<style scoped>
.v-toolbar {
background-color: #f7f8fa !important;
}
</style>
+2 -2
View File
@@ -195,12 +195,12 @@ 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", "tfa_enabled"
)
def __init__(self, *args, **kwargs):
"""Adapt fields to current user."""
super(AccountSerializer, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
request = self.context.get("request")
if not request:
return
+22
View File
@@ -344,6 +344,28 @@ class WritableAccountSerializer(v1_serializers.WritableAccountSerializer):
)
return user
def update(self, instance, validated_data):
"""Update account and associated objects."""
mailbox_data = validated_data.pop("mailbox", None)
password = validated_data.pop("password", None)
domains = validated_data.pop("domains", [])
for key, value in validated_data.items():
setattr(instance, key, value)
if password:
instance.set_password(password)
if mailbox_data:
creator = self.context["request"].user
if instance.mailbox:
if "username" in validated_data:
mailbox_data["email"] = validated_data["username"]
instance.email = validated_data["username"]
instance.mailbox.update_from_dict(creator, mailbox_data)
else:
self._create_mailbox(creator, instance, mailbox_data)
instance.save()
self.set_permissions(instance, domains)
return instance
class DeleteAccountSerializer(serializers.Serializer):
"""Serializer used with delete operation."""
+2 -2
View File
@@ -279,7 +279,7 @@ class Mailbox(mixins.MessageLimitMixin, AdminObject):
def post_create(self, creator):
from modoboa.lib.permissions import grant_access_to_object
super(Mailbox, self).post_create(creator)
super().post_create(creator)
conditions = (
creator.has_perm("admin.add_mailbox"),
not self.user.has_perm("admin.add_domain")
@@ -341,7 +341,7 @@ class Mailbox(mixins.MessageLimitMixin, AdminObject):
if self.quota_value is None:
self.quota_value, created = Quota.objects.get_or_create(
username=self.full_address)
super(Mailbox, self).save(*args, **kwargs)
super().save(*args, **kwargs)
reversion.register(Mailbox)
+12
View File
@@ -220,3 +220,15 @@ class VerifyTFACodeSerializer(serializers.Serializer):
if device is None:
raise serializers.ValidationError(_("This code is invalid"))
return device
class CheckPasswordSerializer(serializers.Serializer):
"""Simple serializer to check user password."""
password = serializers.CharField()
def validate_password(self, value):
user = self.context["user"]
if not user.check_password(value):
raise serializers.ValidationError(_("Invalid password"))
return value
+1
View File
@@ -13,6 +13,7 @@ from . import viewsets
router = routers.SimpleRouter()
router.register(r"account", viewsets.AccountViewSet, basename="account")
router.register(r"languages", viewsets.LanguageViewSet, basename="language")
router.register(r"logs/audit-trail", viewsets.LogViewSet)
urlpatterns = router.urls
+81
View File
@@ -1,11 +1,19 @@
"""Core API v2 viewsets."""
import io
import qrcode
import qrcode.image.svg
import django_otp
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
from drf_spectacular.utils import extend_schema
from rest_framework import filters, 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 serializers as core_v1_serializers
from modoboa.core.api.v1 import viewsets as core_v1_viewsets
from modoboa.lib import pagination
@@ -24,6 +32,19 @@ class AccountViewSet(core_v1_viewsets.AccountViewSet):
serializer = admin_v1_serializers.AccountSerializer(request.user)
return response.Response(serializer.data)
@action(
methods=['post'],
detail=False,
url_path='me/password',
serializer_class=serializers.CheckPasswordSerializer
)
def check_me_password(self, request):
"""Check current user password."""
serializer = serializers.CheckPasswordSerializer(
data=request.data, context={"user": request.user})
serializer.is_valid(raise_exception=True)
return response.Response()
@action(methods=["post"], detail=False, url_path="tfa/verify")
def tfa_verify_code(self, request):
"""Verify given code validity."""
@@ -38,6 +59,52 @@ class AccountViewSet(core_v1_viewsets.AccountViewSet):
"access": str(refresh.access_token)
})
@action(methods=["get"], detail=False, url_path="tfa/setup/qr_code")
def tfa_setup_get_qr_code(self, request):
"""Get a QR code to finalize the setup process."""
if request.user.tfa_enabled:
return response.Response(status=404)
device = request.user.totpdevice_set.first()
if not device:
return response.Response(status=404)
factory = qrcode.image.svg.SvgPathImage
img = qrcode.make(device.config_url, image_factory=factory)
buf = io.BytesIO()
img.save(buf)
return response.Response(buf.getvalue(), content_type="application/xml")
@extend_schema(
request=core_v1_serializers.CheckTFASetupSerializer
)
@action(methods=["post"], detail=False, url_path="tfa/setup/check")
def tfa_setup_check(self, request):
"""Check TFA setup."""
serializer = core_v1_serializers.CheckTFASetupSerializer(
data=request.data, context={"user": request.user})
serializer.is_valid(raise_exception=True)
# create static device for recovery purposes
device = StaticDevice.objects.create(
user=request.user,
name="{} static device".format(request.user)
)
tokens = []
for cpt in range(10):
token = StaticToken.random_token()
device.token_set.create(token=token)
tokens.append(token)
# Set enable flag to True so we can't go here anymore
request.user.tfa_enabled = True
request.user.save()
# Generate new tokens
device = request.user.totpdevice_set.first()
refresh = RefreshToken.for_user(request.user)
refresh[constants.TFA_DEVICE_TOKEN_KEY] = device.persistent_id
return response.Response({
"tokens": tokens,
"refresh": str(refresh),
"access": str(refresh.access_token)
})
class LogViewSet(viewsets.ReadOnlyModelViewSet):
"""Log viewset."""
@@ -53,3 +120,17 @@ class LogViewSet(viewsets.ReadOnlyModelViewSet):
queryset = models.Log.objects.all()
search_fields = ["logger", "level", "message"]
serializer_class = serializers.LogSerializer
class LanguageViewSet(viewsets.ViewSet):
"""Language viewset."""
permission_classes = (
permissions.IsAuthenticated,
)
def list(self, request, *args, **kwargs):
languages = [
{"code": lang[0], "label": lang[1]} for lang in constants.LANGUAGES
]
return response.Response(languages)
+2 -1
View File
@@ -16,7 +16,7 @@ class LocalConfigMiddleware(MiddlewareMixin):
class TwoFAMiddleware:
"""Custom 2FA middleware to enforce verification is used has TFA enabled."""
"""Custom 2FA middleware to enforce verification if user has TFA enabled."""
def __init__(self, get_response=None):
self.get_response = get_response
@@ -32,6 +32,7 @@ class TwoFAMiddleware:
user and
not user.is_anonymous and
user.tfa_enabled and
not request.path.startswith('/api/') and
request.path not in url_exceptions and
not user.is_verified()
)