diff --git a/backend/src/db/migrations/20240222201806_admin-signup-control.ts b/backend/src/db/migrations/20240222201806_admin-signup-control.ts new file mode 100644 index 000000000..c52f753c0 --- /dev/null +++ b/backend/src/db/migrations/20240222201806_admin-signup-control.ts @@ -0,0 +1,20 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const isTablePresent = await knex.schema.hasTable(TableName.SuperAdmin); + if (isTablePresent) { + await knex.schema.alterTable(TableName.SuperAdmin, (t) => { + t.string("allowedSignUpDomain"); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.SuperAdmin, "allowedSignUpDomain")) { + await knex.schema.alterTable(TableName.SuperAdmin, (t) => { + t.dropColumn("allowedSignUpDomain"); + }); + } +} diff --git a/backend/src/db/schemas/super-admin.ts b/backend/src/db/schemas/super-admin.ts index 13bf45e7b..b4631195b 100644 --- a/backend/src/db/schemas/super-admin.ts +++ b/backend/src/db/schemas/super-admin.ts @@ -12,7 +12,8 @@ export const SuperAdminSchema = z.object({ initialized: z.boolean().default(false).nullable().optional(), allowSignUp: z.boolean().default(true).nullable().optional(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + allowedSignUpDomain: z.string().nullable().optional() }); export type TSuperAdmin = z.infer; diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index ab069a2dd..b89e3dc4c 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -31,7 +31,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { method: "PATCH", schema: { body: z.object({ - allowSignUp: z.boolean().optional() + allowSignUp: z.boolean().optional(), + allowedSignUpDomain: z.string().optional().nullable() }), response: { 200: z.object({ diff --git a/backend/src/server/routes/v1/sso-router.ts b/backend/src/server/routes/v1/sso-router.ts index bfcf2f6ae..60bbec7db 100644 --- a/backend/src/server/routes/v1/sso-router.ts +++ b/backend/src/server/routes/v1/sso-router.ts @@ -18,7 +18,6 @@ import { BadRequestError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { fetchGithubEmails } from "@app/lib/requests/github"; import { AuthMethod } from "@app/services/auth/auth-type"; -import { getServerCfg } from "@app/services/super-admin/super-admin-service"; export const registerSsoRouter = async (server: FastifyZodProvider) => { const appCfg = getConfig(); @@ -42,7 +41,6 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { async (req, _accessToken, _refreshToken, profile, cb) => { try { const email = profile?.emails?.[0]?.value; - const serverCfg = await getServerCfg(); if (!email) throw new BadRequestError({ message: "Email not found", @@ -54,8 +52,7 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { firstName: profile?.name?.givenName || "", lastName: profile?.name?.familyName || "", authMethod: AuthMethod.GOOGLE, - callbackPort: req.query.state as string, - isSignupAllowed: Boolean(serverCfg.allowSignUp) + callbackPort: req.query.state as string }); cb(null, { isUserCompleted, providerAuthToken }); } catch (error) { @@ -84,14 +81,12 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { try { const ghEmails = await fetchGithubEmails(accessToken); const { email } = ghEmails.filter((gitHubEmail) => gitHubEmail.primary)[0]; - const serverCfg = await getServerCfg(); const { isUserCompleted, providerAuthToken } = await server.services.login.oauth2Login({ email, firstName: profile.displayName, lastName: "", authMethod: AuthMethod.GITHUB, - callbackPort: req.query.state as string, - isSignupAllowed: Boolean(serverCfg.allowSignUp) + callbackPort: req.query.state as string }); return cb(null, { isUserCompleted, providerAuthToken }); } catch (error) { @@ -120,14 +115,12 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { async (req: any, _accessToken: string, _refreshToken: string, profile: any, cb: any) => { try { const email = profile.emails[0].value; - const serverCfg = await getServerCfg(); const { isUserCompleted, providerAuthToken } = await server.services.login.oauth2Login({ email, firstName: profile.displayName, lastName: "", authMethod: AuthMethod.GITLAB, - callbackPort: req.query.state as string, - isSignupAllowed: Boolean(serverCfg.allowSignUp) + callbackPort: req.query.state as string }); return cb(null, { isUserCompleted, providerAuthToken }); diff --git a/backend/src/server/routes/v3/signup-router.ts b/backend/src/server/routes/v3/signup-router.ts index 209e86ac7..24387d2f5 100644 --- a/backend/src/server/routes/v3/signup-router.ts +++ b/backend/src/server/routes/v3/signup-router.ts @@ -2,7 +2,9 @@ import { z } from "zod"; import { UsersSchema } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; import { authRateLimit } from "@app/server/config/rateLimiter"; +import { getServerCfg } from "@app/services/super-admin/super-admin-service"; import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; export const registerSignupRouter = async (server: FastifyZodProvider) => { @@ -23,8 +25,26 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - await server.services.signup.beginEmailSignupProcess(req.body.email); - return { message: `Sent an email verification code to ${req.body.email}` }; + const { email } = req.body; + + const serverCfg = await getServerCfg(); + if (!serverCfg.allowSignUp) { + throw new BadRequestError({ + message: "Sign up is disabled" + }); + } + + if (serverCfg?.allowedSignUpDomain) { + const domain = email.split("@")[1]; + const allowedDomains = serverCfg.allowedSignUpDomain.split(",").map((e) => e.trim()); + if (!allowedDomains.includes(domain)) { + throw new BadRequestError({ + message: `Email with a domain (@${domain}) is not supported` + }); + } + } + await server.services.signup.beginEmailSignupProcess(email); + return { message: `Sent an email verification code to ${email}` }; } }); @@ -48,6 +68,13 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { + const serverCfg = await getServerCfg(); + if (!serverCfg.allowSignUp) { + throw new BadRequestError({ + message: "Sign up is disabled" + }); + } + const { token, user } = await server.services.signup.verifyEmailSignup(req.body.email, req.body.code); return { message: "Successfuly verified email", token, user }; } @@ -90,6 +117,13 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => { if (!userAgent) throw new Error("user agent header is required"); const appCfg = getConfig(); + const serverCfg = await getServerCfg(); + if (!serverCfg.allowSignUp) { + throw new BadRequestError({ + message: "Sign up is disabled" + }); + } + const { user, accessToken, refreshToken } = await server.services.signup.completeEmailAccountSignup({ ...req.body, ip: req.realIp, diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index dcc9381b7..11ce57735 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -4,6 +4,7 @@ import { TUsers, UserDeviceSchema } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { generateSrpServerKey, srpCheckClientProof } from "@app/lib/crypto"; import { BadRequestError } from "@app/lib/errors"; +import { getServerCfg } from "@app/services/super-admin/super-admin-service"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; import { TokenType } from "../auth-token/auth-token-types"; @@ -261,20 +262,26 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }: /* * OAuth2 login for google,github, and other oauth2 provider * */ - const oauth2Login = async ({ - email, - firstName, - lastName, - authMethod, - callbackPort, - isSignupAllowed - }: TOauthLoginDTO) => { + const oauth2Login = async ({ email, firstName, lastName, authMethod, callbackPort }: TOauthLoginDTO) => { let user = await userDAL.findUserByEmail(email); + const serverCfg = await getServerCfg(); + const appCfg = getConfig(); - const isOauthSignUpDisabled = !isSignupAllowed && !user; - if (isOauthSignUpDisabled) throw new BadRequestError({ message: "User signup disabled", name: "Oauth 2 login" }); if (!user) { + // Create a new user based on oAuth + if (!serverCfg?.allowSignUp) throw new BadRequestError({ message: "Sign up disabled", name: "Oauth 2 login" }); + + if (serverCfg?.allowedSignUpDomain) { + const domain = email.split("@")[1]; + const allowedDomains = serverCfg.allowedSignUpDomain.split(",").map((e) => e.trim()); + if (!allowedDomains.includes(domain)) + throw new BadRequestError({ + message: `Email with a domain (@${domain}) is not supported`, + name: "Oauth 2 login" + }); + } + user = await userDAL.create({ email, firstName, lastName, authMethods: [authMethod], isGhost: false }); } const isLinkingRequired = !user?.authMethods?.includes(authMethod); diff --git a/backend/src/services/auth/auth-login-type.ts b/backend/src/services/auth/auth-login-type.ts index 67f640bc9..86af5a5f9 100644 --- a/backend/src/services/auth/auth-login-type.ts +++ b/backend/src/services/auth/auth-login-type.ts @@ -28,5 +28,4 @@ export type TOauthLoginDTO = { lastName?: string; authMethod: AuthMethod; callbackPort?: string; - isSignupAllowed?: boolean; }; diff --git a/frontend/src/components/signup/EnterEmailStep.tsx b/frontend/src/components/signup/EnterEmailStep.tsx index e317a4ea5..058c23106 100644 --- a/frontend/src/components/signup/EnterEmailStep.tsx +++ b/frontend/src/components/signup/EnterEmailStep.tsx @@ -1,7 +1,9 @@ import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import Link from "next/link"; +import axios from "axios"; +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { useSendVerificationEmail } from "@app/hooks/api"; import { Button, Input } from "../v2"; @@ -25,6 +27,7 @@ export default function EnterEmailStep({ setEmail, incrementStep }: DownloadBackupPDFStepProps): JSX.Element { + const { createNotification } = useNotificationContext(); const { mutateAsync } = useSendVerificationEmail(); const [emailError, setEmailError] = useState(false); const { t } = useTranslation(); @@ -46,8 +49,18 @@ export default function EnterEmailStep({ // If everything is correct, go to the next step if (!emailCheckBool) { - await mutateAsync({ email }); - incrementStep(); + try { + await mutateAsync({ email }); + incrementStep(); + } catch(e) { + if (axios.isAxiosError(e)) { + const { message = "Something went wrong" } = e.response?.data as { message: string}; + createNotification({ + type: "error", + text: message + }) + } + } } }; diff --git a/frontend/src/hooks/api/admin/types.ts b/frontend/src/hooks/api/admin/types.ts index da6b5ce3c..c7022a1d7 100644 --- a/frontend/src/hooks/api/admin/types.ts +++ b/frontend/src/hooks/api/admin/types.ts @@ -1,6 +1,7 @@ export type TServerConfig = { initialized: boolean; allowSignUp: boolean; + allowedSignUpDomain?: string | null; isMigrationModeOn?: boolean; }; diff --git a/frontend/src/hooks/api/serverDetails/types.ts b/frontend/src/hooks/api/serverDetails/types.ts index 32cc92ae1..af7bc5b28 100644 --- a/frontend/src/hooks/api/serverDetails/types.ts +++ b/frontend/src/hooks/api/serverDetails/types.ts @@ -2,7 +2,6 @@ export type ServerStatus = { date: string; message: string; emailConfigured: boolean; - inviteOnlySignup: boolean; secretScanningConfigured: boolean redisConfigured: boolean -}; \ No newline at end of file +}; diff --git a/frontend/src/pages/signupinvite.tsx b/frontend/src/pages/signupinvite.tsx index 5eaa48e63..a3b643e29 100644 --- a/frontend/src/pages/signupinvite.tsx +++ b/frontend/src/pages/signupinvite.tsx @@ -2,7 +2,7 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import crypto from "crypto"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import Head from "next/head"; import Image from "next/image"; import Link from "next/link"; @@ -22,6 +22,7 @@ import { deriveArgonKey } from "@app/components/utilities/cryptography/crypto"; import issueBackupKey from "@app/components/utilities/cryptography/issueBackupKey"; import { saveTokenToLocalStorage } from "@app/components/utilities/saveTokenToLocalStorage"; import SecurityClient from "@app/components/utilities/SecurityClient"; +import { useServerConfig } from "@app/context"; import { completeAccountSignupInvite, verifySignupInvite } from "@app/hooks/api/auth/queries"; import { fetchOrganizations } from "@app/hooks/api/organization/queries"; @@ -56,6 +57,13 @@ export default function SignupInvite() { const token = parsedUrl.token as string; const organizationId = parsedUrl.organization_id as string; const email = (parsedUrl.to as string)?.replace(" ", "+").trim(); + const { config } = useServerConfig(); + + useEffect(() => { + if (!config.allowSignUp) { + router.push("/login"); + } + }, [config.allowSignUp]); // Verifies if the information that the users entered (name, workspace) is there, and if the password matched the criteria. const signupErrorCheck = async () => { diff --git a/frontend/src/views/admin/DashboardPage/DashboardPage.tsx b/frontend/src/views/admin/DashboardPage/DashboardPage.tsx index 508dcda1e..2c7e3662a 100644 --- a/frontend/src/views/admin/DashboardPage/DashboardPage.tsx +++ b/frontend/src/views/admin/DashboardPage/DashboardPage.tsx @@ -1,7 +1,24 @@ import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; import { useRouter } from "next/router"; +import { faAt } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; -import { ContentLoader, Switch, Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + Button, + ContentLoader, + FormControl, + Input, + Select, + SelectItem, + Tab, + TabList, + TabPanel, + Tabs +} from "@app/components/v2"; import { useOrganization, useServerConfig, useUser } from "@app/context"; import { useUpdateServerConfig } from "@app/hooks/api"; @@ -9,16 +26,47 @@ enum TabSections { Settings = "settings" } +enum SignUpModes { + Disabled = "disabled", + Anyone = "anyone" +} + +const formSchema = z.object({ + signUpMode: z.nativeEnum(SignUpModes), + allowedSignUpDomain: z.string().optional().nullable() +}); + +type TDashboardForm = z.infer; export const AdminDashboardPage = () => { const router = useRouter(); const data = useServerConfig(); const { config } = data; + + const { + control, + handleSubmit, + watch, + formState: { isSubmitting, isDirty } + } = useForm({ + resolver: zodResolver(formSchema), + values: { + // eslint-disable-next-line + signUpMode: config.allowSignUp ? SignUpModes.Anyone : SignUpModes.Disabled, + allowedSignUpDomain: config.allowedSignUpDomain + } + }); + + const signupMode = watch("signUpMode"); + const { user, isLoading: isUserLoading } = useUser(); const { orgs } = useOrganization(); - const { mutate: updateServerConfig } = useUpdateServerConfig(); + const { mutateAsync: updateServerConfig } = useUpdateServerConfig(); + + const { createNotification } = useNotificationContext(); const isNotAllowed = !user?.superAdmin; + // TODO(akhilmhdh): on nextjs 14 roadmap this will be properly addressed with context split useEffect(() => { if (isNotAllowed && !isUserLoading) { if (orgs?.length) { @@ -28,37 +76,115 @@ export const AdminDashboardPage = () => { } }, [isNotAllowed, isUserLoading]); + const onFormSubmit = async (formData: TDashboardForm) => { + try { + const { signUpMode, allowedSignUpDomain } = formData; + await updateServerConfig({ + allowSignUp: signUpMode !== SignUpModes.Disabled, + allowedSignUpDomain: signUpMode === SignUpModes.Anyone ? allowedSignUpDomain : null + }); + createNotification({ + text: "Successfully changed sign up setting.", + type: "success" + }); + } catch (e) { + console.error(e); + createNotification({ + type: "error", + text: "Failed to update sign up setting." + }); + } + }; + return ( -
-
+
+

Admin Dashboard

Manage your Infisical instance.

- {isUserLoading || isNotAllowed ? ( - - ) : ( -
- - -
- General -
-
- -
- updateServerConfig({ allowSignUp: isChecked })} - /> -
Enable signup or invite
-
-
-
-
- )}
+ {isUserLoading || isNotAllowed ? ( + + ) : ( +
+ + +
+ General +
+
+ +
+
+
+ Allow user to Sign Up +
+ ( + + + + )} + /> +
+ {signupMode === "anyone" && ( +
+
+ Allow email with only specific domain(s) +
+ ( + + } + /> + + )} + /> +
+ )} + +
+
+
+
+ )}
); };