mirror of
https://github.com/jwinterm/modoboa.git
synced 2026-10-17 05:41:25 +02:00
Merge pull request #2621 from modoboa/feature/api_key_management_ui_v2
Added API key management to UI v2.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
{
|
||||
"API_BASE_URL": "/api/v2/"
|
||||
"API_BASE_URL": "/api/v2/",
|
||||
"API_DOC_URL": "/api/schema-v2/swagger/"
|
||||
}
|
||||
|
||||
@@ -34,5 +34,14 @@ export default {
|
||||
},
|
||||
setForward (data) {
|
||||
return repository.post(`${resource}/forward/`, data)
|
||||
},
|
||||
getAPIToken () {
|
||||
return repository.get(`${resource}/api_token/`)
|
||||
},
|
||||
createAPIToken () {
|
||||
return repository.post(`${resource}/api_token/`)
|
||||
},
|
||||
deleteAPIToken () {
|
||||
return repository.delete(`${resource}/api_token/`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,6 +204,13 @@ export default {
|
||||
}
|
||||
],
|
||||
userSettingsMenuItems: [
|
||||
{
|
||||
text: this.$gettext('API'),
|
||||
roles: ['SuperAdmins'],
|
||||
to: { name: 'APISetup' },
|
||||
icon: 'mdi-api',
|
||||
exact: true
|
||||
},
|
||||
{
|
||||
text: this.$gettext('Profile'),
|
||||
to: { name: 'UserProfile' },
|
||||
|
||||
@@ -136,6 +136,16 @@ const routes = [
|
||||
allowedRoles: ['DomainAdmins', 'SuperAdmins']
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/user/api',
|
||||
name: 'APISetup',
|
||||
component: () => import('../views/user/APISetup.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
allowedRoles: ['SuperAdmins'],
|
||||
layout: 'user'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/user/profile',
|
||||
name: 'UserProfile',
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-toolbar flat>
|
||||
<v-toolbar-title><translate>API access</translate></v-toolbar-title>
|
||||
</v-toolbar>
|
||||
<div class="pa-4">
|
||||
<v-card v-if="!token" flat>
|
||||
<v-card-text>
|
||||
<translate>Your API token has not been generated yet.</translate>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn
|
||||
color="success"
|
||||
:loading="loading"
|
||||
@click="generateKey"
|
||||
>
|
||||
<translate>Generate</translate>
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
<v-card v-else flat>
|
||||
<v-card-text>
|
||||
<label class="m-label"><translate>Your API token is:</translate></label>
|
||||
{{ token }}
|
||||
<v-btn icon @click="copyToClipboard()" :title="'Copy token to clipboard'|translate">
|
||||
<v-icon>mdi-clipboard-plus</v-icon>
|
||||
</v-btn>
|
||||
<v-btn icon @click="deleteToken()" color="error" :title="'Delete token'|translate">
|
||||
<v-icon>mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<v-alert type="info" colored-border border="left" class="mt-4">
|
||||
<translate>A documentation of the API is available</translate> <a :href="apiDocUrl" target="_blank">here</a>.
|
||||
</v-alert>
|
||||
</div>
|
||||
<confirm-dialog ref="confirm" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import account from '@/api/account'
|
||||
import { bus } from '@/main'
|
||||
import ConfirmDialog from '@/components/layout/ConfirmDialog'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ConfirmDialog
|
||||
},
|
||||
computed: {
|
||||
apiDocUrl () {
|
||||
return this.$config.API_DOC_URL
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
loading: false,
|
||||
token: null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
copyToClipboard () {
|
||||
navigator.clipboard.writeText(this.token)
|
||||
bus.$emit('notification', {
|
||||
msg: this.$gettext('API token copied to your clipboard'),
|
||||
type: 'success'
|
||||
})
|
||||
},
|
||||
generateKey () {
|
||||
account.createAPIToken().then(resp => {
|
||||
this.token = resp.data.token
|
||||
this.loading = false
|
||||
bus.$emit('notification', {
|
||||
msg: this.$gettext('API token created'),
|
||||
type: 'success'
|
||||
})
|
||||
})
|
||||
},
|
||||
async deleteToken () {
|
||||
const confirm = await this.$refs.confirm.open(
|
||||
this.$gettext('Warning'),
|
||||
this.$gettext(
|
||||
'You are about to delete your API access token'
|
||||
),
|
||||
{
|
||||
color: 'error',
|
||||
cancelLabel: this.$gettext('Cancel'),
|
||||
agreeLabel: this.$gettext('Proceed')
|
||||
}
|
||||
)
|
||||
if (!confirm) {
|
||||
return
|
||||
}
|
||||
await account.deleteAPIToken()
|
||||
bus.$emit('notification', {
|
||||
msg: this.$gettext('API token deleted'),
|
||||
type: 'success'
|
||||
})
|
||||
this.token = null
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
account.getAPIToken().then(resp => {
|
||||
this.token = resp.data.token
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-toolbar {
|
||||
background-color: #f7f8fa !important;
|
||||
}
|
||||
</style>
|
||||
@@ -236,3 +236,9 @@ class CheckPasswordSerializer(serializers.Serializer):
|
||||
if not user.check_password(value):
|
||||
raise serializers.ValidationError(_("Invalid password"))
|
||||
return value
|
||||
|
||||
|
||||
class UserAPITokenSerializer(serializers.Serializer):
|
||||
"""Serializer used by API access routes."""
|
||||
|
||||
token = serializers.CharField()
|
||||
|
||||
@@ -177,6 +177,28 @@ class AccountViewSetTestCase(ModoAPITestCase):
|
||||
resp = self.client.post(url, data, format="json")
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
def test_api_token(self):
|
||||
# 1. Obtain a JWT token so we can safely play with basic token
|
||||
url = reverse("v2:token_obtain_pair")
|
||||
data = {
|
||||
"username": "admin",
|
||||
"password": "password"
|
||||
}
|
||||
resp = self.client.post(url, data, format="json")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.client.credentials(
|
||||
HTTP_AUTHORIZATION="Bearer {}".format(resp.json()["access"])
|
||||
)
|
||||
|
||||
url = reverse("v2:account-manage-api-token")
|
||||
resp = self.client.get(url)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.json()["token"], self.token.key)
|
||||
resp = self.client.delete(url)
|
||||
self.assertEqual(resp.status_code, 204)
|
||||
resp = self.client.post(url)
|
||||
self.assertEqual(resp.status_code, 201)
|
||||
|
||||
|
||||
class AuthenticationTestCase(ModoAPITestCase):
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ import qrcode.image.svg
|
||||
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.authtoken.models import Token
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from modoboa.admin.api.v1 import serializers as admin_v1_serializers
|
||||
@@ -24,8 +26,8 @@ from . import serializers
|
||||
class AccountViewSet(core_v1_viewsets.AccountViewSet):
|
||||
"""Account viewset."""
|
||||
|
||||
@action(methods=["get"], detail=False)
|
||||
@extend_schema(responses=admin_v1_serializers.AccountSerializer)
|
||||
@action(methods=["get"], detail=False)
|
||||
def me(self, request):
|
||||
"""Return information about connected user."""
|
||||
serializer = admin_v1_serializers.AccountSerializer(request.user)
|
||||
@@ -44,6 +46,47 @@ class AccountViewSet(core_v1_viewsets.AccountViewSet):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
return response.Response()
|
||||
|
||||
@extend_schema(
|
||||
description="Get current API token",
|
||||
responses={200: serializers.UserAPITokenSerializer},
|
||||
methods=["GET"]
|
||||
)
|
||||
@extend_schema(
|
||||
description="Generate new API token",
|
||||
responses={201: serializers.UserAPITokenSerializer},
|
||||
methods=["POST"]
|
||||
)
|
||||
@extend_schema(
|
||||
description="Delete current API token",
|
||||
methods=["DELETE"]
|
||||
)
|
||||
@action(
|
||||
methods=["get", "post", "delete"],
|
||||
detail=False,
|
||||
url_path="api_token",
|
||||
)
|
||||
def manage_api_token(self, request):
|
||||
"""Manage API token."""
|
||||
if not request.user.is_superuser:
|
||||
raise PermissionDenied(
|
||||
"Only super administrators can have API tokens"
|
||||
)
|
||||
if request.method == "DELETE":
|
||||
Token.objects.filter(user=request.user).delete()
|
||||
return response.Response(status=204)
|
||||
status = 200
|
||||
if request.method == "POST":
|
||||
token, created = Token.objects.get_or_create(user=request.user)
|
||||
if created:
|
||||
status = 201
|
||||
else:
|
||||
if hasattr(request.user, "auth_token"):
|
||||
token = request.user.auth_token
|
||||
else:
|
||||
token = ""
|
||||
serializer = serializers.UserAPITokenSerializer({"token": str(token)})
|
||||
return response.Response(serializer.data, status=status)
|
||||
|
||||
@action(methods=["post"], detail=False, url_path="tfa/verify")
|
||||
def tfa_verify_code(self, request):
|
||||
"""Verify given code validity."""
|
||||
|
||||
Reference in New Issue
Block a user