Skip to content

Commit

Permalink
feat: Add API Keys functionality
Browse files Browse the repository at this point in the history
This commit adds functionality for handling API Keys. It includes the following changes:

- Added `apikeys_api` namespace to the API routes
- Implemented routes for getting all API Keys, creating a new API Key, getting a single API Key, and deleting a single API Key
- Created a new model `APIKeys` to handle API Key data in the database
- Updated the `security.py` file to check if a token is related to an API Key when verifying if it is revoked
- Added new components `APIKeyItem`, `APIKeyList`, and `APIKeyForm` to support displaying API Keys and creating new ones
- Added new API Key-related Vue files to the frontend:
  - `APIKeyList.vue` displays a list of API Keys
  - `APIKeyItem.vue` represents an individual API Key item in the list
  - `APIKeyForm.vue` allows users to create a new API Key

Related: #1234
  • Loading branch information
realashleybailey committed Sep 21, 2023
1 parent ad73f3e commit 1a42e64
Show file tree
Hide file tree
Showing 12 changed files with 348 additions and 7 deletions.
2 changes: 2 additions & 0 deletions backend/api/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from app.extensions import api

from .accounts_api import api as accounts_api # REVIEW - This is almost completed
from .apikeys_api import api as apikeys_api
from .authentication_api import api as authentication_api # REVIEW - This is almost completed
from .backup_api import api as backup_api
from .discord_api import api as discord_api
Expand Down Expand Up @@ -101,6 +102,7 @@ def handle_request_exception(error):

# Ordered Alphabetically for easier viewing in Swagger UI
api.add_namespace(accounts_api)
api.add_namespace(apikeys_api)
api.add_namespace(authentication_api)
api.add_namespace(backup_api)
api.add_namespace(discord_api)
Expand Down
62 changes: 62 additions & 0 deletions backend/api/routes/apikeys_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from flask import request
from flask_jwt_extended import jwt_required, current_user, create_access_token, get_jti
from flask_restx import Namespace, Resource
from app.models.database.api_keys import APIKeys
from json import loads, dumps
from playhouse.shortcuts import model_to_dict
from datetime import datetime

api = Namespace('API Keys', description='API Keys related operations', path='/apikeys')

@api.route("")
class APIKeysListAPI(Resource):

method_decorators = [jwt_required()]

@api.doc(description="Get all API Keys")
@api.response(200, "Successfully retrieved all API Keys")
def get(self):
"""Get all API Keys"""
response = list(APIKeys.select().where(APIKeys.user == current_user['id']).dicts())
return loads(dumps(response, indent=4, sort_keys=True, default=str)), 200

@api.doc(description="Create an API Key")
@api.response(200, "Successfully created an API Key")
def post(self):
"""Create an API Key"""
token = create_access_token(fresh=False, identity=current_user['id'], expires_delta=False)
jti = get_jti(encoded_token=token)

api_key = APIKeys.create(
name=str(request.form.get("name")),
key=str(token),
jti=str(jti),
user=current_user['id']
)

response = model_to_dict(APIKeys.get(APIKeys.id == api_key))
return loads(dumps(response, indent=4, sort_keys=True, default=str)), 200

@api.route("/<int:api_key_id>")
class APIKeysAPI(Resource):

method_decorators = [jwt_required()]

@api.doc(description="Get a single API Key")
@api.response(200, "Successfully retrieved API Key")
def get(self, api_key_id):
"""Get a single API Key"""
api_key = APIKeys.get_or_none(APIKeys.id == api_key_id and APIKeys.user == current_user['id'])
if not api_key:
return {"message": "API Key not found"}, 404
return loads(dumps(model_to_dict(api_key), indent=4, sort_keys=True, default=str)), 200

@api.doc(description="Delete a single API Key")
@api.response(200, "Successfully deleted API Key")
def delete(self, api_key_id):
"""Delete a single API Key"""
api_key = APIKeys.get_or_none(APIKeys.id == api_key_id and APIKeys.user == current_user['id'])
if not api_key:
return {"message": "API Key not found"}, 404
api_key.delete_instance()
return {"message": "API Key deleted"}, 200
5 changes: 2 additions & 3 deletions backend/app/models/database/api_keys.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from peewee import SQL, CharField, DateTimeField, ForeignKeyField, IntegerField
from peewee import SQL, CharField, DateTimeField, ForeignKeyField, BooleanField, IntegerField

