mirror of
https://github.com/plankanban/planka.git
synced 2025-12-24 09:15:01 +03:00
@@ -28,6 +28,8 @@
|
||||
* example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ4...
|
||||
* 401:
|
||||
* $ref: '#/components/responses/Unauthorized'
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
|
||||
103
server/api/controllers/users/create-api-key.js
Normal file
103
server/api/controllers/users/create-api-key.js
Normal 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,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -23,8 +23,10 @@ module.exports = {
|
||||
..._.omit(inputs.record, [
|
||||
'password',
|
||||
'avatar',
|
||||
'apiKeyHash',
|
||||
'termsSignature',
|
||||
'passwordChangedAt',
|
||||
'apiKeyCreatedAt',
|
||||
'termsAcceptedAt',
|
||||
]),
|
||||
avatar: inputs.record.avatar && {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
19
server/api/helpers/utils/generate-api-key.js
Normal file
19
server/api/helpers/utils/generate-api-key.js
Normal 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,
|
||||
};
|
||||
},
|
||||
};
|
||||
30
server/api/helpers/utils/generate-random-string.js
Normal file
30
server/api/helpers/utils/generate-random-string.js
Normal 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;
|
||||
},
|
||||
};
|
||||
21
server/api/helpers/utils/hash.js
Normal file
21
server/api/helpers/utils/hash.js
Normal 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');
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
12
server/api/policies/is-session.js
Executable file
12
server/api/policies/is-session.js
Executable 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();
|
||||
};
|
||||
Reference in New Issue
Block a user