From 846b0579b303bfcf27d5888b62a2ad4da8f98174 Mon Sep 17 00:00:00 2001 From: Samuel Date: Sun, 13 Jul 2025 01:56:44 +0200 Subject: [PATCH] feat: add routes for creating, cycling and deleting apiKeys --- server/api/controllers/api-key/create.js | 51 +++++++++++++++++++ server/api/controllers/api-key/cycle.js | 51 +++++++++++++++++++ server/api/controllers/api-key/delete.js | 50 ++++++++++++++++++ .../api/helpers/api-key/create-and-store.js | 48 +++++++++++++++++ server/api/helpers/api-key/delete-one.js | 35 +++++++++++++ server/config/policies.js | 4 ++ server/config/routes.js | 4 ++ 7 files changed, 243 insertions(+) create mode 100644 server/api/controllers/api-key/create.js create mode 100644 server/api/controllers/api-key/cycle.js create mode 100644 server/api/controllers/api-key/delete.js create mode 100644 server/api/helpers/api-key/create-and-store.js create mode 100644 server/api/helpers/api-key/delete-one.js diff --git a/server/api/controllers/api-key/create.js b/server/api/controllers/api-key/create.js new file mode 100644 index 00000000..d47156be --- /dev/null +++ b/server/api/controllers/api-key/create.js @@ -0,0 +1,51 @@ +/*! + * Copyright (c) 2025 PLANKA Software GmbH + * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md + */ + +const { idInput } = require('../../../utils/inputs'); + +const Errors = { + USER_NOT_FOUND: { + userNotFound: 'User not found', + }, + ALREADY_EXISTS: { + alreadyExists: 'API key already exists', + }, +}; + +module.exports = { + inputs: { + id: { + ...idInput, + required: true, + }, + }, + + exits: { + userNotFound: { + responseType: 'notFound', + }, + alreadyExists: { + responseType: 'conflict', + }, + }, + + async fn(inputs) { + const { id } = inputs; + + const { apiKey } = await sails.helpers.apiKey.createAndStore + .with({ + id, + cycle: false, + }) + .intercept('alreadyExists', () => Errors.ALREADY_EXISTS) + .intercept('userNotFound', () => Errors.USER_NOT_FOUND); + + return { + item: { + apiKey, + }, + }; + }, +}; diff --git a/server/api/controllers/api-key/cycle.js b/server/api/controllers/api-key/cycle.js new file mode 100644 index 00000000..4e22f15a --- /dev/null +++ b/server/api/controllers/api-key/cycle.js @@ -0,0 +1,51 @@ +/*! + * Copyright (c) 2025 PLANKA Software GmbH + * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md + */ + +const { idInput } = require('../../../utils/inputs'); + +const Errors = { + USER_NOT_FOUND: { + userNotFound: 'User not found', + }, + DOES_NOT_EXIST: { + doesNotExist: 'User does not have an API key', + }, +}; + +module.exports = { + inputs: { + id: { + ...idInput, + required: true, + }, + }, + + exits: { + userNotFound: { + responseType: 'notFound', + }, + doesNotExist: { + responseType: 'notFound', + }, + }, + + async fn(inputs) { + const { id } = inputs; + + const { apiKey } = await sails.helpers.apiKey.createAndStore + .with({ + id, + cycle: true, + }) + .intercept('doesNotExist', () => Errors.DOES_NOT_EXIST) + .intercept('userNotFound', () => Errors.USER_NOT_FOUND); + + return { + item: { + apiKey, + }, + }; + }, +}; diff --git a/server/api/controllers/api-key/delete.js b/server/api/controllers/api-key/delete.js new file mode 100644 index 00000000..50155065 --- /dev/null +++ b/server/api/controllers/api-key/delete.js @@ -0,0 +1,50 @@ +/*! + * Copyright (c) 2025 PLANKA Software GmbH + * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md + */ + +const { idInput } = require('../../../utils/inputs'); + +const Errors = { + USER_NOT_FOUND: { + userNotFound: 'User not found', + }, + DOES_NOT_EXIST: { + doesNotExist: 'User does not have an API key', + }, +}; + +module.exports = { + inputs: { + id: { + ...idInput, + required: true, + }, + }, + + exits: { + userNotFound: { + responseType: 'notFound', + }, + doesNotExist: { + responseType: 'notFound', + }, + }, + + async fn(inputs) { + const { id } = inputs; + + await sails.helpers.apiKey.deleteOne + .with({ + id, + }) + .intercept('doesNotExist', () => Errors.DOES_NOT_EXIST) + .intercept('userNotFound', () => Errors.USER_NOT_FOUND); + + return { + item: { + success: true, + }, + }; + }, +}; diff --git a/server/api/helpers/api-key/create-and-store.js b/server/api/helpers/api-key/create-and-store.js new file mode 100644 index 00000000..12fb892c --- /dev/null +++ b/server/api/helpers/api-key/create-and-store.js @@ -0,0 +1,48 @@ +const bcrypt = require('bcrypt'); +const { v4: uuidv4 } = require('uuid'); +const crypto = require('crypto'); + +const { idInput } = require('../../../utils/inputs'); + +module.exports = { + inputs: { + id: { + ...idInput, + required: true, + }, + cycle: { type: 'boolean', defaultsTo: false }, + }, + + exits: { + userNotFound: {}, + alreadyExists: {}, + doesNotExist: {}, + }, + + async fn(inputs) { + const { id, cycle } = inputs; + + const user = await User.findOne({ id }); + if (!user) throw 'userNotFound'; + if (user.apiKeyHash && !cycle) throw 'alreadyExists'; + if (!user.apiKeyHash && cycle) throw 'doesNotExist'; + + const prefix = `${Number(id).toString(36).padStart(8, '0')}${crypto + .randomBytes(4) + .toString('hex')}`; + + const rawKey = `${prefix}.${uuidv4().replace( + /-/g, + '', + )}${crypto.randomBytes(16).toString('hex')}`; + + const hash = await bcrypt.hash(rawKey, 12); + + await User.updateOne({ id }).set({ + apiKeyPrefix: prefix, + apiKeyHash: hash, + }); + + return { apiKey: rawKey }; + }, +}; diff --git a/server/api/helpers/api-key/delete-one.js b/server/api/helpers/api-key/delete-one.js new file mode 100644 index 00000000..b87a881a --- /dev/null +++ b/server/api/helpers/api-key/delete-one.js @@ -0,0 +1,35 @@ +/*! + * Copyright (c) 2025 PLANKA Software GmbH + * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md + */ + +const { idInput } = require('../../../utils/inputs'); + +module.exports = { + inputs: { + id: { + ...idInput, + required: true, + }, + }, + + exits: { + userNotFound: {}, + doesNotExist: {}, + }, + + async fn(inputs) { + const { id } = inputs; + + const user = await User.findOne({ id }); + if (!user) throw 'userNotFound'; + if (!user.apiKeyHash) throw 'doesNotExist'; + + await User.updateOne({ id }).set({ + apiKeyPrefix: null, + apiKeyHash: null, + }); + + return { success: true }; + }, +}; diff --git a/server/config/policies.js b/server/config/policies.js index 7591d036..e3ce3721 100644 --- a/server/config/policies.js +++ b/server/config/policies.js @@ -33,6 +33,10 @@ module.exports.policies = { 'users/update-avatar': 'is-authenticated', 'users/delete': ['is-authenticated', 'is-admin'], + 'api-keys/create': ['is-authenticated', 'is-admin'], + 'api-keys/cycle': ['is-authenticated', 'is-admin'], + 'api-keys/delete': ['is-authenticated', 'is-admin'], + 'projects/create': ['is-authenticated', 'is-external', 'is-admin-or-project-owner'], 'config/show': true, diff --git a/server/config/routes.js b/server/config/routes.js index acb761ff..c4461cf8 100644 --- a/server/config/routes.js +++ b/server/config/routes.js @@ -83,6 +83,10 @@ module.exports.routes = { 'POST /api/users/:id/avatar': 'users/update-avatar', 'DELETE /api/users/:id': 'users/delete', + 'POST /api/users/:id/api-key': 'api-key/create', + 'PATCH /api/users/:id/api-key': 'api-key/cycle', + 'DELETE /api/users/:id/api-key': 'api-key/delete', + 'GET /api/projects': 'projects/index', 'POST /api/projects': 'projects/create', 'GET /api/projects/:id': 'projects/show',