diff --git a/client/components/admin.vue b/client/components/admin.vue index fbb09d9061ba317bff055b7df669d4302bc10404..4a8fee1a255a5014c3a8c14ca4b849b2ab3c7277 100644 --- a/client/components/admin.vue +++ b/client/components/admin.vue @@ -83,8 +83,8 @@ template(v-if='hasPermission([`manage:system`, `manage:api`])') v-divider.my-2 v-subheader.pl-4 {{ $t('admin:nav.system') }} - v-list-item(to='/api', v-if='hasPermission([`manage:system`, `manage:api`])', disabled) - v-list-item-avatar(size='24', tile): v-icon(color='grey lighten-2') mdi-call-split + v-list-item(to='/api', v-if='hasPermission([`manage:system`, `manage:api`])') + v-list-item-avatar(size='24', tile): v-icon mdi-call-split v-list-item-title {{ $t('admin:api.title') }} v-list-item(to='/mail', color='primary', v-if='hasPermission(`manage:system`)') v-list-item-avatar(size='24', tile): v-icon mdi-email-multiple-outline diff --git a/client/components/admin/admin-api-create.vue b/client/components/admin/admin-api-create.vue new file mode 100644 index 0000000000000000000000000000000000000000..c7218dffcae3b21f29d8a4f8440f93468129c224 --- /dev/null +++ b/client/components/admin/admin-api-create.vue @@ -0,0 +1,236 @@ +<template lang="pug"> + div + v-dialog(v-model='isShown', max-width='650', persistent) + v-card + .dialog-header.is-short + v-icon.mr-3(color='white') mdi-plus + span {{$t('admin:api.newKeyTitle')}} + v-card-text.pt-5 + v-text-field( + outlined + prepend-icon='mdi-format-title' + v-model='name' + :label='$t(`admin:api.newKeyName`)' + persistent-hint + ref='keyNameInput' + :hint='$t(`admin:api.newKeyNameHint`)' + counter='255' + ) + v-select.mt-3( + :items='expirations' + outlined + prepend-icon='mdi-clock' + v-model='expiration' + :label='$t(`admin:api.newKeyExpiration`)' + :hint='$t(`admin:api.newKeyExpirationHint`)' + persistent-hint + ) + v-divider.mt-4 + v-subheader.pl-2: strong.indigo--text {{$t('admin:api.newKeyPermissionScopes')}} + v-list.pl-8(nav) + v-list-item-group(v-model='fullAccess') + v-list-item( + :value='true' + active-class='indigo--text' + ) + template(v-slot:default='{ active, toggle }') + v-list-item-action + v-checkbox( + :input-value='active' + :true-value='true' + color='indigo' + @click='toggle' + ) + v-list-item-content + v-list-item-title {{$t('admin:api.newKeyFullAccess')}} + v-divider.mt-3 + v-subheader.caption.indigo--text {{$t('admin:api.newKeyGroupPermissions')}} + v-list-item + v-select( + :disabled='fullAccess' + :items='groups' + item-text='name' + item-value='id' + outlined + color='indigo' + v-model='group' + :label='$t(`admin:api.newKeyGroup`)' + :hint='$t(`admin:api.newKeyGroupHint`)' + persistent-hint + ) + v-card-chin + v-spacer + v-btn(text, @click='isShown = false', :disabled='loading') {{$t('common:actions.cancel')}} + v-btn.px-3(depressed, color='primary', @click='generate', :loading='loading') + v-icon(left) mdi-chevron-right + span {{$t('common:actions.generate')}} + + v-dialog( + v-model='isCopyKeyDialogShown' + max-width='750' + persistent + overlay-color='blue darken-5' + overlay-opacity='.9' + ) + v-card + v-toolbar(dense, flat, color='primary', dark) {{$t('admin:api.newKeyTitle')}} + v-card-text.pt-5 + .body-2.text-center + i18next(tag='span', path='admin:api.newKeyCopyWarn') + strong(place='bold') {{$t('admin:api.newKeyCopyWarnBold')}} + v-textarea.mt-3( + ref='keyContentsIpt' + filled + no-resize + readonly + v-model='key' + :rows='10' + hide-details + ) + v-card-chin + v-spacer + v-btn.px-3(depressed, dark, color='primary', @click='isCopyKeyDialogShown = false') {{$t('common:actions.close')}} +</template> + +<script> +import _ from 'lodash' +import gql from 'graphql-tag' + +import groupsQuery from 'gql/admin/users/users-query-groups.gql' + +export default { + props: { + value: { + type: Boolean, + default: false + } + }, + data() { + return { + loading: false, + name: '', + expiration: '1y', + fullAccess: true, + groups: [], + group: null, + isCopyKeyDialogShown: false, + key: '' + } + }, + computed: { + isShown: { + get() { return this.value }, + set(val) { this.$emit('input', val) } + }, + expirations() { + return [ + { value: '30d', text: this.$t('admin:api.expiration30d') }, + { value: '90d', text: this.$t('admin:api.expiration90d') }, + { value: '180d', text: this.$t('admin:api.expiration180d') }, + { value: '1y', text: this.$t('admin:api.expiration1y') }, + { value: '3y', text: this.$t('admin:api.expiration3y') } + ] + } + }, + watch: { + value (newValue, oldValue) { + if (newValue) { + setTimeout(() => { + this.$refs.keyNameInput.focus() + }, 400) + } + } + }, + methods: { + async generate () { + try { + if (_.trim(this.name).length < 2 || this.name.length > 255) { + throw new Error(this.$t('admin:api.newKeyNameError')) + } else if (!this.fullAccess && !this.group) { + throw new Error(this.$t('admin:api.newKeyGroupError')) + } else if (!this.fullAccess && this.group === 2) { + throw new Error(this.$t('admin:api.newKeyGuestGroupError')) + } + } catch (err) { + return this.$store.commit('showNotification', { + style: 'red', + message: err, + icon: 'alert' + }) + } + + this.loading = true + + try { + const resp = await this.$apollo.mutate({ + mutation: gql` + mutation ($name: String!, $expiration: String!, $fullAccess: Boolean!, $group: Int) { + authentication { + createApiKey (name: $name, expiration: $expiration, fullAccess: $fullAccess, group: $group) { + key + responseResult { + succeeded + errorCode + slug + message + } + } + } + } + `, + variables: { + name: this.name, + expiration: this.expiration, + fullAccess: (this.fullAccess === true), + group: this.group + }, + watchLoading (isLoading) { + this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-api-create') + } + }) + if (_.get(resp, 'data.authentication.createApiKey.responseResult.succeeded', false)) { + this.$store.commit('showNotification', { + style: 'success', + message: this.$t('admin:api.newKeySuccess'), + icon: 'check' + }) + + this.name = '' + this.expiration = '1y' + this.fullAccess = true + this.group = null + this.isShown = false + this.$emit('refresh') + + this.key = _.get(resp, 'data.authentication.createApiKey.key', '???') + this.isCopyKeyDialogShown = true + + setTimeout(() => { + this.$refs.keyContentsIpt.$refs.input.select() + }, 400) + } else { + this.$store.commit('showNotification', { + style: 'red', + message: _.get(resp, 'data.authentication.createApiKey.responseResult.message', 'An unexpected error occured.'), + icon: 'alert' + }) + } + } catch (err) { + this.$store.commit('pushGraphError', err) + } + + this.loading = false + } + }, + apollo: { + groups: { + query: groupsQuery, + fetchPolicy: 'network-only', + update: (data) => data.groups.list, + watchLoading (isLoading) { + this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-api-groups-refresh') + } + } + } +} +</script> diff --git a/client/components/admin/admin-api.vue b/client/components/admin/admin-api.vue index a48b05d802205495491e25799299a3675d98bbf9..552ab66f6ed68acf3def829061834e6cef92b160 100644 --- a/client/components/admin/admin-api.vue +++ b/client/components/admin/admin-api.vue @@ -3,128 +3,232 @@ v-layout(row, wrap) v-flex(xs12) .admin-header - img(src='/svg/icon-rest-api.svg', alt='API', style='width: 80px;') + img.animated.fadeInUp(src='/svg/icon-rest-api.svg', alt='API', style='width: 80px;') .admin-header-title - .headline.blue--text.text--darken-2 API Access - .subtitle-1.grey--text Manage keys to access the API #[v-chip(label, color='primary', small).white--text coming soon] + .headline.primary--text.animated.fadeInLeft {{$t('admin:api.title')}} + .subtitle-1.grey--text.animated.fadeInLeft {{$t('admin:api.subtitle')}} v-spacer - v-btn(outline, color='grey', large, @click='refresh', disabled) - v-icon refresh - v-btn(color='green', disabled, depressed, large, @click='globalSwitch') - v-icon(left) power_settings_new - | Enable API - v-btn(color='primary', depressed, large, @click='newKey', disabled) - v-icon(left) add - | New API Key - v-card.mt-3 - v-data-table( - v-model='selected' - :items='items', - :headers='headers', - :search='search', - :pagination.sync='pagination', - :rows-per-page-items='[15]' - select-all, - hide-actions, - disable-initial-sort - ) - template(slot='headers', slot-scope='props') - tr - th(width='50') - th.text-xs-right( - width='80' - :class='[`column sortable`, pagination.descending ? `desc` : `asc`, pagination.sortBy === `id` ? `active` : ``]' - @click='changeSort(`id`)' - ) - v-icon(small) arrow_upward - | ID - th.text-xs-left( - v-for='header in props.headers' - :key='header.text' - :width='header.width' - :class='[`column sortable`, pagination.descending ? `desc` : `asc`, header.value === pagination.sortBy ? `active` : ``]' - @click='changeSort(header.value)' - ) - | {{ header.text }} - v-icon(small) arrow_upward - template(slot='items', slot-scope='props') - tr(:active='props.selected') - td - v-checkbox(hide-details, :input-value='props.selected', color='blue darken-2', @click='props.selected = !props.selected') - td.text-xs-right {{ props.item.id }} - td {{ props.item.name }} - td {{ props.item.key }} - td {{ props.item.createdOn }} - td {{ props.item.updatedOn }} - td: v-btn(icon): v-icon.grey--text.text--darken-1 more_horiz - template(slot='no-data') - v-alert.mt-3(icon='info', :value='true', outline, color='info') No API keys have been generated yet. - .text-xs-center.py-2 - v-pagination(v-model='pagination.page', :length='pages') + template(v-if='enabled') + status-indicator.mr-3(positive, pulse) + .caption.green--text.animated.fadeInLeft {{$t('admin:api.enabled')}} + template(v-else) + status-indicator.mr-3(negative, pulse) + .caption.red--text.animated.fadeInLeft {{$t('admin:api.disabled')}} + v-spacer + v-btn.mr-3.animated.fadeInDown.wait-p2s(outlined, color='grey', large, @click='refresh') + v-icon mdi-refresh + v-btn.mr-3.animated.fadeInDown.wait-p1s(:color='enabled ? `red` : `green`', depressed, large, @click='globalSwitch', dark, :loading='isToggleLoading') + v-icon(left) mdi-power + span(v-if='!enabled') {{$t('admin:api.enableButton')}} + span(v-else) {{$t('admin:api.disableButton')}} + v-btn.animated.fadeInDown(color='primary', depressed, large, @click='newKey', dark) + v-icon(left) mdi-plus + span {{$t('admin:api.newKeyButton')}} + v-card.mt-3.animated.fadeInUp + v-simple-table(v-if='keys && keys.length > 0') + template(v-slot:default) + thead + tr.grey(:class='$vuetify.theme.dark ? `darken-4-d5` : `lighten-5`') + th {{$t('admin:api.headerName')}} + th {{$t('admin:api.headerKeyEnding')}} + th {{$t('admin:api.headerExpiration')}} + th {{$t('admin:api.headerCreated')}} + th {{$t('admin:api.headerLastUpdated')}} + th(width='100') {{$t('admin:api.headerRevoke')}} + tbody + tr(v-for='key of keys', :key='`key-` + key.id') + td + strong(:class='key.isRevoked ? `red--text` : ``') {{ key.name }} + em.caption.ml-1.red--text(v-if='key.isRevoked') (revoked) + td.caption {{ key.keyShort }} + td(:style='key.isRevoked ? `text-decoration: line-through;` : ``') {{ key.expiration | moment('LL') }} + td {{ key.createdAt | moment('calendar') }} + td {{ key.updatedAt | moment('calendar') }} + td: v-btn(icon, @click='revoke(key)', :disabled='key.isRevoked'): v-icon(color='error') mdi-cancel + v-card-text(v-else) + v-alert.mb-0(icon='mdi-information', :value='true', outlined, color='info') {{$t('admin:api.noKeyInfo')}} + + create-api-key(v-model='isCreateDialogShown', @refresh='refresh(false)') + + v-dialog(v-model='isRevokeConfirmDialogShown', max-width='500', persistent) + v-card + .dialog-header.is-red {{$t('admin:api.revokeConfirm')}} + v-card-text.pa-4 + i18next(tag='span', path='admin:api.revokeConfirmText') + strong(place='name') {{ current.name }} + v-card-actions + v-spacer + v-btn(text, @click='isRevokeConfirmDialogShown = false', :disabled='revokeLoading') {{$t('common:actions.cancel')}} + v-btn(color='red', dark, @click='revokeConfirm', :loading='revokeLoading') {{$t('admin:api.revoke')}} </template> <script> +import _ from 'lodash' +import gql from 'graphql-tag' +import { StatusIndicator } from 'vue-status-indicator' + +import CreateApiKey from './admin-api-create.vue' + export default { + components: { + StatusIndicator, + CreateApiKey + }, data() { return { - selected: [], - pagination: {}, - items: [], - headers: [ - { text: 'Name', value: 'name' }, - { text: 'Key', value: 'key' }, - { text: 'Created On', value: 'createdOn' }, - { text: 'Updated On', value: 'updatedOn' }, - { text: '', value: 'actions', sortable: false, width: 50 } - ], - search: '' - } - }, - computed: { - pages () { - if (this.pagination.rowsPerPage == null || this.pagination.totalItems == null) { - return 0 - } - - return Math.ceil(this.pagination.totalItems / this.pagination.rowsPerPage) + enabled: false, + isToggleLoading: false, + keys: [], + isCreateDialogShown: false, + isRevokeConfirmDialogShown: false, + revokeLoading: false, + current: {} } }, methods: { - changeSort (column) { - if (this.pagination.sortBy === column) { - this.pagination.descending = !this.pagination.descending - } else { - this.pagination.sortBy = column - this.pagination.descending = false + async refresh (notify = true) { + this.$apollo.queries.keys.refetch() + if (notify) { + this.$store.commit('showNotification', { + message: this.$t('admin:api.refreshSuccess'), + style: 'success', + icon: 'cached' + }) } }, - toggleAll () { - if (this.selected.length) { - this.selected = [] - } else { - this.selected = this.items.slice() + async globalSwitch () { + this.isToggleLoading = true + try { + const resp = await this.$apollo.mutate({ + mutation: gql` + mutation ($enabled: Boolean!) { + authentication { + setApiState (enabled: $enabled) { + responseResult { + succeeded + errorCode + slug + message + } + } + } + } + `, + variables: { + enabled: !this.enabled + }, + watchLoading (isLoading) { + this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-api-toggle') + } + }) + if (_.get(resp, 'data.authentication.setApiState.responseResult.succeeded', false)) { + this.$store.commit('showNotification', { + style: 'success', + message: this.enabled ? this.$t('admin:api.toggleStateDisabledSuccess') : this.$t('admin:api.toggleStateEnabledSuccess'), + icon: 'check' + }) + await this.$apollo.queries.enabled.refetch() + } else { + this.$store.commit('showNotification', { + style: 'red', + message: _.get(resp, 'data.authentication.setApiState.responseResult.message', 'An unexpected error occured.'), + icon: 'alert' + }) + } + } catch (err) { + this.$store.commit('pushGraphError', err) } + this.isToggleLoading = false }, - async refresh() { - this.$store.commit('showNotification', { - style: 'indigo', - message: `Coming soon...`, - icon: 'directions_boat' - }) + async newKey () { + this.isCreateDialogShown = true }, - async globalSwitch() { - this.$store.commit('showNotification', { - style: 'indigo', - message: `Coming soon...`, - icon: 'directions_boat' - }) + revoke (key) { + this.current = key + this.isRevokeConfirmDialogShown = true + }, + async revokeConfirm () { + this.revokeLoading = true + try { + const resp = await this.$apollo.mutate({ + mutation: gql` + mutation ($id: Int!) { + authentication { + revokeApiKey (id: $id) { + responseResult { + succeeded + errorCode + slug + message + } + } + } + } + `, + variables: { + id: this.current.id + }, + watchLoading (isLoading) { + this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-api-revoke') + } + }) + if (_.get(resp, 'data.authentication.revokeApiKey.responseResult.succeeded', false)) { + this.$store.commit('showNotification', { + style: 'success', + message: this.$t('admin:api.revokeSuccess'), + icon: 'check' + }) + this.refresh(false) + } else { + this.$store.commit('showNotification', { + style: 'red', + message: _.get(resp, 'data.authentication.revokeApiKey.responseResult.message', 'An unexpected error occured.'), + icon: 'alert' + }) + } + } catch (err) { + this.$store.commit('pushGraphError', err) + } + this.isRevokeConfirmDialogShown = false + this.revokeLoading = false + } + }, + apollo: { + enabled: { + query: gql` + { + authentication { + apiState + } + } + `, + fetchPolicy: 'network-only', + update: (data) => data.authentication.apiState, + watchLoading (isLoading) { + this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-api-state-refresh') + } }, - async newKey() { - this.$store.commit('showNotification', { - style: 'indigo', - message: `Coming soon...`, - icon: 'directions_boat' - }) + keys: { + query: gql` + { + authentication { + apiKeys { + id + name + keyShort + expiration + isRevoked + createdAt + updatedAt + } + } + } + `, + fetchPolicy: 'network-only', + update: (data) => data.authentication.apiKeys, + watchLoading (isLoading) { + this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-api-keys-refresh') + } } } } diff --git a/package.json b/package.json index 68c68cc0da1266e82351e99665f7ce21f90128f7..cd63c81023c320d109a8820ea04f64e2f5593cc9 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,7 @@ "moment": "2.24.0", "moment-timezone": "0.5.27", "mongodb": "3.5.2", + "ms": "2.1.2", "mssql": "6.0.1", "multer": "1.4.2", "mysql2": "2.1.0", diff --git a/server/app/data.yml b/server/app/data.yml index f313d25a8abadbaa17f77f9fdb85d58a7085c116..f6f0816bcd56aafddee5d5f070bc43b3edce0830 100644 --- a/server/app/data.yml +++ b/server/app/data.yml @@ -29,6 +29,8 @@ defaults: maxFiles: 10 offline: false # DB defaults + api: + isEnabled: false graphEndpoint: 'https://graph.requarks.io' lang: code: en diff --git a/server/core/auth.js b/server/core/auth.js index 270039e73736050a7bf25531c23fc570ef291199..6f0cde1c2417310d6f753ecfb38160d4ffe2a587 100644 --- a/server/core/auth.js +++ b/server/core/auth.js @@ -17,6 +17,7 @@ module.exports = { cacheExpiration: moment.utc().subtract(1, 'd') }, groups: {}, + validApiKeys: [], /** * Initialize the authentication module @@ -44,6 +45,7 @@ module.exports = { }) this.reloadGroups() + this.reloadApiKeys() return this }, @@ -64,7 +66,8 @@ module.exports = { jwtFromRequest: securityHelper.extractJWT, secretOrKey: WIKI.config.certs.public, audience: WIKI.config.auth.audience, - issuer: 'urn:wiki.js' + issuer: 'urn:wiki.js', + algorithms: ['RS256'] }, (jwtPayload, cb) => { cb(null, jwtPayload) })) @@ -135,6 +138,31 @@ module.exports = { return next() } + // Process API tokens + if (_.has(user, 'api')) { + if (_.includes(WIKI.auth.validApiKeys, user.api)) { + req.user = { + id: 1, + email: 'api@localhost', + name: 'API', + pictureUrl: null, + timezone: 'America/New_York', + localeCode: 'en', + permissions: _.get(WIKI.auth.groups, `${user.grp}.permissions`, []), + groups: [user.grp], + getGlobalPermissions () { + return req.user.permissions + }, + getGroups () { + return req.user.groups + } + } + return next() + } else { + return next(new Error('API Key is invalid or was revoked.')) + } + } + // JWT is valid req.logIn(user, { session: false }, (errc) => { if (errc) { return next(errc) } @@ -248,15 +276,23 @@ module.exports = { /** * Reload Groups from DB */ - async reloadGroups() { + async reloadGroups () { const groupsArray = await WIKI.models.groups.query() this.groups = _.keyBy(groupsArray, 'id') }, + /** + * Reload valid API Keys from DB + */ + async reloadApiKeys () { + const keys = await WIKI.models.apiKeys.query().select('id').where('isRevoked', false).andWhere('expiration', '>', moment.utc().toISOString()) + this.validApiKeys = _.map(keys, 'id') + }, + /** * Generate New Authentication Public / Private Key Certificates */ - async regenerateCertificates() { + async regenerateCertificates () { WIKI.logger.info('Regenerating certificates...') _.set(WIKI.config, 'sessionSecret', (await crypto.randomBytesAsync(32)).toString('hex')) diff --git a/server/db/migrations-sqlite/2.2.3.js b/server/db/migrations-sqlite/2.2.3.js new file mode 100644 index 0000000000000000000000000000000000000000..bb29f975c4e649f2e24732eaa9c9fa2deff32df1 --- /dev/null +++ b/server/db/migrations-sqlite/2.2.3.js @@ -0,0 +1,14 @@ +exports.up = knex => { + return knex.schema + .createTable('apiKeys', table => { + table.increments('id').primary() + table.string('name').notNullable() + table.text('key').notNullable() + table.string('expiration').notNullable() + table.boolean('isRevoked').notNullable().defaultTo(false) + table.string('createdAt').notNullable() + table.string('updatedAt').notNullable() + }) +} + +exports.down = knex => { } diff --git a/server/db/migrations/2.2.3.js b/server/db/migrations/2.2.3.js new file mode 100644 index 0000000000000000000000000000000000000000..29d87c74352d4133b970d2fc8be027a0a456bb28 --- /dev/null +++ b/server/db/migrations/2.2.3.js @@ -0,0 +1,20 @@ +/* global WIKI */ + +exports.up = knex => { + const dbCompat = { + charset: (WIKI.config.db.type === `mysql` || WIKI.config.db.type === `mariadb`) + } + return knex.schema + .createTable('apiKeys', table => { + if (dbCompat.charset) { table.charset('utf8mb4') } + table.increments('id').primary() + table.string('name').notNullable() + table.text('key').notNullable() + table.string('expiration').notNullable() + table.boolean('isRevoked').notNullable().defaultTo(false) + table.string('createdAt').notNullable() + table.string('updatedAt').notNullable() + }) +} + +exports.down = knex => { } diff --git a/server/graph/resolvers/authentication.js b/server/graph/resolvers/authentication.js index eb27b7afd283d07e7c9087fbc98054ff6be3b3a0..a21793e7d6d51c6bfac7cbd9df12800fce6a78b7 100644 --- a/server/graph/resolvers/authentication.js +++ b/server/graph/resolvers/authentication.js @@ -13,6 +13,27 @@ module.exports = { async authentication () { return {} } }, AuthenticationQuery: { + /** + * List of API Keys + */ + async apiKeys (obj, args, context) { + const keys = await WIKI.models.apiKeys.query().orderBy(['isRevoked', 'name']) + return keys.map(k => ({ + id: k.id, + name: k.name, + keyShort: '...' + k.key.substring(k.key.length - 20), + isRevoked: k.isRevoked, + expiration: k.expiration, + createdAt: k.createdAt, + updatedAt: k.updatedAt + })) + }, + /** + * Current API State + */ + apiState () { + return WIKI.config.api.isEnabled + }, /** * Fetch active authentication strategies */ @@ -41,6 +62,19 @@ module.exports = { } }, AuthenticationMutation: { + /** + * Create New API Key + */ + async createApiKey (obj, args, context) { + try { + return { + key: await WIKI.models.apiKeys.createNewKey(args), + responseResult: graphHelper.generateSuccess('API Key created successfully') + } + } catch (err) { + return graphHelper.generateError(err) + } + }, /** * Perform Login */ @@ -101,6 +135,36 @@ module.exports = { return graphHelper.generateError(err) } }, + /** + * Set API state + */ + async setApiState (obj, args, context) { + try { + WIKI.config.api.isEnabled = args.enabled + await WIKI.configSvc.saveToDb(['api']) + return { + responseResult: graphHelper.generateSuccess('API State changed successfully') + } + } catch (err) { + return graphHelper.generateError(err) + } + }, + /** + * Revoke an API key + */ + async revokeApiKey (obj, args, context) { + try { + await WIKI.models.apiKeys.query().findById(args.id).patch({ + isRevoked: true + }) + await WIKI.auth.reloadApiKeys() + return { + responseResult: graphHelper.generateSuccess('API Key revoked successfully') + } + } catch (err) { + return graphHelper.generateError(err) + } + }, /** * Update Authentication Strategies */ diff --git a/server/graph/schemas/authentication.graphql b/server/graph/schemas/authentication.graphql index 712c421db344a2190d5205b562623c0e9eabf29e..263a1aff2c0dcc587776bf0401462434992610f9 100644 --- a/server/graph/schemas/authentication.graphql +++ b/server/graph/schemas/authentication.graphql @@ -15,6 +15,10 @@ extend type Mutation { # ----------------------------------------------- type AuthenticationQuery { + apiKeys: [AuthenticationApiKey] @auth(requires: ["manage:system", "manage:api"]) + + apiState: Boolean! @auth(requires: ["manage:system", "manage:api"]) + strategies( isEnabled: Boolean ): [AuthenticationStrategy] @@ -25,6 +29,13 @@ type AuthenticationQuery { # ----------------------------------------------- type AuthenticationMutation { + createApiKey( + name: String! + expiration: String! + fullAccess: Boolean! + group: Int + ): AuthenticationCreateApiKeyResponse @auth(requires: ["manage:system", "manage:api"]) + login( username: String! password: String! @@ -47,12 +58,21 @@ type AuthenticationMutation { name: String! ): AuthenticationRegisterResponse + revokeApiKey( + id: Int! + ): DefaultResponse @auth(requires: ["manage:system", "manage:api"]) + + setApiState( + enabled: Boolean! + ): DefaultResponse @auth(requires: ["manage:system", "manage:api"]) + updateStrategies( strategies: [AuthenticationStrategyInput]! config: AuthenticationConfigInput ): DefaultResponse @auth(requires: ["manage:system"]) regenerateCertificates: DefaultResponse @auth(requires: ["manage:system"]) + resetGuestUser: DefaultResponse @auth(requires: ["manage:system"]) } @@ -105,3 +125,18 @@ input AuthenticationConfigInput { tokenExpiration: String! tokenRenewal: String! } + +type AuthenticationApiKey { + id: Int! + name: String! + keyShort: String! + expiration: Date! + createdAt: Date! + updatedAt: Date! + isRevoked: Boolean! +} + +type AuthenticationCreateApiKeyResponse { + responseResult: ResponseStatus + key: String +} diff --git a/server/master.js b/server/master.js index b1c1dd75f2dc3e5e6b7e2edc0c2e26346e5e5e9f..ccae1fcede45a6fc6c9bbe1641740ee49ebc63c4 100644 --- a/server/master.js +++ b/server/master.js @@ -167,12 +167,22 @@ module.exports = async () => { }) app.use((err, req, res, next) => { - res.status(err.status || 500) - _.set(res.locals, 'pageMeta.title', 'Error') - res.render('error', { - message: err.message, - error: WIKI.IS_DEBUG ? err : {} - }) + if (req.path === '/graphql') { + res.status(err.status || 500).json({ + data: {}, + errors: [{ + message: err.message, + path: [] + }] + }) + } else { + res.status(err.status || 500) + _.set(res.locals, 'pageMeta.title', 'Error') + res.render('error', { + message: err.message, + error: WIKI.IS_DEBUG ? err : {} + }) + } }) // ---------------------------------------- diff --git a/server/models/apiKeys.js b/server/models/apiKeys.js new file mode 100644 index 0000000000000000000000000000000000000000..0707e318058f6d456ef2cbee98ba871c755cbabd --- /dev/null +++ b/server/models/apiKeys.js @@ -0,0 +1,71 @@ +/* global WIKI */ + +const Model = require('objection').Model +const moment = require('moment') +const ms = require('ms') +const jwt = require('jsonwebtoken') + +/** + * Users model + */ +module.exports = class ApiKey extends Model { + static get tableName() { return 'apiKeys' } + + static get jsonSchema () { + return { + type: 'object', + required: ['name', 'key'], + + properties: { + id: {type: 'integer'}, + name: {type: 'string'}, + key: {type: 'string'}, + expiration: {type: 'string'}, + isRevoked: {type: 'boolean'}, + createdAt: {type: 'string'}, + validUntil: {type: 'string'} + } + } + } + + async $beforeUpdate(opt, context) { + await super.$beforeUpdate(opt, context) + + this.updatedAt = moment.utc().toISOString() + } + async $beforeInsert(context) { + await super.$beforeInsert(context) + + this.createdAt = moment.utc().toISOString() + this.updatedAt = moment.utc().toISOString() + } + + static async createNewKey ({ name, expiration, fullAccess, group }) { + const entry = await WIKI.models.apiKeys.query().insert({ + name, + key: 'pending', + expiration: moment.utc().add(ms(expiration), 'ms').toISOString(), + isRevoked: true + }) + + const key = jwt.sign({ + api: entry.id, + grp: fullAccess ? 1 : group + }, { + key: WIKI.config.certs.private, + passphrase: WIKI.config.sessionSecret + }, { + algorithm: 'RS256', + expiresIn: expiration, + audience: WIKI.config.auth.audience, + issuer: 'urn:wiki.js' + }) + + await WIKI.models.apiKeys.query().findById(entry.id).patch({ + key, + isRevoked: false + }) + + return key + } +} diff --git a/server/models/users.js b/server/models/users.js index 7b5e74668fd223311fc871e37e84526a2c272f33..426efd07ac6b38d540e0bedfac538c0b9e986dd1 100644 --- a/server/models/users.js +++ b/server/models/users.js @@ -26,7 +26,6 @@ module.exports = class User extends Model { name: {type: 'string', minLength: 1, maxLength: 255}, providerId: {type: 'string'}, password: {type: 'string'}, - role: {type: 'string', enum: ['admin', 'guest', 'user']}, tfaIsActive: {type: 'boolean', default: false}, tfaSecret: {type: 'string'}, jobTitle: {type: 'string'}, diff --git a/yarn.lock b/yarn.lock index b6009a94d7a704eaed56eb35ab0e35a96d8a8e40..ff5459b3e8e752fd01b29db67baf74663c21d31c 100644 Binary files a/yarn.lock and b/yarn.lock differ