diff --git a/backend/src/app.ts b/backend/src/app.ts index ca6428447a..461ede0c53 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -38,6 +38,7 @@ import { secret as v2SecretRouter, workspace as v2WorkspaceRouter, serviceTokenData as v2ServiceTokenDataRouter, + apiKeyData as v2APIKeyDataRouter, } from './routes/v2'; import { getLogger } from './utils/logger'; @@ -94,6 +95,7 @@ app.use('/api/v1/integration-auth', v1IntegrationAuthRouter); app.use('/api/v2/workspace', v2WorkspaceRouter); app.use('/api/v2/secret', v2SecretRouter); app.use('/api/v2/service-token-data', v2ServiceTokenDataRouter); +app.use('/api/v2/api-key-data', v2APIKeyDataRouter); //* Handle unrouted requests and respond with proper error message as well as status code app.use((req, res, next)=>{ diff --git a/backend/src/controllers/v2/apiKeyDataController.ts b/backend/src/controllers/v2/apiKeyDataController.ts new file mode 100644 index 0000000000..3aacde8aff --- /dev/null +++ b/backend/src/controllers/v2/apiKeyDataController.ts @@ -0,0 +1,106 @@ +import { Request, Response } from 'express'; +import * as Sentry from '@sentry/node'; +import crypto from 'crypto'; +import bcrypt from 'bcrypt'; +import { + APIKeyData +} from '../../models'; +import { + SALT_ROUNDS +} from '../../config'; + +/** + * Return API key data for user with id [req.user_id] + * @param req + * @param res + * @returns + */ +export const getAPIKeyData = async (req: Request, res: Response) => { + let apiKeyData; + try { + apiKeyData = await APIKeyData.find({ + user: req.user._id + }); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to get API key data' + }); + } + + return res.status(200).send({ + apiKeyData + }); +} + +/** + * Create new API key data for user with id [req.user._id] + * @param req + * @param res + */ +export const createAPIKeyData = async (req: Request, res: Response) => { + let apiKey, apiKeyData; + try { + const { name, expiresIn } = req.body; + + const secret = crypto.randomBytes(16).toString('hex'); + const secretHash = await bcrypt.hash(secret, SALT_ROUNDS); + + const expiresAt = new Date(); + expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn); + + apiKeyData = await new APIKeyData({ + name, + expiresAt, + user: req.user._id, + secretHash + }).save(); + + // return api key data without sensitive data + apiKeyData = await APIKeyData.findById(apiKeyData._id); + + if (!apiKeyData) throw new Error('Failed to find API key data'); + + apiKey = `ak.${apiKeyData._id.toString()}.${secret}`; + + } catch (err) { + console.error(err); + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to API key data' + }); + } + + return res.status(200).send({ + apiKey, + apiKeyData + }); +} + +/** + * Delete API key data with id [apiKeyDataId]. + * @param req + * @param res + * @returns + */ +export const deleteAPIKeyData = async (req: Request, res: Response) => { + let apiKeyData; + try { + const { apiKeyDataId } = req.params; + + apiKeyData = await APIKeyData.findByIdAndDelete(apiKeyDataId); + + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to delete API key data' + }); + } + + return res.status(200).send({ + apiKeyData + }); +} \ No newline at end of file diff --git a/backend/src/controllers/v2/index.ts b/backend/src/controllers/v2/index.ts index d4729c15cf..e867f76923 100644 --- a/backend/src/controllers/v2/index.ts +++ b/backend/src/controllers/v2/index.ts @@ -1,7 +1,9 @@ import * as workspaceController from './workspaceController'; import * as serviceTokenDataController from './serviceTokenDataController'; +import * as apiKeyDataController from './apiKeyDataController'; export { workspaceController, - serviceTokenDataController + serviceTokenDataController, + apiKeyDataController } diff --git a/backend/src/helpers/auth.ts b/backend/src/helpers/auth.ts index ad63d41b4c..2b972c09dd 100644 --- a/backend/src/helpers/auth.ts +++ b/backend/src/helpers/auth.ts @@ -3,22 +3,25 @@ import * as Sentry from '@sentry/node'; import bcrypt from 'bcrypt'; import { User, - ServiceTokenData + ServiceTokenData, + APIKeyData } from '../models'; import { JWT_AUTH_LIFETIME, JWT_AUTH_SECRET, JWT_REFRESH_LIFETIME, - JWT_REFRESH_SECRET, - SALT_ROUNDS + JWT_REFRESH_SECRET } from '../config'; import { AccountNotFoundError, ServiceTokenDataNotFoundError, - UnauthorizedRequestError, - BadRequestError + APIKeyDataNotFoundError, + UnauthorizedRequestError } from '../utils/errors'; +// TODO 1: check if API key works +// TODO 2: optimize middleware + /** * Validate that auth token value [authTokenValue] falls under one of * accepted auth modes [acceptedAuthModes]. @@ -40,6 +43,9 @@ const validateAuthMode = ({ case 'st': authMode = 'serviceToken'; break; + case 'ak': + authMode = 'apiKey'; + break; default: authMode = 'jwt'; break; @@ -106,9 +112,11 @@ const getAuthSTDPayload = async ({ // TODO: optimize double query serviceTokenData = await ServiceTokenData - .findById(TOKEN_IDENTIFIER, 'secretHash expiresAt'); + .findById(TOKEN_IDENTIFIER, '+secretHash +expiresAt'); - if (serviceTokenData?.expiresAt && new Date(serviceTokenData.expiresAt) < new Date()) { + if (!serviceTokenData) { + throw ServiceTokenDataNotFoundError({ message: 'Failed to find service token data' }); + } else if (serviceTokenData?.expiresAt && new Date(serviceTokenData.expiresAt) < new Date()) { // case: service token expired await ServiceTokenData.findByIdAndDelete(serviceTokenData._id); throw UnauthorizedRequestError({ @@ -116,8 +124,6 @@ const getAuthSTDPayload = async ({ }); } - if (!serviceTokenData) throw ServiceTokenDataNotFoundError({ message: 'Failed to find service token data' }); - const isMatch = await bcrypt.compare(TOKEN_SECRET, serviceTokenData.secretHash); if (!isMatch) throw UnauthorizedRequestError({ message: 'Failed to authenticate service token' @@ -136,6 +142,50 @@ const getAuthSTDPayload = async ({ return serviceTokenData; } +/** + * Return API key data payload corresponding to API key [authTokenValue] + * @param {Object} obj + * @param {String} obj.authTokenValue - API key value + * @returns {APIKeyData} apiKeyData - API key data + */ +const getAuthAPIKeyPayload = async ({ + authTokenValue +}: { + authTokenValue: string; +}) => { + let user; + try { + const [_, TOKEN_IDENTIFIER, TOKEN_SECRET] = <[string, string, string]>authTokenValue.split('.', 3); + + const apiKeyData = await APIKeyData + .findById(TOKEN_IDENTIFIER, '+secretHash +expiresAt') + .populate('user', '+publicKey'); + + if (!apiKeyData) { + throw APIKeyDataNotFoundError({ message: 'Failed to find API key data' }); + } else if (apiKeyData?.expiresAt && new Date(apiKeyData.expiresAt) < new Date()) { + // case: API key expired + await APIKeyData.findByIdAndDelete(apiKeyData._id); + throw UnauthorizedRequestError({ + message: 'Failed to authenticate expired API key' + }); + } + + const isMatch = await bcrypt.compare(TOKEN_SECRET, apiKeyData.secretHash); + if (!isMatch) throw UnauthorizedRequestError({ + message: 'Failed to authenticate API key' + }); + + user = apiKeyData.user; + } catch (err) { + throw UnauthorizedRequestError({ + message: 'Failed to authenticate API key' + }); + } + + return user; +} + /** * Return newly issued (JWT) auth and refresh tokens to user with id [userId] * @param {Object} obj @@ -229,6 +279,7 @@ export { validateAuthMode, getAuthUserPayload, getAuthSTDPayload, + getAuthAPIKeyPayload, createToken, issueTokens, clearTokens diff --git a/backend/src/middleware/requireAuth.ts b/backend/src/middleware/requireAuth.ts index 8253cb64eb..5d95883a5b 100644 --- a/backend/src/middleware/requireAuth.ts +++ b/backend/src/middleware/requireAuth.ts @@ -4,7 +4,8 @@ import { User, ServiceTokenData } from '../models'; import { validateAuthMode, getAuthUserPayload, - getAuthSTDPayload + getAuthSTDPayload, + getAuthAPIKeyPayload } from '../helpers/auth'; import { BadRequestError } from '../utils/errors'; @@ -53,6 +54,11 @@ const requireAuth = ({ authTokenValue: AUTH_TOKEN_VALUE }); break; + case 'apiKey': + req.user = await getAuthAPIKeyPayload({ + authTokenValue: AUTH_TOKEN_VALUE + }); + break; default: req.user = await getAuthUserPayload({ authTokenValue: AUTH_TOKEN_VALUE diff --git a/backend/src/models/apiKeyData.ts b/backend/src/models/apiKeyData.ts new file mode 100644 index 0000000000..af73b5f695 --- /dev/null +++ b/backend/src/models/apiKeyData.ts @@ -0,0 +1,37 @@ +import { Schema, model, Types } from 'mongoose'; + +export interface IAPIKeyData { + name: string; + user: Types.ObjectId; + expiresAt: Date; + secretHash: string; +} + +const apiKeyDataSchema = new Schema( + { + name: { + type: String, + required: true + }, + user: { + type: Schema.Types.ObjectId, + ref: 'User', + required: true + }, + expiresAt: { + type: Date + }, + secretHash: { + type: String, + required: true, + select: false + } + }, + { + timestamps: true + } +); + +const APIKeyData = model('APIKeyData', apiKeyDataSchema); + +export default APIKeyData; diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index 8e934d5119..72ffca6077 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -14,7 +14,8 @@ import Token, { IToken } from './token'; import User, { IUser } from './user'; import UserAction, { IUserAction } from './userAction'; import Workspace, { IWorkspace } from './workspace'; -import ServiceTokenData, { IServiceTokenData } from './serviceTokenData '; +import ServiceTokenData, { IServiceTokenData } from './serviceTokenData'; +import APIKeyData, { IAPIKeyData } from './apiKeyData'; export { BackupPrivateKey, @@ -50,5 +51,7 @@ export { Workspace, IWorkspace, ServiceTokenData, - IServiceTokenData + IServiceTokenData, + APIKeyData, + IAPIKeyData }; diff --git a/backend/src/models/serviceTokenData .ts b/backend/src/models/serviceTokenData.ts similarity index 93% rename from backend/src/models/serviceTokenData .ts rename to backend/src/models/serviceTokenData.ts index 8e8ae5eaba..612d07bf63 100644 --- a/backend/src/models/serviceTokenData .ts +++ b/backend/src/models/serviceTokenData.ts @@ -1,5 +1,4 @@ import { Schema, model, Types } from 'mongoose'; -import { ENV_DEV, ENV_TESTING, ENV_STAGING, ENV_PROD } from '../variables'; export interface IServiceTokenData { name: string; @@ -38,7 +37,6 @@ const serviceTokenDataSchema = new Schema( }, secretHash: { type: String, - unique: true, required: true, select: false }, diff --git a/backend/src/routes/v2/apiKeyData.ts b/backend/src/routes/v2/apiKeyData.ts new file mode 100644 index 0000000000..07bbcbc441 --- /dev/null +++ b/backend/src/routes/v2/apiKeyData.ts @@ -0,0 +1,39 @@ +import express from 'express'; +const router = express.Router(); +import { + requireAuth, + validateRequest +} from '../../middleware'; +import { param, body } from 'express-validator'; +import { apiKeyDataController } from '../../controllers/v2'; + +router.get( + '/', + requireAuth({ + acceptedAuthModes: ['jwt'] + }), + apiKeyDataController.getAPIKeyData +); + +router.post( + '/', + requireAuth({ + acceptedAuthModes: ['jwt'] + }), + body('name').exists().trim(), + body('expiresIn'), // measured in ms + validateRequest, + apiKeyDataController.createAPIKeyData +); + +router.delete( + '/:apiKeyDataId', + requireAuth({ + acceptedAuthModes: ['jwt'] + }), + param('apiKeyDataId').exists().trim(), + validateRequest, + apiKeyDataController.deleteAPIKeyData +); + +export default router; \ No newline at end of file diff --git a/backend/src/routes/v2/index.ts b/backend/src/routes/v2/index.ts index acf115a924..d0f3833ba3 100644 --- a/backend/src/routes/v2/index.ts +++ b/backend/src/routes/v2/index.ts @@ -1,9 +1,11 @@ import secret from './secret'; import workspace from './workspace'; import serviceTokenData from './serviceTokenData'; +import apiKeyData from './apiKeyData'; export { secret, workspace, - serviceTokenData + serviceTokenData, + apiKeyData } diff --git a/backend/src/routes/v2/workspace.ts b/backend/src/routes/v2/workspace.ts index 52bc8bb8ff..de3feb8bc5 100644 --- a/backend/src/routes/v2/workspace.ts +++ b/backend/src/routes/v2/workspace.ts @@ -71,5 +71,4 @@ router.get( workspaceController.getWorkspaceServiceTokenData ); - export default router; diff --git a/backend/src/types/express/index.d.ts b/backend/src/types/express/index.d.ts index 1568003603..7b98d924f7 100644 --- a/backend/src/types/express/index.d.ts +++ b/backend/src/types/express/index.d.ts @@ -16,6 +16,7 @@ declare global { serviceToken: any; accessToken: any; serviceTokenData: any; + apiKeyData: any; query?: any; } } diff --git a/backend/src/utils/errors.ts b/backend/src/utils/errors.ts index f7310d0beb..9c8ac852b6 100644 --- a/backend/src/utils/errors.ts +++ b/backend/src/utils/errors.ts @@ -143,4 +143,14 @@ export const ServiceTokenDataNotFoundError = (error?: Partial[API KEY DATA ERRORS]<----- +export const APIKeyDataNotFoundError = (error?: Partial) => new RequestError({ + logLevel: error?.logLevel ?? LogLevel.ERROR, + statusCode: error?.statusCode ?? 404, + type: error?.type ?? 'service_token_data_not_found_error', + message: error?.message ?? 'The requested service token data was not found', + context: error?.context, + stack: error?.stack +}) + //* ----->[MISC ERRORS]<-----