diff --git a/backend/src/controllers/v1/authController.ts b/backend/src/controllers/v1/authController.ts index 9e16a4021..374c4652c 100644 --- a/backend/src/controllers/v1/authController.ts +++ b/backend/src/controllers/v1/authController.ts @@ -24,6 +24,9 @@ import { validateRequest } from "../../helpers/validation"; import * as reqValidator from "../../validation/auth"; declare module "jsonwebtoken" { + export interface AuthnJwtPayload extends jwt.JwtPayload { + authTokenType: AuthTokenType; + } export interface UserIDJwtPayload extends jwt.JwtPayload { userId: string; refreshVersion?: number; diff --git a/backend/src/helpers/auth.ts b/backend/src/helpers/auth.ts index 6cee9b6e0..14024bd03 100644 --- a/backend/src/helpers/auth.ts +++ b/backend/src/helpers/auth.ts @@ -1,396 +1,13 @@ -import { Request } from "express"; import { Types } from "mongoose"; import jwt from "jsonwebtoken"; -import bcrypt from "bcrypt"; -import { - APIKeyData, - APIKeyDataV2, - ITokenVersion, - IUser, - ServiceTokenData, - ServiceTokenDataV3, - TokenVersion, - User, -} from "../models"; -import { - APIKeyDataNotFoundError, - AccountNotFoundError, - BadRequestError, - ServiceTokenDataNotFoundError, - UnauthorizedRequestError, -} from "../utils/errors"; +import { ITokenVersion, TokenVersion } from "../models"; +import { UnauthorizedRequestError } from "../utils/errors"; import { getAuthSecret, getJwtAuthLifetime, getJwtRefreshLifetime } from "../config"; -import { - AuthMode, - AuthTokenType -} from "../variables"; -import { - ServiceTokenAuthData, - ServiceTokenV3AuthData, - UserAuthData -} from "../interfaces/middleware"; - -import { ActorType } from "../ee/models"; -import { getUserAgentType } from "../utils/posthog"; - -/** - * - * @param {Object} obj - * @param {Object} obj.headers - HTTP request headers object - */ -export const validateAuthMode = async ({ - headers, - acceptedAuthModes, -}: { - headers: { [key: string]: string | string[] | undefined }, - acceptedAuthModes: AuthMode[] -}) => { - - const apiKey = headers["x-api-key"]; - const authHeader = headers["authorization"]; - - let authMode, authTokenValue; - if (apiKey === undefined && authHeader === undefined) { - // case: no auth or X-API-KEY header present - throw BadRequestError({ message: "Missing Authorization or X-API-KEY in request header." }); - } - - if (typeof apiKey === "string") { - // case: treat request authentication type as via X-API-KEY (i.e. API Key) - authMode = AuthMode.API_KEY; - authTokenValue = apiKey; - } - - if (typeof authHeader === "string") { - // case: treat request authentication type as via Authorization header (i.e. either JWT or service token) - const [tokenType, tokenValue] = <[string, string]>authHeader.split(" ", 2) ?? [null, null] - - if (tokenType === null) - throw BadRequestError({ message: "Missing Authorization Header in the request header." }); - if (tokenType.toLowerCase() !== "bearer") - throw BadRequestError({ message: `The provided authentication type '${tokenType}' is not supported.` }); - if (tokenValue === null) - throw BadRequestError({ message: "Missing Authorization Body in the request header." }); - - const parts = tokenValue.split("."); - - switch (parts[0]) { - case "st": - authMode = AuthMode.SERVICE_TOKEN; - authTokenValue = tokenValue; - break; - default: { - const decodedToken = ( - jwt.verify(tokenValue, await getAuthSecret()) - ); - - if (decodedToken.authTokenType === AuthTokenType.SERVICE_ACCESS_TOKEN) { - authMode = AuthMode.SERVICE_ACCESS_TOKEN; - } else { - authMode = AuthMode.JWT; - } - - authTokenValue = tokenValue; - } - } - } - - if (!authMode || !authTokenValue) throw BadRequestError({ message: "Missing valid Authorization or X-API-KEY in request header." }); - - if (!acceptedAuthModes.includes(authMode)) throw BadRequestError({ message: "The provided authentication type is not supported." }); - - return ({ - authMode, - authTokenValue, - }); -} - -/** - * Return user payload corresponding to JWT token [authTokenValue] - * that is either for browser / CLI or API Key - * @param {Object} obj - * @param {String} obj.authTokenValue - JWT token value - * @returns {User} user - user corresponding to JWT token - */ -export const getAuthUserPayload = async ({ - req, - authTokenValue, -}: { - req: Request, - authTokenValue: string; -}): Promise => { - const decodedToken = ( - jwt.verify(authTokenValue, await getAuthSecret()) - ); - - if ( - decodedToken.authTokenType !== AuthTokenType.ACCESS_TOKEN && - decodedToken.authTokenType !== AuthTokenType.API_KEY - ) { - throw UnauthorizedRequestError(); - } - - if (decodedToken.authTokenType === AuthTokenType.ACCESS_TOKEN) { - const tokenVersion = await TokenVersion.findOneAndUpdate({ - _id: new Types.ObjectId(decodedToken.tokenVersionId), - user: decodedToken.userId - }, { - lastUsed: new Date(), - }); - - if (!tokenVersion) throw UnauthorizedRequestError(); - - if (decodedToken.accessVersion !== tokenVersion.accessVersion) throw UnauthorizedRequestError(); - } else if (decodedToken.authTokenType === AuthTokenType.API_KEY) { - const apiKeyData = await APIKeyDataV2.findOneAndUpdate( - { - _id: new Types.ObjectId(decodedToken.apiKeyDataId), - user: new Types.ObjectId(decodedToken.userId) - }, - { - lastUsed: new Date(), - $inc: { usageCount: 1 } - }, - { - new: true - } - ); - - if (!apiKeyData) throw UnauthorizedRequestError(); - } - - const user = await User.findOne({ - _id: new Types.ObjectId(decodedToken.userId), - }).select("+publicKey +accessVersion"); - - if (!user) throw AccountNotFoundError({ message: "Failed to find user" }); - - if (!user?.publicKey) throw UnauthorizedRequestError({ message: "Failed to authenticate user with partially set up account" }); - - return { - actor: { - type: ActorType.USER, - metadata: { - userId: user._id.toString(), - email: user.email - } - }, - authPayload: user, - ipAddress: req.realIP, - userAgent: req.headers["user-agent"] ?? "", - userAgentType: getUserAgentType(req.headers["user-agent"]) - } -} - -/** - * Return service token data payload corresponding to service token [authTokenValue] - * @param {Object} obj - * @param {String} obj.authTokenValue - service token value - * @returns {ServiceTokenData} serviceTokenData - service token data - */ -export const getAuthSTDPayload = async ({ - req, - authTokenValue, -}: { - req: Request, - authTokenValue: string; -}): Promise => { - const [_, TOKEN_IDENTIFIER, TOKEN_SECRET] = <[string, string, string]>authTokenValue.split(".", 3); - - const serviceTokenData = await ServiceTokenData - .findById(TOKEN_IDENTIFIER, "+secretHash +expiresAt") - - 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", - }); - } - - const isMatch = await bcrypt.compare(TOKEN_SECRET, serviceTokenData.secretHash); - if (!isMatch) throw UnauthorizedRequestError({ - message: "Failed to authenticate service token", - }); - - const serviceTokenDataToReturn = await ServiceTokenData - .findOneAndUpdate({ - _id: new Types.ObjectId(TOKEN_IDENTIFIER), - }, { - lastUsed: new Date(), - }, { - new: true, - }) - .select("+encryptedKey +iv +tag") - - if (!serviceTokenDataToReturn) throw ServiceTokenDataNotFoundError({ message: "Failed to find service token data" }); - - return { - actor: { - type: ActorType.SERVICE, - metadata: { - serviceId: serviceTokenDataToReturn._id.toString(), - name: serviceTokenDataToReturn.name - } - }, - authPayload: serviceTokenDataToReturn, - ipAddress: req.realIP, - userAgent: req.headers["user-agent"] ?? "", - userAgentType: getUserAgentType(req.headers["user-agent"]) - } -} - -/** - * Return service token data V3 payload corresponding to service token [authTokenValue] - * @param {Object} obj - * @param {String} obj.authTokenValue - service token value - * @returns {ServiceTokenData} serviceTokenData - service token data - */ - export const getAuthSTDV3Payload = async ({ - req, - authTokenValue, -}: { - req: Request, - authTokenValue: string; -}): Promise => { - - const decodedToken = ( - jwt.verify(authTokenValue, await getAuthSecret()) - ); - - if (decodedToken.authTokenType !== AuthTokenType.SERVICE_ACCESS_TOKEN) throw UnauthorizedRequestError(); - - const serviceTokenData = await ServiceTokenDataV3.findOne({ - _id: new Types.ObjectId(decodedToken.serviceTokenDataId), - isActive: true - }); - - if (!serviceTokenData) { - throw UnauthorizedRequestError({ - message: "Failed to authenticate" - }); - } else if (serviceTokenData?.expiresAt && new Date(serviceTokenData.expiresAt) < new Date()) { - // case: service token expired - await ServiceTokenDataV3.findByIdAndUpdate( - serviceTokenData._id, - { - isActive: false - }, - { - new: true - } - ); - - throw UnauthorizedRequestError({ - message: "Failed to authenticate", - }); - } else if (decodedToken.tokenVersion !== serviceTokenData.tokenVersion) { - // TODO: raise alarm - throw UnauthorizedRequestError({ - message: "Failed to authenticate", - }); - } - - await ServiceTokenDataV3.findByIdAndUpdate( - serviceTokenData._id, - { - accessTokenLastUsed: new Date(), - $inc: { accessTokenUsageCount: 1 } - }, - { - new: true - } - ); - - return { - actor: { - type: ActorType.SERVICE_V3, - metadata: { - serviceId: serviceTokenData._id.toString(), - name: serviceTokenData.name - } - }, - authPayload: serviceTokenData, - ipAddress: req.realIP, - userAgent: req.headers["user-agent"] ?? "", - userAgentType: getUserAgentType(req.headers["user-agent"]) - } -} - -/** - * 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 - */ -export const getAuthAPIKeyPayload = async ({ - req, - authTokenValue, -}: { - req: Request, - authTokenValue: string; -}): Promise => { - const [_, TOKEN_IDENTIFIER, TOKEN_SECRET] = <[string, string, string]>authTokenValue.split(".", 3); - - let apiKeyData = await APIKeyData - .findById(TOKEN_IDENTIFIER, "+secretHash +expiresAt") - .populate<{ user: IUser }>("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", - }); - - apiKeyData = await APIKeyData.findOneAndUpdate({ - _id: new Types.ObjectId(TOKEN_IDENTIFIER), - }, { - lastUsed: new Date(), - }, { - new: true, - }); - - if (!apiKeyData) { - throw APIKeyDataNotFoundError({ message: "Failed to find API key data" }); - } - - const user = await User.findById(apiKeyData.user).select("+publicKey"); - - if (!user) { - throw AccountNotFoundError({ - message: "Failed to find user", - }); - } - - return { - actor: { - type: ActorType.USER, - metadata: { - userId: user._id.toString(), - email: user.email - } - }, - authPayload: user, - ipAddress: req.realIP, - userAgent: req.headers["user-agent"] ?? "", - userAgentType: getUserAgentType(req.headers["user-agent"]) - } -} +import { AuthTokenType } from "../variables"; /** * Return newly issued (JWT) auth and refresh tokens to user with id [userId] diff --git a/backend/src/helpers/secrets.ts b/backend/src/helpers/secrets.ts index 44bbf79f1..fa718484d 100644 --- a/backend/src/helpers/secrets.ts +++ b/backend/src/helpers/secrets.ts @@ -49,7 +49,7 @@ import { import { TelemetryService } from "../services"; import { client, getEncryptionKey, getRootEncryptionKey } from "../config"; import { EEAuditLogService, EELogService, EESecretService } from "../ee/services"; -import { getAuthDataPayloadIdObj, getAuthDataPayloadUserObj } from "../utils/auth/authDataExtractors"; +import { getAuthDataPayloadIdObj, getAuthDataPayloadUserObj } from "../utils/authn/authDataExtractors"; import { getFolderByPath, getFolderIdFromServiceToken } from "../services/FolderService"; import picomatch from "picomatch"; import path from "path"; diff --git a/backend/src/middleware/requireAuth.ts b/backend/src/middleware/requireAuth.ts index 5f3246969..87672feb9 100644 --- a/backend/src/middleware/requireAuth.ts +++ b/backend/src/middleware/requireAuth.ts @@ -1,14 +1,9 @@ import jwt from "jsonwebtoken"; import { NextFunction, Request, Response } from "express"; -import { - getAuthAPIKeyPayload, - getAuthSTDPayload, - getAuthSTDV3Payload, - getAuthUserPayload, - validateAuthMode, -} from "../helpers/auth"; import { AuthMode } from "../variables"; import { AuthData } from "../interfaces/middleware"; +import { extractAuthMode, getAuthData } from "../utils/authn/authMode"; +import { UnauthorizedRequestError } from "../utils/errors"; declare module "jsonwebtoken" { export interface UserIDJwtPayload extends jwt.JwtPayload { @@ -32,42 +27,39 @@ const requireAuth = ({ acceptedAuthModes: AuthMode[]; }) => { return async (req: Request, res: Response, next: NextFunction) => { + + // extract auth mode + const { authMode, authTokenValue } = await extractAuthMode({ + headers: req.headers + }); - // validate auth token against accepted auth modes [acceptedAuthModes] - // and return token type [authTokenType] and value [authTokenValue] - const { authMode, authTokenValue } = await validateAuthMode({ - headers: req.headers, - acceptedAuthModes, + // validate auth mode + if (!acceptedAuthModes.includes(authMode)) throw UnauthorizedRequestError({ + message: "Failed to authenticate unaccepted authentication mode" }); - let authData: AuthData; + // get auth data / payload + const authData: AuthData = await getAuthData({ + authMode, + authTokenValue, + ipAddress: req.realIP, + userAgent: req.headers["user-agent"] ?? "" + }); switch (authMode) { case AuthMode.SERVICE_TOKEN: - authData = await getAuthSTDPayload({ - req, - authTokenValue, - }); req.serviceTokenData = authData.authPayload; break; case AuthMode.SERVICE_ACCESS_TOKEN: - authData = await getAuthSTDV3Payload({ - req, - authTokenValue - }); + req.serviceTokenData = authData.authPayload; break; case AuthMode.API_KEY: - authData = await getAuthAPIKeyPayload({ - req, - authTokenValue - }); + req.user = authData.authPayload; + break; + case AuthMode.API_KEY_V2: req.user = authData.authPayload; break; case AuthMode.JWT: - authData = await getAuthUserPayload({ - req, - authTokenValue - }); req.user = authData.authPayload; break; } diff --git a/backend/src/routes/v3/secrets.ts b/backend/src/routes/v3/secrets.ts index 7aca74e73..81e0cb4c0 100644 --- a/backend/src/routes/v3/secrets.ts +++ b/backend/src/routes/v3/secrets.ts @@ -7,7 +7,7 @@ import { AuthMode } from "../../variables"; router.get( "/raw", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] }), secretsController.getSecretsRaw ); @@ -15,7 +15,7 @@ router.get( router.get( "/raw/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] }), requireBlindIndicesEnabled({ locationWorkspaceId: "query" @@ -29,7 +29,7 @@ router.get( router.post( "/raw/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] }), requireBlindIndicesEnabled({ locationWorkspaceId: "body" @@ -43,7 +43,7 @@ router.post( router.patch( "/raw/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] }), requireBlindIndicesEnabled({ locationWorkspaceId: "body" @@ -57,7 +57,7 @@ router.patch( router.delete( "/raw/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] }), requireBlindIndicesEnabled({ locationWorkspaceId: "body" @@ -71,7 +71,7 @@ router.delete( router.get( "/", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] }), requireBlindIndicesEnabled({ locationWorkspaceId: "query" @@ -83,7 +83,7 @@ router.get( router.post( "/batch", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN] }), requireBlindIndicesEnabled({ locationWorkspaceId: "body" @@ -94,7 +94,7 @@ router.post( router.patch( "/batch", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN] }), requireBlindIndicesEnabled({ locationWorkspaceId: "body" @@ -105,7 +105,7 @@ router.patch( router.delete( "/batch", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN] }), requireBlindIndicesEnabled({ locationWorkspaceId: "body" @@ -116,7 +116,7 @@ router.delete( router.post( "/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] }), requireBlindIndicesEnabled({ locationWorkspaceId: "body" @@ -127,7 +127,7 @@ router.post( router.get( "/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] }), requireBlindIndicesEnabled({ locationWorkspaceId: "query" @@ -138,7 +138,7 @@ router.get( router.patch( "/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] }), requireBlindIndicesEnabled({ locationWorkspaceId: "body" @@ -149,7 +149,7 @@ router.patch( router.delete( "/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.API_KEY_V2, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_ACCESS_TOKEN] }), requireBlindIndicesEnabled({ locationWorkspaceId: "body" diff --git a/backend/src/utils/auth/authDataExtractors/index.ts b/backend/src/utils/authn/authDataExtractors/index.ts similarity index 100% rename from backend/src/utils/auth/authDataExtractors/index.ts rename to backend/src/utils/authn/authDataExtractors/index.ts diff --git a/backend/src/utils/authn/authMode/apiKey.ts b/backend/src/utils/authn/authMode/apiKey.ts new file mode 100644 index 000000000..a1bfd28b8 --- /dev/null +++ b/backend/src/utils/authn/authMode/apiKey.ts @@ -0,0 +1,50 @@ +import { Types } from "mongoose"; +import { + APIKeyData, + IUser, + User +} from "../../../models"; +import { AccountNotFoundError, UnauthorizedRequestError } from "../../errors"; +import bcrypt from "bcrypt"; + +interface ValidateAPIKeyParams { + authTokenValue: string; +} + +export const validateAPIKey = async ({ + authTokenValue +}: ValidateAPIKeyParams) => { + + const [_, TOKEN_IDENTIFIER, TOKEN_SECRET] = <[string, string, string]>authTokenValue.split(".", 3); + + let apiKeyData = await APIKeyData + .findById(TOKEN_IDENTIFIER, "+secretHash +expiresAt") + .populate<{ user: IUser }>("user", "+publicKey"); + + if (!apiKeyData) { + throw UnauthorizedRequestError(); + } else if (apiKeyData?.expiresAt && new Date(apiKeyData.expiresAt) < new Date()) { + // case: API key expired + await APIKeyData.findByIdAndDelete(apiKeyData._id); + throw UnauthorizedRequestError(); + } + + const isMatch = await bcrypt.compare(TOKEN_SECRET, apiKeyData.secretHash); + if (!isMatch) throw UnauthorizedRequestError(); + + apiKeyData = await APIKeyData.findOneAndUpdate({ + _id: new Types.ObjectId(TOKEN_IDENTIFIER), + }, { + lastUsed: new Date(), + }, { + new: true, + }); + + if (!apiKeyData) throw UnauthorizedRequestError(); + + const user = await User.findById(apiKeyData.user).select("+publicKey"); + + if (!user) throw AccountNotFoundError(); + + return user; +} \ No newline at end of file diff --git a/backend/src/utils/authn/authMode/apiKeyV2.ts b/backend/src/utils/authn/authMode/apiKeyV2.ts new file mode 100644 index 000000000..2b57542b9 --- /dev/null +++ b/backend/src/utils/authn/authMode/apiKeyV2.ts @@ -0,0 +1,39 @@ +import jwt from "jsonwebtoken"; +import { APIKeyDataV2, User } from "../../../models"; +import { getAuthSecret } from "../../../config"; +import { AuthTokenType } from "../../../variables"; +import { AccountNotFoundError, UnauthorizedRequestError } from "../../errors"; + +interface ValidateAPIKeyV2Params { + authTokenValue: string; +} + +export const validateAPIKeyV2 = async ({ + authTokenValue +}: ValidateAPIKeyV2Params) => { + + const decodedToken = ( + jwt.verify(authTokenValue, await getAuthSecret()) + ); + + if (decodedToken.authTokenType !== AuthTokenType.API_KEY) throw UnauthorizedRequestError(); + + const apiKeyData = await APIKeyDataV2.findByIdAndUpdate( + decodedToken.apiKeyDataId, + { + lastUsed: new Date(), + $inc: { usageCount: 1 } + }, + { + new: true + } + ); + + if (!apiKeyData) throw UnauthorizedRequestError(); + + const user = await User.findById(apiKeyData.user).select("+publicKey"); + + if (!user) throw AccountNotFoundError(); + + return user; +} diff --git a/backend/src/utils/authn/authMode/helpers.ts b/backend/src/utils/authn/authMode/helpers.ts new file mode 100644 index 000000000..ecb852f61 --- /dev/null +++ b/backend/src/utils/authn/authMode/helpers.ts @@ -0,0 +1,191 @@ +import jwt from "jsonwebtoken"; +import { getAuthSecret } from "../../../config"; +import { ActorType } from "../../../ee/models"; +import { AuthMode, AuthTokenType } from "../../../variables"; +import { UnauthorizedRequestError } from "../../errors"; +import { validateAPIKey } from "./apiKey"; +import { validateAPIKeyV2 } from "./apiKeyV2"; +import { validateServiceTokenV2 } from "./serviceTokenV2"; +import { validateServiceTokenV3 } from "./serviceTokenV3"; +import { validateJWT } from "./jwt"; +import { getUserAgentType } from "../../posthog"; +import { AuthData } from "../../../interfaces/middleware"; + +interface ExtractAuthModeParams { + headers: { [key: string]: string | string[] | undefined } +} + +interface ExtractAuthModeReturn { + authMode: AuthMode; + authTokenValue: string; +} + +interface GetAuthDataParams { + authMode: AuthMode; + authTokenValue: string; + ipAddress: string; + userAgent: string; +} + +/** + * Returns the recognized authentication mode based on token in [headers]; accepted token types include: + * - SERVICE_TOKEN + * - API_KEY + * - JWT + * - SERVICE_ACCESS_TOKEN (from ST V3) + * - API_KEY_V2 + * @param {Object} params + * @param {Object.} params.headers - The HTTP request headers, usually from Express's `req.headers`. + * @returns {Promise} The derived authentication mode based on the headers. + * @throws {UnauthorizedError} Throws an error if no applicable authMode is found. + */ +export const extractAuthMode = async ({ + headers +}: ExtractAuthModeParams): Promise => { + + const apiKey = headers["x-api-key"] as string; + const authHeader = headers["authorization"] as string; + + if (apiKey) { + return { authMode: AuthMode.API_KEY, authTokenValue: apiKey }; + } + + if (!authHeader) throw UnauthorizedRequestError({ + message: "Failed to authenticate unknown authentication method" + }); + + if (!authHeader.startsWith("Bearer ")) throw UnauthorizedRequestError({ + message: "Failed to authenticate unknown authentication method" + }); + + const authTokenValue = authHeader.slice(7); + + if (authTokenValue.startsWith("st.")) { + return { authMode: AuthMode.SERVICE_TOKEN, authTokenValue }; + } + + const decodedToken = ( + jwt.verify(authTokenValue, await getAuthSecret()) + ); + + switch (decodedToken.authTokenType) { + case AuthTokenType.ACCESS_TOKEN: + return { authMode: AuthMode.JWT, authTokenValue }; + case AuthTokenType.API_KEY: + return { authMode: AuthMode.API_KEY_V2, authTokenValue }; + case AuthTokenType.SERVICE_ACCESS_TOKEN: + return { authMode: AuthMode.SERVICE_ACCESS_TOKEN, authTokenValue }; + default: + throw UnauthorizedRequestError({ + message: "Failed to authenticate unknown authentication method" + }); + } +} + +export const getAuthData = async ({ + authMode, + authTokenValue, + ipAddress, + userAgent +}: GetAuthDataParams): Promise => { + + const userAgentType = getUserAgentType(userAgent); + + switch (authMode) { + case AuthMode.SERVICE_TOKEN: { + const serviceTokenData = await validateServiceTokenV2({ + authTokenValue + }); + + return { + actor: { + type: ActorType.SERVICE, + metadata: { + serviceId: serviceTokenData._id.toString(), + name: serviceTokenData.name + } + }, + authPayload: serviceTokenData, + ipAddress, + userAgent, + userAgentType + } + } + case AuthMode.SERVICE_ACCESS_TOKEN: { + const serviceTokenData = await validateServiceTokenV3({ + authTokenValue + }); + + return { + actor: { + type: ActorType.SERVICE_V3, + metadata: { + serviceId: serviceTokenData._id.toString(), + name: serviceTokenData.name + } + }, + authPayload: serviceTokenData, + ipAddress, + userAgent, + userAgentType + } + } + case AuthMode.API_KEY: { + const user = await validateAPIKey({ + authTokenValue + }); + + return { + actor: { + type: ActorType.USER, + metadata: { + userId: user._id.toString(), + email: user.email + } + }, + authPayload: user, + ipAddress, + userAgent, + userAgentType + } + } + case AuthMode.API_KEY_V2: { + const user = await validateAPIKeyV2({ + authTokenValue + }); + + return { + actor: { + type: ActorType.USER, + metadata: { + userId: user._id.toString(), + email: user.email + } + }, + authPayload: user, + ipAddress, + userAgent, + userAgentType + } + } + case AuthMode.JWT: { + const user = await validateJWT({ + authTokenValue + }); + + return { + actor: { + type: ActorType.USER, + metadata: { + userId: user._id.toString(), + email: user.email + } + }, + authPayload: user, + ipAddress, + userAgent, + userAgentType + } + } + } +} \ No newline at end of file diff --git a/backend/src/utils/authn/authMode/index.ts b/backend/src/utils/authn/authMode/index.ts new file mode 100644 index 000000000..7d7262551 --- /dev/null +++ b/backend/src/utils/authn/authMode/index.ts @@ -0,0 +1,4 @@ +export { + extractAuthMode, + getAuthData +} from "./helpers"; \ No newline at end of file diff --git a/backend/src/utils/authn/authMode/jwt.ts b/backend/src/utils/authn/authMode/jwt.ts new file mode 100644 index 000000000..f9f0971fc --- /dev/null +++ b/backend/src/utils/authn/authMode/jwt.ts @@ -0,0 +1,41 @@ +import jwt from "jsonwebtoken"; +import { Types } from "mongoose"; +import { TokenVersion, User } from "../../../models"; +import { getAuthSecret } from "../../../config"; +import { AuthTokenType } from "../../../variables"; +import { AccountNotFoundError, UnauthorizedRequestError } from "../../errors"; + +interface ValidateJWTParams { + authTokenValue: string; +} + +export const validateJWT = async ({ + authTokenValue +}: ValidateJWTParams) => { + + const decodedToken = ( + jwt.verify(authTokenValue, await getAuthSecret()) + ); + + if (decodedToken.authTokenType !== AuthTokenType.ACCESS_TOKEN) throw UnauthorizedRequestError(); + + const tokenVersion = await TokenVersion.findOneAndUpdate({ + _id: new Types.ObjectId(decodedToken.tokenVersionId), + user: decodedToken.userId + }, { + lastUsed: new Date(), + }); + + if (!tokenVersion) throw UnauthorizedRequestError(); + if (decodedToken.accessVersion !== tokenVersion.accessVersion) throw UnauthorizedRequestError(); + + const user = await User.findOne({ + _id: new Types.ObjectId(decodedToken.userId), + }).select("+publicKey"); + + if (!user) throw AccountNotFoundError({ message: "Failed to find user" }); + + if (!user?.publicKey) throw UnauthorizedRequestError({ message: "Failed to authenticate user with partially set up account" }); + + return user; +} diff --git a/backend/src/utils/authn/authMode/serviceTokenV2.ts b/backend/src/utils/authn/authMode/serviceTokenV2.ts new file mode 100644 index 000000000..0ebed5963 --- /dev/null +++ b/backend/src/utils/authn/authMode/serviceTokenV2.ts @@ -0,0 +1,44 @@ +import { Types } from "mongoose"; +import { ServiceTokenData } from "../../../models"; +import { ResourceNotFoundError, UnauthorizedRequestError } from "../../errors"; +import bcrypt from "bcrypt"; + +interface ValidateServiceTokenV2Params { + authTokenValue: string; +} + +export const validateServiceTokenV2 = async ({ + authTokenValue +}: ValidateServiceTokenV2Params) => { + const [_, TOKEN_IDENTIFIER, TOKEN_SECRET] = <[string, string, string]>authTokenValue.split(".", 3); + + const serviceTokenData = await ServiceTokenData + .findById(TOKEN_IDENTIFIER, "+secretHash +expiresAt") + + if (!serviceTokenData) { + throw UnauthorizedRequestError(); + } 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", + }); + } + + const isMatch = await bcrypt.compare(TOKEN_SECRET, serviceTokenData.secretHash); + if (!isMatch) throw UnauthorizedRequestError(); + + const serviceTokenDataToReturn = await ServiceTokenData + .findOneAndUpdate({ + _id: new Types.ObjectId(TOKEN_IDENTIFIER), + }, { + lastUsed: new Date(), + }, { + new: true, + }) + .select("+encryptedKey +iv +tag") + + if (!serviceTokenDataToReturn) throw ResourceNotFoundError(); + + return serviceTokenDataToReturn; +} \ No newline at end of file diff --git a/backend/src/utils/authn/authMode/serviceTokenV3.ts b/backend/src/utils/authn/authMode/serviceTokenV3.ts new file mode 100644 index 000000000..330f60c1c --- /dev/null +++ b/backend/src/utils/authn/authMode/serviceTokenV3.ts @@ -0,0 +1,64 @@ +import jwt from "jsonwebtoken"; +import { Types } from "mongoose"; +import { ServiceTokenDataV3 } from "../../../models"; +import { getAuthSecret } from "../../../config"; +import { AuthTokenType } from "../../../variables"; +import { UnauthorizedRequestError } from "../../errors"; + +interface ValidateServiceTokenV3Params { + authTokenValue: string; +} + +export const validateServiceTokenV3 = async ({ + authTokenValue +}: ValidateServiceTokenV3Params) => { + const decodedToken = ( + jwt.verify(authTokenValue, await getAuthSecret()) + ); + + if (decodedToken.authTokenType !== AuthTokenType.SERVICE_ACCESS_TOKEN) throw UnauthorizedRequestError(); + + const serviceTokenData = await ServiceTokenDataV3.findOne({ + _id: new Types.ObjectId(decodedToken.serviceTokenDataId), + isActive: true + }); + + if (!serviceTokenData) { + throw UnauthorizedRequestError({ + message: "Failed to authenticate" + }); + } else if (serviceTokenData?.expiresAt && new Date(serviceTokenData.expiresAt) < new Date()) { + // case: service token expired + await ServiceTokenDataV3.findByIdAndUpdate( + serviceTokenData._id, + { + isActive: false + }, + { + new: true + } + ); + + throw UnauthorizedRequestError({ + message: "Failed to authenticate", + }); + } else if (decodedToken.tokenVersion !== serviceTokenData.tokenVersion) { + // TODO: raise alarm + throw UnauthorizedRequestError({ + message: "Failed to authenticate", + }); + } + + await ServiceTokenDataV3.findByIdAndUpdate( + serviceTokenData._id, + { + accessTokenLastUsed: new Date(), + $inc: { accessTokenUsageCount: 1 } + }, + { + new: true + } + ); + + return serviceTokenData; +} \ No newline at end of file diff --git a/backend/src/utils/auth/passport/github.ts b/backend/src/utils/authn/passport/github.ts similarity index 100% rename from backend/src/utils/auth/passport/github.ts rename to backend/src/utils/authn/passport/github.ts diff --git a/backend/src/utils/auth/passport/gitlab.ts b/backend/src/utils/authn/passport/gitlab.ts similarity index 100% rename from backend/src/utils/auth/passport/gitlab.ts rename to backend/src/utils/authn/passport/gitlab.ts diff --git a/backend/src/utils/auth/passport/google.ts b/backend/src/utils/authn/passport/google.ts similarity index 100% rename from backend/src/utils/auth/passport/google.ts rename to backend/src/utils/authn/passport/google.ts diff --git a/backend/src/utils/auth/passport/helpers.ts b/backend/src/utils/authn/passport/helpers.ts similarity index 100% rename from backend/src/utils/auth/passport/helpers.ts rename to backend/src/utils/authn/passport/helpers.ts diff --git a/backend/src/utils/auth/passport/index.ts b/backend/src/utils/authn/passport/index.ts similarity index 100% rename from backend/src/utils/auth/passport/index.ts rename to backend/src/utils/authn/passport/index.ts diff --git a/backend/src/utils/auth/passport/saml.ts b/backend/src/utils/authn/passport/saml.ts similarity index 100% rename from backend/src/utils/auth/passport/saml.ts rename to backend/src/utils/authn/passport/saml.ts diff --git a/backend/src/utils/setup/index.ts b/backend/src/utils/setup/index.ts index b07ad8225..58ab0a7ec 100644 --- a/backend/src/utils/setup/index.ts +++ b/backend/src/utils/setup/index.ts @@ -31,7 +31,7 @@ import { initializeGitLabStrategy, initializeGoogleStrategy, initializeSamlStrategy -} from "../auth/passport"; +} from "../authn/passport"; /** * Prepare Infisical upon startup. This includes tasks like: diff --git a/backend/src/variables/authentication.ts b/backend/src/variables/authentication.ts index 2969c88e1..bdad84ff6 100644 --- a/backend/src/variables/authentication.ts +++ b/backend/src/variables/authentication.ts @@ -15,7 +15,8 @@ export enum AuthMode { JWT = "jwt", SERVICE_TOKEN = "serviceToken", SERVICE_ACCESS_TOKEN = "serviceAccessToken", - API_KEY = "apiKey" + API_KEY = "apiKey", + API_KEY_V2 = "apiKeyV2" } export const K8_USER_AGENT_NAME = "k8-operator" \ No newline at end of file diff --git a/frontend/src/views/Settings/PersonalSettingsPage/PersonalAPIKeyTab/PersonalAPIKeyTab.tsx b/frontend/src/views/Settings/PersonalSettingsPage/PersonalAPIKeyTab/PersonalAPIKeyTab.tsx index c27e280a5..035f0b322 100644 --- a/frontend/src/views/Settings/PersonalSettingsPage/PersonalAPIKeyTab/PersonalAPIKeyTab.tsx +++ b/frontend/src/views/Settings/PersonalSettingsPage/PersonalAPIKeyTab/PersonalAPIKeyTab.tsx @@ -1,10 +1,10 @@ -// import { APIKeyV2Section } from "../APIKeyV2Section"; import { APIKeySection } from "../APIKeySection"; +import { APIKeyV2Section } from "../APIKeyV2Section"; export const PersonalAPIKeyTab = () => { return ( <> - {/* */} + ); diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectServiceTokensTab/ProjectServiceTokensTab.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectServiceTokensTab/ProjectServiceTokensTab.tsx index d4f8e79e7..12312dc78 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectServiceTokensTab/ProjectServiceTokensTab.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectServiceTokensTab/ProjectServiceTokensTab.tsx @@ -1,10 +1,10 @@ import { ServiceTokenSection } from "../ServiceTokenSection"; -// import { ServiceTokenV3Section } from "../ServiceTokenV3Section"; +import { ServiceTokenV3Section } from "../ServiceTokenV3Section"; export const ProjectServiceTokensTab = () => { return ( <> - {/* */} + );