From dbd55441f2f32547865e4df5dc925a07a954247a Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Sat, 12 Aug 2023 13:53:55 +0700 Subject: [PATCH] Update login with multiple auth methods to toggle button and logic --- backend/src/controllers/v2/usersController.ts | 61 +-- backend/src/controllers/v3/authController.ts | 369 ++++++++---------- .../src/controllers/v3/signupController.ts | 4 +- .../src/ee/controllers/v1/ssoController.ts | 4 +- backend/src/helpers/auth.ts | 4 +- backend/src/models/index.ts | 4 +- backend/src/models/user.ts | 24 +- backend/src/routes/v2/users.ts | 34 +- backend/src/utils/auth.ts | 40 +- backend/src/utils/setup/backfillData.ts | 39 ++ backend/src/utils/setup/index.ts | 4 +- frontend/src/hooks/api/users/index.tsx | 3 +- frontend/src/hooks/api/users/queries.tsx | 37 +- frontend/src/hooks/api/users/types.ts | 11 +- .../AuthMethodSection/AuthMethodSection.tsx | 175 +++++---- 15 files changed, 384 insertions(+), 429 deletions(-) diff --git a/backend/src/controllers/v2/usersController.ts b/backend/src/controllers/v2/usersController.ts index 48acbf490..9e23ab8ac 100644 --- a/backend/src/controllers/v2/usersController.ts +++ b/backend/src/controllers/v2/usersController.ts @@ -4,7 +4,7 @@ import crypto from "crypto"; import bcrypt from "bcrypt"; import { APIKeyData, - AuthProvider, + AuthMethod, MembershipOrg, TokenVersion, User @@ -113,21 +113,26 @@ export const updateName = async (req: Request, res: Response) => { } /** - * Update auth provider of the current user to [authProvider] + * Update auth method of the current user to [authMethods] * @param req * @param res * @returns */ -export const updateAuthProvider = async (req: Request, res: Response) => { + export const updateAuthMethods = async (req: Request, res: Response) => { const { - authProvider + authMethods } = req.body; - if ( - req.user?.authProvider === AuthProvider.OKTA_SAML - || req.user?.authProvider === AuthProvider.AZURE_SAML - || req.user?.authProvider === AuthProvider.JUMPCLOUD_SAML - ) { + const hasSamlEnabled = req.user.authMethods + .some( + (authMethod: AuthMethod) => [ + AuthMethod.OKTA_SAML, + AuthMethod.AZURE_SAML, + AuthMethod.JUMPCLOUD_SAML + ].includes(authMethod) + ); + + if (hasSamlEnabled) { return res.status(400).send({ message: "Failed to update user authentication method because SAML SSO is enforced" }); @@ -136,43 +141,7 @@ export const updateAuthProvider = async (req: Request, res: Response) => { const user = await User.findByIdAndUpdate( req.user._id.toString(), { - authProvider - }, - { - new: true - } - ); - - return res.status(200).send({ - user - }); -} - -/** - * Update auth provider of the current user to [authProvider] - * @param req - * @param res - * @returns - */ - export const updateAuthProviders = async (req: Request, res: Response) => { - const { - authProviders - } = req.body; - - if ( - req.user?.authProvider === AuthProvider.OKTA_SAML - || req.user?.authProvider === AuthProvider.AZURE_SAML - || req.user?.authProvider === AuthProvider.JUMPCLOUD_SAML - ) { - return res.status(400).send({ - message: "Failed to update user authentication method because SAML SSO is enforced" - }); - } - - const user = await User.findByIdAndUpdate( - req.user._id.toString(), - { - authProviders + authMethods }, { new: true diff --git a/backend/src/controllers/v3/authController.ts b/backend/src/controllers/v3/authController.ts index 055d8a400..ebbcb61b2 100644 --- a/backend/src/controllers/v3/authController.ts +++ b/backend/src/controllers/v3/authController.ts @@ -1,7 +1,6 @@ /* eslint-disable @typescript-eslint/no-var-requires */ import { Request, Response } from "express"; import jwt from "jsonwebtoken"; -import * as Sentry from "@sentry/node"; import * as bigintConversion from "bigint-conversion"; const jsrp = require("jsrp"); import { LoginSRPDetail, User } from "../../models"; @@ -21,13 +20,13 @@ import { getJwtMfaLifetime, getJwtMfaSecret, } from "../../config"; -import { AuthProvider } from "../../models/user"; +import { AuthMethod } from "../../models/user"; declare module "jsonwebtoken" { export interface ProviderAuthJwtPayload extends jwt.JwtPayload { userId: string; email: string; - authProvider: AuthProvider; + authProvider: AuthMethod; isUserCompleted: boolean, } } @@ -39,65 +38,54 @@ declare module "jsonwebtoken" { * @returns */ export const login1 = async (req: Request, res: Response) => { - try { - const { + const { + email, + providerAuthToken, + clientPublicKey, + }: { + email: string; + clientPublicKey: string, + providerAuthToken?: string; + } = req.body; + + const user = await User.findOne({ + email, + }).select("+salt +verifier"); + + if (!user) throw new Error("Failed to find user"); + + if (!user.authMethods.includes(AuthMethod.EMAIL)) { + await validateProviderAuthToken({ email, + user, providerAuthToken, - clientPublicKey, - }: { - email: string; - clientPublicKey: string, - providerAuthToken?: string; - } = req.body; - - const user = await User.findOne({ - email, - }).select("+salt +verifier"); - - if (!user) throw new Error("Failed to find user"); - - const authProviders = [...(user.authProviders || [])]; - user.authProvider && authProviders.push(user.authProvider); - - if (authProviders.length && !authProviders.includes(AuthProvider.EMAIL)) { - await validateProviderAuthToken({ - email, - user, - providerAuthToken, - }) - } - - const server = new jsrp.server(); - server.init( - { - salt: user.salt, - verifier: user.verifier, - }, - async () => { - // generate server-side public key - const serverPublicKey = server.getPublicKey(); - await LoginSRPDetail.findOneAndReplace({ - email: email, - }, { - email, - userId: user.id, - clientPublicKey: clientPublicKey, - serverBInt: bigintConversion.bigintToBuf(server.bInt), - }, { upsert: true, returnNewDocument: false }); - - return res.status(200).send({ - serverPublicKey, - salt: user.salt, - }); - } - ); - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - return res.status(400).send({ - message: "Failed to start authentication process", }); } + + const server = new jsrp.server(); + server.init( + { + salt: user.salt, + verifier: user.verifier, + }, + async () => { + // generate server-side public key + const serverPublicKey = server.getPublicKey(); + await LoginSRPDetail.findOneAndReplace({ + email: email, + }, { + email, + userId: user.id, + clientPublicKey: clientPublicKey, + serverBInt: bigintConversion.bigintToBuf(server.bInt), + }, { upsert: true, returnNewDocument: false }); + + return res.status(200).send({ + serverPublicKey, + salt: user.salt, + }); + } + ); }; /** @@ -108,162 +96,151 @@ export const login1 = async (req: Request, res: Response) => { * @returns */ export const login2 = async (req: Request, res: Response) => { - try { - if (!req.headers["user-agent"]) throw InternalServerError({ message: "User-Agent header is required" }); + if (!req.headers["user-agent"]) throw InternalServerError({ message: "User-Agent header is required" }); - const { email, clientProof, providerAuthToken } = req.body; + const { email, clientProof, providerAuthToken } = req.body; - const user = await User.findOne({ + const user = await User.findOne({ + email, + }).select("+salt +verifier +encryptionVersion +protectedKey +protectedKeyIV +protectedKeyTag +publicKey +encryptedPrivateKey +iv +tag +devices"); + + if (!user) throw new Error("Failed to find user"); + + if (!user.authMethods.includes(AuthMethod.EMAIL)) { + await validateProviderAuthToken({ email, - }).select("+salt +verifier +encryptionVersion +protectedKey +protectedKeyIV +protectedKeyTag +publicKey +encryptedPrivateKey +iv +tag +devices"); + user, + providerAuthToken, + }) + } - if (!user) throw new Error("Failed to find user"); + const loginSRPDetail = await LoginSRPDetail.findOneAndDelete({ email: email }) - const authProviders = [...(user.authProviders || [])]; - user.authProvider && authProviders.push(user.authProvider); - - if (authProviders.length && !authProviders.includes(AuthProvider.EMAIL)) { - await validateProviderAuthToken({ - email, - user, - providerAuthToken, - }) - } + if (!loginSRPDetail) { + return BadRequestError(Error("Failed to find login details for SRP")) + } - const loginSRPDetail = await LoginSRPDetail.findOneAndDelete({ email: email }) + const server = new jsrp.server(); + server.init( + { + salt: user.salt, + verifier: user.verifier, + b: loginSRPDetail.serverBInt, + }, + async () => { + server.setClientPublicKey(loginSRPDetail.clientPublicKey); - if (!loginSRPDetail) { - return BadRequestError(Error("Failed to find login details for SRP")) - } + // compare server and client shared keys + if (server.checkClientProof(clientProof)) { - const server = new jsrp.server(); - server.init( - { - salt: user.salt, - verifier: user.verifier, - b: loginSRPDetail.serverBInt, - }, - async () => { - server.setClientPublicKey(loginSRPDetail.clientPublicKey); + if (user.isMfaEnabled) { + // case: user has MFA enabled - // compare server and client shared keys - if (server.checkClientProof(clientProof)) { - - if (user.isMfaEnabled) { - // case: user has MFA enabled - - // generate temporary MFA token - const token = createToken({ - payload: { - userId: user._id.toString(), - }, - expiresIn: await getJwtMfaLifetime(), - secret: await getJwtMfaSecret(), - }); - - const code = await TokenService.createToken({ - type: TOKEN_EMAIL_MFA, - email, - }); - - // send MFA code [code] to [email] - await sendMail({ - template: "emailMfa.handlebars", - subjectLine: "Infisical MFA code", - recipients: [user.email], - substitutions: { - code, - }, - }); - - return res.status(200).send({ - mfaEnabled: true, - token, - }); - } - - await checkUserDevice({ - user, - ip: req.realIP, - userAgent: req.headers["user-agent"] ?? "", + // generate temporary MFA token + const token = createToken({ + payload: { + userId: user._id.toString(), + }, + expiresIn: await getJwtMfaLifetime(), + secret: await getJwtMfaSecret(), }); - // issue tokens - const tokens = await issueAuthTokens({ - userId: user._id, - ip: req.realIP, - userAgent: req.headers["user-agent"] ?? "", - }); - - // store (refresh) token in httpOnly cookie - res.cookie("jid", tokens.refreshToken, { - httpOnly: true, - path: "/", - sameSite: "strict", - secure: await getHttpsEnabled(), + const code = await TokenService.createToken({ + type: TOKEN_EMAIL_MFA, + email, }); - // case: user does not have MFA enablgged - // return (access) token in response - - interface ResponseData { - mfaEnabled: boolean; - encryptionVersion: any; - protectedKey?: string; - protectedKeyIV?: string; - protectedKeyTag?: string; - token: string; - publicKey?: string; - encryptedPrivateKey?: string; - iv?: string; - tag?: string; - } - - const response: ResponseData = { - mfaEnabled: false, - encryptionVersion: user.encryptionVersion, - token: tokens.token, - publicKey: user.publicKey, - encryptedPrivateKey: user.encryptedPrivateKey, - iv: user.iv, - tag: user.tag, - } - - if ( - user?.protectedKey && - user?.protectedKeyIV && - user?.protectedKeyTag - ) { - response.protectedKey = user.protectedKey; - response.protectedKeyIV = user.protectedKeyIV - response.protectedKeyTag = user.protectedKeyTag; - } - - const loginAction = await EELogService.createAction({ - name: ACTION_LOGIN, - userId: user._id, + // send MFA code [code] to [email] + await sendMail({ + template: "emailMfa.handlebars", + subjectLine: "Infisical MFA code", + recipients: [user.email], + substitutions: { + code, + }, }); - loginAction && await EELogService.createLog({ - userId: user._id, - actions: [loginAction], - channel: getUserAgentType(req.headers["user-agent"]), - ipAddress: req.realIP, + return res.status(200).send({ + mfaEnabled: true, + token, }); - - return res.status(200).send(response); } - return res.status(400).send({ - message: "Failed to authenticate. Try again?", + await checkUserDevice({ + user, + ip: req.realIP, + userAgent: req.headers["user-agent"] ?? "", }); + + // issue tokens + const tokens = await issueAuthTokens({ + userId: user._id, + ip: req.realIP, + userAgent: req.headers["user-agent"] ?? "", + }); + + // store (refresh) token in httpOnly cookie + res.cookie("jid", tokens.refreshToken, { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: await getHttpsEnabled(), + }); + + // case: user does not have MFA enablgged + // return (access) token in response + + interface ResponseData { + mfaEnabled: boolean; + encryptionVersion: any; + protectedKey?: string; + protectedKeyIV?: string; + protectedKeyTag?: string; + token: string; + publicKey?: string; + encryptedPrivateKey?: string; + iv?: string; + tag?: string; + } + + const response: ResponseData = { + mfaEnabled: false, + encryptionVersion: user.encryptionVersion, + token: tokens.token, + publicKey: user.publicKey, + encryptedPrivateKey: user.encryptedPrivateKey, + iv: user.iv, + tag: user.tag, + } + + if ( + user?.protectedKey && + user?.protectedKeyIV && + user?.protectedKeyTag + ) { + response.protectedKey = user.protectedKey; + response.protectedKeyIV = user.protectedKeyIV + response.protectedKeyTag = user.protectedKeyTag; + } + + const loginAction = await EELogService.createAction({ + name: ACTION_LOGIN, + userId: user._id, + }); + + loginAction && await EELogService.createLog({ + userId: user._id, + actions: [loginAction], + channel: getUserAgentType(req.headers["user-agent"]), + ipAddress: req.realIP, + }); + + return res.status(200).send(response); } - ); - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - return res.status(400).send({ - message: "Failed to authenticate. Try again?", - }); - } + + return res.status(400).send({ + message: "Failed to authenticate. Try again?", + }); + } + ); }; diff --git a/backend/src/controllers/v3/signupController.ts b/backend/src/controllers/v3/signupController.ts index 06fac4a85..12f225c4e 100644 --- a/backend/src/controllers/v3/signupController.ts +++ b/backend/src/controllers/v3/signupController.ts @@ -12,7 +12,7 @@ import { standardRequest } from "../../config/request"; import { getHttpsEnabled, getJwtSignupSecret, getLoopsApiKey } from "../../config"; import { BadRequestError } from "../../utils/errors"; import { TelemetryService } from "../../services"; -import { AuthProvider } from "../../models"; +import { AuthMethod } from "../../models"; /** * Complete setting up user by adding their personal and auth information as part of the @@ -117,7 +117,7 @@ export const completeAccountSignup = async (req: Request, res: Response) => { if (!user) throw new Error("Failed to complete account for non-existent user"); // ensure user is non-null - if (!user.authProviders?.includes(AuthProvider.OKTA_SAML)) { + if (!user.authMethods?.includes(AuthMethod.OKTA_SAML)) { // initialize default organization and workspace await initializeDefaultOrg({ organizationName, diff --git a/backend/src/ee/controllers/v1/ssoController.ts b/backend/src/ee/controllers/v1/ssoController.ts index 94d146344..55c518192 100644 --- a/backend/src/ee/controllers/v1/ssoController.ts +++ b/backend/src/ee/controllers/v1/ssoController.ts @@ -3,7 +3,7 @@ import { Types } from "mongoose"; import { BotOrgService } from "../../../services"; import { SSOConfig } from "../../models"; import { - AuthProvider, + AuthMethod, MembershipOrg, User } from "../../../models"; @@ -171,7 +171,7 @@ export const updateSSOConfig = async (req: Request, res: Response) => { } }, { - authProviders: [AuthProvider.EMAIL], + authProviders: [AuthMethod.EMAIL], $unset: { authProvider: 1, } diff --git a/backend/src/helpers/auth.ts b/backend/src/helpers/auth.ts index c54f84859..e15283c75 100644 --- a/backend/src/helpers/auth.ts +++ b/backend/src/helpers/auth.ts @@ -407,10 +407,8 @@ export const validateProviderAuthToken = async ({ jwt.verify(providerAuthToken, await getJwtProviderAuthSecret()) ); - let authProviders = [...(user.authProviders || []), user.authProvider]; - if ( - !authProviders.includes(decodedToken.authProvider) || + !user.authMethods.includes(decodedToken.authProvider) || decodedToken.email !== email ) { throw new Error("Invalid authentication credentials.") diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index f7197d7bd..e588364ee 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -19,7 +19,7 @@ import ServiceAccountKey, { IServiceAccountKey } from "./serviceAccountKey"; // import ServiceAccountOrganizationPermission, { IServiceAccountOrganizationPermission } from "./serviceAccountOrganizationPermission"; // new import ServiceAccountWorkspacePermission, { IServiceAccountWorkspacePermission } from "./serviceAccountWorkspacePermission"; // new import TokenData, { ITokenData } from "./tokenData"; -import User, { AuthProvider, IUser } from "./user"; +import User, { AuthMethod, IUser } from "./user"; import UserAction, { IUserAction } from "./userAction"; import Workspace, { IWorkspace } from "./workspace"; import ServiceTokenData, { IServiceTokenData } from "./serviceTokenData"; @@ -28,7 +28,7 @@ import LoginSRPDetail, { ILoginSRPDetail } from "./loginSRPDetail"; import TokenVersion, { ITokenVersion } from "./tokenVersion"; export { - AuthProvider, + AuthMethod, BackupPrivateKey, IBackupPrivateKey, Bot, diff --git a/backend/src/models/user.ts b/backend/src/models/user.ts index 852214d52..10139c85e 100644 --- a/backend/src/models/user.ts +++ b/backend/src/models/user.ts @@ -1,6 +1,6 @@ import { Document, Schema, Types, model } from "mongoose"; -export enum AuthProvider { +export enum AuthMethod { EMAIL = "email", GOOGLE = "google", GITHUB = "github", @@ -11,9 +11,8 @@ export enum AuthProvider { export interface IUser extends Document { _id: Types.ObjectId; - authId?: string; - authProvider?: AuthProvider; - authProviders?: AuthProvider[]; + authProvider?: AuthMethod; + authMethods: AuthMethod[]; email: string; firstName?: string; lastName?: string; @@ -37,17 +36,18 @@ export interface IUser extends Document { const userSchema = new Schema( { - authId: { + authProvider: { // TODO field: deprecate type: String, + enum: AuthMethod, }, - authProvider: { - type: String, - enum: AuthProvider, + authMethods: { + type: [{ + type: String, + enum: AuthMethod, + }], + default: [AuthMethod.EMAIL], + required: true }, - authProviders: [{ - type: String, - enum: AuthProvider, - }], email: { type: String, required: true, diff --git a/backend/src/routes/v2/users.ts b/backend/src/routes/v2/users.ts index e60b2fe6f..c13abfcb9 100644 --- a/backend/src/routes/v2/users.ts +++ b/backend/src/routes/v2/users.ts @@ -8,7 +8,7 @@ import { body, param } from "express-validator"; import { usersController } from "../../controllers/v2"; import { AuthMode } from "../../variables"; import { - AuthProvider + AuthMethod } from "../../models"; router.get( @@ -40,36 +40,22 @@ router.patch( usersController.updateName ); -router.patch( - "/me/auth-provider", +router.put( + "/me/auth-methods", requireAuth({ acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY], }), - body("authProvider").exists().isString().isIn([ - AuthProvider.EMAIL, - AuthProvider.GOOGLE, - AuthProvider.GITHUB - ]), - validateRequest, - usersController.updateAuthProvider -); - -router.put( - "/me/auth-providers", - requireAuth({ - acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY], - }), - body("authProviders").exists().isArray({ + body("authMethods").exists().isArray({ min: 1, - }).custom((authProviders: AuthProvider[]) => { - return authProviders.every(provider => [ - AuthProvider.EMAIL, - AuthProvider.GOOGLE, - AuthProvider.GITHUB + }).custom((authMethods: AuthMethod[]) => { + return authMethods.every(provider => [ + AuthMethod.EMAIL, + AuthMethod.GOOGLE, + AuthMethod.GITHUB ].includes(provider)) }), validateRequest, - usersController.updateAuthProviders, + usersController.updateAuthMethods, ); router.get( diff --git a/backend/src/utils/auth.ts b/backend/src/utils/auth.ts index 7942de628..2516337af 100644 --- a/backend/src/utils/auth.ts +++ b/backend/src/utils/auth.ts @@ -3,7 +3,7 @@ import passport from "passport"; import { Types } from "mongoose"; import { AuthData } from "../interfaces/middleware"; import { - AuthProvider, + AuthMethod, MembershipOrg, Organization, ServiceAccount, @@ -100,16 +100,13 @@ const initializePassport = async () => { if (!user) { user = await new User({ email, - authProviders: [AuthProvider.GOOGLE], - authId: profile.id, + authMethods: [AuthMethod.GOOGLE], firstName: profile.name.givenName, lastName: profile.name.familyName }).save(); } - const authProviders = [...(user.authProviders || []), user.authProvider]; - - if (!authProviders.includes(AuthProvider.GOOGLE)) { + if (!user.authMethods.includes(AuthMethod.GOOGLE)) { done(InternalServerError()); } @@ -120,7 +117,7 @@ const initializePassport = async () => { email: user.email, firstName: user.firstName, lastName: user.lastName, - authProvider: AuthProvider.GOOGLE, + authMethod: AuthMethod.GOOGLE, isUserCompleted, ...(req.query.state ? { callbackPort: req.query.state as string @@ -156,16 +153,13 @@ const initializePassport = async () => { if (!user) { user = await new User({ email: email, - authProviders: [AuthProvider.GITHUB], - authId: profile.id, + authMethods: [AuthMethod.GITHUB], firstName: profile.displayName, lastName: "" }).save(); } - - const authProviders = [...(user.authProviders || []), user.authProvider]; - if (!authProviders.includes(AuthProvider.GITHUB)) { + if (!user.authMethods.includes(AuthMethod.GITHUB)) { done(InternalServerError()); } @@ -176,7 +170,7 @@ const initializePassport = async () => { email: user.email, firstName: user.firstName, lastName: user.lastName, - authProvider: AuthProvider.GITHUB, + authMethod: AuthMethod.GITHUB, isUserCompleted, ...(req.query.state ? { callbackPort: req.query.state as string @@ -222,7 +216,7 @@ const initializePassport = async () => { audience: await getSiteURL() }); - if (ssoConfig.authProvider === AuthProvider.JUMPCLOUD_SAML) { + if (ssoConfig.authProvider.toString() === AuthMethod.JUMPCLOUD_SAML.toString()) { samlConfig.wantAuthnResponseSigned = false; } @@ -247,11 +241,21 @@ const initializePassport = async () => { }).select("+publicKey"); if (user) { - if (!user.authProvider || user.authProvider === AuthProvider.EMAIL || user.authProvider === AuthProvider.GOOGLE) { + // if user does not have SAML enabled then update + const hasSamlEnabled = user.authMethods + .some( + (authMethod: AuthMethod) => [ + AuthMethod.OKTA_SAML, + AuthMethod.AZURE_SAML, + AuthMethod.JUMPCLOUD_SAML + ].includes(authMethod) + ); + + if (!hasSamlEnabled) { await User.findByIdAndUpdate( user._id, { - authProviders: [req.ssoConfig.authProvider] + authMethods: [req.ssoConfig.authProvider] }, { new: true @@ -283,7 +287,7 @@ const initializePassport = async () => { } else { user = await new User({ email, - authProviders: [req.ssoConfig.authProvider], + authMethods: [req.ssoConfig.authProvider], firstName, lastName }).save(); @@ -305,7 +309,7 @@ const initializePassport = async () => { firstName, lastName, organizationName: organization?.name, - authProvider: req.ssoConfig.authProvider, + authMethod: req.ssoConfig.authProvider, isUserCompleted, ...(req.body.RelayState ? { callbackPort: req.body.RelayState as string diff --git a/backend/src/utils/setup/backfillData.ts b/backend/src/utils/setup/backfillData.ts index 57ddd68aa..bdc26c0be 100644 --- a/backend/src/utils/setup/backfillData.ts +++ b/backend/src/utils/setup/backfillData.ts @@ -11,6 +11,7 @@ import { TrustedIP } from "../../ee/models"; import { + AuthMethod, BackupPrivateKey, Bot, BotOrg, @@ -21,6 +22,7 @@ import { Secret, SecretBlindIndexData, ServiceTokenData, + User, Workspace } from "../../models"; import { generateKeyPair } from "../../utils/crypto"; @@ -631,3 +633,40 @@ export const backfillTrustedIps = async () => { console.log("Backfill: Trusted IPs complete"); } } + +export const backfillUserAuthMethods = async () => { + await User.updateMany( + { + authProvider: { + $exists: false + }, + authMethods: { + $exists: false + } + }, + { + authMethods: [AuthMethod.EMAIL] + } + ); + + await User.updateMany( + { + authProvider: { + $exists: true + }, + authMethods: { + $exists: false + } + }, + [ + { + $set: { + authMethods: ["$authProvider"] + } + }, + { + $unset: ["authProvider", "authId"] + } + ] +); +} diff --git a/backend/src/utils/setup/index.ts b/backend/src/utils/setup/index.ts index aa1e22f90..4fb8f0c8f 100644 --- a/backend/src/utils/setup/index.ts +++ b/backend/src/utils/setup/index.ts @@ -16,7 +16,8 @@ import { backfillSecretVersions, backfillServiceToken, backfillServiceTokenMultiScope, - backfillTrustedIps + backfillTrustedIps, + backfillUserAuthMethods } from "./backfillData"; import { reencryptBotOrgKeys, @@ -79,6 +80,7 @@ export const setup = async () => { await backfillIntegration(); await backfillServiceTokenMultiScope(); await backfillTrustedIps(); + await backfillUserAuthMethods(); // re-encrypt any data previously encrypted under server hex 128-bit ENCRYPTION_KEY // to base64 256-bit ROOT_ENCRYPTION_KEY diff --git a/frontend/src/hooks/api/users/index.tsx b/frontend/src/hooks/api/users/index.tsx index 1853fef7a..e4d94d041 100644 --- a/frontend/src/hooks/api/users/index.tsx +++ b/frontend/src/hooks/api/users/index.tsx @@ -17,6 +17,5 @@ export { useRevokeMySessions, useUpdateMfaEnabled, useUpdateOrgUserRole, - useUpdateUserAuthProvider, - useUpdateUserAuthProviders, + useUpdateUserAuthMethods } from "./queries"; \ No newline at end of file diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx index 643937c22..e8a7a45f6 100644 --- a/frontend/src/hooks/api/users/queries.tsx +++ b/frontend/src/hooks/api/users/queries.tsx @@ -13,14 +13,14 @@ import { AddUserToWsDTO, AddUserToWsRes, APIKeyData, + AuthMethod, CreateAPIKeyRes, DeletOrgMembershipDTO, OrgUser, RenameUserDTO, TokenVersion, UpdateOrgUserRoleDTO, - User -} from "./types"; + User} from "./types"; const userKeys = { getUser: ["user"] as const, @@ -61,39 +61,18 @@ export const useRenameUser = () => { }); }; -export const useUpdateUserAuthProvider = () => { + +export const useUpdateUserAuthMethods = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: async ({ - authProvider + authMethods }: { - authProvider: string; + authMethods: AuthMethod[]; }) => { - const { data: { user } } = await apiRequest.patch("/api/v2/users/me/auth-provider", { - authProvider - }); - - return user; - }, - onSuccess: () => { - queryClient.invalidateQueries(userKeys.getUser); - } - }); -}; - - -export const useUpdateUserAuthProviders = () => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async ({ - authProviders - }: { - authProviders: string[]; - }) => { - const { data: { user } } = await apiRequest.put("/api/v2/users/me/auth-providers", { - authProviders + const { data: { user } } = await apiRequest.put("/api/v2/users/me/auth-methods", { + authMethods }); return user; diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index a2c084b10..8e0d19646 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -1,9 +1,12 @@ import { UserWsKeyPair } from "../keys/types"; -export enum AuthProvider { +export enum AuthMethod { EMAIL = "email", GOOGLE = "google", - OKTA_SAML = "okta-saml" + GITHUB = "github", + OKTA_SAML = "okta-saml", + AZURE_SAML = "azure-saml", + JUMPCLOUD_SAML = "jumpcloud-saml" } export type User = { @@ -12,8 +15,8 @@ export type User = { email: string; firstName?: string; lastName?: string; - authProvider?: AuthProvider; - authProviders?: AuthProvider[]; + authProvider?: AuthMethod; + authMethods: AuthMethod[]; encryptionVersion?: number; protectedKey?: string; protectedKeyIV?: string; diff --git a/frontend/src/views/Settings/PersonalSettingsPage/AuthMethodSection/AuthMethodSection.tsx b/frontend/src/views/Settings/PersonalSettingsPage/AuthMethodSection/AuthMethodSection.tsx index 5458525ce..0f3647350 100644 --- a/frontend/src/views/Settings/PersonalSettingsPage/AuthMethodSection/AuthMethodSection.tsx +++ b/frontend/src/views/Settings/PersonalSettingsPage/AuthMethodSection/AuthMethodSection.tsx @@ -1,27 +1,33 @@ import { useEffect } from "react"; import { useForm } from "react-hook-form"; +import { faGithub, faGoogle, IconDefinition } from "@fortawesome/free-brands-svg-icons"; +import { faEnvelope } from "@fortawesome/free-regular-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { yupResolver } from "@hookform/resolvers/yup"; import * as yup from "yup"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; -import { - Button, - Checkbox -} from "@app/components/v2"; +import { Switch } from "@app/components/v2"; import { useUser } from "@app/context"; +import { useUpdateUserAuthMethods } from "@app/hooks/api"; import { - useUpdateUserAuthProviders -} from "@app/hooks/api"; + AuthMethod +} from "@app/hooks/api/users/types"; -const authMethodList = [ - { label: "Email", value: "email" }, - { label: "Google SSO", value: "google" }, - { label: "GitHub SSO", value: "github" }, - { label: "Okta SAML", value: "okta-saml" }, - { label: "Azure SAML", value: "azure-saml" }, - { label: "JumpCloud SAML", value: "jumpcloud-saml" } +interface AuthMethodOption { + label: string, + value: AuthMethod, + icon: IconDefinition; +} + +const authMethodOpts: AuthMethodOption[] = [ + { label: "Email", value: AuthMethod.EMAIL, icon: faEnvelope }, + { label: "Google", value: AuthMethod.GOOGLE, icon: faGoogle }, + { label: "GitHub", value: AuthMethod.GITHUB, icon: faGithub } ]; +const samlProviders = [AuthMethod.OKTA_SAML, AuthMethod.JUMPCLOUD_SAML, AuthMethod.AZURE_SAML]; + const schema = yup.object({ authMethods: yup.array().required("Auth method is required") }); @@ -31,108 +37,101 @@ export type FormData = yup.InferType; export const AuthMethodSection = () => { const { createNotification } = useNotificationContext(); const { user } = useUser(); - const { mutateAsync, isLoading } = useUpdateUserAuthProviders(); - - const defaultAuthMethods = user.authProviders?.length ? - user.authProviders : - [user?.authProvider ?? "email"]; + const { mutateAsync } = useUpdateUserAuthMethods(); const { reset, - handleSubmit, setValue, watch, } = useForm({ defaultValues: { - authMethods: defaultAuthMethods, + authMethods: user.authMethods, }, resolver: yupResolver(schema) }); - const selectedAuthMethods = watch("authMethods"); + const authMethods = watch("authMethods"); useEffect(() => { if (user) { reset({ - authMethods: defaultAuthMethods, + authMethods: user.authMethods, }); } }, [user]); + + const onAuthMethodToggle = async (value: boolean, authMethodOpt: AuthMethodOption) => { + const hasSamlEnabled = user.authMethods + .some((authMethod: AuthMethod) => samlProviders.includes(authMethod)); - const onFormSubmit = async ({ - authMethods - }: FormData) => { - try { - if ( - authMethods.includes("okta-saml") - || authMethods.includes("azure-saml") - || authMethods.includes("jumpcloud-saml") - ) { - createNotification({ - text: "SAML authentication can only be configured in your organization settings", - type: "error" - }); - - return; - } - - await mutateAsync({ - authProviders: authMethods - }); - + if (hasSamlEnabled) { createNotification({ - text: "Successfully updated authentication method", - type: "success" - }); - } catch (err) { - console.error(err); - createNotification({ - text: "Failed to update authentication method", + text: "SAML authentication can only be configured in your organization settings", type: "error" }); } + + const newAuthMethods = value + ? [...authMethods, authMethodOpt.value] + : authMethods.filter(auth => auth !== authMethodOpt.value); + + if (value) { + const newUser = await mutateAsync({ + authMethods: newAuthMethods + }); + + setValue("authMethods", newUser.authMethods); + createNotification({ + text: "Successfully enabled authentication method", + type: "success" + }); + return; + } + + if (newAuthMethods.length === 0) { + createNotification({ + text: "You must keep at least 1 authentication method enabled", + type: "error" + }); + return; + } + + const newUser = await mutateAsync({ + authMethods: newAuthMethods + }); + + setValue("authMethods", newUser.authMethods); + createNotification({ + text: "Successfully disabled authentication method", + type: "success" + }); } return ( -
-

- Authentication Method +
+

+ Authentication methods

-
- { - authMethodList.map(authMethod => ( - { - if (checked) { - setValue("authMethods", [ - ...selectedAuthMethods, - authMethod.value - ]) - } else { - setValue("authMethods", selectedAuthMethods.filter(auth => auth !== authMethod.value)) - } - }}> - {authMethod.label} - - )) - } +

+ By enabling a SSO provider, you are allowing an account with that provider which uses the same email address as your existing Infisical account to be able to log in to Infisical. +

+
+ {user && authMethodOpts.map((authMethodOpt) => { + return ( +
+
+ +

{authMethodOpt.label}

+
+ onAuthMethodToggle(value, authMethodOpt)} + isChecked={authMethods?.includes(authMethodOpt.value) ?? false} + /> +
+ ); + })}
- - - +
); }