From 5604232aeaef62f0b65cb0824f11219e447785c4 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Sun, 6 Aug 2023 19:09:38 +0800 Subject: [PATCH 01/12] added user controller and modified auth method page --- backend/src/controllers/v2/usersController.ts | 37 ++++++++ backend/src/models/user.ts | 1 + backend/src/routes/v2/users.ts | 18 ++++ frontend/src/components/v2/Select/Select.tsx | 1 + frontend/src/hooks/api/users/index.tsx | 3 +- frontend/src/hooks/api/users/queries.tsx | 22 +++++ frontend/src/hooks/api/users/types.ts | 1 + .../AuthMethodSection/AuthMethodSection.tsx | 87 +++++++++---------- 8 files changed, 123 insertions(+), 47 deletions(-) diff --git a/backend/src/controllers/v2/usersController.ts b/backend/src/controllers/v2/usersController.ts index 66a48b550..48acbf490 100644 --- a/backend/src/controllers/v2/usersController.ts +++ b/backend/src/controllers/v2/usersController.ts @@ -148,6 +148,43 @@ export const updateAuthProvider = async (req: Request, res: Response) => { }); } +/** + * 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 + }, + { + new: true + } + ); + + return res.status(200).send({ + user + }); +} + + /** * Return organizations that the current user is part of. * @param req diff --git a/backend/src/models/user.ts b/backend/src/models/user.ts index fb14e8b8a..77b8f522f 100644 --- a/backend/src/models/user.ts +++ b/backend/src/models/user.ts @@ -13,6 +13,7 @@ export interface IUser extends Document { _id: Types.ObjectId; authId?: string; authProvider?: AuthProvider; + authProviders?: AuthProvider[]; email: string; firstName?: string; lastName?: string; diff --git a/backend/src/routes/v2/users.ts b/backend/src/routes/v2/users.ts index 334ef523b..6042e6e3f 100644 --- a/backend/src/routes/v2/users.ts +++ b/backend/src/routes/v2/users.ts @@ -57,6 +57,24 @@ router.patch( usersController.updateAuthProvider ); +router.put( + "/me/auth-providers", + requireAuth({ + acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY], + }), + body("authProviders").exists().isArray({ + min: 1, + }).custom((authProviders: AuthProvider[]) => { + return authProviders.every(provider => [ + AuthProvider.EMAIL, + AuthProvider.GOOGLE, + AuthProvider.GITHUB + ].includes(provider)) + }), + validateRequest, + usersController.updateAuthProviders, +); + router.get( "/me/organizations", requireAuth({ diff --git a/frontend/src/components/v2/Select/Select.tsx b/frontend/src/components/v2/Select/Select.tsx index cdf790634..ccac29a2a 100644 --- a/frontend/src/components/v2/Select/Select.tsx +++ b/frontend/src/components/v2/Select/Select.tsx @@ -16,6 +16,7 @@ type Props = { position?: "item-aligned" | "popper"; isDisabled?: boolean; icon?: IconProp; + isMulti?: boolean; }; export type SelectProps = Omit & Props; diff --git a/frontend/src/hooks/api/users/index.tsx b/frontend/src/hooks/api/users/index.tsx index a209367e2..8aec39c4e 100644 --- a/frontend/src/hooks/api/users/index.tsx +++ b/frontend/src/hooks/api/users/index.tsx @@ -15,5 +15,6 @@ export { useRegisterUserAction, useRevokeMySessions, useUpdateOrgUserRole, - useUpdateUserAuthProvider + useUpdateUserAuthProvider, + useUpdateUserAuthProviders, } from "./queries"; diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx index 93d8d41de..0def469e9 100644 --- a/frontend/src/hooks/api/users/queries.tsx +++ b/frontend/src/hooks/api/users/queries.tsx @@ -80,6 +80,28 @@ export const useUpdateUserAuthProvider = () => { }); }; + +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 + }); + + return user; + }, + onSuccess: () => { + queryClient.invalidateQueries(userKeys.getUser); + } + }); +}; + export const useGetUserAction = (action: string) => useQuery({ queryKey: userKeys.userAction, diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index 0c312b2b6..51bfa3452 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -13,6 +13,7 @@ export type User = { firstName?: string; lastName?: string; authProvider?: AuthProvider; + authProviders?: AuthProvider[]; 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 b8246180c..6d874de0e 100644 --- a/frontend/src/views/Settings/PersonalSettingsPage/AuthMethodSection/AuthMethodSection.tsx +++ b/frontend/src/views/Settings/PersonalSettingsPage/AuthMethodSection/AuthMethodSection.tsx @@ -1,17 +1,16 @@ import { useEffect } from "react"; -import { Controller, useForm } from "react-hook-form"; +import { useForm } from "react-hook-form"; import { yupResolver } from "@hookform/resolvers/yup"; import * as yup from "yup"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { Button, - FormControl, - Select, - SelectItem} from "@app/components/v2"; + Checkbox +} from "@app/components/v2"; import { useUser } from "@app/context"; import { - useUpdateUserAuthProvider + useUpdateUserAuthProviders } from "@app/hooks/api"; const authMethods = [ @@ -24,7 +23,7 @@ const authMethods = [ ]; const schema = yup.object({ - authMethod: yup.string().required("Auth method is required") + authMethods: yup.array().required("Auth method is required") }); export type FormData = yup.InferType; @@ -32,35 +31,38 @@ export type FormData = yup.InferType; export const AuthMethodSection = () => { const { createNotification } = useNotificationContext(); const { user } = useUser(); - const { mutateAsync, isLoading } = useUpdateUserAuthProvider(); + const { mutateAsync, isLoading } = useUpdateUserAuthProviders(); const { reset, - control, - handleSubmit + handleSubmit, + setValue, + watch, } = useForm({ defaultValues: { - authMethod: user?.authProvider ?? "email" + authMethods: [user?.authProvider ?? "email"] }, resolver: yupResolver(schema) }); + const selectedAuthMethods = watch("authMethods"); + useEffect(() => { if (user) { reset({ - authMethod: user?.authProvider ?? "email" + authMethods: [user?.authProvider ?? "email"] }); } }, [user]); const onFormSubmit = async ({ - authMethod + authMethods }: FormData) => { try { if ( - authMethod === "okta-saml" - || authMethod === "azure-saml" - || authMethod === "jumpcloud-saml" + authMethods.includes("okta-saml") + || authMethods.includes("azure-saml") + || authMethods.includes("jumpcloud-saml") ) { createNotification({ text: "SAML authentication can only be configured in your organization settings", @@ -71,7 +73,7 @@ export const AuthMethodSection = () => { } await mutateAsync({ - authProvider: authMethod + authProviders: authMethods }); createNotification({ @@ -96,36 +98,29 @@ export const AuthMethodSection = () => { Authentication Method
- ( - - - - )} - /> + { + authMethods.map(authMethod => ( + { + if (checked) { + setValue("authMethods", [ + ...selectedAuthMethods, + authMethod.value + ]) + } else { + setValue("authMethods", selectedAuthMethods.filter(auth => auth !== authMethod.value)) + } + }}> + {authMethod.label} + + )) + }
+ ); -} \ No newline at end of file +} From 04fdccc45d7c8a49dfc6f82a7de1780c678ff538 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Sun, 6 Aug 2023 19:45:27 +0800 Subject: [PATCH 02/12] modified backend controllers to support new auth providers --- backend/src/controllers/v3/authController.ts | 10 ++++++++-- backend/src/helpers/auth.ts | 7 +++++-- backend/src/models/user.ts | 3 +++ backend/src/utils/auth.ts | 3 +++ .../AuthMethodSection/AuthMethodSection.tsx | 8 ++++++-- 5 files changed, 25 insertions(+), 6 deletions(-) diff --git a/backend/src/controllers/v3/authController.ts b/backend/src/controllers/v3/authController.ts index 08ef7fa8d..69aa8874e 100644 --- a/backend/src/controllers/v3/authController.ts +++ b/backend/src/controllers/v3/authController.ts @@ -56,7 +56,10 @@ export const login1 = async (req: Request, res: Response) => { if (!user) throw new Error("Failed to find user"); - if (user.authProvider && user.authProvider !== AuthProvider.EMAIL) { + const shouldValidateProviderAuth = (user.authProvider && user.authProvider !== AuthProvider.EMAIL) + || (user.authProviders && !user.authProviders?.includes(AuthProvider.EMAIL)) + + if (shouldValidateProviderAuth) { await validateProviderAuthToken({ email, user, @@ -116,7 +119,10 @@ export const login2 = async (req: Request, res: Response) => { if (!user) throw new Error("Failed to find user"); - if (user.authProvider && user.authProvider !== AuthProvider.EMAIL) { + const shouldValidateProviderAuth = (user.authProvider && user.authProvider !== AuthProvider.EMAIL) + || (user.authProviders && !user.authProviders?.includes(AuthProvider.EMAIL)) + + if (shouldValidateProviderAuth) { await validateProviderAuthToken({ email, user, diff --git a/backend/src/helpers/auth.ts b/backend/src/helpers/auth.ts index eb6eeb4d2..f6b676a36 100644 --- a/backend/src/helpers/auth.ts +++ b/backend/src/helpers/auth.ts @@ -390,10 +390,13 @@ export const validateProviderAuthToken = async ({ jwt.verify(providerAuthToken, await getJwtProviderAuthSecret()) ); + const doesProviderMatch = (user.authProvider && user.authProvider === decodedToken.authProvider) + || (user.authProviders && user.authProviders.includes(decodedToken.authProvider)); + if ( - decodedToken.authProvider !== user.authProvider || + !doesProviderMatch || decodedToken.email !== email ) { throw new Error("Invalid authentication credentials.") } -} \ No newline at end of file +} diff --git a/backend/src/models/user.ts b/backend/src/models/user.ts index 77b8f522f..9251c487d 100644 --- a/backend/src/models/user.ts +++ b/backend/src/models/user.ts @@ -44,6 +44,9 @@ const userSchema = new Schema( type: String, enum: AuthProvider, }, + authProviders: [{ + type: String, + }], email: { type: String, required: true, diff --git a/backend/src/utils/auth.ts b/backend/src/utils/auth.ts index 8faba80bf..3fdd5915f 100644 --- a/backend/src/utils/auth.ts +++ b/backend/src/utils/auth.ts @@ -119,6 +119,7 @@ const initializePassport = async () => { firstName: user.firstName, lastName: user.lastName, authProvider: user.authProvider, + authProviders: user.authProviders, isUserCompleted, ...(req.query.state ? { callbackPort: req.query.state as string @@ -173,6 +174,7 @@ const initializePassport = async () => { firstName: user.firstName, lastName: user.lastName, authProvider: user.authProvider, + authProviders: user.authProviders, isUserCompleted, ...(req.query.state ? { callbackPort: req.query.state as string @@ -302,6 +304,7 @@ const initializePassport = async () => { lastName, organizationName: organization?.name, authProvider: user.authProvider, + authProviders: user.authProviders, isUserCompleted, ...(req.body.RelayState ? { callbackPort: req.body.RelayState as string diff --git a/frontend/src/views/Settings/PersonalSettingsPage/AuthMethodSection/AuthMethodSection.tsx b/frontend/src/views/Settings/PersonalSettingsPage/AuthMethodSection/AuthMethodSection.tsx index 6d874de0e..6e3b006e3 100644 --- a/frontend/src/views/Settings/PersonalSettingsPage/AuthMethodSection/AuthMethodSection.tsx +++ b/frontend/src/views/Settings/PersonalSettingsPage/AuthMethodSection/AuthMethodSection.tsx @@ -33,6 +33,10 @@ export const AuthMethodSection = () => { const { user } = useUser(); const { mutateAsync, isLoading } = useUpdateUserAuthProviders(); + const defaultAuthMethods = user.authProviders?.length ? + user.authProviders : + [user?.authProvider ?? "email"]; + const { reset, handleSubmit, @@ -40,7 +44,7 @@ export const AuthMethodSection = () => { watch, } = useForm({ defaultValues: { - authMethods: [user?.authProvider ?? "email"] + authMethods: defaultAuthMethods, }, resolver: yupResolver(schema) }); @@ -50,7 +54,7 @@ export const AuthMethodSection = () => { useEffect(() => { if (user) { reset({ - authMethods: [user?.authProvider ?? "email"] + authMethods: defaultAuthMethods, }); } }, [user]); From 3a9bf5409be87fd81c0e8305909537a97de89bf2 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Sun, 6 Aug 2023 21:46:20 +0800 Subject: [PATCH 03/12] finalization of create token logic --- backend/src/controllers/v3/authController.ts | 14 +++---- backend/src/helpers/auth.ts | 5 +-- backend/src/models/user.ts | 1 + backend/src/utils/auth.ts | 39 ++++++++++---------- 4 files changed, 29 insertions(+), 30 deletions(-) diff --git a/backend/src/controllers/v3/authController.ts b/backend/src/controllers/v3/authController.ts index 69aa8874e..831cb81b7 100644 --- a/backend/src/controllers/v3/authController.ts +++ b/backend/src/controllers/v3/authController.ts @@ -56,10 +56,9 @@ export const login1 = async (req: Request, res: Response) => { if (!user) throw new Error("Failed to find user"); - const shouldValidateProviderAuth = (user.authProvider && user.authProvider !== AuthProvider.EMAIL) - || (user.authProviders && !user.authProviders?.includes(AuthProvider.EMAIL)) - - if (shouldValidateProviderAuth) { + let authProviders = [...(user.authProviders || []), user.authProvider]; + + if (!authProviders.includes(AuthProvider.EMAIL)) { await validateProviderAuthToken({ email, user, @@ -119,10 +118,9 @@ export const login2 = async (req: Request, res: Response) => { if (!user) throw new Error("Failed to find user"); - const shouldValidateProviderAuth = (user.authProvider && user.authProvider !== AuthProvider.EMAIL) - || (user.authProviders && !user.authProviders?.includes(AuthProvider.EMAIL)) - - if (shouldValidateProviderAuth) { + let authProviders = [...(user.authProviders || []), user.authProvider]; + + if (!authProviders.includes(AuthProvider.EMAIL)) { await validateProviderAuthToken({ email, user, diff --git a/backend/src/helpers/auth.ts b/backend/src/helpers/auth.ts index f6b676a36..5e5101ce7 100644 --- a/backend/src/helpers/auth.ts +++ b/backend/src/helpers/auth.ts @@ -390,11 +390,10 @@ export const validateProviderAuthToken = async ({ jwt.verify(providerAuthToken, await getJwtProviderAuthSecret()) ); - const doesProviderMatch = (user.authProvider && user.authProvider === decodedToken.authProvider) - || (user.authProviders && user.authProviders.includes(decodedToken.authProvider)); + let authProviders = [...(user.authProviders || []), user.authProvider]; if ( - !doesProviderMatch || + !authProviders.includes(decodedToken.authProvider) || decodedToken.email !== email ) { throw new Error("Invalid authentication credentials.") diff --git a/backend/src/models/user.ts b/backend/src/models/user.ts index 9251c487d..852214d52 100644 --- a/backend/src/models/user.ts +++ b/backend/src/models/user.ts @@ -46,6 +46,7 @@ const userSchema = new Schema( }, authProviders: [{ type: String, + enum: AuthProvider, }], email: { type: String, diff --git a/backend/src/utils/auth.ts b/backend/src/utils/auth.ts index 3fdd5915f..b58a135c9 100644 --- a/backend/src/utils/auth.ts +++ b/backend/src/utils/auth.ts @@ -97,20 +97,22 @@ const initializePassport = async () => { email }).select("+publicKey"); - if (user && user.authProvider !== AuthProvider.GOOGLE) { - done(InternalServerError()); - } - if (!user) { user = await new User({ email, - authProvider: AuthProvider.GOOGLE, + authProviders: [AuthProvider.GOOGLE], authId: profile.id, firstName: profile.name.givenName, lastName: profile.name.familyName }).save(); } + let authProviders = [...(user.authProviders || []), user.authProvider]; + + if (!authProviders.includes(AuthProvider.GOOGLE)) { + done(InternalServerError()); + } + const isUserCompleted = !!user.publicKey; const providerAuthToken = createToken({ payload: { @@ -118,8 +120,7 @@ const initializePassport = async () => { email: user.email, firstName: user.firstName, lastName: user.lastName, - authProvider: user.authProvider, - authProviders: user.authProviders, + authProvider: AuthProvider.GOOGLE, isUserCompleted, ...(req.query.state ? { callbackPort: req.query.state as string @@ -151,21 +152,23 @@ const initializePassport = async () => { let user = await User.findOne({ email }).select("+publicKey"); - - if (user && user.authProvider !== AuthProvider.GITHUB) { - done(InternalServerError()); - } - + if (!user) { user = await new User({ email: email, - authProvider: AuthProvider.GITHUB, + authProviders: [AuthProvider.GITHUB], authId: profile.id, firstName: profile.displayName, lastName: "" }).save(); } + let authProviders = [...(user.authProviders || []), user.authProvider]; + + if (!authProviders.includes(AuthProvider.GITHUB)) { + done(InternalServerError()); + } + const isUserCompleted = !!user.publicKey; const providerAuthToken = createToken({ payload: { @@ -173,8 +176,7 @@ const initializePassport = async () => { email: user.email, firstName: user.firstName, lastName: user.lastName, - authProvider: user.authProvider, - authProviders: user.authProviders, + authProvider: AuthProvider.GITHUB, isUserCompleted, ...(req.query.state ? { callbackPort: req.query.state as string @@ -249,7 +251,7 @@ const initializePassport = async () => { await User.findByIdAndUpdate( user._id, { - authProvider: req.ssoConfig.authProvider + authProviders: [req.ssoConfig.authProvider] }, { new: true @@ -281,7 +283,7 @@ const initializePassport = async () => { } else { user = await new User({ email, - authProvider: req.ssoConfig.authProvider, + authProviders: [req.ssoConfig.authProvider], firstName, lastName }).save(); @@ -303,8 +305,7 @@ const initializePassport = async () => { firstName, lastName, organizationName: organization?.name, - authProvider: user.authProvider, - authProviders: user.authProviders, + authProvider: req.ssoConfig.authProvider, isUserCompleted, ...(req.body.RelayState ? { callbackPort: req.body.RelayState as string From 0a140f5333042c278e60b5ac292e76746400fe33 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Sun, 6 Aug 2023 22:04:39 +0800 Subject: [PATCH 04/12] updated implementation of user update after sso change --- backend/src/ee/controllers/v1/ssoController.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/src/ee/controllers/v1/ssoController.ts b/backend/src/ee/controllers/v1/ssoController.ts index 42b4d12be..a310927b9 100644 --- a/backend/src/ee/controllers/v1/ssoController.ts +++ b/backend/src/ee/controllers/v1/ssoController.ts @@ -3,6 +3,7 @@ import { Types } from "mongoose"; import { BotOrgService } from "../../../services"; import { SSOConfig } from "../../models"; import { + AuthProvider, MembershipOrg, User } from "../../../models"; @@ -156,7 +157,10 @@ export const updateSSOConfig = async (req: Request, res: Response) => { } }, { - authProvider: ssoConfig.authProvider + authProviders: [ssoConfig.authProvider], + $unset: { + authProvider: 1 + } } ); } else { @@ -167,8 +171,9 @@ export const updateSSOConfig = async (req: Request, res: Response) => { } }, { + authProviders: [AuthProvider.EMAIL], $unset: { - authProvider: 1 + authProvider: 1, } } ); From bde30049bc599c1807f3d038ab3051c2e85815ba Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Sun, 6 Aug 2023 22:29:31 +0800 Subject: [PATCH 05/12] ensured backwards compatibility --- backend/src/controllers/v3/authController.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/backend/src/controllers/v3/authController.ts b/backend/src/controllers/v3/authController.ts index 831cb81b7..0317cb09d 100644 --- a/backend/src/controllers/v3/authController.ts +++ b/backend/src/controllers/v3/authController.ts @@ -56,9 +56,10 @@ export const login1 = async (req: Request, res: Response) => { if (!user) throw new Error("Failed to find user"); - let authProviders = [...(user.authProviders || []), user.authProvider]; + const authProviders = [...(user.authProviders || [])]; + user.authProvider && authProviders.push(user.authProvider); - if (!authProviders.includes(AuthProvider.EMAIL)) { + if (authProviders.length && !authProviders.includes(AuthProvider.EMAIL)) { await validateProviderAuthToken({ email, user, @@ -118,9 +119,10 @@ export const login2 = async (req: Request, res: Response) => { if (!user) throw new Error("Failed to find user"); - let authProviders = [...(user.authProviders || []), user.authProvider]; + const authProviders = [...(user.authProviders || [])]; + user.authProvider && authProviders.push(user.authProvider); - if (!authProviders.includes(AuthProvider.EMAIL)) { + if (authProviders.length && !authProviders.includes(AuthProvider.EMAIL)) { await validateProviderAuthToken({ email, user, From 681255187f76424835cc8c35b3c3cda4882805ba Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Sun, 6 Aug 2023 22:30:02 +0800 Subject: [PATCH 06/12] modified initialize org to check for auth providers --- backend/src/controllers/v3/signupController.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/controllers/v3/signupController.ts b/backend/src/controllers/v3/signupController.ts index b33a7fe0c..06fac4a85 100644 --- a/backend/src/controllers/v3/signupController.ts +++ b/backend/src/controllers/v3/signupController.ts @@ -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.authProvider !== AuthProvider.OKTA_SAML) { + if (!user.authProviders?.includes(AuthProvider.OKTA_SAML)) { // initialize default organization and workspace await initializeDefaultOrg({ organizationName, From b4dbdbabac8e6782c0bc4023413d8059ac3a4a9f Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Sun, 6 Aug 2023 22:33:53 +0800 Subject: [PATCH 07/12] used const --- backend/src/utils/auth.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/utils/auth.ts b/backend/src/utils/auth.ts index b58a135c9..7942de628 100644 --- a/backend/src/utils/auth.ts +++ b/backend/src/utils/auth.ts @@ -107,7 +107,7 @@ const initializePassport = async () => { }).save(); } - let authProviders = [...(user.authProviders || []), user.authProvider]; + const authProviders = [...(user.authProviders || []), user.authProvider]; if (!authProviders.includes(AuthProvider.GOOGLE)) { done(InternalServerError()); @@ -163,7 +163,7 @@ const initializePassport = async () => { }).save(); } - let authProviders = [...(user.authProviders || []), user.authProvider]; + const authProviders = [...(user.authProviders || []), user.authProvider]; if (!authProviders.includes(AuthProvider.GITHUB)) { done(InternalServerError()); From dc3f2c78c1be86871f80862677e7fab64b9979e3 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Sun, 6 Aug 2023 22:38:24 +0800 Subject: [PATCH 08/12] resolved lint issue --- .../AuthMethodSection/AuthMethodSection.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/views/Settings/PersonalSettingsPage/AuthMethodSection/AuthMethodSection.tsx b/frontend/src/views/Settings/PersonalSettingsPage/AuthMethodSection/AuthMethodSection.tsx index 6e3b006e3..5458525ce 100644 --- a/frontend/src/views/Settings/PersonalSettingsPage/AuthMethodSection/AuthMethodSection.tsx +++ b/frontend/src/views/Settings/PersonalSettingsPage/AuthMethodSection/AuthMethodSection.tsx @@ -13,7 +13,7 @@ import { useUpdateUserAuthProviders } from "@app/hooks/api"; -const authMethods = [ +const authMethodList = [ { label: "Email", value: "email" }, { label: "Google SSO", value: "google" }, { label: "GitHub SSO", value: "github" }, @@ -103,7 +103,7 @@ export const AuthMethodSection = () => {
{ - authMethods.map(authMethod => ( + authMethodList.map(authMethod => ( Date: Sat, 12 Aug 2023 13:53:55 +0700 Subject: [PATCH 09/12] 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} + /> +
+ ); + })}
- - - +
); } From c0f3aecad3a6e1aecb255eb0d3b568c2d30a178c Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Sun, 13 Aug 2023 11:12:07 +0700 Subject: [PATCH 10/12] Fix lint issues --- .../AuthMethodSection/AuthMethodSection.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/views/Settings/PersonalSettingsPage/AuthMethodSection/AuthMethodSection.tsx b/frontend/src/views/Settings/PersonalSettingsPage/AuthMethodSection/AuthMethodSection.tsx index 0f3647350..42917aae6 100644 --- a/frontend/src/views/Settings/PersonalSettingsPage/AuthMethodSection/AuthMethodSection.tsx +++ b/frontend/src/views/Settings/PersonalSettingsPage/AuthMethodSection/AuthMethodSection.tsx @@ -119,15 +119,16 @@ export const AuthMethodSection = () => { {user && authMethodOpts.map((authMethodOpt) => { return (
-
+
-

{authMethodOpt.label}

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

{authMethodOpt.label}

+
); })} From 95d25b114eafe7060993682233cca5abae6063f1 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Sun, 13 Aug 2023 14:08:26 +0700 Subject: [PATCH 11/12] Fix incorrect field in validateProviderAuthToken --- backend/src/controllers/v1/signupController.ts | 5 +++-- backend/src/controllers/v3/signupController.ts | 12 +++++++++++- backend/src/helpers/auth.ts | 2 +- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/backend/src/controllers/v1/signupController.ts b/backend/src/controllers/v1/signupController.ts index b545320a6..ce464e214 100644 --- a/backend/src/controllers/v1/signupController.ts +++ b/backend/src/controllers/v1/signupController.ts @@ -1,5 +1,5 @@ import { Request, Response } from "express"; -import { User } from "../../models"; +import { AuthMethod, User } from "../../models"; import { checkEmailVerification, sendEmailVerification } from "../../helpers/signup"; import { createToken } from "../../helpers/auth"; import { BadRequestError } from "../../utils/errors"; @@ -81,7 +81,8 @@ export const verifyEmailSignup = async (req: Request, res: Response) => { if (!user) { user = await new User({ - email + email, + authMethods: [AuthMethod.EMAIL] }).save(); } diff --git a/backend/src/controllers/v3/signupController.ts b/backend/src/controllers/v3/signupController.ts index 12f225c4e..f17384189 100644 --- a/backend/src/controllers/v3/signupController.ts +++ b/backend/src/controllers/v3/signupController.ts @@ -117,7 +117,17 @@ 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.authMethods?.includes(AuthMethod.OKTA_SAML)) { + const hasSamlEnabled = user.authMethods + .some( + (authMethod: AuthMethod) => + [ + AuthMethod.OKTA_SAML, + AuthMethod.AZURE_SAML, + AuthMethod.JUMPCLOUD_SAML + ].includes(authMethod) + ); + + if (!hasSamlEnabled) { // TODO: modify this part // initialize default organization and workspace await initializeDefaultOrg({ organizationName, diff --git a/backend/src/helpers/auth.ts b/backend/src/helpers/auth.ts index e15283c75..f24c54859 100644 --- a/backend/src/helpers/auth.ts +++ b/backend/src/helpers/auth.ts @@ -408,7 +408,7 @@ export const validateProviderAuthToken = async ({ ); if ( - !user.authMethods.includes(decodedToken.authProvider) || + !user.authMethods.includes(decodedToken.authMethod) || decodedToken.email !== email ) { throw new Error("Invalid authentication credentials.") From dd8f55804c9882131b3829fc63f94d1b17cb76c1 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Sun, 13 Aug 2023 16:18:11 +0800 Subject: [PATCH 12/12] finalized sso controller --- backend/src/ee/controllers/v1/ssoController.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/backend/src/ee/controllers/v1/ssoController.ts b/backend/src/ee/controllers/v1/ssoController.ts index 55c518192..d75cf25d2 100644 --- a/backend/src/ee/controllers/v1/ssoController.ts +++ b/backend/src/ee/controllers/v1/ssoController.ts @@ -157,10 +157,7 @@ export const updateSSOConfig = async (req: Request, res: Response) => { } }, { - authProviders: [ssoConfig.authProvider], - $unset: { - authProvider: 1 - } + authMethods: [ssoConfig.authProvider], } ); } else { @@ -171,10 +168,7 @@ export const updateSSOConfig = async (req: Request, res: Response) => { } }, { - authProviders: [AuthMethod.EMAIL], - $unset: { - authProvider: 1, - } + authMethods: [AuthMethod.EMAIL], } ); }