feat: Add API key authentication (#1254)

Closes #945
This commit is contained in:
Samuel
2025-11-06 20:56:48 +01:00
committed by GitHub
parent 5a2564f575
commit b4cbd32bf2
75 changed files with 1501 additions and 94 deletions

View File

@@ -28,6 +28,8 @@
* example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ4...
* 401:
* $ref: '#/components/responses/Unauthorized'
* security:
* - bearerAuth: []
*/
module.exports = {

View File

@@ -0,0 +1,103 @@
/*!
* Copyright (c) 2025 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
/**
* @swagger
* /users/{id}/api-key:
* post:
* summary: Create user API key
* description: Generates a user's API key. The full API key is returned only once and cannot be retrieved again.
* tags:
* - Users
* operationId: createUserApiKey
* parameters:
* - name: id
* in: path
* required: true
* description: ID of the user to create API key for
* schema:
* type: string
* example: "1357158568008091264"
* responses:
* 200:
* description: API key created successfully
* content:
* application/json:
* schema:
* type: object
* required:
* - item
* - included
* properties:
* item:
* $ref: '#/components/schemas/User'
* included:
* type: object
* required:
* - apiKey
* properties:
* apiKey:
* type: string
* description: API key of the user (returned only once)
* example: D89VszVs_oSS6TdDtYmi0j1LhugOioY40dDVssESO
* 400:
* $ref: '#/components/responses/ValidationError'
* 401:
* $ref: '#/components/responses/Unauthorized'
* 404:
* $ref: '#/components/responses/NotFound'
*/
const { idInput } = require('../../../utils/inputs');
const Errors = {
USER_NOT_FOUND: {
userNotFound: 'User not found',
},
};
module.exports = {
inputs: {
id: {
...idInput,
required: true,
},
},
exits: {
userNotFound: {
responseType: 'notFound',
},
},
async fn(inputs) {
const { currentUser } = this.req;
let user = await User.qm.getOneById(inputs.id);
if (!user) {
throw Errors.USER_NOT_FOUND;
}
const { key: apiKey, prefix: apiKeyPrefix } = sails.helpers.utils.generateApiKey();
user = await sails.helpers.users.updateOne.with({
record: user,
values: {
apiKeyPrefix,
apiKeyHash: sails.helpers.utils.hash(apiKey),
},
actorUser: currentUser,
request: this.req,
});
return {
item: sails.helpers.users.presentOne(user, currentUser),
included: {
apiKey,
},
};
},
};

View File

@@ -161,7 +161,7 @@ module.exports = {
throw Errors.USER_NOT_FOUND;
}
if (user.id === currentUser.id) {
if (currentSession && user.id === currentUser.id) {
const { token: accessToken } = sails.helpers.utils.createJwtToken(
user.id,
user.passwordChangedAt,

View File

@@ -59,6 +59,11 @@
* enum: [ar-YE, bg-BG, cs-CZ, da-DK, de-DE, el-GR, en-GB, en-US, es-ES, et-EE, fa-IR, fi-FI, fr-FR, hu-HU, id-ID, it-IT, ja-JP, ko-KR, nl-NL, pl-PL, pt-BR, pt-PT, ro-RO, ru-RU, sk-SK, sr-Cyrl-RS, sr-Latn-RS, sv-SE, tr-TR, uk-UA, uz-UZ, zh-CN, zh-TW]
* description: Preferred language for user interface and notifications
* example: en-US
* apiKey:
* type: object
* nullable: true
* description: API key of the user (only null value to remove API key)
* example: null
* subscribeToOwnCards:
* type: boolean
* description: Whether the user subscribes to their own cards
@@ -167,6 +172,10 @@ module.exports = {
type: 'string',
isIn: User.LANGUAGES,
},
apiKey: {
type: 'json',
custom: _.isNull,
},
subscribeToOwnCards: {
type: 'boolean',
},
@@ -220,6 +229,10 @@ module.exports = {
throw Errors.USER_NOT_FOUND; // Forbidden
}
if (currentUser.role === User.Roles.ADMIN) {
availableInputKeys.push('apiKey');
}
if (_.difference(Object.keys(inputs), availableInputKeys).length > 0) {
throw Errors.NOT_ENOUGH_RIGHTS;
}
@@ -253,6 +266,7 @@ module.exports = {
'phone',
'organization',
'language',
'apiKey',
'subscribeToOwnCards',
'subscribeToCardWhenCommenting',
'turnOffRecentCardHighlighting',

View File

@@ -23,8 +23,10 @@ module.exports = {
..._.omit(inputs.record, [
'password',
'avatar',
'apiKeyHash',
'termsSignature',
'passwordChangedAt',
'apiKeyCreatedAt',
'termsAcceptedAt',
]),
avatar: inputs.record.avatar && {

View File

@@ -38,13 +38,23 @@ module.exports = {
values.email = values.email.toLowerCase();
}
let isOnlyEmailChange = false;
let isOnlyPasswordChange = false;
if (_.isNull(values.apiKey)) {
Object.assign(values, {
apiKeyPrefix: null,
apiKeyHash: null,
apiKeyCreatedAt: null,
});
delete values.apiKey;
}
let isOnlyPrivateFieldsChange = false;
let isOnlyPersonalFieldsChange = false;
let isOnlyPasswordChange = false;
let isDeactivatedChangeToTrue = false;
if (!_.isUndefined(values.email) && Object.keys(values).length === 1) {
isOnlyEmailChange = true;
if (_.difference(Object.keys(values), User.PRIVATE_FIELD_NAMES).length === 0) {
isOnlyPrivateFieldsChange = true;
}
if (_.difference(Object.keys(values), User.PERSONAL_FIELD_NAMES).length === 0) {
@@ -64,6 +74,10 @@ module.exports = {
values.username = values.username.toLowerCase();
}
if (values.apiKeyHash) {
values.apiKeyCreatedAt = new Date().toISOString();
}
if (values.isDeactivated && values.isDeactivated !== inputs.record.isDeactivated) {
isDeactivatedChangeToTrue = true;
}
@@ -154,7 +168,7 @@ module.exports = {
);
});
if (!isOnlyEmailChange) {
if (!isOnlyPrivateFieldsChange) {
if (inputs.record.role === User.Roles.ADMIN && user.role !== User.Roles.ADMIN) {
const managerProjectIds = await sails.helpers.users.getManagerProjectIds(user.id);

View File

@@ -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
*/
module.exports = {
sync: true,
fn() {
const prefix = sails.helpers.utils.generateRandomString(8);
const secret = sails.helpers.utils.generateRandomString(32);
const key = `${prefix}_${secret}`;
return {
key,
prefix,
};
},
};

View File

@@ -0,0 +1,30 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
const crypto = require('crypto');
const CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
module.exports = {
sync: true,
inputs: {
size: {
type: 'number',
required: true,
},
},
fn(inputs) {
const bytes = crypto.randomBytes(inputs.size);
let result = '';
for (let i = 0; i < inputs.size; i += 1) {
result += CHARS[bytes[i] % 62];
}
return result;
},
};

View File

@@ -0,0 +1,21 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
const crypto = require('crypto');
module.exports = {
sync: true,
inputs: {
data: {
type: 'ref',
required: true,
},
},
fn(inputs) {
return crypto.createHash('sha256').update(inputs.data).digest('hex');
},
};

View File

@@ -13,8 +13,9 @@
module.exports = function defineCurrentUserHook(sails) {
const TOKEN_PATTERN = /^Bearer /;
const API_KEY_HEADER_NAME = 'x-api-key';
const getSessionAndUser = async (accessToken, httpOnlyToken) => {
const getSessionAndUserByAccessToken = async (accessToken, httpOnlyToken) => {
let payload;
try {
payload = sails.helpers.utils.verifyJwtToken(accessToken);
@@ -50,6 +51,12 @@ module.exports = function defineCurrentUserHook(sails) {
};
};
const getUserByApiKey = (apiKey) => {
const apiKeyHash = sails.helpers.utils.hash(apiKey);
return User.qm.getOneActiveByApiKeyHash(apiKeyHash);
};
return {
/**
* Runs when this Sails app loads/lifts.
@@ -63,7 +70,8 @@ module.exports = function defineCurrentUserHook(sails) {
before: {
'/api/*': {
async fn(req, res, next) {
const { authorization: authorizationHeader } = req.headers;
const { authorization: authorizationHeader, [API_KEY_HEADER_NAME]: apiKey } =
req.headers;
if (authorizationHeader && TOKEN_PATTERN.test(authorizationHeader)) {
const accessToken = authorizationHeader.replace(TOKEN_PATTERN, '');
@@ -73,7 +81,11 @@ module.exports = function defineCurrentUserHook(sails) {
req.currentUser = User.INTERNAL;
} else {
const { httpOnlyToken } = req.cookies;
const sessionAndUser = await getSessionAndUser(accessToken, httpOnlyToken);
const sessionAndUser = await getSessionAndUserByAccessToken(
accessToken,
httpOnlyToken,
);
if (sessionAndUser) {
const { session, user } = sessionAndUser;
@@ -93,6 +105,20 @@ module.exports = function defineCurrentUserHook(sails) {
}
}
}
} else if (apiKey) {
const user = await getUserByApiKey(apiKey);
if (user) {
if (user.language) {
req.setLocale(user.language);
}
req.currentUser = user;
if (req.isSocket) {
sails.sockets.join(req, `@user:${user.id}`);
}
}
}
return next();
@@ -103,7 +129,10 @@ module.exports = function defineCurrentUserHook(sails) {
const { accessToken, httpOnlyToken } = req.cookies;
if (accessToken) {
const sessionAndUser = await getSessionAndUser(accessToken, httpOnlyToken);
const sessionAndUser = await getSessionAndUserByAccessToken(
accessToken,
httpOnlyToken,
);
if (sessionAndUser) {
const { session, user } = sessionAndUser;
@@ -113,6 +142,16 @@ module.exports = function defineCurrentUserHook(sails) {
currentUser: user,
});
}
} else {
const { [API_KEY_HEADER_NAME]: apiKey } = req.headers;
if (apiKey) {
const user = await getUserByApiKey(apiKey);
if (user) {
req.currentUser = user;
}
}
}
return next();

View File

@@ -78,6 +78,12 @@ const getOneActiveByEmailOrUsername = (emailOrUsername) => {
});
};
const getOneActiveByApiKeyHash = (apiKeyHash) =>
User.findOne({
apiKeyHash,
isDeactivated: false,
});
const updateOne = async (criteria, values) => {
const enforceActiveLimit =
values.isDeactivated === false && sails.config.custom.activeUsersLimit !== null;
@@ -201,6 +207,7 @@ module.exports = {
getOneById,
getOneByEmail,
getOneActiveByEmailOrUsername,
getOneActiveByApiKeyHash,
updateOne,
deleteOne,
};

View File

@@ -100,6 +100,11 @@
* nullable: true
* description: Preferred language for user interface and notifications (personal field)
* example: en-US
* apiKeyPrefix:
* type: string
* nullable: true
* description: Prefix of the API key for display purposes (private field)
* example: D89VszVs
* subscribeToOwnCards:
* type: boolean
* default: false
@@ -235,7 +240,8 @@ const LANGUAGES = [
'zh-TW',
];
const PRIVATE_FIELD_NAMES = ['email', 'isSsoUser'];
// TODO: find better way to handle apiKeyHash and apiKeyCreatedAt
const PRIVATE_FIELD_NAMES = ['email', 'apiKeyPrefix', 'apiKeyHash', 'isSsoUser', 'apiKeyCreatedAt'];
const PERSONAL_FIELD_NAMES = [
'language',
@@ -319,6 +325,18 @@ module.exports = {
isIn: LANGUAGES,
allowNull: true,
},
apiKeyPrefix: {
type: 'string',
isNotEmptyString: true,
allowNull: true,
columnName: 'api_key_prefix',
},
apiKeyHash: {
type: 'string',
isNotEmptyString: true,
allowNull: true,
columnName: 'api_key_hash',
},
subscribeToOwnCards: {
type: 'boolean',
defaultsTo: false,
@@ -377,6 +395,10 @@ module.exports = {
type: 'ref',
columnName: 'password_changed_at',
},
apiKeyCreatedAt: {
type: 'ref',
columnName: 'api_key_created_at',
},
termsAcceptedAt: {
type: 'ref',
columnName: 'terms_accepted_at',

View File

@@ -5,6 +5,7 @@
module.exports = async function isAuthenticated(req, res, proceed) {
if (!req.currentUser) {
// TODO: provide separate error for API keys?
return res.unauthorized('Access token is missing, invalid or expired');
}

View File

@@ -0,0 +1,12 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = async function isSession(req, res, proceed) {
if (!req.currentSession) {
return res.notFound(); // Forbidden
}
return proceed();
};