Skip to content

Commit

Permalink
Merge pull request #191 from Infisical/api-key
Browse files Browse the repository at this point in the history
Add API Key auth mode
  • Loading branch information
dangtony98 authored Jan 4, 2023
2 parents 58830ea + d3efe35 commit d7dd65b
Show file tree
Hide file tree
Showing 13 changed files with 273 additions and 17 deletions.
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

2 comments on commit d7dd65b

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage report for backend

St.
Category Percentage Covered / Total
🟡 Statements 71.01% 49/69
🔴 Branches 0% 0/5
🔴 Functions 50% 1/2
🟡 Lines 72.06% 49/68

Test suite run success

1 tests passing in 1 suite.

Report generated by 🧪jest coverage report action from d7dd65b

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage report for backend

St.
Category Percentage Covered / Total
🟡 Statements 71.01% 49/69
🔴 Branches 0% 0/5
🔴 Functions 50% 1/2
🟡 Lines 72.06% 49/68

Test suite run success

1 tests passing in 1 suite.

Report generated by 🧪jest coverage report action from d7dd65b

Please sign in to comment.