Update API Keys to be tied to a user, add apikey lru-cache, handle deactivating expired keys

This commit is contained in:
advplyr 2025-06-30 14:53:11 -05:00
parent af1ff12dbb
commit 4d32a22de9
13 changed files with 335 additions and 217 deletions

View file

@ -13,102 +13,23 @@
<ui-text-input-with-label v-model.trim="newApiKey.name" :readonly="!isNew" :label="$strings.LabelName" /> <ui-text-input-with-label v-model.trim="newApiKey.name" :readonly="!isNew" :label="$strings.LabelName" />
</div> </div>
<div v-if="isNew" class="w-1/2 px-2"> <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> </div>
<div class="flex py-2"> <div class="flex items-center pt-4 pb-2 gap-2">
<div class="flex items-center pt-4 px-2"> <div class="flex items-center px-2">
<p class="px-3 font-semibold" id="user-enabled-toggle">{{ $strings.LabelEnable }}</p> <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> </div>
<div v-if="newApiKey.permissions" class="w-full border-t border-b border-black-200 py-2 px-3 mt-4"> <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.HeaderPermissions }}</p> <p class="text-lg mb-2 font-semibold">{{ $strings.LabelApiKeyUser }}</p>
<div class="flex items-center my-2 max-w-md"> <p class="text-sm mb-2 text-gray-400">{{ $strings.LabelApiKeyUserDescription }}</p>
<div class="w-1/2"> <ui-select-input v-model="newApiKey.userId" :disabled="isExpired && !apiKey.isActive" :items="userItems" :placeholder="$strings.LabelSelectUser" :label="$strings.LabelApiKeyUser" label-hidden />
<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> </div>
<div class="flex pt-4 px-2"> <div class="flex pt-4 px-2">
@ -128,15 +49,17 @@ export default {
apiKey: { apiKey: {
type: Object, type: Object,
default: () => null default: () => null
},
users: {
type: Array,
default: () => []
} }
}, },
data() { data() {
return { return {
processing: false, processing: false,
newApiKey: {}, newApiKey: {},
isNew: true, isNew: true
tags: [],
loadingTags: false
} }
}, },
watch: { watch: {
@ -160,64 +83,29 @@ export default {
title() { title() {
return this.isNew ? this.$strings.HeaderNewApiKey : this.$strings.HeaderUpdateApiKey return this.isNew ? this.$strings.HeaderNewApiKey : this.$strings.HeaderUpdateApiKey
}, },
libraries() { userItems() {
return this.$store.state.libraries.libraries 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() { isExpired() {
return this.libraries.map((lib) => ({ text: lib.name, value: lib.id })) if (!this.apiKey || !this.apiKey.expiresAt) return false
},
itemTags() { return new Date(this.apiKey.expiresAt).getTime() < Date.now()
return this.tags.map((t) => {
return {
text: t,
value: t
}
})
},
tagsSelectionText() {
return this.newApiKey.permissions.selectedTagsNotAccessible ? this.$strings.LabelTagsNotAccessibleToUser : this.$strings.LabelTagsAccessibleToUser
} }
}, },
methods: { 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() { submitForm() {
if (!this.newApiKey.name) { if (!this.newApiKey.name) {
this.$toast.error(this.$strings.ToastNewApiKeyNameError) this.$toast.error(this.$strings.ToastNameRequired)
return return
} }
if (!this.newApiKey.permissions.accessAllLibraries && !this.newApiKey.permissions.librariesAccessible.length) {
this.$toast.error(this.$strings.ToastNewApiKeyLibraryError) if (!this.newApiKey.userId) {
return this.$toast.error(this.$strings.ToastNewApiKeyUserError)
}
if (!this.newApiKey.permissions.accessAllTags && !this.newApiKey.itemTagsSelected.length) {
this.$toast.error(this.$strings.ToastNewApiKeyTagError)
return return
} }
@ -228,9 +116,15 @@ export default {
} }
}, },
submitUpdateApiKey() { 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, isActive: this.newApiKey.isActive,
permissions: this.newApiKey.permissions userId: this.newApiKey.userId
} }
this.processing = true this.processing = true
@ -281,33 +175,20 @@ export default {
}) })
}, },
init() { init() {
this.fetchAllTags()
this.isNew = !this.apiKey this.isNew = !this.apiKey
if (this.apiKey) { if (this.apiKey) {
this.newApiKey = { this.newApiKey = {
name: this.apiKey.name, name: this.apiKey.name,
isActive: this.apiKey.isActive, isActive: this.apiKey.isActive,
permissions: { ...this.apiKey.permissions } userId: this.apiKey.userId
} }
} else { } else {
this.newApiKey = { this.newApiKey = {
name: null, name: null,
expiresIn: null, expiresIn: null,
isActive: true, isActive: true,
permissions: { userId: null
download: true,
update: false,
delete: false,
upload: false,
accessAllLibraries: true,
accessAllTags: true,
accessExplicitContent: false,
selectedTagsNotAccessible: false,
createEreader: false,
librariesAccessible: [],
itemTagsSelected: []
}
} }
} }
} }

