misc: added monitoring for user auth

This commit is contained in:
Sheen Capadngan
2025-10-30 00:11:38 +08:00
parent 09b9bfaede
commit e40031aeb5
9 changed files with 261 additions and 97 deletions
+2
View File
@@ -136,6 +136,8 @@ declare module "@fastify/request-context" {
interface RequestContextData { interface RequestContextData {
reqId: string; reqId: string;
orgId?: string; orgId?: string;
ip?: string;
userAgent?: string;
orgName?: string; orgName?: string;
userAuthInfo?: { userAuthInfo?: {
userId: string; userId: string;
+31 -9
View File
@@ -7,6 +7,7 @@
// All the any rules are disabled because passport typesense with fastify is really poor // All the any rules are disabled because passport typesense with fastify is really poor
import { Authenticator } from "@fastify/passport"; import { Authenticator } from "@fastify/passport";
import { requestContext } from "@fastify/request-context";
import fastifySession from "@fastify/session"; import fastifySession from "@fastify/session";
import { MultiSamlStrategy } from "@node-saml/passport-saml"; import { MultiSamlStrategy } from "@node-saml/passport-saml";
import { FastifyRequest } from "fastify"; import { FastifyRequest } from "fastify";
@@ -17,6 +18,7 @@ import { ApiDocsTags, SamlSso } from "@app/lib/api-docs";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { SanitizedSamlConfigSchema } from "@app/server/routes/sanitizedSchema/directory-config"; import { SanitizedSamlConfigSchema } from "@app/server/routes/sanitizedSchema/directory-config";
@@ -102,15 +104,15 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
}, },
// eslint-disable-next-line // eslint-disable-next-line
async (req, profile, cb) => { async (req, profile, cb) => {
if (!profile) throw new BadRequestError({ message: "Missing profile" });
const email =
profile?.email ??
// entra sends data in this format
(profile["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/email"] as string) ??
(profile?.emailAddress as string); // emailRippling is added because in Rippling the field `email` reserved\
try { try {
if (!profile) throw new BadRequestError({ message: "Missing profile" });
const email =
profile?.email ??
// entra sends data in this format
(profile["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/email"] as string) ??
(profile?.emailAddress as string); // emailRippling is added because in Rippling the field `email` reserved\
const firstName = (profile.firstName ?? const firstName = (profile.firstName ??
// entra sends data in this format // entra sends data in this format
profile["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/firstName"]) as string; profile["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/firstName"]) as string;
@@ -144,7 +146,7 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
}) })
.filter((el) => el.key && !["email", "firstName", "lastName"].includes(el.key)); .filter((el) => el.key && !["email", "firstName", "lastName"].includes(el.key));
const { isUserCompleted, providerAuthToken } = await server.services.saml.samlLogin({ const { isUserCompleted, providerAuthToken, user, organization } = await server.services.saml.samlLogin({
externalId: profile.nameID, externalId: profile.nameID,
email: email.toLowerCase(), email: email.toLowerCase(),
firstName, firstName,
@@ -154,8 +156,28 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
orgId: (req as unknown as FastifyRequest).ssoConfig?.orgId, orgId: (req as unknown as FastifyRequest).ssoConfig?.orgId,
metadata: userMetadata metadata: userMetadata
}); });
authAttemptCounter.add(1, {
"infisical.user.email": email.toLowerCase(),
"infisical.user.id": user.id,
"infisical.organization.id": organization.id,
"infisical.organization.name": organization.name,
"infisical.auth.method": AuthAttemptAuthMethod.SAML,
"infisical.auth.result": AuthAttemptAuthResult.SUCCESS,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
});
cb(null, { isUserCompleted, providerAuthToken }); cb(null, { isUserCompleted, providerAuthToken });
} catch (error) { } catch (error) {
authAttemptCounter.add(1, {
"infisical.user.email": email.toLowerCase(),
"infisical.auth.method": AuthAttemptAuthMethod.SAML,
"infisical.auth.result": AuthAttemptAuthResult.FAILURE,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
});
logger.error(error); logger.error(error);
cb(error as Error); cb(error as Error);
} }
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-call */
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import { requestContext } from "@fastify/request-context";
import { Issuer, Issuer as OpenIdIssuer, Strategy as OpenIdStrategy, TokenSet } from "openid-client"; import { Issuer, Issuer as OpenIdIssuer, Strategy as OpenIdStrategy, TokenSet } from "openid-client";
import { AccessScope, OrganizationActionScope, OrgMembershipStatus, TableName, TUsers } from "@app/db/schemas"; import { AccessScope, OrganizationActionScope, OrgMembershipStatus, TableName, TUsers } from "@app/db/schemas";
@@ -15,6 +16,7 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { crypto } from "@app/lib/crypto"; import { crypto } from "@app/lib/crypto";
import { BadRequestError, ForbiddenRequestError, NotFoundError, OidcAuthError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, NotFoundError, OidcAuthError } from "@app/lib/errors";
import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics";
import { OrgServiceActor } from "@app/lib/types"; import { OrgServiceActor } from "@app/lib/types";
import { ActorType, AuthMethod, AuthTokenType } from "@app/services/auth/auth-type"; import { ActorType, AuthMethod, AuthTokenType } from "@app/services/auth/auth-type";
import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service";
@@ -471,7 +473,7 @@ export const oidcConfigServiceFactory = ({
}); });
} }
return { isUserCompleted, providerAuthToken }; return { isUserCompleted, providerAuthToken, user };
}; };
const updateOidcCfg = async ({ const updateOidcCfg = async ({
@@ -754,10 +756,31 @@ export const oidcConfigServiceFactory = ({
callbackPort, callbackPort,
manageGroupMemberships: oidcCfg.manageGroupMemberships manageGroupMemberships: oidcCfg.manageGroupMemberships
}) })
.then(({ isUserCompleted, providerAuthToken }) => { .then(({ isUserCompleted, providerAuthToken, user }) => {
authAttemptCounter.add(1, {
"infisical.user.email": claims?.email?.toLowerCase(),
"infisical.user.id": user.id,
"infisical.organization.id": org.id,
"infisical.organization.name": org.name,
"infisical.auth.method": AuthAttemptAuthMethod.OIDC,
"infisical.auth.result": AuthAttemptAuthResult.SUCCESS,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
});
cb(null, { isUserCompleted, providerAuthToken }); cb(null, { isUserCompleted, providerAuthToken });
}) })
.catch((error) => { .catch((error) => {
authAttemptCounter.add(1, {
"infisical.user.email": claims?.email?.toLowerCase(),
"infisical.organization.id": org.id,
"infisical.organization.name": org.name,
"infisical.auth.method": AuthAttemptAuthMethod.OIDC,
"infisical.auth.result": AuthAttemptAuthResult.FAILURE,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
});
cb(error); cb(error);
}); });
} }
@@ -769,7 +769,7 @@ export const samlConfigServiceFactory = ({
}); });
} }
return { isUserCompleted, providerAuthToken }; return { isUserCompleted, providerAuthToken, user, organization };
}; };
return { return {
@@ -1,4 +1,4 @@
import { TSamlConfigs } from "@app/db/schemas"; import { TOrganizations, TSamlConfigs, TUsers } from "@app/db/schemas";
import { TOrgPermission } from "@app/lib/types"; import { TOrgPermission } from "@app/lib/types";
import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type";
@@ -78,5 +78,7 @@ export type TSamlConfigServiceFactory = {
samlLogin: (arg: TSamlLoginDTO) => Promise<{ samlLogin: (arg: TSamlLoginDTO) => Promise<{
isUserCompleted: boolean; isUserCompleted: boolean;
providerAuthToken: string; providerAuthToken: string;
user: TUsers;
organization: TOrganizations;
}>; }>;
}; };
+22
View File
@@ -0,0 +1,22 @@
import opentelemetry from "@opentelemetry/api";
const infisicalMeter = opentelemetry.metrics.getMeter("Infisical");
export enum AuthAttemptAuthMethod {
EMAIL = "email",
SAML = "saml",
OIDC = "oidc",
GOOGLE = "google",
GITHUB = "github",
GITLAB = "gitlab"
}
export enum AuthAttemptAuthResult {
SUCCESS = "success",
FAILURE = "failure"
}
export const authAttemptCounter = infisicalMeter.createCounter("infisical.auth.attempt.count", {
description: "Authentication attempts (both successful and failed)",
unit: "{attempt}"
});
+3 -1
View File
@@ -141,7 +141,9 @@ export const main = async ({
await server.register(fastifyRequestContext, { await server.register(fastifyRequestContext, {
defaultStoreValues: (req) => ({ defaultStoreValues: (req) => ({
reqId: req.id, reqId: req.id,
log: req.log.child({ reqId: req.id }) log: req.log.child({ reqId: req.id }),
ip: req.realIp,
userAgent: req.headers["user-agent"]
}) })
}); });
+100 -36
View File
@@ -7,6 +7,7 @@
// All the any rules are disabled because passport typesense with fastify is really poor // All the any rules are disabled because passport typesense with fastify is really poor
import { Authenticator } from "@fastify/passport"; import { Authenticator } from "@fastify/passport";
import { requestContext } from "@fastify/request-context";
import fastifySession from "@fastify/session"; import fastifySession from "@fastify/session";
import RedisStore from "connect-redis"; import RedisStore from "connect-redis";
import { CronJob } from "cron"; import { CronJob } from "cron";
@@ -21,6 +22,7 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { ms } from "@app/lib/ms"; import { ms } from "@app/lib/ms";
import { fetchGithubEmails, fetchGithubUser } from "@app/lib/requests/github"; import { fetchGithubEmails, fetchGithubUser } from "@app/lib/requests/github";
import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics";
import { authRateLimit } from "@app/server/config/rateLimiter"; import { authRateLimit } from "@app/server/config/rateLimiter";
import { addAuthOriginDomainCookie } from "@app/server/lib/cookie"; import { addAuthOriginDomainCookie } from "@app/server/lib/cookie";
import { AuthMethod } from "@app/services/auth/auth-type"; import { AuthMethod } from "@app/services/auth/auth-type";
@@ -51,30 +53,51 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => {
}, },
// eslint-disable-next-line // eslint-disable-next-line
async (req, _accessToken, _refreshToken, profile, cb) => { async (req, _accessToken, _refreshToken, profile, cb) => {
try { // @ts-expect-error this is because this is express type and not fastify
// @ts-expect-error this is because this is express type and not fastify const callbackPort = req.session.get("callbackPort");
const callbackPort = req.session.get("callbackPort"); // @ts-expect-error this is because this is express type and not fastify
// @ts-expect-error this is because this is express type and not fastify const orgSlug = req.session.get("orgSlug");
const orgSlug = req.session.get("orgSlug");
const email = profile?.emails?.[0]?.value; const email = profile?.emails?.[0]?.value;
if (!email) if (!email)
throw new NotFoundError({ throw new NotFoundError({
message: "Email not found", message: "Email not found",
name: "OauthGoogleRegister" name: "OauthGoogleRegister"
});
try {
const { isUserCompleted, providerAuthToken, user, orgId, orgName } =
await server.services.login.oauth2Login({
email,
firstName: profile?.name?.givenName || "",
lastName: profile?.name?.familyName || "",
authMethod: AuthMethod.GOOGLE,
callbackPort,
orgSlug
}); });
const { isUserCompleted, providerAuthToken } = await server.services.login.oauth2Login({ authAttemptCounter.add(1, {
email, "infisical.user.email": email,
firstName: profile?.name?.givenName || "", "infisical.user.id": user.id,
lastName: profile?.name?.familyName || "", "infisical.organization.id": orgId,
authMethod: AuthMethod.GOOGLE, "infisical.organization.name": orgName,
callbackPort, "infisical.auth.method": AuthAttemptAuthMethod.GOOGLE,
orgSlug "infisical.auth.result": AuthAttemptAuthResult.SUCCESS,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
}); });
cb(null, { isUserCompleted, providerAuthToken }); cb(null, { isUserCompleted, providerAuthToken });
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
authAttemptCounter.add(1, {
"infisical.user.email": email,
"infisical.auth.method": AuthAttemptAuthMethod.GOOGLE,
"infisical.auth.result": AuthAttemptAuthResult.FAILURE,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
});
cb(error as Error, false); cb(error as Error, false);
} }
} }
@@ -101,27 +124,47 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => {
}, },
// eslint-disable-next-line // eslint-disable-next-line
async (req: any, accessToken: string, _refreshToken: string, _profile: any, done: Function) => { async (req: any, accessToken: string, _refreshToken: string, _profile: any, done: Function) => {
const ghEmails = await fetchGithubEmails(accessToken);
const { email } = ghEmails.filter((gitHubEmail) => gitHubEmail.primary)[0];
if (!email) throw new Error("No primary email found");
try { try {
const ghEmails = await fetchGithubEmails(accessToken);
const { email } = ghEmails.filter((gitHubEmail) => gitHubEmail.primary)[0];
if (!email) throw new Error("No primary email found");
// profile does not get automatically populated so we need to manually fetch user info // profile does not get automatically populated so we need to manually fetch user info
const user = await fetchGithubUser(accessToken); const githubUser = await fetchGithubUser(accessToken);
const callbackPort = req.session.get("callbackPort"); const callbackPort = req.session.get("callbackPort");
const { isUserCompleted, providerAuthToken } = await server.services.login.oauth2Login({ const { isUserCompleted, providerAuthToken, user, orgId, orgName } =
email, await server.services.login.oauth2Login({
firstName: user.name || user.login, email,
lastName: "", firstName: githubUser.name || githubUser.login,
authMethod: AuthMethod.GITHUB, lastName: "",
callbackPort authMethod: AuthMethod.GITHUB,
callbackPort
});
authAttemptCounter.add(1, {
"infisical.user.email": email,
"infisical.user.id": user.id,
"infisical.organization.id": orgId,
"infisical.organization.name": orgName,
"infisical.auth.method": AuthAttemptAuthMethod.GITHUB,
"infisical.auth.result": AuthAttemptAuthResult.SUCCESS,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
}); });
done(null, { isUserCompleted, providerAuthToken, externalProviderAccessToken: accessToken }); done(null, { isUserCompleted, providerAuthToken, externalProviderAccessToken: accessToken });
} catch (err) { } catch (err) {
authAttemptCounter.add(1, {
"infisical.user.email": email,
"infisical.auth.method": AuthAttemptAuthMethod.GITHUB,
"infisical.auth.result": AuthAttemptAuthResult.FAILURE,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
});
logger.error(err); logger.error(err);
done(err as Error, false); done(err as Error, false);
} }
@@ -147,20 +190,41 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => {
pkce: true pkce: true
}, },
async (req: any, _accessToken: string, _refreshToken: string, profile: any, cb: any) => { async (req: any, _accessToken: string, _refreshToken: string, profile: any, cb: any) => {
const email = profile.emails[0].value;
try { try {
const callbackPort = req.session.get("callbackPort"); const callbackPort = req.session.get("callbackPort");
const email = profile.emails[0].value; const { isUserCompleted, providerAuthToken, user, orgId, orgName } =
const { isUserCompleted, providerAuthToken } = await server.services.login.oauth2Login({ await server.services.login.oauth2Login({
email, email,
firstName: profile.displayName || profile.username || "", firstName: profile.displayName || profile.username || "",
lastName: "", lastName: "",
authMethod: AuthMethod.GITLAB, authMethod: AuthMethod.GITLAB,
callbackPort callbackPort
});
authAttemptCounter.add(1, {
"infisical.user.email": email,
"infisical.user.id": user.id,
"infisical.organization.id": orgId,
"infisical.organization.name": orgName,
"infisical.auth.method": AuthAttemptAuthMethod.GITLAB,
"infisical.auth.result": AuthAttemptAuthResult.SUCCESS,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
}); });
return cb(null, { isUserCompleted, providerAuthToken }); return cb(null, { isUserCompleted, providerAuthToken });
} catch (error) { } catch (error) {
authAttemptCounter.add(1, {
"infisical.user.email": email,
"infisical.auth.method": AuthAttemptAuthMethod.GITLAB,
"infisical.auth.result": AuthAttemptAuthResult.FAILURE,
"client.address": requestContext.get("ip"),
"user_agent.original": requestContext.get("userAgent")
});
logger.error(error); logger.error(error);
cb(error as Error, false); cb(error as Error, false);
} }
+74 -47
View File
@@ -16,6 +16,7 @@ import { getUserPrivateKey } from "@app/lib/crypto/srp";
import { BadRequestError, DatabaseError, ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors"; import { BadRequestError, DatabaseError, ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors";
import { getMinExpiresIn, removeTrailingSlash } from "@app/lib/fn"; import { getMinExpiresIn, removeTrailingSlash } from "@app/lib/fn";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics";
import { getUserAgentType } from "@app/server/plugins/audit-log"; import { getUserAgentType } from "@app/server/plugins/audit-log";
import { getServerCfg } from "@app/services/super-admin/super-admin-service"; import { getServerCfg } from "@app/services/super-admin/super-admin-service";
@@ -385,63 +386,88 @@ export const authLoginServiceFactory = ({
providerAuthToken?: string; providerAuthToken?: string;
captchaToken?: string; captchaToken?: string;
}) => { }) => {
const usersByUsername = await userDAL.findUserEncKeyByUsername({ try {
username: email const usersByUsername = await userDAL.findUserEncKeyByUsername({
}); username: email
const userEnc = });
usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0]; const userEnc =
usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0];
if (!userEnc) throw new BadRequestError({ message: "User not found" }); if (!userEnc) throw new BadRequestError({ message: "User not found" });
if (userEnc.encryptionVersion !== UserEncryption.V2) { if (userEnc.encryptionVersion !== UserEncryption.V2) {
throw new BadRequestError({ message: "Legacy encryption scheme not supported", name: "LegacyEncryptionScheme" });
}
if (!userEnc.hashedPassword) {
if (userEnc.authMethods?.includes(AuthMethod.EMAIL)) {
throw new BadRequestError({ throw new BadRequestError({
message: "Legacy encryption scheme not supported", message: "Legacy encryption scheme not supported",
name: "LegacyEncryptionScheme" name: "LegacyEncryptionScheme"
}); });
} }
throw new BadRequestError({ message: "No password found" }); if (!userEnc.hashedPassword) {
} if (userEnc.authMethods?.includes(AuthMethod.EMAIL)) {
throw new BadRequestError({
const { authMethod, organizationId } = getAuthMethodAndOrgId(email, providerAuthToken); message: "Legacy encryption scheme not supported",
await verifyCaptcha(userEnc, captchaToken); name: "LegacyEncryptionScheme"
});
if (!(await crypto.hashing().compareHash(password, userEnc.hashedPassword))) {
await userDAL.update(
{ id: userEnc.userId },
{
$incr: {
consecutiveFailedPasswordAttempts: 1
}
} }
);
throw new BadRequestError({ message: "Invalid username or email" }); throw new BadRequestError({ message: "No password found" });
}
const { authMethod, organizationId } = getAuthMethodAndOrgId(email, providerAuthToken);
await verifyCaptcha(userEnc, captchaToken);
if (!(await crypto.hashing().compareHash(password, userEnc.hashedPassword))) {
await userDAL.update(
{ id: userEnc.userId },
{
$incr: {
consecutiveFailedPasswordAttempts: 1
}
}
);
throw new BadRequestError({ message: "Invalid username or email" });
}
const token = await generateUserTokens({
user: {
...userEnc,
id: userEnc.userId
},
ip,
userAgent,
authMethod,
organizationId
});
authAttemptCounter.add(1, {
"infisical.organization.id": organizationId,
"infisical.user.email": email,
"infisical.user.id": userEnc.userId,
"infisical.auth.method": AuthAttemptAuthMethod.EMAIL,
"infisical.auth.result": AuthAttemptAuthResult.SUCCESS,
"client.address": ip,
"user_agent.original": userAgent
});
return {
tokens: {
accessToken: token.access,
refreshToken: token.refresh
},
user: userEnc
} as const;
} catch (error) {
authAttemptCounter.add(1, {
"infisical.user.email": email,
"infisical.auth.method": AuthAttemptAuthMethod.EMAIL,
"infisical.auth.result": AuthAttemptAuthResult.FAILURE,
"client.address": ip,
"user_agent.original": userAgent
});
throw error;
} }
const token = await generateUserTokens({
user: {
...userEnc,
id: userEnc.userId
},
ip,
userAgent,
authMethod,
organizationId
});
return {
tokens: {
accessToken: token.access,
refreshToken: token.refresh
},
user: userEnc
} as const;
}; };
const selectOrganization = async ({ const selectOrganization = async ({
@@ -965,7 +991,8 @@ export const authLoginServiceFactory = ({
expiresIn: appCfg.JWT_PROVIDER_AUTH_LIFETIME expiresIn: appCfg.JWT_PROVIDER_AUTH_LIFETIME
} }
); );
return { isUserCompleted, providerAuthToken };
return { isUserCompleted, providerAuthToken, user, orgId, orgName };
}; };
/** /**