Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add API Key auth mode #191

Merged
merged 4 commits into from
Jan 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)=>{
Expand Down
106 changes: 106 additions & 0 deletions backend/src/controllers/v2/apiKeyDataController.ts
Original file line number Diff line number Diff line change
@@ -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
});
}
4 changes: 3 additions & 1 deletion backend/src/controllers/v2/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import * as workspaceController from './workspaceController';
import * as serviceTokenDataController from './serviceTokenDataController';
import * as apiKeyDataController from './apiKeyDataController';

export {
workspaceController,
serviceTokenDataController
serviceTokenDataController,
apiKeyDataController
}
69 changes: 60 additions & 9 deletions backend/src/helpers/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand All @@ -40,6 +43,9 @@ const validateAuthMode = ({
case 'st':
authMode = 'serviceToken';
break;
case 'ak':
authMode = 'apiKey';
break;
default:
authMode = 'jwt';
break;
Expand Down Expand Up @@ -106,18 +112,18 @@ 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({
message: 'Failed to authenticate expired service token'
});
}

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'
Expand All @@ -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
Expand Down Expand Up @@ -229,6 +279,7 @@ export {
validateAuthMode,
getAuthUserPayload,
getAuthSTDPayload,
getAuthAPIKeyPayload,
createToken,
issueTokens,
clearTokens
Expand Down
8 changes: 7 additions & 1 deletion backend/src/middleware/requireAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { User, ServiceTokenData } from '../models';
import {
validateAuthMode,
getAuthUserPayload,
getAuthSTDPayload
getAuthSTDPayload,
getAuthAPIKeyPayload
} from '../helpers/auth';
import { BadRequestError } from '../utils/errors';

Expand Down Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions backend/src/models/apiKeyData.ts
Original file line number Diff line number Diff line change
@@ -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<IAPIKeyData>(
{
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<IAPIKeyData>('APIKeyData', apiKeyDataSchema);

export default APIKeyData;
7 changes: 5 additions & 2 deletions backend/src/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -50,5 +51,7 @@ export {
Workspace,
IWorkspace,
ServiceTokenData,
IServiceTokenData
IServiceTokenData,
APIKeyData,
IAPIKeyData
};
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -38,7 +37,6 @@ const serviceTokenDataSchema = new Schema<IServiceTokenData>(
},
secretHash: {
type: String,
unique: true,
required: true,
select: false
},
Expand Down
39 changes: 39 additions & 0 deletions backend/src/routes/v2/apiKeyData.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading