mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-07-12 18:35:00 +02:00
Update API Keys to be tied to a user, add apikey lru-cache, handle deactivating expired keys
This commit is contained in:
parent
af1ff12dbb
commit
4d32a22de9
13 changed files with 335 additions and 217 deletions
|
@ -13,102 +13,23 @@
|
|||
<ui-text-input-with-label v-model.trim="newApiKey.name" :readonly="!isNew" :label="$strings.LabelName" />
|
||||
</div>
|
||||
<div v-if="isNew" class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model.trim="newApiKey.expiresIn" :label="$strings.LabelExpiresInSeconds" type="number" />
|
||||
<ui-text-input-with-label v-model.trim="newApiKey.expiresIn" :label="$strings.LabelExpiresInSeconds" type="number" :min="0" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex py-2">
|
||||
<div class="flex items-center pt-4 px-2">
|
||||
<div class="flex items-center pt-4 pb-2 gap-2">
|
||||
<div class="flex items-center px-2">
|
||||
<p class="px-3 font-semibold" id="user-enabled-toggle">{{ $strings.LabelEnable }}</p>
|
||||
<ui-toggle-switch labeledBy="user-enabled-toggle" v-model="newApiKey.isActive" />
|
||||
<ui-toggle-switch :disabled="isExpired && !apiKey.isActive" labeledBy="user-enabled-toggle" v-model="newApiKey.isActive" />
|
||||
</div>
|
||||
<div v-if="isExpired" class="px-2">
|
||||
<p class="text-sm text-error">{{ $strings.LabelExpired }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="newApiKey.permissions" class="w-full border-t border-b border-black-200 py-2 px-3 mt-4">
|
||||
<p class="text-lg mb-2 font-semibold">{{ $strings.HeaderPermissions }}</p>
|
||||
<div class="flex items-center my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p id="download-permissions-toggle">{{ $strings.LabelPermissionsDownload }}</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch labeledBy="download-permissions-toggle" v-model="newApiKey.permissions.download" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p id="update-permissions-toggle">{{ $strings.LabelPermissionsUpdate }}</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch labeledBy="update-permissions-toggle" v-model="newApiKey.permissions.update" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p id="delete-permissions-toggle">{{ $strings.LabelPermissionsDelete }}</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch labeledBy="delete-permissions-toggle" v-model="newApiKey.permissions.delete" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p id="upload-permissions-toggle">{{ $strings.LabelPermissionsUpload }}</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch labeledBy="upload-permissions-toggle" v-model="newApiKey.permissions.upload" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p id="ereader-permissions-toggle">{{ $strings.LabelPermissionsCreateEreader }}</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch labeledBy="ereader-permissions-toggle" v-model="newApiKey.permissions.createEreader" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p id="explicit-content-permissions-toggle">{{ $strings.LabelPermissionsAccessExplicitContent }}</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch labeledBy="explicit-content-permissions-toggle" v-model="newApiKey.permissions.accessExplicitContent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p id="access-all-libs--permissions-toggle">{{ $strings.LabelPermissionsAccessAllLibraries }}</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch labeledBy="access-all-libs--permissions-toggle" v-model="newApiKey.permissions.accessAllLibraries" @input="accessAllLibrariesToggled" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!newApiKey.permissions.accessAllLibraries" class="my-4">
|
||||
<ui-multi-select-dropdown v-model="newApiKey.permissions.librariesAccessible" :items="libraryItems" :label="$strings.LabelLibrariesAccessibleToUser" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-cen~ter my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p>{{ $strings.LabelPermissionsAccessAllTags }}</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch v-model="newApiKey.permissions.accessAllTags" @input="accessAllTagsToggled" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!newApiKey.permissions.accessAllTags" class="my-4">
|
||||
<div class="flex items-center">
|
||||
<ui-multi-select-dropdown v-model="newApiKey.itemTagsSelected" :items="itemTags" :label="tagsSelectionText" />
|
||||
<div class="flex items-center pt-4 px-2">
|
||||
<p class="px-3 font-semibold" id="selected-tags-not-accessible--permissions-toggle">{{ $strings.LabelInvert }}</p>
|
||||
<ui-toggle-switch labeledBy="selected-tags-not-accessible--permissions-toggle" v-model="newApiKey.permissions.selectedTagsNotAccessible" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full border-t border-b border-black-200 py-4 px-3 mt-4">
|
||||
<p class="text-lg mb-2 font-semibold">{{ $strings.LabelApiKeyUser }}</p>
|
||||
<p class="text-sm mb-2 text-gray-400">{{ $strings.LabelApiKeyUserDescription }}</p>
|
||||
<ui-select-input v-model="newApiKey.userId" :disabled="isExpired && !apiKey.isActive" :items="userItems" :placeholder="$strings.LabelSelectUser" :label="$strings.LabelApiKeyUser" label-hidden />
|
||||
</div>
|
||||
|
||||
<div class="flex pt-4 px-2">
|
||||
|
@ -128,15 +49,17 @@ export default {
|
|||
apiKey: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
users: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
newApiKey: {},
|
||||
isNew: true,
|
||||
tags: [],
|
||||
loadingTags: false
|
||||
isNew: true
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
@ -160,64 +83,29 @@ export default {
|
|||
title() {
|
||||
return this.isNew ? this.$strings.HeaderNewApiKey : this.$strings.HeaderUpdateApiKey
|
||||
},
|
||||
libraries() {
|
||||
return this.$store.state.libraries.libraries
|
||||
userItems() {
|
||||
return this.users
|
||||
.filter((u) => {
|
||||
// Only show root user if the current user is root
|
||||
return u.type !== 'root' || this.$store.getters['user/getIsRoot']
|
||||
})
|
||||
.map((u) => ({ text: u.username, value: u.id, subtext: u.type }))
|
||||
},
|
||||
libraryItems() {
|
||||
return this.libraries.map((lib) => ({ text: lib.name, value: lib.id }))
|
||||
},
|
||||
itemTags() {
|
||||
return this.tags.map((t) => {
|
||||
return {
|
||||
text: t,
|
||||
value: t
|
||||
}
|
||||
})
|
||||
},
|
||||
tagsSelectionText() {
|
||||
return this.newApiKey.permissions.selectedTagsNotAccessible ? this.$strings.LabelTagsNotAccessibleToUser : this.$strings.LabelTagsAccessibleToUser
|
||||
isExpired() {
|
||||
if (!this.apiKey || !this.apiKey.expiresAt) return false
|
||||
|
||||
return new Date(this.apiKey.expiresAt).getTime() < Date.now()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
accessAllTagsToggled(val) {
|
||||
if (val) {
|
||||
if (this.newApiKey.itemTagsSelected?.length) {
|
||||
this.newApiKey.itemTagsSelected = []
|
||||
}
|
||||
this.newApiKey.permissions.selectedTagsNotAccessible = false
|
||||
}
|
||||
},
|
||||
fetchAllTags() {
|
||||
this.loadingTags = true
|
||||
this.$axios
|
||||
.$get(`/api/tags`)
|
||||
.then((res) => {
|
||||
this.tags = res.tags
|
||||
this.loadingTags = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load tags', error)
|
||||
this.loadingTags = false
|
||||
})
|
||||
},
|
||||
accessAllLibrariesToggled(val) {
|
||||
if (!val && !this.newApiKey.permissions.librariesAccessible.length) {
|
||||
this.newApiKey.permissions.librariesAccessible = this.libraries.map((l) => l.id)
|
||||
} else if (val && this.newApiKey.permissions.librariesAccessible.length) {
|
||||
this.newApiKey.permissions.librariesAccessible = []
|
||||
}
|
||||
},
|
||||
submitForm() {
|
||||
if (!this.newApiKey.name) {
|
||||
this.$toast.error(this.$strings.ToastNewApiKeyNameError)
|
||||
this.$toast.error(this.$strings.ToastNameRequired)
|
||||
return
|
||||
}
|
||||
if (!this.newApiKey.permissions.accessAllLibraries && !this.newApiKey.permissions.librariesAccessible.length) {
|
||||
this.$toast.error(this.$strings.ToastNewApiKeyLibraryError)
|
||||
return
|
||||
}
|
||||
if (!this.newApiKey.permissions.accessAllTags && !this.newApiKey.itemTagsSelected.length) {
|
||||
this.$toast.error(this.$strings.ToastNewApiKeyTagError)
|
||||
|
||||
if (!this.newApiKey.userId) {
|
||||
this.$toast.error(this.$strings.ToastNewApiKeyUserError)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -228,9 +116,15 @@ export default {
|
|||
}
|
||||
},
|
||||
submitUpdateApiKey() {
|
||||
var apiKey = {
|
||||
if (this.newApiKey.isActive === this.apiKey.isActive && this.newApiKey.userId === this.apiKey.userId) {
|
||||
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
|
||||
this.show = false
|
||||
return
|
||||
}
|
||||
|
||||
const apiKey = {
|
||||
isActive: this.newApiKey.isActive,
|
||||
permissions: this.newApiKey.permissions
|
||||
userId: this.newApiKey.userId
|
||||
}
|
||||
|
||||
this.processing = true
|
||||
|
@ -281,33 +175,20 @@ export default {
|
|||
})
|
||||
},
|
||||
init() {
|
||||
this.fetchAllTags()
|
||||
this.isNew = !this.apiKey
|
||||
|
||||
if (this.apiKey) {
|
||||
this.newApiKey = {
|
||||
name: this.apiKey.name,
|
||||
isActive: this.apiKey.isActive,
|
||||
permissions: { ...this.apiKey.permissions }
|
||||
userId: this.apiKey.userId
|
||||
}
|
||||
} else {
|
||||
this.newApiKey = {
|
||||
name: null,
|
||||
expiresIn: null,
|
||||
isActive: true,
|
||||
permissions: {
|
||||
download: true,
|
||||
update: false,
|
||||
delete: false,
|
||||
upload: false,
|
||||
accessAllLibraries: true,
|
||||
accessAllTags: true,
|
||||
accessExplicitContent: false,
|
||||
selectedTagsNotAccessible: false,
|
||||
createEreader: false,
|
||||
librariesAccessible: [],
|
||||
itemTagsSelected: []
|
||||
}
|
||||
userId: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,7 +23,7 @@ export default {
|
|||
processing: Boolean,
|
||||
persistent: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
default: false
|
||||
},
|
||||
width: {
|
||||
type: [String, Number],
|
||||
|
@ -99,7 +99,7 @@ export default {
|
|||
this.preventClickoutside = false
|
||||
return
|
||||
}
|
||||
if (this.processing && this.persistent) return
|
||||
if (this.processing || this.persistent) return
|
||||
if (ev.srcElement && ev.srcElement.classList.contains('modal-bg')) {
|
||||
this.show = false
|
||||
}
|
||||
|
|
|
@ -4,8 +4,8 @@
|
|||
<table v-if="apiKeys.length > 0" id="api-keys">
|
||||
<tr>
|
||||
<th>{{ $strings.LabelName }}</th>
|
||||
<th class="w-44">{{ $strings.LabelApiKeyUser }}</th>
|
||||
<th class="w-32">{{ $strings.LabelExpiresAt }}</th>
|
||||
<th class="w-32">{{ $strings.LabelLastUsed }}</th>
|
||||
<th class="w-32">{{ $strings.LabelCreatedAt }}</th>
|
||||
<th class="w-32"></th>
|
||||
</tr>
|
||||
|
@ -15,11 +15,15 @@
|
|||
<p class="pl-2 truncate">{{ apiKey.name }}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-xs">{{ apiKey.expiresAt ? $formatJsDatetime(new Date(apiKey.expiresAt), dateFormat, timeFormat) : $strings.LabelExpiresNever }}</td>
|
||||
<td class="text-xs">
|
||||
<ui-tooltip v-if="apiKey.lastUsedAt" direction="top" :text="$formatJsDatetime(new Date(apiKey.lastUsedAt), dateFormat, timeFormat)">
|
||||
{{ $dateDistanceFromNow(new Date(apiKey.lastUsedAt).getTime()) }}
|
||||
</ui-tooltip>
|
||||
<nuxt-link v-if="apiKey.user" :to="`/config/users/${apiKey.user.id}`" class="text-xs hover:underline">
|
||||
{{ apiKey.user.username }}
|
||||
</nuxt-link>
|
||||
<p v-else class="text-xs">Error</p>
|
||||
</td>
|
||||
<td class="text-xs">
|
||||
<p v-if="apiKey.expiresAt" class="text-xs" :title="apiKey.expiresAt">{{ getExpiresAtText(apiKey) }}</p>
|
||||
<p v-else class="text-xs">{{ $strings.LabelExpiresNever }}</p>
|
||||
</td>
|
||||
<td class="text-xs font-mono">
|
||||
<ui-tooltip direction="top" :text="$formatJsDatetime(new Date(apiKey.createdAt), dateFormat, timeFormat)">
|
||||
|
@ -60,6 +64,12 @@ export default {
|
|||
}
|
||||
},
|
||||
methods: {
|
||||
getExpiresAtText(apiKey) {
|
||||
if (new Date(apiKey.expiresAt).getTime() < Date.now()) {
|
||||
return this.$strings.LabelExpired
|
||||
}
|
||||
return this.$formatJsDatetime(new Date(apiKey.expiresAt), this.dateFormat, this.timeFormat)
|
||||
},
|
||||
deleteApiKeyClick(apiKey) {
|
||||
if (this.isDeletingApiKey) return
|
||||
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
<template>
|
||||
<div class="relative w-full">
|
||||
<p v-if="label" class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
||||
<p v-if="label && !labelHidden" class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
||||
<button ref="buttonWrapper" type="button" :aria-label="longLabel" :disabled="disabled" class="relative w-full border rounded-sm shadow-xs pl-3 pr-8 py-2 text-left sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||
<span class="flex items-center">
|
||||
<span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small }">{{ selectedText }}</span>
|
||||
<span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small, 'text-gray-400': !selectedText }">{{ selectedText || placeholder }}</span>
|
||||
<span v-if="selectedSubtext">: </span>
|
||||
<span v-if="selectedSubtext" class="font-normal block truncate font-sans text-sm text-gray-400">{{ selectedSubtext }}</span>
|
||||
</span>
|
||||
|
@ -36,10 +36,15 @@ export default {
|
|||
type: String,
|
||||
default: ''
|
||||
},
|
||||
labelHidden: Boolean,
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
disabled: Boolean,
|
||||
small: Boolean,
|
||||
menuMaxHeight: {
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
|
||||
</label>
|
||||
</slot>
|
||||
<ui-text-input :placeholder="placeholder || label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" :show-copy="showCopy" class="w-full" :class="inputClass" :trim-whitespace="trimWhitespace" @blur="inputBlurred" />
|
||||
<ui-text-input :placeholder="placeholder || label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" :min="min" :show-copy="showCopy" class="w-full" :class="inputClass" :trim-whitespace="trimWhitespace" @blur="inputBlurred" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -21,6 +21,7 @@ export default {
|
|||
type: String,
|
||||
default: 'text'
|
||||
},
|
||||
min: [String, Number],
|
||||
readonly: Boolean,
|
||||
disabled: Boolean,
|
||||
inputClass: String,
|
||||
|
|
|
@ -14,12 +14,12 @@
|
|||
|
||||
<div class="grow" />
|
||||
|
||||
<ui-btn color="bg-primary" small @click="setShowApiKeyModal()">{{ $strings.ButtonAddApiKey }}</ui-btn>
|
||||
<ui-btn color="bg-primary" :disabled="loadingUsers || users.length === 0" small @click="setShowApiKeyModal()">{{ $strings.ButtonAddApiKey }}</ui-btn>
|
||||
</template>
|
||||
|
||||
<tables-api-keys-table ref="apiKeysTable" class="pt-2" @edit="setShowApiKeyModal" @numApiKeys="(count) => (numApiKeys = count)" />
|
||||
</app-settings-content>
|
||||
<modals-api-key-modal ref="apiKeyModal" v-model="showApiKeyModal" :api-key="selectedApiKey" @created="apiKeyCreated" @deleted="apiKeyDeleted" @updated="apiKeyUpdated" />
|
||||
<modals-api-key-modal ref="apiKeyModal" v-model="showApiKeyModal" :api-key="selectedApiKey" :users="users" @created="apiKeyCreated" @deleted="apiKeyDeleted" @updated="apiKeyUpdated" />
|
||||
<modals-api-key-created-modal ref="apiKeyCreatedModal" v-model="showApiKeyCreatedModal" :api-key="selectedApiKey" />
|
||||
</div>
|
||||
</template>
|
||||
|
@ -33,10 +33,12 @@ export default {
|
|||
},
|
||||
data() {
|
||||
return {
|
||||
loadingUsers: false,
|
||||
selectedApiKey: null,
|
||||
showApiKeyModal: false,
|
||||
showApiKeyCreatedModal: false,
|
||||
numApiKeys: 0
|
||||
numApiKeys: 0,
|
||||
users: []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
@ -45,7 +47,6 @@ export default {
|
|||
this.selectedApiKey = apiKey
|
||||
this.showApiKeyCreatedModal = true
|
||||
if (this.$refs.apiKeysTable) {
|
||||
console.log('apiKeyCreated', apiKey)
|
||||
this.$refs.apiKeysTable.addApiKey(apiKey)
|
||||
}
|
||||
},
|
||||
|
@ -60,9 +61,27 @@ export default {
|
|||
setShowApiKeyModal(selectedApiKey) {
|
||||
this.selectedApiKey = selectedApiKey
|
||||
this.showApiKeyModal = true
|
||||
},
|
||||
loadUsers() {
|
||||
this.loadingUsers = true
|
||||
this.$axios
|
||||
.$get('/api/users')
|
||||
.then((res) => {
|
||||
this.users = res.users.sort((a, b) => {
|
||||
return a.createdAt - b.createdAt
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
})
|
||||
.finally(() => {
|
||||
this.loadingUsers = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {},
|
||||
mounted() {
|
||||
this.loadUsers()
|
||||
},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -242,6 +242,8 @@
|
|||
"LabelAlreadyInYourLibrary": "Already in your library",
|
||||
"LabelApiKeyCreated": "API Key \"{0}\" created successfully.",
|
||||
"LabelApiKeyCreatedDescription": "Make sure to copy the API key now as you will not be able to see this again.",
|
||||
"LabelApiKeyUser": "Act on behalf of user",
|
||||
"LabelApiKeyUserDescription": "This API key will have the same permissions as the user it is acting on behalf of. This will appear the same in logs as if the user was making the request.",
|
||||
"LabelApiToken": "API Token",
|
||||
"LabelAppend": "Append",
|
||||
"LabelAudioBitrate": "Audio Bitrate (e.g. 128k)",
|
||||
|
@ -353,6 +355,7 @@
|
|||
"LabelExample": "Example",
|
||||
"LabelExpandSeries": "Expand Series",
|
||||
"LabelExpandSubSeries": "Expand Sub Series",
|
||||
"LabelExpired": "Expired",
|
||||
"LabelExpiresAt": "Expires At",
|
||||
"LabelExpiresInSeconds": "Expires in (seconds)",
|
||||
"LabelExpiresNever": "Never",
|
||||
|
@ -418,7 +421,6 @@
|
|||
"LabelLastSeen": "Last Seen",
|
||||
"LabelLastTime": "Last Time",
|
||||
"LabelLastUpdate": "Last Update",
|
||||
"LabelLastUsed": "Last Used",
|
||||
"LabelLayout": "Layout",
|
||||
"LabelLayoutSinglePage": "Single page",
|
||||
"LabelLayoutSplitPage": "Split page",
|
||||
|
@ -556,6 +558,7 @@
|
|||
"LabelSelectAll": "Select all",
|
||||
"LabelSelectAllEpisodes": "Select all episodes",
|
||||
"LabelSelectEpisodesShowing": "Select {0} episodes showing",
|
||||
"LabelSelectUser": "Select user",
|
||||
"LabelSelectUsers": "Select users",
|
||||
"LabelSendEbookToDevice": "Send Ebook to...",
|
||||
"LabelSequence": "Sequence",
|
||||
|
@ -1046,6 +1049,7 @@
|
|||
"ToastMustHaveAtLeastOnePath": "Must have at least one path",
|
||||
"ToastNameEmailRequired": "Name and email are required",
|
||||
"ToastNameRequired": "Name is required",
|
||||
"ToastNewApiKeyUserError": "Must select a user",
|
||||
"ToastNewEpisodesFound": "{0} new episodes found",
|
||||
"ToastNewUserCreatedFailed": "Failed to create account: \"{0}\"",
|
||||
"ToastNewUserCreatedSuccess": "New account created",
|
||||
|
|
|
@ -65,7 +65,9 @@ class Auth {
|
|||
new JwtStrategy(
|
||||
{
|
||||
jwtFromRequest: ExtractJwt.fromExtractors([ExtractJwt.fromAuthHeaderAsBearerToken(), ExtractJwt.fromUrlQueryParameter('token')]),
|
||||
secretOrKey: Database.serverSettings.tokenSecret
|
||||
secretOrKey: Database.serverSettings.tokenSecret,
|
||||
// Handle expiration manaully in order to disable api keys that are expired
|
||||
ignoreExpiration: true
|
||||
},
|
||||
this.jwtAuthCheck.bind(this)
|
||||
)
|
||||
|
@ -1044,6 +1046,7 @@ class Auth {
|
|||
}
|
||||
await Database.updateServerSettings()
|
||||
|
||||
// TODO: Old method of non-expiring tokens
|
||||
// New token secret creation added in v2.1.0 so generate new API tokens for each user
|
||||
const users = await Database.userModel.findAll({
|
||||
attributes: ['id', 'username', 'token']
|
||||
|
@ -1057,22 +1060,49 @@ class Auth {
|
|||
}
|
||||
|
||||
/**
|
||||
* Checks if the user in the validated jwt_payload really exists and is active.
|
||||
* Checks if the user or api key in the validated jwt_payload exists and is active.
|
||||
* @param {Object} jwt_payload
|
||||
* @param {function} done
|
||||
*/
|
||||
async jwtAuthCheck(jwt_payload, done) {
|
||||
// load user by id from the jwt token
|
||||
const user = await Database.userModel.getUserByIdOrOldId(jwt_payload.userId)
|
||||
if (jwt_payload.type === 'api') {
|
||||
const apiKey = await Database.apiKeyModel.getById(jwt_payload.keyId)
|
||||
|
||||
if (!user?.isActive) {
|
||||
// deny login
|
||||
done(null, null)
|
||||
return
|
||||
if (!apiKey?.isActive) {
|
||||
done(null, null)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the api key is expired and deactivate it
|
||||
if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) {
|
||||
done(null, null)
|
||||
|
||||
apiKey.isActive = false
|
||||
await apiKey.save()
|
||||
Logger.info(`[Auth] API key ${apiKey.id} is expired - deactivated`)
|
||||
return
|
||||
}
|
||||
|
||||
const user = await Database.userModel.getUserById(apiKey.userId)
|
||||
done(null, user)
|
||||
} else {
|
||||
// Check if the jwt is expired
|
||||
if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) {
|
||||
done(null, null)
|
||||
return
|
||||
}
|
||||
|
||||
// load user by id from the jwt token
|
||||
const user = await Database.userModel.getUserByIdOrOldId(jwt_payload.userId)
|
||||
|
||||
if (!user?.isActive) {
|
||||
// deny login
|
||||
done(null, null)
|
||||
return
|
||||
}
|
||||
// approve login
|
||||
done(null, user)
|
||||
}
|
||||
// approve login
|
||||
done(null, user)
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -670,6 +670,7 @@ class Database {
|
|||
* Remove playback sessions that are 3 seconds or less
|
||||
* Remove duplicate mediaProgresses
|
||||
* Remove expired auth sessions
|
||||
* Deactivate expired api keys
|
||||
*/
|
||||
async cleanDatabase() {
|
||||
// Remove invalid Podcast records
|
||||
|
@ -802,6 +803,23 @@ WHERE EXISTS (
|
|||
|
||||
// Remove expired Session records
|
||||
await this.cleanupExpiredSessions()
|
||||
|
||||
// Deactivate expired api keys
|
||||
await this.deactivateExpiredApiKeys()
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate expired api keys
|
||||
*/
|
||||
async deactivateExpiredApiKeys() {
|
||||
try {
|
||||
const affectedCount = await this.apiKeyModel.deactivateExpiredApiKeys()
|
||||
if (affectedCount > 0) {
|
||||
Logger.info(`[Database] Deactivated ${affectedCount} expired api keys`)
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`[Database] Error deactivating expired api keys: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -20,7 +20,19 @@ class ApiKeyController {
|
|||
* @param {Response} res
|
||||
*/
|
||||
async getAll(req, res) {
|
||||
const apiKeys = await Database.apiKeyModel.findAll()
|
||||
const apiKeys = await Database.apiKeyModel.findAll({
|
||||
include: [
|
||||
{
|
||||
model: Database.userModel,
|
||||
attributes: ['id', 'username', 'type']
|
||||
},
|
||||
{
|
||||
model: Database.userModel,
|
||||
as: 'createdByUser',
|
||||
attributes: ['id', 'username', 'type']
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
return res.json({
|
||||
apiKeys: apiKeys.map((a) => a.toJSON())
|
||||
|
@ -42,10 +54,21 @@ class ApiKeyController {
|
|||
Logger.warn(`[ApiKeyController] create: Invalid expiresIn: ${req.body.expiresIn}`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
if (!req.body.userId || typeof req.body.userId !== 'string') {
|
||||
Logger.warn(`[ApiKeyController] create: Invalid userId: ${req.body.userId}`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
const user = await Database.userModel.getUserById(req.body.userId)
|
||||
if (!user) {
|
||||
Logger.warn(`[ApiKeyController] create: User not found: ${req.body.userId}`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
if (user.type === 'root' && !req.user.isRoot) {
|
||||
Logger.warn(`[ApiKeyController] create: Root user API key cannot be created by non-root user`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const keyId = uuidv4() // Generate key id ahead of time to use in JWT
|
||||
|
||||
const permissions = Database.apiKeyModel.mergePermissionsWithDefault(req.body.permissions)
|
||||
const apiKey = await Database.apiKeyModel.generateApiKey(keyId, req.body.name, req.body.expiresIn)
|
||||
|
||||
if (!apiKey) {
|
||||
|
@ -60,9 +83,9 @@ class ApiKeyController {
|
|||
id: keyId,
|
||||
name: req.body.name,
|
||||
expiresAt,
|
||||
permissions,
|
||||
userId: req.user.id,
|
||||
isActive: !!req.body.isActive
|
||||
userId: req.body.userId,
|
||||
isActive: !!req.body.isActive,
|
||||
createdByUserId: req.user.id
|
||||
})
|
||||
|
||||
Logger.info(`[ApiKeyController] Created API key "${apiKeyInstance.name}"`)
|
||||
|
@ -76,34 +99,64 @@ class ApiKeyController {
|
|||
|
||||
/**
|
||||
* PATCH: /api/api-keys/:id
|
||||
* Only isActive and permissions can be updated because name and expiresIn are in the JWT
|
||||
* Only isActive and userId can be updated because name and expiresIn are in the JWT
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async update(req, res) {
|
||||
const apiKey = await Database.apiKeyModel.findByPk(req.params.id)
|
||||
const apiKey = await Database.apiKeyModel.findByPk(req.params.id, {
|
||||
include: {
|
||||
model: Database.userModel
|
||||
}
|
||||
})
|
||||
if (!apiKey) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
// Only root user can update root user API keys
|
||||
if (apiKey.user.type === 'root' && !req.user.isRoot) {
|
||||
Logger.warn(`[ApiKeyController] update: Root user API key cannot be updated by non-root user`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
let hasUpdates = false
|
||||
if (req.body.userId !== undefined) {
|
||||
if (typeof req.body.userId !== 'string') {
|
||||
Logger.warn(`[ApiKeyController] update: Invalid userId: ${req.body.userId}`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
const user = await Database.userModel.getUserById(req.body.userId)
|
||||
if (!user) {
|
||||
Logger.warn(`[ApiKeyController] update: User not found: ${req.body.userId}`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
if (user.type === 'root' && !req.user.isRoot) {
|
||||
Logger.warn(`[ApiKeyController] update: Root user API key cannot be created by non-root user`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
if (apiKey.userId !== req.body.userId) {
|
||||
apiKey.userId = req.body.userId
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
if (req.body.isActive !== undefined) {
|
||||
if (typeof req.body.isActive !== 'boolean') {
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
apiKey.isActive = req.body.isActive
|
||||
if (apiKey.isActive !== req.body.isActive) {
|
||||
apiKey.isActive = req.body.isActive
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
if (req.body.permissions && Object.keys(req.body.permissions).length > 0) {
|
||||
const permissions = Database.apiKeyModel.mergePermissionsWithDefault(req.body.permissions)
|
||||
apiKey.permissions = permissions
|
||||
if (hasUpdates) {
|
||||
await apiKey.save()
|
||||
Logger.info(`[ApiKeyController] Updated API key "${apiKey.name}"`)
|
||||
} else {
|
||||
Logger.info(`[ApiKeyController] No updates needed to API key "${apiKey.name}"`)
|
||||
}
|
||||
|
||||
await apiKey.save()
|
||||
|
||||
Logger.info(`[ApiKeyController] Updated API key "${apiKey.name}"`)
|
||||
|
||||
return res.json({
|
||||
apiKey: apiKey.toJSON()
|
||||
})
|
||||
|
|
|
@ -36,6 +36,7 @@ class CronManager {
|
|||
* Closes open share sessions that have not been updated in 24 hours
|
||||
* Closes open playback sessions that have not been updated in 36 hours
|
||||
* Cleans up expired auth sessions
|
||||
* Deactivates expired api keys
|
||||
* TODO: Clients should re-open the session if it is closed so that stale sessions can be closed sooner
|
||||
*/
|
||||
initOpenSessionCleanupCron() {
|
||||
|
@ -44,6 +45,7 @@ class CronManager {
|
|||
ShareManager.closeStaleOpenShareSessions()
|
||||
await this.playbackSessionManager.closeStaleOpenSessions()
|
||||
await Database.cleanupExpiredSessions()
|
||||
await Database.deactivateExpiredApiKeys()
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -80,7 +80,11 @@ async function up({ context: { queryInterface, logger } }) {
|
|||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
name: DataTypes.STRING,
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
description: DataTypes.TEXT,
|
||||
expiresAt: DataTypes.DATE,
|
||||
lastUsedAt: DataTypes.DATE,
|
||||
isActive: {
|
||||
|
@ -105,6 +109,17 @@ async function up({ context: { queryInterface, logger } }) {
|
|||
},
|
||||
key: 'id'
|
||||
},
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
createdByUserId: {
|
||||
type: DataTypes.UUID,
|
||||
references: {
|
||||
model: {
|
||||
tableName: 'users',
|
||||
as: 'createdByUser'
|
||||
},
|
||||
key: 'id'
|
||||
},
|
||||
onDelete: 'SET NULL'
|
||||
}
|
||||
})
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
const { DataTypes, Model, Op } = require('sequelize')
|
||||
const jwt = require('jsonwebtoken')
|
||||
const { LRUCache } = require('lru-cache')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
/**
|
||||
|
@ -17,6 +18,32 @@ const Logger = require('../Logger')
|
|||
* @property {string[]} itemTagsSelected
|
||||
*/
|
||||
|
||||
class ApiKeyCache {
|
||||
constructor() {
|
||||
this.cache = new LRUCache({ max: 100 })
|
||||
}
|
||||
|
||||
getById(id) {
|
||||
const apiKey = this.cache.get(id)
|
||||
return apiKey
|
||||
}
|
||||
|
||||
set(apiKey) {
|
||||
apiKey.fromCache = true
|
||||
this.cache.set(apiKey.id, apiKey)
|
||||
}
|
||||
|
||||
delete(apiKeyId) {
|
||||
this.cache.delete(apiKeyId)
|
||||
}
|
||||
|
||||
maybeInvalidate(apiKey) {
|
||||
if (!apiKey.fromCache) this.delete(apiKey.id)
|
||||
}
|
||||
}
|
||||
|
||||
const apiKeyCache = new ApiKeyCache()
|
||||
|
||||
class ApiKey extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
@ -25,13 +52,15 @@ class ApiKey extends Model {
|
|||
this.id
|
||||
/** @type {string} */
|
||||
this.name
|
||||
/** @type {string} */
|
||||
this.description
|
||||
/** @type {Date} */
|
||||
this.expiresAt
|
||||
/** @type {Date} */
|
||||
this.lastUsedAt
|
||||
/** @type {boolean} */
|
||||
this.isActive
|
||||
/** @type {Object} */
|
||||
/** @type {ApiKeyPermissions} */
|
||||
this.permissions
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
|
@ -39,6 +68,8 @@ class ApiKey extends Model {
|
|||
this.updatedAt
|
||||
/** @type {UUIDV4} */
|
||||
this.userId
|
||||
/** @type {UUIDV4} */
|
||||
this.createdByUserId
|
||||
|
||||
// Expanded properties
|
||||
|
||||
|
@ -104,18 +135,24 @@ class ApiKey extends Model {
|
|||
}
|
||||
|
||||
/**
|
||||
* Clean up expired api keys from the database
|
||||
* @returns {Promise<number>} Number of api keys deleted
|
||||
* Deactivate expired api keys
|
||||
* @returns {Promise<number>} Number of api keys affected
|
||||
*/
|
||||
static async cleanupExpiredApiKeys() {
|
||||
const deletedCount = await ApiKey.destroy({
|
||||
where: {
|
||||
expiresAt: {
|
||||
[Op.lt]: new Date()
|
||||
static async deactivateExpiredApiKeys() {
|
||||
const [affectedCount] = await ApiKey.update(
|
||||
{
|
||||
isActive: false
|
||||
},
|
||||
{
|
||||
where: {
|
||||
isActive: true,
|
||||
expiresAt: {
|
||||
[Op.lt]: new Date()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return deletedCount
|
||||
)
|
||||
return affectedCount
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -152,6 +189,24 @@ class ApiKey extends Model {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an api key by id, from cache or database
|
||||
* @param {string} apiKeyId
|
||||
* @returns {Promise<ApiKey | null>}
|
||||
*/
|
||||
static async getById(apiKeyId) {
|
||||
if (!apiKeyId) return null
|
||||
|
||||
const cachedApiKey = apiKeyCache.getById(apiKeyId)
|
||||
if (cachedApiKey) return cachedApiKey
|
||||
|
||||
const apiKey = await ApiKey.findByPk(apiKeyId)
|
||||
if (!apiKey) return null
|
||||
|
||||
apiKeyCache.set(apiKey)
|
||||
return apiKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize model
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
|
@ -164,7 +219,11 @@ class ApiKey extends Model {
|
|||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
name: DataTypes.STRING,
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
description: DataTypes.TEXT,
|
||||
expiresAt: DataTypes.DATE,
|
||||
lastUsedAt: DataTypes.DATE,
|
||||
isActive: {
|
||||
|
@ -182,9 +241,30 @@ class ApiKey extends Model {
|
|||
|
||||
const { user } = sequelize.models
|
||||
user.hasMany(ApiKey, {
|
||||
onDelete: 'SET NULL'
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
ApiKey.belongsTo(user)
|
||||
|
||||
user.hasMany(ApiKey, {
|
||||
foreignKey: 'createdByUserId',
|
||||
onDelete: 'SET NULL'
|
||||
})
|
||||
ApiKey.belongsTo(user, { as: 'createdByUser', foreignKey: 'createdByUserId' })
|
||||
}
|
||||
|
||||
async update(values, options) {
|
||||
apiKeyCache.maybeInvalidate(this)
|
||||
return await super.update(values, options)
|
||||
}
|
||||
|
||||
async save(options) {
|
||||
apiKeyCache.maybeInvalidate(this)
|
||||
return await super.save(options)
|
||||
}
|
||||
|
||||
async destroy(options) {
|
||||
apiKeyCache.delete(this.id)
|
||||
await super.destroy(options)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue