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/api/models/User.js b/server/api/models/User.js index f1089a77..7aa20e93 100755 --- a/server/api/models/User.js +++ b/server/api/models/User.js @@ -204,6 +204,19 @@ module.exports = { type: 'ref', columnName: 'password_changed_at', }, + apiKeyPrefix: { + type: 'string', + columnName: 'api_key_prefix', + isNotEmptyString: true, + allowNull: true, + unique: true, + }, + apiKeyHash: { + type: 'string', + columnName: 'api_key_hash', + isNotEmptyString: true, + allowNull: true, + }, // ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗ // ║╣ ║║║╠╩╗║╣ ║║╚═╗ diff --git a/server/api/policies/is-authenticated.js b/server/api/policies/is-authenticated.js index 9d80367a..d198aa4d 100755 --- a/server/api/policies/is-authenticated.js +++ b/server/api/policies/is-authenticated.js @@ -3,10 +3,29 @@ * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md */ +const bcrypt = require('bcrypt'); + +const API_KEY_HEADER = 'x-api-key'; + module.exports = async function isAuthenticated(req, res, proceed) { - if (!req.currentUser) { + if (req.currentUser) return proceed(); + + const apiKeyHeader = req.headers[API_KEY_HEADER.toLowerCase()]; + if (!apiKeyHeader) { return res.unauthorized('Access token is missing, invalid or expired'); } + if (!apiKeyHeader.includes('.')) return res.unauthorized('Invalid API key'); + + const [prefix] = apiKeyHeader.split('.'); + if (!prefix) return res.unauthorized('Invalid API key'); + + const user = await User.findOne({ apiKeyPrefix: prefix, apiKeyHash: { '!=': null } }); + if (!user) return res.unauthorized('Invalid API key'); + + const isMatch = await bcrypt.compare(apiKeyHeader, user.apiKeyHash); + if (!isMatch) return res.unauthorized('Invalid API key'); + + req.currentUser = user; return proceed(); }; 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', diff --git a/server/db/migrations/20250712223423_add_api_key_fields_to_user.js b/server/db/migrations/20250712223423_add_api_key_fields_to_user.js new file mode 100644 index 00000000..96629b48 --- /dev/null +++ b/server/db/migrations/20250712223423_add_api_key_fields_to_user.js @@ -0,0 +1,19 @@ +/*! + * Copyright (c) 2024 PLANKA Software GmbH + * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md + */ + +exports.up = (knex) => { + return knex.schema.alterTable('user_account', (table) => { + table.string('api_key_prefix', 255).unique().nullable(); + table.string('api_key_hash', 255).nullable(); + }); +}; + +exports.down = (knex) => { + return knex.schema.alterTable('user_account', (table) => { + table.dropUnique(['api_key_prefix']); + table.dropColumn('api_key_prefix'); + table.dropColumn('api_key_hash'); + }); +};