diff --git a/backend/package-lock.json b/backend/package-lock.json index 1423cf03e..49b39c030 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -46,6 +46,7 @@ "nodemailer": "^6.8.0", "passport": "^0.6.0", "passport-github": "^1.1.0", + "passport-gitlab2": "^5.0.0", "passport-google-oauth20": "^2.0.0", "posthog-node": "^2.6.0", "probot": "^12.3.1", @@ -13698,6 +13699,17 @@ "node": ">= 0.4.0" } }, + "node_modules/passport-gitlab2": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/passport-gitlab2/-/passport-gitlab2-5.0.0.tgz", + "integrity": "sha512-cXQMgM6JQx9wHVh7JLH30D8fplfwjsDwRz+zS0pqC8JS+4bNmc1J04NGp5g2M4yfwylH9kQRrMN98GxMw7q7cg==", + "dependencies": { + "passport-oauth2": "^1.4.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/passport-google-oauth20": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", @@ -27074,6 +27086,14 @@ "passport-oauth2": "1.x.x" } }, + "passport-gitlab2": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/passport-gitlab2/-/passport-gitlab2-5.0.0.tgz", + "integrity": "sha512-cXQMgM6JQx9wHVh7JLH30D8fplfwjsDwRz+zS0pqC8JS+4bNmc1J04NGp5g2M4yfwylH9kQRrMN98GxMw7q7cg==", + "requires": { + "passport-oauth2": "^1.4.0" + } + }, "passport-google-oauth20": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index a70ea9974..31528343f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -37,6 +37,7 @@ "nodemailer": "^6.8.0", "passport": "^0.6.0", "passport-github": "^1.1.0", + "passport-gitlab2": "^5.0.0", "passport-google-oauth20": "^2.0.0", "posthog-node": "^2.6.0", "probot": "^12.3.1", diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index b5e50d9da..07c2e7438 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -52,6 +52,8 @@ export const getClientIdGoogleLogin = async () => (await client.getSecret("CLIEN export const getClientSecretGoogleLogin = async () => (await client.getSecret("CLIENT_SECRET_GOOGLE_LOGIN")).secretValue; export const getClientIdGitHubLogin = async () => (await client.getSecret("CLIENT_ID_GITHUB_LOGIN")).secretValue; export const getClientSecretGitHubLogin = async () => (await client.getSecret("CLIENT_SECRET_GITHUB_LOGIN")).secretValue; +export const getClientIdGitLabLogin = async () => (await client.getSecret("CLIENT_ID_GITLAB_LOGIN")).secretValue; +export const getClientSecretGitLabLogin = async () => (await client.getSecret("CLIENT_SECRET_GITLAB_LOGIN")).secretValue; export const getPostHogHost = async () => (await client.getSecret("POSTHOG_HOST")).secretValue || "https://app.posthog.com"; export const getPostHogProjectApiKey = async () => (await client.getSecret("POSTHOG_PROJECT_API_KEY")).secretValue || "phc_nSin8j5q2zdhpFDI1ETmFNUIuTG4DwKVyIigrY10XiE"; diff --git a/backend/src/ee/routes/v1/sso.ts b/backend/src/ee/routes/v1/sso.ts index 005f84a0e..df4242cc8 100644 --- a/backend/src/ee/routes/v1/sso.ts +++ b/backend/src/ee/routes/v1/sso.ts @@ -65,6 +65,29 @@ router.get( ssoController.redirectSSO ); +router.get( + "/redirect/gitlab", + authLimiter, + (req, res, next) => { + passport.authenticate("gitlab", { + session: false, + ...(req.query.callback_port ? { + state: req.query.callback_port as string + } : {}) + })(req, res, next); + } +) + +router.get( + "/gitlab", + authLimiter, + passport.authenticate("gitlab", { + failureRedirect: "/login/provider/error", + session: false + }), + ssoController.redirectSSO +) + router.get( "/redirect/saml2/:ssoIdentifier", authLimiter, diff --git a/backend/src/models/user.ts b/backend/src/models/user.ts index c85c0936b..911776a63 100644 --- a/backend/src/models/user.ts +++ b/backend/src/models/user.ts @@ -4,6 +4,7 @@ export enum AuthMethod { EMAIL = "email", GOOGLE = "google", GITHUB = "github", + GITLAB = "gitlab", OKTA_SAML = "okta-saml", AZURE_SAML = "azure-saml", JUMPCLOUD_SAML = "jumpcloud-saml", diff --git a/backend/src/routes/v2/users.ts b/backend/src/routes/v2/users.ts index c13abfcb9..850fa2cc2 100644 --- a/backend/src/routes/v2/users.ts +++ b/backend/src/routes/v2/users.ts @@ -51,7 +51,8 @@ router.put( return authMethods.every(provider => [ AuthMethod.EMAIL, AuthMethod.GOOGLE, - AuthMethod.GITHUB + AuthMethod.GITHUB, + AuthMethod.GITLAB ].includes(provider)) }), validateRequest, diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index 0f50bf639..0cddf3566 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -155,6 +155,12 @@ Other environment variables are listed below to increase the functionality of yo OAuth2 client secret for GitHub login + + OAuth2 client ID for GitLab login + + + OAuth2 client secret for GitLab login + #### JWT diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index 8e0d19646..903bfea3e 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -4,6 +4,7 @@ export enum AuthMethod { EMAIL = "email", GOOGLE = "google", GITHUB = "github", + GITLAB = "gitlab", OKTA_SAML = "okta-saml", AZURE_SAML = "azure-saml", JUMPCLOUD_SAML = "jumpcloud-saml" diff --git a/frontend/src/views/Login/components/InitialStep/InitialStep.tsx b/frontend/src/views/Login/components/InitialStep/InitialStep.tsx index 7bab7414e..50eaf5cb7 100644 --- a/frontend/src/views/Login/components/InitialStep/InitialStep.tsx +++ b/frontend/src/views/Login/components/InitialStep/InitialStep.tsx @@ -2,10 +2,10 @@ import { FormEvent, useState } from "react"; import { useTranslation } from "react-i18next"; import Link from "next/link"; import { useRouter } from "next/router"; -import { faGithub,faGoogle } from "@fortawesome/free-brands-svg-icons"; +import { faGithub, faGitlab, faGoogle } from "@fortawesome/free-brands-svg-icons"; import { faLock } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import axios from "axios" +import axios from "axios"; import Error from "@app/components/basic/Error"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; @@ -16,208 +16,234 @@ import { fetchOrganizations } from "@app/hooks/api/organization/queries"; import { useFetchServerStatus } from "@app/hooks/api/serverDetails"; type Props = { - setStep: (step: number) => void; - email: string; - setEmail: (email: string) => void; - password: string; - setPassword: (email: string) => void; -} + setStep: (step: number) => void; + email: string; + setEmail: (email: string) => void; + password: string; + setPassword: (email: string) => void; +}; -export const InitialStep = ({ - setStep, - email, - setEmail, - password, - setPassword -}: Props) => { - const router = useRouter(); - const { createNotification } = useNotificationContext(); - const { t } = useTranslation(); - const [isLoading, setIsLoading] = useState(false); - const [loginError, setLoginError] = useState(false); - const { data: serverDetails } = useFetchServerStatus(); - const queryParams = new URLSearchParams(window.location.search); +export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: Props) => { + const router = useRouter(); + const { createNotification } = useNotificationContext(); + const { t } = useTranslation(); + const [isLoading, setIsLoading] = useState(false); + const [loginError, setLoginError] = useState(false); + const { data: serverDetails } = useFetchServerStatus(); + const queryParams = new URLSearchParams(window.location.search); - const handleLogin = async (e: FormEvent) => { - e.preventDefault() - try { - if (!email || !password) { - return; - } + const handleLogin = async (e: FormEvent) => { + e.preventDefault(); + try { + if (!email || !password) { + return; + } - setIsLoading(true); - if (queryParams && queryParams.get("callback_port")) { - const callbackPort = queryParams.get("callback_port") + setIsLoading(true); + if (queryParams && queryParams.get("callback_port")) { + const callbackPort = queryParams.get("callback_port"); - // attemptCliLogin - const isCliLoginSuccessful = await attemptCliLogin({ - email: email.toLowerCase(), - password, - }) + // attemptCliLogin + const isCliLoginSuccessful = await attemptCliLogin({ + email: email.toLowerCase(), + password + }); - if (isCliLoginSuccessful && isCliLoginSuccessful.success) { + if (isCliLoginSuccessful && isCliLoginSuccessful.success) { + if (isCliLoginSuccessful.mfaEnabled) { + // case: login requires MFA step + setStep(1); + setIsLoading(false); + return; + } + // case: login was successful + const cliUrl = `http://localhost:${callbackPort}`; - if (isCliLoginSuccessful.mfaEnabled) { - // case: login requires MFA step - setStep(1); - setIsLoading(false); - return; - } - // case: login was successful - const cliUrl = `http://localhost:${callbackPort}` + // send request to server endpoint + const instance = axios.create(); + await instance.post(cliUrl, { ...isCliLoginSuccessful.loginResponse }); - // send request to server endpoint - const instance = axios.create() - await instance.post(cliUrl, { ...isCliLoginSuccessful.loginResponse }) + // cli page + router.push("/cli-redirect"); - // cli page - router.push("/cli-redirect"); - - // on success, router.push to cli Login Successful page - - } - } else { - const isLoginSuccessful = await attemptLogin({ - email: email.toLowerCase(), - password, - }); - if (isLoginSuccessful && isLoginSuccessful.success) { - // case: login was successful - - if (isLoginSuccessful.mfaEnabled) { - // case: login requires MFA step - setStep(1); - setIsLoading(false); - return; - } - const userOrgs = await fetchOrganizations(); - const userOrg = userOrgs[0] && userOrgs[0]._id; - - // case: login does not require MFA step - createNotification({ - text: "Successfully logged in", - type: "success" - }); - router.push(`/org/${userOrg}/overview`); - } - } - - - } catch (err) { - setLoginError(true); - createNotification({ - text: "Login unsuccessful. Double-check your credentials and try again.", - type: "error" - }); + // on success, router.push to cli Login Successful page } + } else { + const isLoginSuccessful = await attemptLogin({ + email: email.toLowerCase(), + password + }); + if (isLoginSuccessful && isLoginSuccessful.success) { + // case: login was successful - setIsLoading(false); + if (isLoginSuccessful.mfaEnabled) { + // case: login requires MFA step + setStep(1); + setIsLoading(false); + return; + } + const userOrgs = await fetchOrganizations(); + const userOrg = userOrgs[0] && userOrgs[0]._id; + + // case: login does not require MFA step + createNotification({ + text: "Successfully logged in", + type: "success" + }); + router.push(`/org/${userOrg}/overview`); + } + } + } catch (err) { + setLoginError(true); + createNotification({ + text: "Login unsuccessful. Double-check your credentials and try again.", + type: "error" + }); } - return ( -
-

Login to Infisical

-
- -
-
- -
-
- -
-
-
- or -
-
-
- setEmail(e.target.value)} - type="email" - placeholder="Enter your email..." - isRequired - autoComplete="username" - className="h-11" - /> -
-
- setPassword(e.target.value)} - type="password" - placeholder="Enter your password..." - isRequired - autoComplete="current-password" - id="current-password" - className="h-11 select:-webkit-autofill:focus" - /> -
-
- -
- {!isLoading && loginError && } - { - !serverDetails?.inviteOnlySignup ? -
- Don't have an acount yet? - - {t("login.create-account")} - -
:
- } -
- Forgot password? - - Recover your account - -
- - ); -} \ No newline at end of file + setIsLoading(false); + }; + + return ( +
+

+ Login to Infisical +

+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ or +
+
+
+ setEmail(e.target.value)} + type="email" + placeholder="Enter your email..." + isRequired + autoComplete="username" + className="h-11" + /> +
+
+ setPassword(e.target.value)} + type="password" + placeholder="Enter your password..." + isRequired + autoComplete="current-password" + id="current-password" + className="select:-webkit-autofill:focus h-11" + /> +
+
+ +
+ {!isLoading && loginError && } + {!serverDetails?.inviteOnlySignup ? ( +
+ Don't have an acount yet? + + + {t("login.create-account")} + + +
+ ) : ( +
+ )} +
+ Forgot password? + + + Recover your account + + +
+ + ); +}; diff --git a/frontend/src/views/Settings/PersonalSettingsPage/AuthMethodSection/AuthMethodSection.tsx b/frontend/src/views/Settings/PersonalSettingsPage/AuthMethodSection/AuthMethodSection.tsx index 42917aae6..73d266e2e 100644 --- a/frontend/src/views/Settings/PersonalSettingsPage/AuthMethodSection/AuthMethodSection.tsx +++ b/frontend/src/views/Settings/PersonalSettingsPage/AuthMethodSection/AuthMethodSection.tsx @@ -1,6 +1,6 @@ import { useEffect } from "react"; import { useForm } from "react-hook-form"; -import { faGithub, faGoogle, IconDefinition } from "@fortawesome/free-brands-svg-icons"; +import { faGithub, faGitlab, 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"; @@ -10,129 +10,127 @@ import { useNotificationContext } from "@app/components/context/Notifications/No import { Switch } from "@app/components/v2"; import { useUser } from "@app/context"; import { useUpdateUserAuthMethods } from "@app/hooks/api"; -import { - AuthMethod -} from "@app/hooks/api/users/types"; +import { AuthMethod } from "@app/hooks/api/users/types"; interface AuthMethodOption { - label: string, - value: AuthMethod, - icon: IconDefinition; + 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 } + { label: "Email", value: AuthMethod.EMAIL, icon: faEnvelope }, + { label: "Google", value: AuthMethod.GOOGLE, icon: faGoogle }, + { label: "GitHub", value: AuthMethod.GITHUB, icon: faGithub }, + { label: "GitLab", value: AuthMethod.GITLAB, icon: faGitlab } ]; const samlProviders = [AuthMethod.OKTA_SAML, AuthMethod.JUMPCLOUD_SAML, AuthMethod.AZURE_SAML]; const schema = yup.object({ - authMethods: yup.array().required("Auth method is required") + authMethods: yup.array().required("Auth method is required") }); export type FormData = yup.InferType; export const AuthMethodSection = () => { - const { createNotification } = useNotificationContext(); - const { user } = useUser(); - const { mutateAsync } = useUpdateUserAuthMethods(); - - const { - reset, - setValue, - watch, - } = useForm({ - defaultValues: { - authMethods: user.authMethods, - }, - resolver: yupResolver(schema) - }); - - const authMethods = watch("authMethods"); - - useEffect(() => { - if (user) { - reset({ - authMethods: user.authMethods, - }); - } - }, [user]); - - const onAuthMethodToggle = async (value: boolean, authMethodOpt: AuthMethodOption) => { - const hasSamlEnabled = user.authMethods - .some((authMethod: AuthMethod) => samlProviders.includes(authMethod)); + const { createNotification } = useNotificationContext(); + const { user } = useUser(); + const { mutateAsync } = useUpdateUserAuthMethods(); - if (hasSamlEnabled) { - createNotification({ - 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 - }); + const { reset, setValue, watch } = useForm({ + defaultValues: { + authMethods: user.authMethods + }, + resolver: yupResolver(schema) + }); - 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" - }); + const authMethods = watch("authMethods"); + + useEffect(() => { + if (user) { + reset({ + authMethods: user.authMethods + }); } - - return ( -
-

- Authentication methods -

-

- 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 ( -
-
- -
- onAuthMethodToggle(value, authMethodOpt)} - isChecked={authMethods?.includes(authMethodOpt.value) ?? false} - > -

{authMethodOpt.label}

-
-
- ); - })} -
-
+ }, [user]); + + const onAuthMethodToggle = async (value: boolean, authMethodOpt: AuthMethodOption) => { + const hasSamlEnabled = user.authMethods.some((authMethod: AuthMethod) => + samlProviders.includes(authMethod) ); -} + + if (hasSamlEnabled) { + createNotification({ + 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 methods +

+

+ 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 ( +
+
+ +
+ onAuthMethodToggle(value, authMethodOpt)} + isChecked={authMethods?.includes(authMethodOpt.value) ?? false} + > +

{authMethodOpt.label}

+
+
+ ); + })} +
+
+ ); +};