View file

@ -23,7 +23,7 @@ export default {
processing: Boolean, processing: Boolean,
persistent: { persistent: {
type: Boolean, type: Boolean,
default: true default: false
}, },
width: { width: {
type: [String, Number], type: [String, Number],
@ -99,7 +99,7 @@ export default {
this.preventClickoutside = false this.preventClickoutside = false
return return
} }
if (this.processing && this.persistent) return if (this.processing || this.persistent) return
if (ev.srcElement && ev.srcElement.classList.contains('modal-bg')) { if (ev.srcElement && ev.srcElement.classList.contains('modal-bg')) {
this.show = false this.show = false
} }

View file

@ -4,8 +4,8 @@
<table v-if="apiKeys.length > 0" id="api-keys"> <table v-if="apiKeys.length > 0" id="api-keys">
<tr> <tr>
<th>{{ $strings.LabelName }}</th> <th>{{ $strings.LabelName }}</th>
<th class="w-44">{{ $strings.LabelApiKeyUser }}</th>
<th class="w-32">{{ $strings.LabelExpiresAt }}</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">{{ $strings.LabelCreatedAt }}</th>
<th class="w-32"></th> <th class="w-32"></th>
</tr> </tr>
@ -15,11 +15,15 @@
<p class="pl-2 truncate">{{ apiKey.name }}</p> <p class="pl-2 truncate">{{ apiKey.name }}</p>
</div> </div>
</td> </td>
<td class="text-xs">{{ apiKey.expiresAt ? $formatJsDatetime(new Date(apiKey.expiresAt), dateFormat, timeFormat) : $strings.LabelExpiresNever }}</td>
<td class="text-xs"> <td class="text-xs">
<ui-tooltip v-if="apiKey.lastUsedAt" direction="top" :text="$formatJsDatetime(new Date(apiKey.lastUsedAt), dateFormat, timeFormat)"> <nuxt-link v-if="apiKey.user" :to="`/config/users/${apiKey.user.id}`" class="text-xs hover:underline">
{{ $dateDistanceFromNow(new Date(apiKey.lastUsedAt).getTime()) }} {{ apiKey.user.username }}
</ui-tooltip> </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>
<td class="text-xs font-mono"> <td class="text-xs font-mono">
<ui-tooltip direction="top" :text="$formatJsDatetime(new Date(apiKey.createdAt), dateFormat, timeFormat)"> <ui-tooltip direction="top" :text="$formatJsDatetime(new Date(apiKey.createdAt), dateFormat, timeFormat)">
@ -60,6 +64,12 @@ export default {
} }
}, },
methods: { 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) { deleteApiKeyClick(apiKey) {
if (this.isDeletingApiKey) return if (this.isDeletingApiKey) return

View file

@ -1,9 +1,9 @@
<template> <template>
<div class="relative w-full"> <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"> <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="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">:&nbsp;</span> <span v-if="selectedSubtext">:&nbsp;</span>
<span v-if="selectedSubtext" class="font-normal block truncate font-sans text-sm text-gray-400">{{ selectedSubtext }}</span> <span v-if="selectedSubtext" class="font-normal block truncate font-sans text-sm text-gray-400">{{ selectedSubtext }}</span>
</span> </span>
@ -36,10 +36,15 @@ export default {
type: String, type: String,
default: '' default: ''
}, },
labelHidden: Boolean,
items: { items: {
type: Array, type: Array,
default: () => [] default: () => []
}, },
placeholder: {
type: String,
default: ''
},
disabled: Boolean, disabled: Boolean,
small: Boolean, small: Boolean,
menuMaxHeight: { menuMaxHeight: {

View file

@ -6,7 +6,7 @@
<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em> <em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
</label> </label>
</slot> </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> </div>
</template> </template>
@ -21,6 +21,7 @@ export default {
type: String, type: String,
default: 'text' default: 'text'
}, },
min: [String, Number],
readonly: Boolean, readonly: Boolean,
disabled: Boolean, disabled: Boolean,
inputClass: String, inputClass: String,

View file

@ -14,12 +14,12 @@
<div class="grow" /> <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> </template>
<tables-api-keys-table ref="apiKeysTable" class="pt-2" @edit="setShowApiKeyModal" @numApiKeys="(count) => (numApiKeys = count)" /> <tables-api-keys-table ref="apiKeysTable" class="pt-2" @edit="setShowApiKeyModal" @numApiKeys="(count) => (numApiKeys = count)" />
</app-settings-content> </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" /> <modals-api-key-created-modal ref="apiKeyCreatedModal" v-model="showApiKeyCreatedModal" :api-key="selectedApiKey" />
</div> </div>
</template> </template>
@ -33,10 +33,12 @@ export default {
}, },
data() { data() {
return { return {
loadingUsers: false,
selectedApiKey: null, selectedApiKey: null,
showApiKeyModal: false, showApiKeyModal: false,
showApiKeyCreatedModal: false, showApiKeyCreatedModal: false,
numApiKeys: 0 numApiKeys: 0,
users: []
} }
}, },
methods: { methods: {
@ -45,7 +47,6 @@ export default {
this.selectedApiKey = apiKey this.selectedApiKey = apiKey
this.showApiKeyCreatedModal = true this.showApiKeyCreatedModal = true
if (this.$refs.apiKeysTable) { if (this.$refs.apiKeysTable) {
console.log('apiKeyCreated', apiKey)
this.$refs.apiKeysTable.addApiKey(apiKey) this.$refs.apiKeysTable.addApiKey(apiKey)
} }
}, },
@ -60,9 +61,27 @@ export default {
setShowApiKeyModal(selectedApiKey) { setShowApiKeyModal(selectedApiKey) {
this.selectedApiKey = selectedApiKey this.selectedApiKey = selectedApiKey
this.showApiKeyModal = true 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() {} beforeDestroy() {}
} }
</script> </script>

View file

@ -242,6 +242,8 @@
"LabelAlreadyInYourLibrary": "Already in your library", "LabelAlreadyInYourLibrary": "Already in your library",
"LabelApiKeyCreated": "API Key \"{0}\" created successfully.", "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.", "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", "LabelApiToken": "API Token",
"LabelAppend": "Append", "LabelAppend": "Append",
"LabelAudioBitrate": "Audio Bitrate (e.g. 128k)", "LabelAudioBitrate": "Audio Bitrate (e.g. 128k)",
@ -353,6 +355,7 @@
"LabelExample": "Example", "LabelExample": "Example",
"LabelExpandSeries": "Expand Series", "LabelExpandSeries": "Expand Series",
"LabelExpandSubSeries": "Expand Sub Series", "LabelExpandSubSeries": "Expand Sub Series",
"LabelExpired": "Expired",
"LabelExpiresAt": "Expires At", "LabelExpiresAt": "Expires At",
"LabelExpiresInSeconds": "Expires in (seconds)", "LabelExpiresInSeconds": "Expires in (seconds)",
"LabelExpiresNever": "Never", "LabelExpiresNever": "Never",
@ -418,7 +421,6 @@
"LabelLastSeen": "Last Seen", "LabelLastSeen": "Last Seen",
"LabelLastTime": "Last Time", "LabelLastTime": "Last Time",
"LabelLastUpdate": "Last Update", "LabelLastUpdate": "Last Update",
"LabelLastUsed": "Last Used",
"LabelLayout": "Layout", "LabelLayout": "Layout",
"LabelLayoutSinglePage": "Single page", "LabelLayoutSinglePage": "Single page",
"LabelLayoutSplitPage": "Split page", "LabelLayoutSplitPage": "Split page",
@ -556,6 +558,7 @@
"LabelSelectAll": "Select all", "LabelSelectAll": "Select all",
"LabelSelectAllEpisodes": "Select all episodes", "LabelSelectAllEpisodes": "Select all episodes",
"LabelSelectEpisodesShowing": "Select {0} episodes showing", "LabelSelectEpisodesShowing": "Select {0} episodes showing",
"LabelSelectUser": "Select user",
"LabelSelectUsers": "Select users", "LabelSelectUsers": "Select users",
"LabelSendEbookToDevice": "Send Ebook to...", "LabelSendEbookToDevice": "Send Ebook to...",
"LabelSequence": "Sequence", "LabelSequence": "Sequence",
@ -1046,6 +1049,7 @@
"ToastMustHaveAtLeastOnePath": "Must have at least one path", "ToastMustHaveAtLeastOnePath": "Must have at least one path",
"ToastNameEmailRequired": "Name and email are required", "ToastNameEmailRequired": "Name and email are required",
"ToastNameRequired": "Name is required", "ToastNameRequired": "Name is required",
"ToastNewApiKeyUserError": "Must select a user",
"ToastNewEpisodesFound": "{0} new episodes found", "ToastNewEpisodesFound": "{0} new episodes found",
"ToastNewUserCreatedFailed": "Failed to create account: \"{0}\"", "ToastNewUserCreatedFailed": "Failed to create account: \"{0}\"",
"ToastNewUserCreatedSuccess": "New account created", "ToastNewUserCreatedSuccess": "New account created",

View file

@ -65,7 +65,9 @@ class Auth {
new JwtStrategy( new JwtStrategy(
{ {
jwtFromRequest: ExtractJwt.fromExtractors([ExtractJwt.fromAuthHeaderAsBearerToken(), ExtractJwt.fromUrlQueryParameter('token')]), 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) this.jwtAuthCheck.bind(this)
) )
@ -1044,6 +1046,7 @@ class Auth {
} }
await Database.updateServerSettings() 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 // New token secret creation added in v2.1.0 so generate new API tokens for each user
const users = await Database.userModel.findAll({ const users = await Database.userModel.findAll({
attributes: ['id', 'username', 'token'] 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 {Object} jwt_payload
* @param {function} done * @param {function} done
*/ */
async jwtAuthCheck(jwt_payload, done) { async jwtAuthCheck(jwt_payload, done) {
// load user by id from the jwt token if (jwt_payload.type === 'api') {
const user = await Database.userModel.getUserByIdOrOldId(jwt_payload.userId) const apiKey = await Database.apiKeyModel.getById(jwt_payload.keyId)
if (!user?.isActive) { if (!apiKey?.isActive) {
// deny login done(null, null)
done(null, null) return
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
} }
/** /**

View file

@ -670,6 +670,7 @@ class Database {
* Remove playback sessions that are 3 seconds or less * Remove playback sessions that are 3 seconds or less
* Remove duplicate mediaProgresses * Remove duplicate mediaProgresses
* Remove expired auth sessions * Remove expired auth sessions
* Deactivate expired api keys
*/ */
async cleanDatabase() { async cleanDatabase() {
// Remove invalid Podcast records // Remove invalid Podcast records
@ -802,6 +803,23 @@ WHERE EXISTS (
// Remove expired Session records // Remove expired Session records
await this.cleanupExpiredSessions() 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}`)
}
} }
/** /**

View file

@ -20,7 +20,19 @@ class ApiKeyController {
* @param {Response} res * @param {Response} res
*/ */
async getAll(req, 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({ return res.json({
apiKeys: apiKeys.map((a) => a.toJSON()) apiKeys: apiKeys.map((a) => a.toJSON())
@ -42,10 +54,21 @@ class ApiKeyController {
Logger.warn(`[ApiKeyController] create: Invalid expiresIn: ${req.body.expiresIn}`) Logger.warn(`[ApiKeyController] create: Invalid expiresIn: ${req.body.expiresIn}`)
return res.sendStatus(400) 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 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) const apiKey = await Database.apiKeyModel.generateApiKey(keyId, req.body.name, req.body.expiresIn)
if (!apiKey) { if (!apiKey) {
@ -60,9 +83,9 @@ class ApiKeyController {
id: keyId, id: keyId,
name: req.body.name, name: req.body.name,
expiresAt, expiresAt,
permissions, userId: req.body.userId,
userId: req.user.id, isActive: !!req.body.isActive,
isActive: !!req.body.isActive createdByUserId: req.user.id
}) })
Logger.info(`[ApiKeyController] Created API key "${apiKeyInstance.name}"`) Logger.info(`[ApiKeyController] Created API key "${apiKeyInstance.name}"`)
@ -76,34 +99,64 @@ class ApiKeyController {
/** /**
* PATCH: /api/api-keys/:id * 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 {RequestWithUser} req
* @param {Response} res * @param {Response} res
*/ */
async update(req, 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) { if (!apiKey) {
return res.sendStatus(404) 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 (req.body.isActive !== undefined) {
if (typeof req.body.isActive !== 'boolean') { if (typeof req.body.isActive !== 'boolean') {
return res.sendStatus(400) return res.sendStatus(400)
} }
if (apiKey.isActive !== req.body.isActive) {
apiKey.isActive = req.body.isActive apiKey.isActive = req.body.isActive
hasUpdates = true
}
} }
if (req.body.permissions && Object.keys(req.body.permissions).length > 0) { if (hasUpdates) {
const permissions = Database.apiKeyModel.mergePermissionsWithDefault(req.body.permissions) await apiKey.save()
apiKey.permissions = permissions 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({ return res.json({
apiKey: apiKey.toJSON() apiKey: apiKey.toJSON()
}) })

View file

@ -36,6 +36,7 @@ class CronManager {
* Closes open share sessions that have not been updated in 24 hours * Closes open share sessions that have not been updated in 24 hours
* Closes open playback sessions that have not been updated in 36 hours * Closes open playback sessions that have not been updated in 36 hours
* Cleans up expired auth sessions * 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 * TODO: Clients should re-open the session if it is closed so that stale sessions can be closed sooner
*/ */
initOpenSessionCleanupCron() { initOpenSessionCleanupCron() {
@ -44,6 +45,7 @@ class CronManager {
ShareManager.closeStaleOpenShareSessions() ShareManager.closeStaleOpenShareSessions()
await this.playbackSessionManager.closeStaleOpenSessions() await this.playbackSessionManager.closeStaleOpenSessions()
await Database.cleanupExpiredSessions() await Database.cleanupExpiredSessions()
await Database.deactivateExpiredApiKeys()
}) })
} }

View file

@ -80,7 +80,11 @@ async function up({ context: { queryInterface, logger } }) {
defaultValue: DataTypes.UUIDV4, defaultValue: DataTypes.UUIDV4,
primaryKey: true primaryKey: true
}, },
name: DataTypes.STRING, name: {
type: DataTypes.STRING,
allowNull: false
},
description: DataTypes.TEXT,
expiresAt: DataTypes.DATE, expiresAt: DataTypes.DATE,
lastUsedAt: DataTypes.DATE, lastUsedAt: DataTypes.DATE,
isActive: { isActive: {
@ -105,6 +109,17 @@ async function up({ context: { queryInterface, logger } }) {
}, },
key: 'id' key: 'id'
}, },
onDelete: 'CASCADE'
},
createdByUserId: {
type: DataTypes.UUID,
references: {
model: {
tableName: 'users',
as: 'createdByUser'
},
key: 'id'
},
onDelete: 'SET NULL' onDelete: 'SET NULL'
} }
}) })

View file

@ -1,5 +1,6 @@
const { DataTypes, Model, Op } = require('sequelize') const { DataTypes, Model, Op } = require('sequelize')
const jwt = require('jsonwebtoken') const jwt = require('jsonwebtoken')
const { LRUCache } = require('lru-cache')
const Logger = require('../Logger') const Logger = require('../Logger')
/** /**
@ -17,6 +18,32 @@ const Logger = require('../Logger')
* @property {string[]} itemTagsSelected * @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 { class ApiKey extends Model {
constructor(values, options) { constructor(values, options) {
super(values, options) super(values, options)
@ -25,13 +52,15 @@ class ApiKey extends Model {
this.id this.id
/** @type {string} */ /** @type {string} */
this.name this.name
/** @type {string} */
this.description
/** @type {Date} */ /** @type {Date} */
this.expiresAt this.expiresAt
/** @type {Date} */ /** @type {Date} */
this.lastUsedAt this.lastUsedAt
/** @type {boolean} */ /** @type {boolean} */
this.isActive this.isActive
/** @type {Object} */ /** @type {ApiKeyPermissions} */
this.permissions this.permissions
/** @type {Date} */ /** @type {Date} */
this.createdAt this.createdAt
@ -39,6 +68,8 @@ class ApiKey extends Model {
this.updatedAt this.updatedAt
/** @type {UUIDV4} */ /** @type {UUIDV4} */
this.userId this.userId
/** @type {UUIDV4} */
this.createdByUserId
// Expanded properties // Expanded properties
@ -104,18 +135,24 @@ class ApiKey extends Model {
} }
/** /**
* Clean up expired api keys from the database * Deactivate expired api keys
* @returns {Promise<number>} Number of api keys deleted * @returns {Promise<number>} Number of api keys affected
*/ */
static async cleanupExpiredApiKeys() { static async deactivateExpiredApiKeys() {
const deletedCount = await ApiKey.destroy({ const [affectedCount] = await ApiKey.update(
where: { {
expiresAt: { isActive: false
[Op.lt]: new Date() },
{
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 * Initialize model
* @param {import('../Database').sequelize} sequelize * @param {import('../Database').sequelize} sequelize
@ -164,7 +219,11 @@ class ApiKey extends Model {
defaultValue: DataTypes.UUIDV4, defaultValue: DataTypes.UUIDV4,
primaryKey: true primaryKey: true
}, },
name: DataTypes.STRING, name: {
type: DataTypes.STRING,
allowNull: false
},
description: DataTypes.TEXT,
expiresAt: DataTypes.DATE, expiresAt: DataTypes.DATE,
lastUsedAt: DataTypes.DATE, lastUsedAt: DataTypes.DATE,
isActive: { isActive: {
@ -182,9 +241,30 @@ class ApiKey extends Model {
const { user } = sequelize.models const { user } = sequelize.models
user.hasMany(ApiKey, { user.hasMany(ApiKey, {
onDelete: 'SET NULL' onDelete: 'CASCADE'
}) })
ApiKey.belongsTo(user) 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)
} }
} }