From ad3c9ebfe9be3a79f2f20dd86551227f8bc2257c Mon Sep 17 00:00:00 2001 From: "a.bouhuolia" Date: Sun, 7 May 2023 17:22:18 +0200 Subject: [PATCH 1/3] feat(server): sign-up restrictions for self-hosted --- docker-compose.prod.yml | 5 ++ packages/server/Dockerfile | 12 ++++- .../src/api/controllers/Authentication.ts | 54 +++++++++++++++++++ packages/server/src/config/index.ts | 14 +++++ .../server/src/interfaces/Authentication.ts | 4 ++ .../Authentication/AuthApplication.ts | 19 ++++++- .../src/services/Authentication/AuthSignup.ts | 42 ++++++++++++++- .../services/Authentication/GetAuthMeta.ts | 16 ++++++ .../src/services/Authentication/_constants.ts | 4 ++ packages/server/src/utils/index.ts | 5 ++ 10 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 packages/server/src/services/Authentication/GetAuthMeta.ts diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index ac8938ff3..157d4ab1b 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -72,6 +72,11 @@ services: - AGENDASH_AUTH_USER=${AGENDASH_AUTH_USER} - AGENDASH_AUTH_PASSWORD=${AGENDASH_AUTH_PASSWORD} + # Sign-up restrictions + - SIGNUP_DISABLED=${SIGNUP_DISABLED} + - SIGNUP_ALLOWED_DOMAINS=${SIGNUP_ALLOWED_DOMAINS} + - SIGNUP_ALLOWED_EMAILS=${SIGNUP_ALLOWED_EMAILS} + database_migration: container_name: bigcapital-database-migration build: diff --git a/packages/server/Dockerfile b/packages/server/Dockerfile index aebf36981..af2de4263 100644 --- a/packages/server/Dockerfile +++ b/packages/server/Dockerfile @@ -34,7 +34,11 @@ ARG MAIL_HOST= \ BASE_URL= \ # Agendash AGENDASH_AUTH_USER=agendash \ - AGENDASH_AUTH_PASSWORD=123123 + AGENDASH_AUTH_PASSWORD=123123 \ + # Sign-up restriction + SIGNUP_DISABLED= \ + SIGNUP_ALLOWED_DOMAINS= \ + SIGNUP_ALLOWED_EMAILS= ENV MAIL_HOST=$MAIL_HOST \ MAIL_USERNAME=$MAIL_USERNAME \ @@ -68,7 +72,11 @@ ENV MAIL_HOST=$MAIL_HOST \ # MongoDB MONGODB_DATABASE_URL=$MONGODB_DATABASE_URL \ # Application - BASE_URL=$BASE_URL + BASE_URL=$BASE_URL \ + # Sign-up restriction + SIGNUP_DISABLED=$SIGNUP_DISABLED \ + SIGNUP_ALLOWED_DOMAINS=$SIGNUP_ALLOWED_DOMAINS \ + SIGNUP_ALLOWED_EMAILS=$SIGNUP_ALLOWED_EMAILS # Create app directory. WORKDIR /app diff --git a/packages/server/src/api/controllers/Authentication.ts b/packages/server/src/api/controllers/Authentication.ts index e0f9073ea..a8bb1b4d5 100644 --- a/packages/server/src/api/controllers/Authentication.ts +++ b/packages/server/src/api/controllers/Authentication.ts @@ -49,6 +49,7 @@ export default class AuthenticationController extends BaseController { asyncMiddleware(this.resetPassword.bind(this)), this.handlerErrors ); + router.get('/meta', asyncMiddleware(this.getAuthMeta.bind(this))); return router; } @@ -207,6 +208,23 @@ export default class AuthenticationController extends BaseController { } } + /** + * Retrieves the authentication meta for SPA. + * @param {Request} req + * @param {Response} res + * @param {Function} next + * @returns {Response|void} + */ + private async getAuthMeta(req: Request, res: Response, next: Function) { + try { + const meta = await this.authApplication.getAuthMeta(); + + return res.status(200).send({ meta }); + } catch (error) { + next(error); + } + } + /** * Handles the service errors. */ @@ -247,6 +265,42 @@ export default class AuthenticationController extends BaseController { errors: [{ type: 'EMAIL.EXISTS', code: 600 }], }); } + if (error.errorType === 'SIGNUP_RESTRICTED') { + return res.status(400).send({ + errors: [ + { + type: 'SIGNUP_RESTRICTED', + message: + 'Sign-up is restricted no one can sign-up to the system.', + code: 700, + }, + ], + }); + } + if (error.errorType === 'SIGNUP_NOT_ALLOWED_EMAIL_DOMAIN') { + return res.status(400).send({ + errors: [ + { + type: 'SIGNUP_NOT_ALLOWED_EMAIL_DOMAIN', + message: + 'Sign-up is restricted the given email domain is not allowed to sign-up.', + code: 710, + }, + ], + }); + } + if (error.errorType === 'SIGNUP_NOT_ALLOWED_EMAIL_ADDRESS') { + return res.status(400).send({ + errors: [ + { + type: 'SIGNUP_NOT_ALLOWED_EMAIL_ADDRESS', + message: + 'The sign-up restricted the given email address is not allowed to sign-up.', + code: 720, + }, + ], + }); + } } next(error); } diff --git a/packages/server/src/config/index.ts b/packages/server/src/config/index.ts index 928eac2a8..c32be4bac 100644 --- a/packages/server/src/config/index.ts +++ b/packages/server/src/config/index.ts @@ -1,5 +1,6 @@ import dotenv from 'dotenv'; import path from 'path'; +import { castCommaListEnvVarToArray, parseBoolean } from '@/utils'; dotenv.config(); @@ -146,6 +147,19 @@ module.exports = { }, }, + /** + * Sign-up restrictions + */ + signupRestrictions: { + disabled: parseBoolean(process.env.SIGNUP_DISABLED, false), + allowedDomains: castCommaListEnvVarToArray( + process.env.SIGNUP_ALLOWED_DOMAINS + ), + allowedEmails: castCommaListEnvVarToArray( + process.env.SIGNUP_ALLOWED_EMAILS + ), + }, + /** * Puppeteer remote browserless connection. */ diff --git a/packages/server/src/interfaces/Authentication.ts b/packages/server/src/interfaces/Authentication.ts index fb26c4fa6..253c178f9 100644 --- a/packages/server/src/interfaces/Authentication.ts +++ b/packages/server/src/interfaces/Authentication.ts @@ -74,4 +74,8 @@ export interface IAuthSendingResetPassword { export interface IAuthSendedResetPassword { user: ISystemUser, token: string; +} + +export interface IAuthGetMetaPOJO { + signupDisabled: boolean; } \ No newline at end of file diff --git a/packages/server/src/services/Authentication/AuthApplication.ts b/packages/server/src/services/Authentication/AuthApplication.ts index 5c077530d..9fa74c973 100644 --- a/packages/server/src/services/Authentication/AuthApplication.ts +++ b/packages/server/src/services/Authentication/AuthApplication.ts @@ -1,8 +1,14 @@ import { Service, Inject, Container } from 'typedi'; -import { IRegisterDTO, ISystemUser, IPasswordReset } from '@/interfaces'; +import { + IRegisterDTO, + ISystemUser, + IPasswordReset, + IAuthGetMetaPOJO, +} from '@/interfaces'; import { AuthSigninService } from './AuthSignin'; import { AuthSignupService } from './AuthSignup'; import { AuthSendResetPassword } from './AuthSendResetPassword'; +import { GetAuthMeta } from './GetAuthMeta'; @Service() export default class AuthenticationApplication { @@ -15,6 +21,9 @@ export default class AuthenticationApplication { @Inject() private authResetPasswordService: AuthSendResetPassword; + @Inject() + private authGetMeta: GetAuthMeta; + /** * Signin and generates JWT token. * @throws {ServiceError} @@ -53,4 +62,12 @@ export default class AuthenticationApplication { public async resetPassword(token: string, password: string): Promise { return this.authResetPasswordService.resetPassword(token, password); } + + /** + * Retrieves the authentication meta for SPA. + * @returns {Promise} + */ + public async getAuthMeta(): Promise { + return this.authGetMeta.getAuthMeta(); + } } diff --git a/packages/server/src/services/Authentication/AuthSignup.ts b/packages/server/src/services/Authentication/AuthSignup.ts index b2d11ac22..0adbdb9fe 100644 --- a/packages/server/src/services/Authentication/AuthSignup.ts +++ b/packages/server/src/services/Authentication/AuthSignup.ts @@ -1,4 +1,4 @@ -import { omit } from 'lodash'; +import { isEmpty, omit } from 'lodash'; import moment from 'moment'; import { ServiceError } from '@/exceptions'; import { @@ -13,6 +13,7 @@ import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; import TenantsManagerService from '../Tenancy/TenantsManager'; import events from '@/subscribers/events'; import { hashPassword } from '@/utils'; +import config from '@/config'; export class AuthSignupService { @Inject() @@ -33,6 +34,9 @@ export class AuthSignupService { public async signUp(signupDTO: IRegisterDTO): Promise { const { systemUserRepository } = this.sysRepositories; + // Validates the signup disable restrictions. + await this.validateSignupRestrictions(signupDTO.email); + // Validates the given email uniqiness. await this.validateEmailUniqiness(signupDTO.email); @@ -74,4 +78,40 @@ export class AuthSignupService { throw new ServiceError(ERRORS.EMAIL_EXISTS); } } + + /** + * Validate sign-up disable restrictions. + * @param {string} email + */ + private async validateSignupRestrictions(email: string) { + // Can't continue if the signup is not disabled. + if (!config.signupRestrictions.disabled) return; + + // Validate the allowed domains. + if (!isEmpty(config.signupRestrictions.allowedDomains)) { + const emailDomain = email.split('@').pop(); + const isAllowed = config.signupRestrictions.allowedDomains.some( + (domain) => emailDomain === domain + ); + if (!isAllowed) { + throw new ServiceError(ERRORS.SIGNUP_NOT_ALLOWED_EMAIL_DOMAIN); + } + } + // Validate the allowed email addresses. + if (!isEmpty(config.signupRestrictions.allowedEmails)) { + const isAllowed = + config.signupRestrictions.allowedEmails.indexOf(email) !== -1; + + if (!isAllowed) { + throw new ServiceError(ERRORS.SIGNUP_NOT_ALLOWED_EMAIL_ADDRESS); + } + } + // Throw error if the signup is disabled with no exceptions. + if ( + isEmpty(config.signupRestrictions.allowedDomains) && + isEmpty(config.signupRestrictions.allowedEmails) + ) { + throw new ServiceError(ERRORS.SIGNUP_RESTRICTED); + } + } } diff --git a/packages/server/src/services/Authentication/GetAuthMeta.ts b/packages/server/src/services/Authentication/GetAuthMeta.ts new file mode 100644 index 000000000..80e68d682 --- /dev/null +++ b/packages/server/src/services/Authentication/GetAuthMeta.ts @@ -0,0 +1,16 @@ +import { Service } from 'typedi'; +import { IAuthGetMetaPOJO } from '@/interfaces'; +import config from '@/config'; + +@Service() +export class GetAuthMeta { + /** + * Retrieves the authentication meta for SPA. + * @returns {Promise} + */ + public async getAuthMeta(): Promise { + return { + signupDisabled: config.signupRestrictions.disabled, + }; + } +} diff --git a/packages/server/src/services/Authentication/_constants.ts b/packages/server/src/services/Authentication/_constants.ts index 16a3a5831..fc2718d4b 100644 --- a/packages/server/src/services/Authentication/_constants.ts +++ b/packages/server/src/services/Authentication/_constants.ts @@ -7,4 +7,8 @@ export const ERRORS = { TOKEN_EXPIRED: 'TOKEN_EXPIRED', PHONE_NUMBER_EXISTS: 'PHONE_NUMBER_EXISTS', EMAIL_EXISTS: 'EMAIL_EXISTS', + + SIGNUP_NOT_ALLOWED_EMAIL_ADDRESS: 'SIGNUP_NOT_ALLOWED_EMAIL_ADDRESS', + SIGNUP_NOT_ALLOWED_EMAIL_DOMAIN: 'SIGNUP_NOT_ALLOWED_EMAIL_DOMAIN', + SIGNUP_RESTRICTED: 'SIGNUP_RESTRICTED', }; diff --git a/packages/server/src/utils/index.ts b/packages/server/src/utils/index.ts index e1178a2f6..fa2bc6772 100644 --- a/packages/server/src/utils/index.ts +++ b/packages/server/src/utils/index.ts @@ -467,6 +467,10 @@ const assocDepthLevelToObjectTree = ( return objects; }; +const castCommaListEnvVarToArray = (envVar: string): Array => { + return envVar ? envVar?.split(',')?.map(_.trim) : []; +}; + export { templateRender, accumSum, @@ -499,4 +503,5 @@ export { mergeObjectsBykey, nestedArrayToFlatten, assocDepthLevelToObjectTree, + castCommaListEnvVarToArray }; From dd26bdc482e0521aabdd7abf5591a39f19964025 Mon Sep 17 00:00:00 2001 From: "a.bouhuolia" Date: Sun, 7 May 2023 23:54:42 +0200 Subject: [PATCH 2/3] feat(webapp): sign-up restrictions --- .../Authentication/AuthMetaBoot.tsx | 36 +++++++++++++ .../Authentication/Authentication.tsx | 50 +++++++++++-------- .../src/containers/Authentication/Login.tsx | 14 ++++-- .../containers/Authentication/Register.tsx | 6 ++- .../Authentication/ResetPassword.tsx | 12 +++-- .../Authentication/SendResetPassword.tsx | 14 ++++-- .../src/containers/Authentication/utils.tsx | 26 ++++++++++ .../webapp/src/hooks/query/authentication.tsx | 20 ++++++++ packages/webapp/src/hooks/query/types.tsx | 5 ++ 9 files changed, 148 insertions(+), 35 deletions(-) create mode 100644 packages/webapp/src/containers/Authentication/AuthMetaBoot.tsx diff --git a/packages/webapp/src/containers/Authentication/AuthMetaBoot.tsx b/packages/webapp/src/containers/Authentication/AuthMetaBoot.tsx new file mode 100644 index 000000000..0c5ccc4e3 --- /dev/null +++ b/packages/webapp/src/containers/Authentication/AuthMetaBoot.tsx @@ -0,0 +1,36 @@ +// @ts-nocheck +import React, { createContext } from 'react'; +import { useAuthMetadata } from '@/hooks/query'; +import { Spinner } from '@blueprintjs/core'; +import styled from 'styled-components'; + +const AuthMetaBootContext = createContext(); + +/** + * Boots the authentication page metadata. + */ +function AuthMetaBootProvider({ ...props }) { + const { isLoading: isAuthMetaLoading, data: authMeta } = useAuthMetadata(); + + const state = { + isAuthMetaLoading, + signupDisabled: authMeta?.meta?.signup_disabled, + }; + + if (isAuthMetaLoading) { + return ( + + + + ); + } + return ; +} + +const useAuthMetaBoot = () => React.useContext(AuthMetaBootContext); + +export { AuthMetaBootContext, AuthMetaBootProvider, useAuthMetaBoot }; + +const SpinnerRoot = styled.div` + margin-top: 5rem; +`; diff --git a/packages/webapp/src/containers/Authentication/Authentication.tsx b/packages/webapp/src/containers/Authentication/Authentication.tsx index 1fc2515e9..8d2887849 100644 --- a/packages/webapp/src/containers/Authentication/Authentication.tsx +++ b/packages/webapp/src/containers/Authentication/Authentication.tsx @@ -10,12 +10,11 @@ import { Icon, FormattedMessage as T } from '@/components'; import { useIsAuthenticated } from '@/hooks/state'; import '@/style/pages/Authentication/Auth.scss'; +import { AuthMetaBootProvider } from './AuthMetaBoot'; export function Authentication() { const to = { pathname: '/' }; - const location = useLocation(); const isAuthenticated = useIsAuthenticated(); - const locationKey = location.pathname; if (isAuthenticated) { return ; @@ -28,30 +27,41 @@ export function Authentication() { - - - - {authenticationRoutes.map((route, index) => ( - - ))} - - - + + + ); } +function AuthenticationRoutes() { + const location = useLocation(); + const locationKey = location.pathname; + + return ( + + + + {authenticationRoutes.map((route, index) => ( + + ))} + + + + ); +} + const AuthPage = styled.div``; const AuthInsider = styled.div` width: 384px; diff --git a/packages/webapp/src/containers/Authentication/Login.tsx b/packages/webapp/src/containers/Authentication/Login.tsx index 4032f2e67..641e5e213 100644 --- a/packages/webapp/src/containers/Authentication/Login.tsx +++ b/packages/webapp/src/containers/Authentication/Login.tsx @@ -14,11 +14,12 @@ import { AuthFooterLink, AuthInsiderCard, } from './_components'; +import { useAuthMetaBoot } from './AuthMetaBoot'; const initialValues = { crediential: '', password: '', - keepLoggedIn: false + keepLoggedIn: false, }; /** @@ -64,12 +65,15 @@ export default function Login() { } function LoginFooterLinks() { + const { signupDisabled } = useAuthMetaBoot(); + return ( - - Don't have an account? Sign up - - + {!signupDisabled && ( + + Don't have an account? Sign up + + )} diff --git a/packages/webapp/src/containers/Authentication/Register.tsx b/packages/webapp/src/containers/Authentication/Register.tsx index aba50d734..4b4cc79f4 100644 --- a/packages/webapp/src/containers/Authentication/Register.tsx +++ b/packages/webapp/src/containers/Authentication/Register.tsx @@ -10,7 +10,7 @@ import AuthInsider from '@/containers/Authentication/AuthInsider'; import { useAuthLogin, useAuthRegister } from '@/hooks/query/authentication'; import RegisterForm from './RegisterForm'; -import { RegisterSchema, transformRegisterErrorsToForm } from './utils'; +import { RegisterSchema, transformRegisterErrorsToForm, transformRegisterToastMessages } from './utils'; import { AuthFooterLinks, AuthFooterLink, @@ -57,7 +57,11 @@ export default function RegisterUserForm() { }, }) => { const formErrors = transformRegisterErrorsToForm(errors); + const toastMessages = transformRegisterToastMessages(errors); + toastMessages.forEach((toastMessage) => { + AppToaster.show(toastMessage); + }); setErrors(formErrors); setSubmitting(false); }, diff --git a/packages/webapp/src/containers/Authentication/ResetPassword.tsx b/packages/webapp/src/containers/Authentication/ResetPassword.tsx index 8b3613c81..136d28174 100644 --- a/packages/webapp/src/containers/Authentication/ResetPassword.tsx +++ b/packages/webapp/src/containers/Authentication/ResetPassword.tsx @@ -16,6 +16,7 @@ import { } from './_components'; import ResetPasswordForm from './ResetPasswordForm'; import { ResetPasswordSchema } from './utils'; +import { useAuthMetaBoot } from './AuthMetaBoot'; const initialValues = { password: '', @@ -79,12 +80,15 @@ export default function ResetPassword() { } function ResetPasswordFooterLinks() { + const { signupDisabled } = useAuthMetaBoot(); + return ( - - Don't have an account? Sign up - - + {!signupDisabled && ( + + Don't have an account? Sign up + + )} Return to Sign In diff --git a/packages/webapp/src/containers/Authentication/SendResetPassword.tsx b/packages/webapp/src/containers/Authentication/SendResetPassword.tsx index 8bf07809f..c90f872c1 100644 --- a/packages/webapp/src/containers/Authentication/SendResetPassword.tsx +++ b/packages/webapp/src/containers/Authentication/SendResetPassword.tsx @@ -19,6 +19,7 @@ import { transformSendResetPassErrorsToToasts, } from './utils'; import AuthInsider from '@/containers/Authentication/AuthInsider'; +import { useAuthMetaBoot } from './AuthMetaBoot'; const initialValues = { crediential: '', @@ -27,7 +28,7 @@ const initialValues = { /** * Send reset password page. */ -export default function SendResetPassword({ requestSendResetPassword }) { +export default function SendResetPassword() { const history = useHistory(); const { mutateAsync: sendResetPasswordMutate } = useAuthSendResetPassword(); @@ -75,12 +76,15 @@ export default function SendResetPassword({ requestSendResetPassword }) { } function SendResetPasswordFooterLinks() { + const { signupDisabled } = useAuthMetaBoot(); + return ( - - Don't have an account? Sign up - - + {!signupDisabled && ( + + Don't have an account? Sign up + + )} Return to Sign In diff --git a/packages/webapp/src/containers/Authentication/utils.tsx b/packages/webapp/src/containers/Authentication/utils.tsx index 2d8edfafd..c4d5ff7e7 100644 --- a/packages/webapp/src/containers/Authentication/utils.tsx +++ b/packages/webapp/src/containers/Authentication/utils.tsx @@ -94,3 +94,29 @@ export const transformRegisterErrorsToForm = (errors) => { } return formErrors; }; + +export const transformRegisterToastMessages = (errors) => { + const toastErrors = []; + + if (errors.some((e) => e.type === 'SIGNUP_NOT_ALLOWED_EMAIL_DOMAIN')) { + toastErrors.push({ + message: + 'The sign-up is restricted, the given email domain is not allowed to sign-up.', + intent: Intent.DANGER, + }); + } else if ( + errors.some((e) => e.type === 'SIGNUP_NOT_ALLOWED_EMAIL_ADDRESS') + ) { + toastErrors.push({ + message: + 'The sign-up is restricted, the given email address is not allowed to sign-up.', + intent: Intent.DANGER, + }); + } else if (errors.find((e) => e.type === 'SIGNUP_RESTRICTED')) { + toastErrors.push({ + message: 'Sign-up is disabled, and no new accounts can be created.', + intent: Intent.DANGER, + }); + } + return toastErrors; +}; diff --git a/packages/webapp/src/hooks/query/authentication.tsx b/packages/webapp/src/hooks/query/authentication.tsx index e00dd02ae..a604ec322 100644 --- a/packages/webapp/src/hooks/query/authentication.tsx +++ b/packages/webapp/src/hooks/query/authentication.tsx @@ -2,6 +2,8 @@ import { useMutation } from 'react-query'; import useApiRequest from '../useRequest'; import { setCookie } from '../../utils'; +import { useRequestQuery } from '../useQueryRequest'; +import t from './types'; /** * Saves the response data to cookies. @@ -70,3 +72,21 @@ export const useAuthResetPassword = (props) => { props, ); }; + +/** + * Fetches the authentication page metadata. + */ +export const useAuthMetadata = (props) => { + return useRequestQuery( + [t.AUTH_METADATA_PAGE,], + { + method: 'get', + url: `auth/meta`, + }, + { + select: (res) => res.data, + defaultData: {}, + ...props, + }, + ); +} diff --git a/packages/webapp/src/hooks/query/types.tsx b/packages/webapp/src/hooks/query/types.tsx index 696aa68c9..511ddccb4 100644 --- a/packages/webapp/src/hooks/query/types.tsx +++ b/packages/webapp/src/hooks/query/types.tsx @@ -1,4 +1,8 @@ // @ts-nocheck +const Authentication = { + AUTH_METADATA_PAGE: 'AUTH_META_PAGE' +} + const ACCOUNTS = { ACCOUNT: 'ACCOUNT', ACCOUNT_TRANSACTION: 'ACCOUNT_TRANSACTION', @@ -217,6 +221,7 @@ const DASHBOARD = { }; export default { + ...Authentication, ...ACCOUNTS, ...BILLS, ...VENDORS, From 18b9e25f2ba1b466da9399183c500a5156fb90ac Mon Sep 17 00:00:00 2001 From: "a.bouhuolia" Date: Sun, 7 May 2023 23:59:41 +0200 Subject: [PATCH 3/3] chore: update .env.example --- .env.example | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.env.example b/.env.example index 0858d27cd..b3e69464b 100644 --- a/.env.example +++ b/.env.example @@ -32,3 +32,8 @@ CONTACT_US_MAIL=support@bigcapital.ly # Agendash AGENDASH_AUTH_USER=agendash AGENDASH_AUTH_PASSWORD=123123 + +# Sign-up restrictions +SIGNUP_DISABLED=true +SIGNUP_ALLOWED_DOMAINS= +SIGNUP_ALLOWED_EMAILS= \ No newline at end of file