From e40031aeb5e2dc66de3c278bd96c5d728c46b669 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Thu, 30 Oct 2025 00:11:38 +0800 Subject: [PATCH] misc: added monitoring for user auth --- backend/src/@types/fastify.d.ts | 2 + backend/src/ee/routes/v1/saml-router.ts | 40 ++++-- .../ee/services/oidc/oidc-config-service.ts | 27 +++- .../saml-config/saml-config-service.ts | 2 +- .../services/saml-config/saml-config-types.ts | 4 +- backend/src/lib/telemetry/metrics.ts | 22 +++ backend/src/server/app.ts | 4 +- backend/src/server/routes/v1/sso-router.ts | 136 +++++++++++++----- .../src/services/auth/auth-login-service.ts | 121 ++++++++++------ 9 files changed, 261 insertions(+), 97 deletions(-) create mode 100644 backend/src/lib/telemetry/metrics.ts diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index fdd3cabc0..769698abe 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -136,6 +136,8 @@ declare module "@fastify/request-context" { interface RequestContextData { reqId: string; orgId?: string; + ip?: string; + userAgent?: string; orgName?: string; userAuthInfo?: { userId: string; diff --git a/backend/src/ee/routes/v1/saml-router.ts b/backend/src/ee/routes/v1/saml-router.ts index 76bff60e8..51d87f191 100644 --- a/backend/src/ee/routes/v1/saml-router.ts +++ b/backend/src/ee/routes/v1/saml-router.ts @@ -7,6 +7,7 @@ // All the any rules are disabled because passport typesense with fastify is really poor import { Authenticator } from "@fastify/passport"; +import { requestContext } from "@fastify/request-context"; import fastifySession from "@fastify/session"; import { MultiSamlStrategy } from "@node-saml/passport-saml"; import { FastifyRequest } from "fastify"; @@ -17,6 +18,7 @@ import { ApiDocsTags, SamlSso } from "@app/lib/api-docs"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; +import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { SanitizedSamlConfigSchema } from "@app/server/routes/sanitizedSchema/directory-config"; @@ -102,15 +104,15 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { }, // eslint-disable-next-line 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 { - 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 ?? // entra sends data in this format 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)); - const { isUserCompleted, providerAuthToken } = await server.services.saml.samlLogin({ + const { isUserCompleted, providerAuthToken, user, organization } = await server.services.saml.samlLogin({ externalId: profile.nameID, email: email.toLowerCase(), firstName, @@ -154,8 +156,28 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { orgId: (req as unknown as FastifyRequest).ssoConfig?.orgId, 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 }); } 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); cb(error as Error); } diff --git a/backend/src/ee/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts index e80ec7cf5..0fa4be4e6 100644 --- a/backend/src/ee/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-call */ import { ForbiddenError } from "@casl/ability"; +import { requestContext } from "@fastify/request-context"; import { Issuer, Issuer as OpenIdIssuer, Strategy as OpenIdStrategy, TokenSet } from "openid-client"; 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 { crypto } from "@app/lib/crypto"; 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 { ActorType, AuthMethod, AuthTokenType } from "@app/services/auth/auth-type"; 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 ({ @@ -754,10 +756,31 @@ export const oidcConfigServiceFactory = ({ callbackPort, 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 }); }) .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); }); } diff --git a/backend/src/ee/services/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index c99ae8b28..7206bd293 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -769,7 +769,7 @@ export const samlConfigServiceFactory = ({ }); } - return { isUserCompleted, providerAuthToken }; + return { isUserCompleted, providerAuthToken, user, organization }; }; return { diff --git a/backend/src/ee/services/saml-config/saml-config-types.ts b/backend/src/ee/services/saml-config/saml-config-types.ts index 983ec4db7..5ca3e09fb 100644 --- a/backend/src/ee/services/saml-config/saml-config-types.ts +++ b/backend/src/ee/services/saml-config/saml-config-types.ts @@ -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 { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; @@ -78,5 +78,7 @@ export type TSamlConfigServiceFactory = { samlLogin: (arg: TSamlLoginDTO) => Promise<{ isUserCompleted: boolean; providerAuthToken: string; + user: TUsers; + organization: TOrganizations; }>; }; diff --git a/backend/src/lib/telemetry/metrics.ts b/backend/src/lib/telemetry/metrics.ts new file mode 100644 index 000000000..6748cf426 --- /dev/null +++ b/backend/src/lib/telemetry/metrics.ts @@ -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}" +}); diff --git a/backend/src/server/app.ts b/backend/src/server/app.ts index f1176b932..60b678f63 100644 --- a/backend/src/server/app.ts +++ b/backend/src/server/app.ts @@ -141,7 +141,9 @@ export const main = async ({ await server.register(fastifyRequestContext, { defaultStoreValues: (req) => ({ 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"] }) }); diff --git a/backend/src/server/routes/v1/sso-router.ts b/backend/src/server/routes/v1/sso-router.ts index 366fa331d..9529c272e 100644 --- a/backend/src/server/routes/v1/sso-router.ts +++ b/backend/src/server/routes/v1/sso-router.ts @@ -7,6 +7,7 @@ // All the any rules are disabled because passport typesense with fastify is really poor import { Authenticator } from "@fastify/passport"; +import { requestContext } from "@fastify/request-context"; import fastifySession from "@fastify/session"; import RedisStore from "connect-redis"; import { CronJob } from "cron"; @@ -21,6 +22,7 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { ms } from "@app/lib/ms"; 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 { addAuthOriginDomainCookie } from "@app/server/lib/cookie"; import { AuthMethod } from "@app/services/auth/auth-type"; @@ -51,30 +53,51 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => { }, // eslint-disable-next-line async (req, _accessToken, _refreshToken, profile, cb) => { - try { - // @ts-expect-error this is because this is express type and not fastify - const callbackPort = req.session.get("callbackPort"); - // @ts-expect-error this is because this is express type and not fastify - const orgSlug = req.session.get("orgSlug"); + // @ts-expect-error this is because this is express type and not fastify + const callbackPort = req.session.get("callbackPort"); + // @ts-expect-error this is because this is express type and not fastify + const orgSlug = req.session.get("orgSlug"); - const email = profile?.emails?.[0]?.value; - if (!email) - throw new NotFoundError({ - message: "Email not found", - name: "OauthGoogleRegister" + const email = profile?.emails?.[0]?.value; + if (!email) + throw new NotFoundError({ + message: "Email not found", + 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({ - email, - firstName: profile?.name?.givenName || "", - lastName: profile?.name?.familyName || "", - authMethod: AuthMethod.GOOGLE, - callbackPort, - orgSlug + authAttemptCounter.add(1, { + "infisical.user.email": email, + "infisical.user.id": user.id, + "infisical.organization.id": orgId, + "infisical.organization.name": orgName, + "infisical.auth.method": AuthAttemptAuthMethod.GOOGLE, + "infisical.auth.result": AuthAttemptAuthResult.SUCCESS, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") }); + cb(null, { isUserCompleted, providerAuthToken }); } catch (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); } } @@ -101,27 +124,47 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => { }, // eslint-disable-next-line 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 { - 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 - const user = await fetchGithubUser(accessToken); + const githubUser = await fetchGithubUser(accessToken); const callbackPort = req.session.get("callbackPort"); - const { isUserCompleted, providerAuthToken } = await server.services.login.oauth2Login({ - email, - firstName: user.name || user.login, - lastName: "", - authMethod: AuthMethod.GITHUB, - callbackPort + const { isUserCompleted, providerAuthToken, user, orgId, orgName } = + await server.services.login.oauth2Login({ + email, + firstName: githubUser.name || githubUser.login, + lastName: "", + 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 }); } 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); done(err as Error, false); } @@ -147,20 +190,41 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => { pkce: true }, async (req: any, _accessToken: string, _refreshToken: string, profile: any, cb: any) => { + const email = profile.emails[0].value; + try { const callbackPort = req.session.get("callbackPort"); - const email = profile.emails[0].value; - const { isUserCompleted, providerAuthToken } = await server.services.login.oauth2Login({ - email, - firstName: profile.displayName || profile.username || "", - lastName: "", - authMethod: AuthMethod.GITLAB, - callbackPort + const { isUserCompleted, providerAuthToken, user, orgId, orgName } = + await server.services.login.oauth2Login({ + email, + firstName: profile.displayName || profile.username || "", + lastName: "", + authMethod: AuthMethod.GITLAB, + 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 }); } 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); cb(error as Error, false); } diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index 31a9cc5a8..47e1b687f 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -16,6 +16,7 @@ import { getUserPrivateKey } from "@app/lib/crypto/srp"; import { BadRequestError, DatabaseError, ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors"; import { getMinExpiresIn, removeTrailingSlash } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; +import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics"; import { getUserAgentType } from "@app/server/plugins/audit-log"; import { getServerCfg } from "@app/services/super-admin/super-admin-service"; @@ -385,63 +386,88 @@ export const authLoginServiceFactory = ({ providerAuthToken?: string; captchaToken?: string; }) => { - const usersByUsername = await userDAL.findUserEncKeyByUsername({ - username: email - }); - const userEnc = - usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0]; + try { + const usersByUsername = await userDAL.findUserEncKeyByUsername({ + username: email + }); + 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) { - throw new BadRequestError({ message: "Legacy encryption scheme not supported", name: "LegacyEncryptionScheme" }); - } - - if (!userEnc.hashedPassword) { - if (userEnc.authMethods?.includes(AuthMethod.EMAIL)) { + if (userEnc.encryptionVersion !== UserEncryption.V2) { throw new BadRequestError({ message: "Legacy encryption scheme not supported", name: "LegacyEncryptionScheme" }); } - 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 - } + if (!userEnc.hashedPassword) { + if (userEnc.authMethods?.includes(AuthMethod.EMAIL)) { + throw new BadRequestError({ + message: "Legacy encryption scheme not supported", + name: "LegacyEncryptionScheme" + }); } - ); - 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 ({ @@ -965,7 +991,8 @@ export const authLoginServiceFactory = ({ expiresIn: appCfg.JWT_PROVIDER_AUTH_LIFETIME } ); - return { isUserCompleted, providerAuthToken }; + + return { isUserCompleted, providerAuthToken, user, orgId, orgName }; }; /**