from app.models.database.base import BaseModel
from app.models.database.users import Users
Expand All @@ -7,7 +7,6 @@ class APIKeys(BaseModel):
id = IntegerField(primary_key=True)
name = CharField()
key = CharField()
jti = CharField()
user = ForeignKeyField(Users, backref='api_keys', on_delete='CASCADE')
created = DateTimeField(constraints=[SQL("DEFAULT (datetime('now'))")])
expires = DateTimeField(null=True)
valid = IntegerField(default=1)
5 changes: 3 additions & 2 deletions backend/app/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
verify_jwt_in_request)
from playhouse.shortcuts import model_to_dict

from app.models.database import Sessions, Settings, Accounts
from app.models.database import Sessions, Settings, Accounts, APIKeys

# Yh this code looks messy but it works so ill tidy it up later
database_dir = path.abspath(path.join(__file__, "../", "../", "../", "database"))
Expand Down Expand Up @@ -65,7 +65,8 @@ def secret_key(length: int = 32) -> str:
def check_if_token_revoked(_, jwt_payload: dict) -> bool:
jti = jwt_payload["jti"]
session = Sessions.get_or_none((Sessions.access_jti == jti) | (Sessions.refresh_jti == jti))
return session.revoked if session else True
api = not APIKeys.select().where(APIKeys.jti == jti).exists()
return session.revoked if session else api

def user_identity_lookup(user):
return user
Expand Down
60 changes: 60 additions & 0 deletions frontend/src/components/APIKeyList/APIKeyItem.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<template>
<ListItem icon="fa-key">
<template #title>
<span class="text-lg">{{ apikey.name }}</span>
<div class="flex flex-col">
<!-- <p v-else class="text-xs truncate text-gray-500 dark:text-gray-400 w-full">No email</p> -->
<p class="text-xs truncate text-gray-500 dark:text-gray-400 w-full">{{ $filter("timeAgo", apikey.created) }}</p>
</div>
</template>
<template #buttons>
<div class="flex flex-row space-x-2">
<button @click="localDeleteAPIKey" :disabled="disabled.delete" class="bg-red-600 hover:bg-primary_hover focus:outline-none text-white font-medium rounded px-3.5 py-2 text-sm dark:bg-red-600 dark:hover:bg-primary_hover">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</template>
</ListItem>
</template>

<script lang="ts">
import { defineComponent } from "vue";
import { mapActions } from "pinia";
import { useAPIKeyStore } from "@/stores/apikeys";
import type { APIKey } from "@/types/api/apikeys";
import ListItem from "../ListItem.vue";
export default defineComponent({
name: "UserItem",
components: {
ListItem,
},
props: {
apikey: {
type: Object as () => APIKey,
required: true,
},
},
data() {
return {
disabled: {
delete: false,
},
};
},
methods: {
async localDeleteAPIKey() {
if (await this.$modal.confirmModal(this.__("Are you sure?"), this.__("Are you sure you want to delete this API key?"))) {
this.disabled.delete = true;
await this.deleteAPIKey(this.apikey.id).finally(() => (this.disabled.delete = false));
this.$toast.info(this.__("API key deleted successfully"));
} else {
this.$toast.info(this.__("API key deletion cancelled"));
}
},
...mapActions(useAPIKeyStore, ["deleteAPIKey"]),
},
});
</script>
39 changes: 39 additions & 0 deletions frontend/src/components/APIKeyList/APIKeyList.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<template>
<Draggable v-if="apikeys && apikeys.length > 0" v-model="apikeys" tag="ul" group="apikeys" ghost-class="moving-card" :animation="200" item-key="id">
<template #item="{ element }">
<li class="mb-2">
<APIKeyItem :apikey="element" />
</li>
</template>
</Draggable>
<div v-else class="flex flex-col justify-center items-center space-y-1">
<i class="fa-solid fa-info-circle text-3xl text-gray-400"></i>
<span class="text-gray-400">{{ __("No API Keys found") }}</span>
</div>
</template>

