diff --git a/.env b/.env index e24113e..2e1f59d 100644 --- a/.env +++ b/.env @@ -11,3 +11,4 @@ JWT_ALGORITHM=HS256 JWT_PRIVATE_KEY=secretkey JWT_TOKEN_EXPIRES=15 JWT_REFRESH_TOKEN_EXPIRES=1440 +ALLOW_EMPTY_ORGANIZATION=true diff --git a/README.md b/README.md index 8efc60f..13ff447 100644 --- a/README.md +++ b/README.md @@ -14,20 +14,29 @@ CREATE EXTENSION IF NOT EXISTS citext WITH SCHEMA public; CREATE TABLE "role" ( name text NOT NULL PRIMARY KEY, - created_at timestamp with time zone NOT NULL DEFAULT now(), + created_at timestamp with time zone NOT NULL DEFAULT now() ); -INSERT INTO "roles" ("name") VALUES ('user'); +INSERT INTO "role" ("name") VALUES ('user'); + +CREATE TABLE "organization" ( + id uuid DEFAULT gen_random_uuid() NOT NULL CONSTRAINT organization_pkey PRIMARY KEY, + name text NOT NULL, + created_at timestamp WITH TIME ZONE DEFAULT now() NOT NULL, + updated_at timestamp WITH TIME ZONE DEFAULT now() NOT NULL +); CREATE TABLE "user" ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - email citext NOT NULL UNIQUE, + organization_id uuid NULL CONSTRAINT user_organization_id_fkey REFERENCES "organization" ON UPDATE CASCADE ON DELETE CASCADE, + email citext NOT NULL, password text NOT NULL, default_role text NOT NULL DEFAULT 'user', is_active boolean NOT NULL DEFAULT false, secret_token uuid NOT NULL, created_at timestamp with time zone NOT NULL DEFAULT now(), updated_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT user_email_organization_id_key UNIQUE (email, organization_id), FOREIGN KEY (default_role) REFERENCES role (name) ); @@ -88,7 +97,8 @@ In "Headers for the remote GraphQL server" select the option "Forward all header | `JWT_ALGORITHM` | `HS256` | JWT Algorithm | | `JWT_PRIVATE_KEY` | `secretkey` | JWT Secret key used to generate token | | `JWT_TOKEN_EXPIRES` | `15` | Life time in minutes of JWT | -| `allowRegistrationFor` | `*` | Allow registration by role | +| `ALLOW_REGISTRATION_FOR` | `*` | Allow registration by role | +| `ALLOW_EMPTY_ORGANIZATION` | `true` | Allow use empty organization | ## Todo diff --git a/docker-compose.development.yaml b/docker-compose.development.yaml index ce6a7e6..6c1055c 100644 --- a/docker-compose.development.yaml +++ b/docker-compose.development.yaml @@ -12,3 +12,5 @@ services: # command: yarn start:dev volumes: - /app/node_modules + environment: + ALLOW_EMPTY_ORGANIZATION: 'false' \ No newline at end of file diff --git a/src/auth-tools.ts b/src/auth-tools.ts index caffc9b..acec5d2 100644 --- a/src/auth-tools.ts +++ b/src/auth-tools.ts @@ -24,10 +24,15 @@ export const generateJwtRefreshToken = (payload: any) => { export const generateClaimsJwtToken = (user: User, sessionId: string) => { const payload = { [vars.hasuraGraphqlClaimsKey]: { - [`${vars.hasuraHeaderPrefix}allowed-roles`]: uniq([user.default_role, ...user.user_roles.map(({ role }) => role)]).filter(role => !!role), + [`${vars.hasuraHeaderPrefix}allowed-roles`]: uniq([ + user.default_role, + ...user.user_roles.map(({ role }) => role), + ]).filter(role => !!role), [`${vars.hasuraHeaderPrefix}default-role`]: user.default_role, - [`${vars.hasuraHeaderPrefix}user-id`]: user.id.toString(), [`${vars.hasuraHeaderPrefix}session-id`]: sessionId, + [`${vars.hasuraHeaderPrefix}user-id`]: user.id.toString(), + [`${vars.hasuraHeaderPrefix}organization-id`]: + user.organization_id === null ? '' : user.organization_id, }, }; diff --git a/src/hasura/activate-user.ts b/src/hasura/activate-user.ts index 43a5e1e..2a661ee 100644 --- a/src/hasura/activate-user.ts +++ b/src/hasura/activate-user.ts @@ -2,12 +2,14 @@ import uuidv4 from 'uuid/v4'; import { updateUser } from './update-user'; export const activateUser = async ( + organizationId: string | null, email: string, secretToken: string, ): Promise => { const result = await updateUser( { email: { _eq: email }, + organization_id: { _eq: organizationId }, secret_token: { _eq: secretToken }, is_active: { _eq: false }, }, diff --git a/src/hasura/create-user-account.ts b/src/hasura/create-user-account.ts index 86ffe5d..0d1e4e1 100644 --- a/src/hasura/create-user-account.ts +++ b/src/hasura/create-user-account.ts @@ -5,14 +5,18 @@ import gql from 'graphql-tag'; import { hasuraQuery } from './client'; import { USER_FRAGMENT } from './user-fragment'; import { User } from './user-type'; -import { getUserByEmail } from './get-user-by-email'; +import { getUserByOrganizationIdAndEmail } from './get-user-by-organization-id-email'; import { userRegistrationAutoActive } from '../vars'; export const createUserAccount = async ( + organizationId: string | null, email: string, password: string, ): Promise => { - const user: User | undefined = await getUserByEmail(email); + const user: User | undefined = await getUserByOrganizationIdAndEmail( + organizationId, + email, + ); if (user) { throw new Error('Email already registered'); @@ -36,6 +40,7 @@ export const createUserAccount = async ( email: email.toLowerCase(), password: passwordHash, secret_token: uuidv4(), + organization_id: organizationId, is_active: userRegistrationAutoActive, }, }, diff --git a/src/hasura/get-user-by-credentials.ts b/src/hasura/get-user-by-credentials.ts index 44f20ee..6bf4319 100644 --- a/src/hasura/get-user-by-credentials.ts +++ b/src/hasura/get-user-by-credentials.ts @@ -1,12 +1,16 @@ import bcrypt from 'bcryptjs'; import { User } from './user-type'; -import { getUserByEmail } from './get-user-by-email'; +import { getUserByOrganizationIdAndEmail } from './get-user-by-organization-id-email'; export const getUserByCredentials = async ( + organizationId: string | null, email: string, password: string, ): Promise => { - const user: User | undefined = await getUserByEmail(email); + const user: User | undefined = await getUserByOrganizationIdAndEmail( + organizationId, + email, + ); if (!user) { throw new Error('Invalid "email" or "password"'); diff --git a/src/hasura/get-user-by-email.ts b/src/hasura/get-user-by-organization-id-email.ts similarity index 82% rename from src/hasura/get-user-by-email.ts rename to src/hasura/get-user-by-organization-id-email.ts index 44654cd..93703d1 100644 --- a/src/hasura/get-user-by-email.ts +++ b/src/hasura/get-user-by-organization-id-email.ts @@ -4,7 +4,8 @@ import { hasuraQuery } from './client'; import { User } from './user-type'; import { USER_FRAGMENT } from './user-fragment'; -export const getUserByEmail = async ( +export const getUserByOrganizationIdAndEmail = async ( + organizationId: string | null, email: string, ): Promise => { try { @@ -19,6 +20,7 @@ export const getUserByEmail = async ( `, { where: { + organization_id: { _eq: organizationId }, email: { _eq: email.toLowerCase() }, }, }, diff --git a/src/hasura/index.ts b/src/hasura/index.ts index 5bc8e45..8a55e76 100644 --- a/src/hasura/index.ts +++ b/src/hasura/index.ts @@ -1,6 +1,6 @@ export { User } from './user-type'; export { getUserById } from './get-user-by-id'; -export { getUserByEmail } from './get-user-by-email'; +import { getUserByOrganizationIdAndEmail } from './get-user-by-organization-id-email'; export { getUserByCredentials } from './get-user-by-credentials'; export { createUserSession } from './create-user-session'; export { createUserAccount } from './create-user-account'; diff --git a/src/hasura/user-fragment.ts b/src/hasura/user-fragment.ts index f23a940..41717d6 100644 --- a/src/hasura/user-fragment.ts +++ b/src/hasura/user-fragment.ts @@ -3,6 +3,7 @@ import gql from 'graphql-tag'; export const USER_FRAGMENT = gql` fragment UserParts on user { id + organization_id email password is_active @@ -12,6 +13,12 @@ export const USER_FRAGMENT = gql` id role } + organization { + id + name + created_at + updated_at + } created_at updated_at } diff --git a/src/hasura/user-type.ts b/src/hasura/user-type.ts index ebf6442..a20a0bf 100644 --- a/src/hasura/user-type.ts +++ b/src/hasura/user-type.ts @@ -5,6 +5,7 @@ interface Role { export interface User { id: string; + organization_id: string | null; email: string; is_active: boolean; default_role: string; diff --git a/src/resolvers.ts b/src/resolvers.ts index 2fe443e..833dd81 100644 --- a/src/resolvers.ts +++ b/src/resolvers.ts @@ -16,10 +16,13 @@ import { getUserByCredentials, } from './hasura'; -const getRole = (req: Request) => getIn(req, `headers["${vars.hasuraHeaderPrefix}role"]`, ''); +const getRole = (req: Request) => + getIn(req, `headers["${vars.hasuraHeaderPrefix}role"]`, ''); const isAdmin = (req: Request) => getRole(req) === 'admin'; -const getDataFromVerifiedAuthorizationToken = (req: Request): undefined | any => { +const getDataFromVerifiedAuthorizationToken = ( + req: Request, +): undefined | any => { const { authorization } = req.headers; if (authorization === undefined) { @@ -38,10 +41,14 @@ const getDataFromVerifiedAuthorizationToken = (req: Request): undefined | any => const getFieldFromDataAuthorizationToken = (req: Request, field) => { const verifiedToken: any = getDataFromVerifiedAuthorizationToken(req); - return getIn(verifiedToken, `["${vars.hasuraGraphqlClaimsKey}"]${vars.hasuraHeaderPrefix}${field}`); + return getIn( + verifiedToken, + `["${vars.hasuraGraphqlClaimsKey}"]${vars.hasuraHeaderPrefix}${field}`, + ); }; -const getCurrentUserId = (req: Request) => getFieldFromDataAuthorizationToken(req, 'user-id'); +const getCurrentUserId = (req: Request) => + getFieldFromDataAuthorizationToken(req, 'user-id'); const isAuthenticated = (req: Request): boolean => { return !!getDataFromVerifiedAuthorizationToken(req); @@ -100,13 +107,21 @@ const resolvers = { return getUserById(currentUserId); } catch (e) { - throw new Error('Not logged in.'); + throw new Error('Not logged in'); } }, }, Mutation: { - async auth_login(_, { email, password }, ctx) { - const user: User = await getUserByCredentials(email, password); + async auth_login(_, { organization_id, email, password }, ctx) { + if (!vars.allowEmptyOrganization && !organization_id) { + throw new Error('Missing organization_id'); + } + + const user: User = await getUserByCredentials( + organization_id, + email, + password, + ); const ipAddress = ( ctx.req.headers['x-forwarded-for'] || @@ -125,19 +140,27 @@ const resolvers = { const accessToken = generateClaimsJwtToken(user, sessionId); return { - accessToken, - refreshToken: generateJwtRefreshToken({ + access_token: accessToken, + refresh_token: generateJwtRefreshToken({ token: refreshToken, }), - userId: user.id, + organization_id: user.organization_id, + user_id: user.id, }; }, - async auth_register(_, { email, password }, ctx) { + async auth_register(_, { organization_id, email, password }, ctx) { + if (!vars.allowEmptyOrganization && !organization_id) { + throw new Error('Missing organization_id'); + } + if (!checkUserCanDoRegistration(ctx.req)) { throw new Error('Forbidden'); } - const user = await createUserAccount(email, password); - return user !== undefined; + + const user = await createUserAccount(organization_id, email, password); + return { + affected_rows: Number(user !== undefined), + }; }, async auth_change_password(_, { user_id, new_password }, ctx) { if (!checkUserIsPartOfStaffOrIsTheCurrentUser(ctx.req, user_id)) { @@ -147,12 +170,20 @@ const resolvers = { const user: User | undefined = await getUserById(user_id); if (!user) { - throw new Error('Unable to find user.'); + throw new Error('Unable to find user'); } - return await changeUserPassword(user, new_password); + const result = await changeUserPassword(user, new_password); + + return { + affected_rows: Number(result), + }; }, - async auth_activate_account(_, { email, secret_token }) { + async auth_activate_account(_, { organization_id, email, secret_token }) { + if (!vars.allowEmptyOrganization && !organization_id) { + throw new Error('Missing organization_id'); + } + if (isEmpty(email)) { throw new Error('Invalid email'); } @@ -161,7 +192,11 @@ const resolvers = { throw new Error('Invalid secret_token'); } - return await activateUser(email, secret_token); + const result = await activateUser(organization_id, email, secret_token); + + return { + affected_rows: Number(result), + }; }, async auth_refresh_token(_, {}, ctx) { const { authorization } = ctx.req.headers; @@ -209,11 +244,12 @@ const resolvers = { const accessToken = generateClaimsJwtToken(user, sessionId); return { - accessToken, - refreshToken: generateJwtRefreshToken({ + access_token: accessToken, + refresh_token: generateJwtRefreshToken({ token: newRefreshToken, }), - userId, + organization_id: user.organization_id, + user_id: user.id, }; }, }, diff --git a/src/typeDefs.ts b/src/typeDefs.ts index 6c9fa5a..44f9cbe 100644 --- a/src/typeDefs.ts +++ b/src/typeDefs.ts @@ -2,32 +2,59 @@ import gql from 'graphql-tag'; export const typeDefs = gql` type AuthPayload { - accessToken: String! - refreshToken: String! - userId: ID! + access_token: String! + refresh_token: String! + user_id: ID! + organization_id: String + } + + type Organization { + id: String! + name: String! + created_at: String! + updated_at: String! } type Role { id: String! user_id: String! + organization_id: String role: String! created_at: String! } type User { id: ID! + organization_id: String! email: String! is_active: String! + organization: Organization default_role: String! user_roles: [Role!]! created_at: String! } + type AffectedRows { + affected_rows: Int! + } + type Mutation { - auth_login(email: String!, password: String!): AuthPayload - auth_register(email: String!, password: String!): Boolean - auth_change_password(user_id: ID!, new_password: String!): Boolean - auth_activate_account(email: ID!, secret_token: String!): Boolean + auth_login( + organization_id: String + email: String! + password: String! + ): AuthPayload + auth_register( + organization_id: String + email: String! + password: String! + ): AffectedRows + auth_change_password(user_id: ID!, new_password: String!): AffectedRows + auth_activate_account( + organization_id: String + email: ID! + secret_token: String! + ): AffectedRows auth_refresh_token: AuthPayload } diff --git a/src/vars.ts b/src/vars.ts index 3e92e0f..e67b914 100644 --- a/src/vars.ts +++ b/src/vars.ts @@ -1,5 +1,8 @@ import dotEnvFlow from 'dotenv-flow'; +const valueToBoolean = (value?: string) => + value && value.toLowerCase().trim() === 'true'; + dotEnvFlow.config({ node_env: process.env.NODE_ENV, default_node_env: 'development', @@ -13,6 +16,9 @@ export const hasuraGraphqlClaimsKey = process.env .HASURA_GRAPHQL_CLAIMS_KEY as string; export const hasuraHeaderPrefix = process.env.HASURA_GRAPHQL_HEADER_PREFIX; export const jwtAlgorithm = process.env.JWT_ALGORITHM as string; +export const allowEmptyOrganization = valueToBoolean( + process.env.ALLOW_EMPTY_ORGANIZATION, +); export const jwtTokenExpiresIn = `${process.env.JWT_TOKEN_EXPIRES as string}m`; export const jwtRefreshTokenExpiresIn = `${process.env .JWT_REFRESH_TOKEN_EXPIRES as string}m`; @@ -20,7 +26,8 @@ export const jwtSecretKey = process.env.JWT_PRIVATE_KEY as string; export const refreshTokenExpiresIn = Number(process.env .REFRESH_TOKEN_EXPIRES_IN as string); export const port = Number(process.env.PORT as string); -export const allowRegistrationFor = process.env.ALLOW_REGISTRATION_FOR as string; -export const userRegistrationAutoActive = Boolean( +export const allowRegistrationFor = process.env + .ALLOW_REGISTRATION_FOR as string; +export const userRegistrationAutoActive = valueToBoolean( process.env.USER_REGISTRATION_AUTO_ACTIVE, );