diff --git a/.changeset/nine-shrimps-visit.md b/.changeset/nine-shrimps-visit.md new file mode 100644 index 000000000..d6196a86f --- /dev/null +++ b/.changeset/nine-shrimps-visit.md @@ -0,0 +1,5 @@ +--- +'hasura-auth': minor +--- + +feat: add option to disable user sign-up through `AUTH_DISABLE_SIGNUP` diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 3d32c2b76..df56381f7 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -8,7 +8,7 @@ | HASURA_GRAPHQL_ADMIN_SECRET\* | Hasura GraphQL Admin Secret. Required to manipulate account data. | | | AUTH_HOST | Server host. This option is available until Hasura-auth `v0.6.0`. [Docs](http://expressjs.com/en/5x/api.html#app.listen) | `0.0.0.0` | | AUTH_PORT | Server port. [Docs](http://expressjs.com/en/5x/api.html#app.listen) | `4000` | -| AUTH_API_PREFIX | API prefix | `/` | +| AUTH_API_PREFIX | API prefix | `/` | | AUTH_SERVER_URL | Server URL of where Hasura Backend Plus is running. This value is to used as a callback in email templates and for the OAuth authentication process. | | | AUTH_CLIENT_URL | URL of your frontend application. Used to redirect users to the right page once actions based on emails or OAuth succeed. | | | AUTH_CONCEAL_ERRORS | Conceal sensitive error messages to avoid leaking information about user accounts to attackers | `false` | @@ -24,6 +24,7 @@ | AUTH_GRAVATAR_RATING | One of 'g', 'pg', 'r', 'x'. | `g` | | AUTH_ANONYMOUS_USERS_ENABLED | Enables users to register as an anonymous user. | `false` | | AUTH_DISABLE_NEW_USERS | If set, new users will be disabled after finishing registration and won't be able to connect. | `false` | +| AUTH_DISABLE_SIGNUP | If set to true, all signup methods will throw an unauthorized error. | `false` | | AUTH_ACCESS_CONTROL_ALLOWED_EMAILS | Comma-separated list of emails that are allowed to register. | | | AUTH_ACCESS_CONTROL_ALLOWED_EMAIL_DOMAINS | Comma-separated list of email domains that are allowed to register. If `ALLOWED_EMAIL_DOMAINS` is `tesla.com,ikea.se`, only emails from tesla.com and ikea.se would be allowed to register an account. | `` (allow all email domains) | | AUTH_ACCESS_CONTROL_BLOCKED_EMAILS | Comma-separated list of emails that cannot register. | | diff --git a/src/app.ts b/src/app.ts index e95f04071..3afbb3797 100644 --- a/src/app.ts +++ b/src/app.ts @@ -11,7 +11,6 @@ import { addOpenApiRoute } from './openapi'; import router from './routes'; import { ENV } from './utils/env'; - const app = express(); if (process.env.NODE_ENV === 'production') { diff --git a/src/errors.ts b/src/errors.ts index c189b8104..aae9ad951 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -151,6 +151,10 @@ export const ERRORS = asErrors({ status: StatusCodes.INTERNAL_SERVER_ERROR, message: 'Invalid OAuth configuration', }, + 'signup-disabled': { + status: StatusCodes.FORBIDDEN, + message: 'Sign up is disabled.', + }, }); export const sendError = ( diff --git a/src/routes/oauth/index.ts b/src/routes/oauth/index.ts index 7a8fa6753..2595ce20d 100644 --- a/src/routes/oauth/index.ts +++ b/src/routes/oauth/index.ts @@ -148,8 +148,10 @@ export const oauthProviders = Router() * @see {@link file://./config/index.ts} */ .use((req, res, next) => { - res.locals.grant = {dynamic: { - origin: `${req.protocol}://${req.headers.host}`}, + res.locals.grant = { + dynamic: { + origin: `${req.protocol}://${req.headers.host}`, + }, }; next(); }) @@ -274,7 +276,11 @@ export const oauthProviders = Router() } } else { // * No user found with this email. Create a new user - // TODO feature: check if registration is enabled + + if (ENV.AUTH_DISABLE_SIGNUP) { + return sendError(res, 'signup-disabled'); + } + const userInput = await transformOauthProfile(profile, options); user = await insertUser({ ...userInput, diff --git a/src/routes/signin/passwordless/email.ts b/src/routes/signin/passwordless/email.ts index da50b8951..611a3f2a3 100644 --- a/src/routes/signin/passwordless/email.ts +++ b/src/routes/signin/passwordless/email.ts @@ -53,6 +53,10 @@ export const signInPasswordlessEmailHandler: RequestHandler< // if no user exists, create the user if (!user) { + if (ENV.AUTH_DISABLE_SIGNUP) { + return sendError(res, 'signup-disabled'); + } + user = await insertUser({ displayName: displayName ?? email, locale, diff --git a/src/routes/signin/passwordless/sms/sms.ts b/src/routes/signin/passwordless/sms/sms.ts index 58f52dfe6..a608480bc 100644 --- a/src/routes/signin/passwordless/sms/sms.ts +++ b/src/routes/signin/passwordless/sms/sms.ts @@ -47,6 +47,10 @@ export const signInPasswordlessSmsHandler: RequestHandler< // if no user exists, create the user if (!userExists) { + if (ENV.AUTH_DISABLE_SIGNUP) { + return sendError(res, 'signup-disabled'); + } + user = await insertUser({ disabled: ENV.AUTH_DISABLE_NEW_USERS, displayName, diff --git a/src/routes/signup/email-password.ts b/src/routes/signup/email-password.ts index aa4d46cc5..d51ef16d2 100644 --- a/src/routes/signup/email-password.ts +++ b/src/routes/signup/email-password.ts @@ -24,6 +24,10 @@ export const signUpEmailPasswordHandler: RequestHandler< const { body } = req; const { email, password, options } = body; + if (ENV.AUTH_DISABLE_SIGNUP) { + return sendError(res, 'signup-disabled'); + } + // check if email already in use by some other user if (await getUserByEmail(email)) { return sendError(res, 'email-already-in-use'); diff --git a/src/routes/signup/webauthn/signup.ts b/src/routes/signup/webauthn/signup.ts index d7f613c95..9e6726ce3 100644 --- a/src/routes/signup/webauthn/signup.ts +++ b/src/routes/signup/webauthn/signup.ts @@ -34,6 +34,10 @@ export const signUpWebauthnHandler: RequestHandler< return sendError(res, 'disabled-endpoint'); } + if (ENV.AUTH_DISABLE_SIGNUP) { + return sendError(res, 'signup-disabled'); + } + // check if email already in use by some other user if (await getUserByEmail(email)) { return sendError(res, 'email-already-in-use'); diff --git a/src/utils/env.ts b/src/utils/env.ts index d061db4fa..4fb5677a7 100644 --- a/src/utils/env.ts +++ b/src/utils/env.ts @@ -229,6 +229,10 @@ export const ENV = { return castBooleanEnv('AUTH_SHOW_LOG_QUERY_PARAMS', false); }, + get AUTH_DISABLE_SIGNUP() { + return castBooleanEnv('AUTH_DISABLE_SIGNUP', false); + }, + // * See ../server.ts // get AUTH_SKIP_INIT() { // return castBooleanEnv('AUTH_SKIP_INIT', false); diff --git a/test/routes/signin/passwordless/email.test.ts b/test/routes/signin/passwordless/email.test.ts index 49ed5b441..b2669e1bc 100644 --- a/test/routes/signin/passwordless/email.test.ts +++ b/test/routes/signin/passwordless/email.test.ts @@ -205,4 +205,17 @@ describe('passwordless email (magic link)', () => { error: 'invalid-request', }); }); + + it('should not be possible to signin in when signup is disabled', async () => { + await request.post('/change-env').send({ + AUTH_DISABLE_SIGNUP: true, + }); + + await request + .post('/signin/passwordless/email') + .send({ + email: faker.internet.email(), + }) + .expect(StatusCodes.FORBIDDEN); + }); }); diff --git a/test/routes/signin/passwordless/sms/sms.test.ts b/test/routes/signin/passwordless/sms/sms.test.ts index 5b63daa9e..618e9ab5d 100644 --- a/test/routes/signin/passwordless/sms/sms.test.ts +++ b/test/routes/signin/passwordless/sms/sms.test.ts @@ -54,4 +54,20 @@ describe('passwordless sms', () => { }) .expect(StatusCodes.INTERNAL_SERVER_ERROR); }); + + it('should fail when signup is disabled', async () => { + const phoneNumber = `+3598${faker.phone.phoneNumber('########')}`; + + await request.post('/change-env').send({ + AUTH_DISABLE_SIGNUP: true, + AUTH_SMS_TEST_PHONE_NUMBERS: phoneNumber, + }); + + await request + .post('/signin/passwordless/sms') + .send({ + phoneNumber, + }) + .expect(StatusCodes.FORBIDDEN); + }); }); diff --git a/test/routes/signin/webauthn.test.ts b/test/routes/signin/webauthn.test.ts index d9c371465..7dc7fb11e 100644 --- a/test/routes/signin/webauthn.test.ts +++ b/test/routes/signin/webauthn.test.ts @@ -199,4 +199,17 @@ describe('webauthn', () => { .send({ email, credential }) .expect(StatusCodes.BAD_REQUEST); }); + + it('should fail if signup is disabled', async () => { + const email = faker.internet.email(); + + await request.post('/change-env').send({ + AUTH_DISABLE_SIGNUP: true, + }); + + await request + .post('/signup/webauthn') + .send({ email }) + .expect(StatusCodes.FORBIDDEN); + }); }); diff --git a/test/routes/signup/email-password.test.ts b/test/routes/signup/email-password.test.ts index b07a07b50..5be064917 100644 --- a/test/routes/signup/email-password.test.ts +++ b/test/routes/signup/email-password.test.ts @@ -215,4 +215,23 @@ describe('email-password', () => { 'The value of "options.redirectTo" is not allowed.' ); }); + + it('should return an unauthorized error when signup is disabled', async () => { + const email = faker.internet.email(); + const password = faker.internet.password(); + + await request.post('/change-env').send({ + AUTH_DISABLE_SIGNUP: true, + }); + + const { body } = await request + .post('/signup/email-password') + .send({ + email, + password, + }) + .expect(StatusCodes.FORBIDDEN); + + expect(body.message).toEqual('Sign up is disabled.'); + }); });