<script lang="ts">
import { defineComponent } from "vue";
import { useAPIKeyStore } from "@/stores/apikeys";
import { mapActions, mapWritableState } from "pinia";
import Draggable from "vuedraggable";
import APIKeyItem from "./APIKeyItem.vue";
export default defineComponent({
name: "APIKeyList",
components: {
Draggable,
APIKeyItem,
},
computed: {
...mapWritableState(useAPIKeyStore, ["apikeys"]),
},
methods: {
...mapActions(useAPIKeyStore, ["getAPIKeys"]),
},
async created() {
await this.getAPIKeys();
},
});
</script>
49 changes: 49 additions & 0 deletions frontend/src/components/Forms/APIKeyForm.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<template>
<Transition name="fade" mode="out-in" :duration="{ enter: 300, leave: 300 }">
<template v-if="newAPIKey === null">
<FormKit v-model="apikey" @submit="localCreateAPIKey" type="form" :classes="{ input: '!bg-secondary' }" :submit-label="__('Create API Key')" :submit-attrs="{ wrapperClass: 'flex justify-end' }">
<FormKit typ="text" name="name" validation="required|alpha_spaces:latin" :label="__('Name')" :placeholder="__('My API Key')" />
</FormKit>
</template>
<template v-else>
<div class="flex flex-col">
<div class="text-sm mb-4">
{{ __("Please take a copy your API key. You will not be able to see it again, please make sure to store it somewhere safe.") }}
</div>
<FormKit type="text" :value="newAPIKey ?? 'Unknown'" :label="__('API Key')" :disabled="true" :classes="{ input: '!w-full' }" />
<FormKit type="button" @click="copyAPIKey" :classes="{ input: '!bg-secondary', wrapper: 'flex justify-end' }">
{{ __("Copy") }}
</FormKit>
</div>
</template>
</Transition>
</template>

<script lang="ts">
import { defineComponent } from "vue";
import { useAPIKeyStore } from "@/stores/apikeys";
import { mapActions } from "pinia";
import { useClipboard } from "@vueuse/core";
export default defineComponent({
name: "WebhookForm",
data() {
return {
useClipboard: useClipboard(),
newAPIKey: null as string | null,
apikey: { name: "" },
};
},
methods: {
copyAPIKey() {
this.useClipboard.copy(this.newAPIKey ?? "");
this.$toast.info(this.__("Copied to clipboard"));
},
async localCreateAPIKey() {
const apikey = await this.createAPIKey(this.apikey);
this.newAPIKey = apikey?.key ?? null;
},
...mapActions(useAPIKeyStore, ["createAPIKey"]),
},
});
</script>
30 changes: 30 additions & 0 deletions frontend/src/modules/settings/pages/APIKeys.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<template>
<APIKeyList />
<div class="fixed right-6 bottom-6 group">
<FormKit type="button" :classes="{ input: '!w-14 !h-14' }" @click="createAPIKey">
<i class="fas fa-plus text-xl transition-transform group-hover:rotate-45"></i>
</FormKit>
</div>
</template>

<script lang="ts">
import { defineComponent } from "vue";
import APIKeyList from "@/components/APIKeyList/APIKeyList.vue";
import APIKeyForm from "@/components/Forms/APIKeyForm.vue";
export default defineComponent({
name: "APIKeysView",
components: {
APIKeyList,
},
methods: {
createAPIKey() {
this.$modal.openModal(APIKeyForm, {
title: this.__("Create API Key"),
disableFooter: true,
});
},
},
});
</script>
3 changes: 1 addition & 2 deletions frontend/src/modules/settings/pages/Main.vue
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,7 @@ export default defineComponent({
description: this.__("Add API keys for external services"),
roles: ["moderator"],
icon: "fas fa-key",
url: "/admin/settings/api",
disabled: true,
url: "/admin/settings/apikeys",
},
{
title: this.__("Webhooks"),
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/modules/settings/router/children.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ const children: RouteRecordRaw[] = [
component: () => import("../pages/Media.vue"),
meta: { header: "Manage Media", subheader: "Configure media server" },
},
{
path: "apikeys",
name: "admin-settings-apikeys",
component: () => import("../pages/APIKeys.vue"),
meta: { header: "Manage API Keys", subheader: "Configure API keys" },
},
{
path: "webhooks",
name: "admin-settings-webhooks",
Expand Down
Loading

0 comments on commit 1a42e64

Please sign in to comment.