Merge pull request #2621 from modoboa/feature/api_key_management_ui_v2

Added API key management to UI v2.
This commit is contained in:
Antoine Nguyen
2022-10-02 09:37:02 +02:00
committed by GitHub
10 changed files with 1582 additions and 434 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,3 +1,4 @@
{
"API_BASE_URL": "/api/v2/"
"API_BASE_URL": "/api/v2/",
"API_DOC_URL": "/api/schema-v2/swagger/"
}
+9
View File
@@ -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' },
+10
View File
@@ -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',
+114
View File
@@ -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>
+6
View File
@@ -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()
+22
View File
@@ -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):
+44 -1
View File
@@ -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."""