From da1fa9c9fc44e2319b9a1f412ed063e7818d8393 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 5 Jul 2019 21:49:41 +0000 Subject: [PATCH] [CORL-437] SSO Token Documentation + Updates (#2390) * feat: updated README, added more SSO functionality * fix: lint * fix: lint * fix: lint * fix: typos --- README.md | 88 +- .../common/UserBox/UserBoxContainer.spec.tsx | 5 + .../common/UserBox/UserBoxContainer.tsx | 5 +- .../server/app/middleware/passport/index.ts | 29 +- .../passport/strategies/verifiers/sso.spec.ts | 49 - .../passport/strategies/verifiers/sso.ts | 123 +- .../user/helpers.spec.ts} | 3 +- src/core/server/models/user/helpers.ts | 54 +- src/core/server/models/user/index.ts | 1706 +--------------- src/core/server/models/user/user.ts | 1756 +++++++++++++++++ src/core/server/services/users/auth/reset.ts | 2 +- src/core/server/services/users/index.ts | 5 +- 12 files changed, 2018 insertions(+), 1807 deletions(-) rename src/core/server/{helpers/users.spec.ts => models/user/helpers.spec.ts} (93%) create mode 100644 src/core/server/models/user/user.ts diff --git a/README.md b/README.md index 05156f19d..656fb7e8a 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Preview Coral easily by running Coral via a Heroku App: - [Source](#source) - [Development](#development) - [Embed On Your Site](#embed-on-your-site) + - [Single Sign On](#single-sign-on) - [Email](#email) - [Design Language System (UI Components)](#design-language-system-ui-components) - [Configuration](#configuration) @@ -185,6 +186,7 @@ npm run test ``` #### Embed On Your Site + With Coral setup and running locally you can test embeding the comment stream with this sample embed script: ``` @@ -205,7 +207,91 @@ With Coral setup and running locally you can test embeding the comment stream wi })(); ``` -Replace the value of CORAL_DOMAIN_NAME with the location of your running instance of Coral. + +> **NOTE:** Replace the value of `{{ CORAL_DOMAIN_NAME }}` with the location of your running instance of Coral. + +#### Single Sign On + +In order to allow seamless connection to an existing authentication system, +Coral utilizes the industry standard [JWT Token](https://jwt.io/) to connect. To +learn more about how to create a JWT token, see [this introduction](https://jwt.io/introduction/). + +1. Visit: `https://{{ CORAL_DOMAIN_NAME }}/admin/configure/auth` +2. Scroll to the `Login with Single Sign On` section +3. Enable the Single Sign On Authentication Integration +4. Enable `Allow Registration` +5. Copy the string in the `Key` box +6. Click Save + +> **NOTE:** Replace the value of `{{ CORAL_DOMAIN_NAME }}` with the location of your running instance of Coral. + +You will then have to generate a JWT with the following claims: + +- `jti` (_optional_) - A unique ID for this particular JWT token. We recommend + using a [UUID](https://en.wikipedia.org/wiki/Universally_unique_identifier) + for this value. Without this parameter, the logout functionality inside the + embed stream will not work and you will need to call logout on the embed + itself. +- `exp` (_optional_) - When the given SSO token should expire. This is + specified as a unix time stamp in seconds. Once the token has expired, a new + token should be generated and passed into Coral. Without this parameter, the + logout functionality inside the embed stream will not work and you will need + to call logout on the embed itself. +- `iat` (_optional_) - When the given SSO token was issued. This is required to + utilize the automatic user detail update system. If this time is newer than + the time we received the last update, the contents of the token will be used + to update the user. +- `user.id` (**required**) - the ID of the user from your authentication system. + This is required to connect the user in your system to allow a seamless + connection to Coral. +- `user.email` (**required**) - the email address of the user from your + authentication system. This is required to facilitate notification email's + about status changes on a user account such as bans or suspensions. +- `user.username` (**required**) - the username that should be used when being + presented inside Coral to moderators and other users. + +An example of the claims for this token would be: + +```json +{ + "jti": "151c19fc-ad15-4f80-a49c-09f137789fbb", + "exp": 1572172094, + "iat": 1562172094, + "user": { + "id": "628bdc61-6616-4add-bfec-dd79156715d4", + "email": "bob@example.com", + "username": "bob" + } +} +``` + +With the claims provided, you can sign them with the `Key` obtained from the +Coral administration panel in the previous steps with a `HS256` algorithm. This +token can be provided in the above mentioned embed code by adding it to the +`createStreamEmbed` function: + +```js +Coral.createStreamEmbed({ + // Don't forget to include the parameters from the + // "Embed On Your Site" section. + accessToken: "{{ SSO_TOKEN }}", +}); +``` + +Or by calling the `login/logout` method on the embed object: + +```js +var embed = Coral.createStreamEmbed({ + // Don't forget to include the parameters from the + // "Embed On Your Site" section. +}); + +// Login the current embed with the generated SSO token. +embed.login("{{ SSO_TOKEN }}"); + +// Logout the user. +embed.logout(); +``` #### Email diff --git a/src/core/client/stream/common/UserBox/UserBoxContainer.spec.tsx b/src/core/client/stream/common/UserBox/UserBoxContainer.spec.tsx index 2a8babe98..9b73eac3e 100644 --- a/src/core/client/stream/common/UserBox/UserBoxContainer.spec.tsx +++ b/src/core/client/stream/common/UserBox/UserBoxContainer.spec.tsx @@ -19,6 +19,7 @@ it("renders fully", () => { }, accessToken: "access-token", accessTokenJTI: "JTI", + accessTokenExp: 1562172094, }, viewer: null, settings: { @@ -77,6 +78,7 @@ it("renders without logout button", () => { }, accessToken: "access-token", accessTokenJTI: null, + accessTokenExp: null, }, viewer: null, settings: { @@ -135,6 +137,7 @@ it("renders sso only", () => { }, accessToken: "access-token", accessTokenJTI: "JTI", + accessTokenExp: 1562172094, }, viewer: null, settings: { @@ -193,6 +196,7 @@ it("renders sso only without logout button", () => { }, accessToken: "access-token", accessTokenJTI: "JTI", + accessTokenExp: 1562172094, }, viewer: null, settings: { @@ -251,6 +255,7 @@ it("renders without register button", () => { }, accessToken: "access-token", accessTokenJTI: "JTI", + accessTokenExp: 1562172094, }, viewer: null, settings: { diff --git a/src/core/client/stream/common/UserBox/UserBoxContainer.tsx b/src/core/client/stream/common/UserBox/UserBoxContainer.tsx index 5f6467cd9..e928b7791 100644 --- a/src/core/client/stream/common/UserBox/UserBoxContainer.tsx +++ b/src/core/client/stream/common/UserBox/UserBoxContainer.tsx @@ -46,7 +46,9 @@ export class UserBoxContainer extends Component { private get supportsLogout() { return Boolean( - !this.props.local.accessToken || this.props.local.accessTokenJTI + !this.props.local.accessToken || + (this.props.local.accessTokenJTI !== null && + this.props.local.accessTokenExp !== null) ); } @@ -129,6 +131,7 @@ const enhanced = withSignOutMutation( } accessToken accessTokenJTI + accessTokenExp } ` )( diff --git a/src/core/server/app/middleware/passport/index.ts b/src/core/server/app/middleware/passport/index.ts index 3f52e96af..48c85eeac 100644 --- a/src/core/server/app/middleware/passport/index.ts +++ b/src/core/server/app/middleware/passport/index.ts @@ -59,14 +59,16 @@ export function createPassport( } interface LogoutToken { - jti: string; - exp: number; + jti?: string; + exp?: number; } -const LogoutTokenSchema = Joi.object().keys({ - jti: Joi.string(), - exp: Joi.number(), -}); +const LogoutTokenSchema = Joi.object() + .keys({ + jti: Joi.string().default(undefined), + exp: Joi.number().default(undefined), + }) + .optionalKeys(["jti", "exp"]); export async function handleLogout(redis: Redis, req: Request, res: Response) { // Extract the token from the request. @@ -88,13 +90,14 @@ export async function handleLogout(redis: Redis, req: Request, res: Response) { // Grab the JTI from the decoded token. const { jti, exp }: LogoutToken = validate(LogoutTokenSchema, decoded); - - // Compute the number of seconds that the token will be valid for. - const validFor = exp - now.valueOf() / 1000; - if (validFor > 0) { - // Invalidate the token, the expiry is in the future and it needs to be - // revoked. - await revokeJWT(redis, jti, validFor, now); + if (jti && exp) { + // Compute the number of seconds that the token will be valid for. + const validFor = exp - now.valueOf() / 1000; + if (validFor > 0) { + // Invalidate the token, the expiry is in the future and it needs to be + // revoked. + await revokeJWT(redis, jti, validFor, now); + } } // Clear the cookie. diff --git a/src/core/server/app/middleware/passport/strategies/verifiers/sso.spec.ts b/src/core/server/app/middleware/passport/strategies/verifiers/sso.spec.ts index 2861ba330..84125245d 100644 --- a/src/core/server/app/middleware/passport/strategies/verifiers/sso.spec.ts +++ b/src/core/server/app/middleware/passport/strategies/verifiers/sso.spec.ts @@ -28,55 +28,6 @@ describe("SSOUserProfileSchema", () => { id: "id", email: "email", username: "username", - avatar: "avatar", - }; - - expect(validate(SSOUserProfileSchema, profile)).toEqual(profile); - expect(isSSOToken({ user: profile })).toEqual(true); - }); - - it("allows an empty avatar", () => { - const profile = { - id: "id", - email: "email", - username: "username", - }; - - expect(validate(SSOUserProfileSchema, profile)).toEqual(profile); - expect(isSSOToken({ user: profile })).toEqual(true); - }); - - it("allows a valid payload", () => { - const profile = { - id: "id", - email: "email", - username: "username", - avatar: "avatar", - displayName: "displayName", - }; - - expect(validate(SSOUserProfileSchema, profile)).toEqual(profile); - expect(isSSOToken({ user: profile })).toEqual(true); - }); - - it("allows an empty avatar", () => { - const profile = { - id: "id", - email: "email", - username: "username", - displayName: "displayName", - }; - - expect(validate(SSOUserProfileSchema, profile)).toEqual(profile); - expect(isSSOToken({ user: profile })).toEqual(true); - }); - - it("allows an empty displayName", () => { - const profile = { - id: "id", - email: "email", - username: "username", - avatar: "avatar", }; expect(validate(SSOUserProfileSchema, profile)).toEqual(profile); diff --git a/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts b/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts index cab3aedff..35344e4d6 100644 --- a/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts +++ b/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts @@ -9,13 +9,24 @@ import { GQLUSER_ROLE, } from "coral-server/graph/tenant/schema/__generated__/types"; import { Tenant } from "coral-server/models/tenant"; -import { retrieveUserWithProfile, SSOProfile } from "coral-server/models/user"; +import { + retrieveUserWithProfile, + SSOProfile, + updateUserFromSSO, +} from "coral-server/models/user"; import { insert } from "coral-server/services/users"; import { + getSSOProfile, + needsSSOUpdate, +} from "coral-server/models/user/helpers"; +import { + isJWTRevoked, SymmetricSigningAlgorithm, verifyJWT, } from "coral-server/services/jwt"; +import { AugmentedRedis } from "coral-server/services/redis"; +import { DateTime } from "luxon"; import { Verifier } from "../jwt"; export interface SSOStrategyOptions { @@ -26,29 +37,40 @@ export interface SSOUserProfile { id: string; email: string; username: string; - avatar?: string; } export interface SSOToken { + jti?: string; + exp?: number; + iat?: number; user: SSOUserProfile; } -export const SSOUserProfileSchema = Joi.object() - .keys({ - id: Joi.string().required(), - email: Joi.string().required(), - username: Joi.string().required(), - avatar: Joi.string().default(undefined), - displayName: Joi.string().default(undefined), - }) - .optionalKeys(["avatar", "displayName"]); +export function isSSOToken(token: SSOToken | object): token is SSOToken { + const { error } = Joi.validate(token, SSOTokenSchema); + return isNil(error); +} -export const SSOTokenSchema = Joi.object().keys({ - user: SSOUserProfileSchema.required(), +export const SSOUserProfileSchema = Joi.object().keys({ + id: Joi.string().required(), + email: Joi.string() + .lowercase() + .required(), + username: Joi.string().required(), }); +export const SSOTokenSchema = Joi.object() + .keys({ + jti: Joi.string().default(undefined), + exp: Joi.number().default(undefined), + iat: Joi.number().default(undefined), + user: SSOUserProfileSchema.required(), + }) + .optionalKeys(["jti", "exp", "iat"]); + export async function findOrCreateSSOUser( mongo: Db, + redis: AugmentedRedis, tenant: Tenant, integration: GQLSSOAuthIntegration, token: SSOToken, @@ -59,19 +81,31 @@ export async function findOrCreateSSOUser( throw new Error("token is malformed, missing user claim"); } - // Unpack/validate the token content. - const { id, email, username, avatar }: SSOUserProfile = validate( - SSOUserProfileSchema, - token.user - ); + // Validate the token content. + const decodedToken: SSOToken = validate(SSOTokenSchema, token); - const profile: SSOProfile = { - type: "sso", - id, - }; + // Unpack the token. + const { + jti, + exp, + user: { id, email, username }, + iat, + } = decodedToken; + + // If the token has a JTI and EXP claim, then it can be logged out. Check to + // see if it was revoked. + if (jti && exp && (await isJWTRevoked(redis, jti))) { + return null; + } + + // Compute the last issued at time stamp. + const lastIssuedAt = iat ? DateTime.fromSeconds(iat).toJSDate() : now; // Try to lookup user given their id provided in the `sub` claim. - let user = await retrieveUserWithProfile(mongo, tenant.id, profile); + let user = await retrieveUserWithProfile(mongo, tenant.id, { + type: "sso", + id, + }); if (!user) { if (!integration.allowRegistration) { // Registration is disabled, so we can't create the user user here. @@ -80,6 +114,12 @@ export async function findOrCreateSSOUser( // FIXME: (wyattjoh) implement rules! Not all users should be able to create an account via this method. + const profile: SSOProfile = { + type: "sso", + id, + lastIssuedAt, + }; + // Create the new user, as one didn't exist before! user = await insert( mongo, @@ -88,32 +128,42 @@ export async function findOrCreateSSOUser( username, role: GQLUSER_ROLE.COMMENTER, email, - avatar, profiles: [profile], }, now ); + } else if (iat && needsSSOUpdate(decodedToken.user, user)) { + // Get the SSO Profile. + const profile = getSSOProfile(user); + if (profile && profile.lastIssuedAt < lastIssuedAt) { + // The token presented to us has a newer issue date than the one + // associated with this profile, we should update the user with new + // details. + user = await updateUserFromSSO( + mongo, + tenant.id, + user.id, + { email, username }, + lastIssuedAt + ); + } } - // TODO: (wyattjoh) possibly update the user profile if the remaining details mismatch? - return user; } -export function isSSOToken(token: SSOToken | object): token is SSOToken { - const { error } = Joi.validate(token, SSOTokenSchema); - return isNil(error); -} - export interface SSOVerifierOptions { mongo: Db; + redis: AugmentedRedis; } export class SSOVerifier implements Verifier { private mongo: Db; + private redis: AugmentedRedis; - constructor({ mongo }: SSOVerifierOptions) { + constructor({ mongo, redis }: SSOVerifierOptions) { this.mongo = mongo; + this.redis = redis; } public supports(token: SSOToken | object, tenant: Tenant): token is SSOToken { @@ -147,6 +197,13 @@ export class SSOVerifier implements Verifier { now ); - return findOrCreateSSOUser(this.mongo, tenant, integration, token, now); + return findOrCreateSSOUser( + this.mongo, + this.redis, + tenant, + integration, + token, + now + ); } } diff --git a/src/core/server/helpers/users.spec.ts b/src/core/server/models/user/helpers.spec.ts similarity index 93% rename from src/core/server/helpers/users.spec.ts rename to src/core/server/models/user/helpers.spec.ts index 9d9940ff0..bc3e28f25 100644 --- a/src/core/server/helpers/users.spec.ts +++ b/src/core/server/models/user/helpers.spec.ts @@ -1,5 +1,5 @@ import { LocalProfile, SSOProfile } from "coral-server/models/user"; -import { getLocalProfile, hasLocalProfile } from "./users"; +import { getLocalProfile, hasLocalProfile } from "./helpers"; const localProfile: LocalProfile = { type: "local", @@ -10,6 +10,7 @@ const localProfile: LocalProfile = { const ssoProfile: SSOProfile = { type: "sso", id: "sso-id", + lastIssuedAt: new Date(0), }; it("will get the local profile", () => { diff --git a/src/core/server/models/user/helpers.ts b/src/core/server/models/user/helpers.ts index 1f910cd90..d87597a36 100644 --- a/src/core/server/models/user/helpers.ts +++ b/src/core/server/models/user/helpers.ts @@ -1,7 +1,7 @@ import { GQLUSER_ROLE } from "coral-server/graph/tenant/schema/__generated__/types"; -import { STAFF_ROLES } from "coral-server/models/user/constants"; -import { User } from "."; +import { STAFF_ROLES } from "./constants"; +import { LocalProfile, SSOProfile, User } from "./user"; export function roleIsStaff(role: GQLUSER_ROLE) { if (STAFF_ROLES.includes(role)) { @@ -14,3 +14,53 @@ export function roleIsStaff(role: GQLUSER_ROLE) { export function userIsStaff(user: Pick) { return roleIsStaff(user.role); } + +export function getSSOProfile(user: Pick) { + return user.profiles.find(profile => profile.type === "sso") as + | SSOProfile + | undefined; +} + +export function needsSSOUpdate( + token: Pick, + user: Pick +) { + return user.email !== token.email || user.username !== token.username; +} + +/** + * getLocalProfile will get the LocalProfile from the User if it exists. + * + * @param user the User to pull the LocalProfile out of + */ +export function getLocalProfile( + user: Pick +): LocalProfile | undefined { + return user.profiles.find(({ type }) => type === "local") as + | LocalProfile + | undefined; +} + +/** + * hasLocalProfile will return true if the User has a LocalProfile, optionally + * checking the email on it as well. + * + * @param user the User to pull the LocalProfile out of + * @param withEmail when specified, will ensure that the LocalProfile has the + * specific email provided + */ +export function hasLocalProfile( + user: Pick, + withEmail?: string +): boolean { + const profile = getLocalProfile(user); + if (!profile) { + return false; + } + + if (withEmail && profile.id !== withEmail) { + return false; + } + + return true; +} diff --git a/src/core/server/models/user/index.ts b/src/core/server/models/user/index.ts index f910cc12f..7616f9e53 100644 --- a/src/core/server/models/user/index.ts +++ b/src/core/server/models/user/index.ts @@ -1,1705 +1 @@ -import bcrypt from "bcryptjs"; -import { Db, MongoError } from "mongodb"; -import uuid from "uuid"; - -import { DeepPartial, Omit, Sub } from "coral-common/types"; -import { dotize } from "coral-common/utils/dotize"; -import { - ConfirmEmailTokenExpired, - DuplicateEmailError, - DuplicateUserError, - LocalProfileAlreadySetError, - LocalProfileNotSetError, - PasswordResetTokenExpired, - TokenNotFoundError, - UserAlreadyBannedError, - UserAlreadySuspendedError, - UsernameAlreadySetError, - UserNotFoundError, -} from "coral-server/errors"; -import { - GQLBanStatus, - GQLSuspensionStatus, - GQLTimeRange, - GQLUSER_ROLE, -} from "coral-server/graph/tenant/schema/__generated__/types"; -import { getLocalProfile, hasLocalProfile } from "coral-server/helpers/users"; -import logger from "coral-server/logger"; -import { - Connection, - ConnectionInput, - resolveConnection, -} from "coral-server/models/helpers/connection"; -import { - createConnectionOrderVariants, - createIndexFactory, -} from "coral-server/models/helpers/indexing"; -import Query from "coral-server/models/helpers/query"; -import { TenantResource } from "coral-server/models/tenant"; - -function collection(mongo: Db) { - return mongo.collection>("users"); -} - -export interface LocalProfile { - type: "local"; - id: string; - password: string; - - /** - * resetID is used during a password reset process to prevent replay attacks. - * When a password reset email is sent, a resetID is associated with the - * account and the token. When a given reset token is used, it is cleared from - * the user, preventing the same reset URL from being used multiple times. - */ - resetID?: string; -} - -export interface OIDCProfile { - type: "oidc"; - id: string; - issuer: string; - audience: string; -} - -export interface SSOProfile { - type: "sso"; - id: string; -} - -export interface FacebookProfile { - type: "facebook"; - id: string; -} - -export interface GoogleProfile { - type: "google"; - id: string; -} - -/** - * Profile is all the different profiles that a given User may have associated - * with their account. - */ -export type Profile = - | LocalProfile - | OIDCProfile - | SSOProfile - | FacebookProfile - | GoogleProfile; - -export interface Token { - readonly id: string; - name: string; - createdAt: Date; -} - -/** - * SuspensionStatusHistory SuspensionStatusHistory is the list of all suspension - * events against a specific User. - */ -export interface SuspensionStatusHistory { - /** - * id is a specific reference for a particular suspension status that will be - * used internally to update suspension records. - */ - id: string; - - /** - * from represents a range of time where a user suspension applies. - */ - from: GQLTimeRange; - - /** - * createdBy is the ID for the User that suspended the User. If `null`, the - * suspension was created by the system. - */ - createdBy?: string; - - /** - * createdAt is the time that the given suspension time frame was created. - */ - createdAt: Date; - - /** - * modifiedBy is the ID for the User that modified the suspension for this - * User. If `null`, the suspension has not been edited, or has been edited by - * the system. - */ - modifiedBy?: string; - - /** - * modifiedAt is the time that the date that the given suspension time frame - * was edited at. - */ - modifiedAt?: Date; -} - -/** - * SuspensionStatus stores the user suspension status as well as the history of - * changes. - */ -export interface SuspensionStatus { - /** - * history is the list of all suspension events against a specific User. - */ - history: SuspensionStatusHistory[]; -} - -/** - * BanStatusHistory is the list of all ban events against a specific User. - */ -export interface BanStatusHistory { - /** - * id is a specific reference for a particular banned status that will be - * used internally to update banned records. - */ - id: string; - - /** - * active, when true, indicates that the user is banned from this status. - */ - active: boolean; - - /** - * createdBy is the ID for the User that banned the User. If `null`, the ban - * was created by the system. - */ - createdBy?: string; - - /** - * createdAt is the time that the given ban was added. - */ - createdAt: Date; -} - -/** - * BanStatus contains information about a ban for a given User. - */ -export interface BanStatus { - /** - * active when true, indicates that the given user is banned. - */ - active: boolean; - - /** - * history is the list of all ban events against a specific User. - */ - history: BanStatusHistory[]; -} - -/** - * UserStatus stores the user status information regarding moderation state. - */ -export interface UserStatus { - /** - * suspension stores the user suspension status as well as the history of - * changes. - */ - suspension: SuspensionStatus; - - /** - * ban stores the user ban status as well as the history of changes. - */ - ban: BanStatus; -} - -/** - * IgnoredUser is the entry describing a User being ignored. - */ -export interface IgnoredUser { - /** - * id is the ID of the User that was ignored. - */ - id: string; - - /** - * createdAt is the date that the User was ignored on. - */ - createdAt: Date; -} - -/** - * User is someone that leaves Comments, and logs in. - */ -export interface User extends TenantResource { - /** - * id is the identifier of the User. - */ - readonly id: string; - - /** - * username is the name of the User visible to other Users. - */ - username?: string; - - /** - * avatar is the url to the avatar for a specific User. - */ - avatar?: string; - - /** - * email is the current email address for the User. - */ - email?: string; - - /** - * emailVerificationID is used to store state regarding the verification state - * of an email address to prevent replay attacks. - */ - emailVerificationID?: string; - - /** - * emailVerified when true indicates that the given email address has been verified. - */ - emailVerified?: boolean; - - /** - * profiles is the array of profiles assigned to the user. - */ - profiles: Profile[]; - - /** - * tokens lists the access tokens associated with the account. - */ - tokens: Token[]; - - /** - * role is the current role of the User. - */ - role: GQLUSER_ROLE; - - /** - * status stores the user status information regarding moderation state. - */ - status: UserStatus; - - /** - * ignoredUsers stores the users that have been ignored by this User. - */ - ignoredUsers: IgnoredUser[]; - - /** - * createdAt is the time that the User was created at. - */ - createdAt: Date; -} - -export async function createUserIndexes(mongo: Db) { - const createIndex = createIndexFactory(collection(mongo)); - - // UNIQUE { id } - await createIndex({ tenantID: 1, id: 1 }, { unique: true }); - - // UNIQUE - PARTIAL { email } - await createIndex( - { tenantID: 1, email: 1 }, - { unique: true, partialFilterExpression: { email: { $exists: true } } } - ); - - // UNIQUE { profiles.type, profiles.id } - await createIndex( - { tenantID: 1, "profiles.type": 1, "profiles.id": 1 }, - // TODO: (wyattjoh) change the `partialFilterExpression` to `{ "profiles.id": { $exists: true } }` - { unique: true, partialFilterExpression: { profiles: { $exists: true } } } - ); - - // { profiles } - await createIndex( - { tenantID: 1, profiles: 1, email: 1 }, - { - partialFilterExpression: { profiles: { $exists: true } }, - background: true, - } - ); - - // TEXT { id, username, email, createdAt } - await createIndex( - { - tenantID: 1, - id: "text", - username: "text", - email: "text", - createdAt: -1, - }, - { background: true } - ); - - const variants = createConnectionOrderVariants>( - [{ createdAt: -1 }], - { background: true } - ); - - // User Connection pagination. - // { ...connectionParams } - await variants(createIndex, { - tenantID: 1, - }); - - // Role based User Connection pagination. - // { role, ...connectionParams } - await variants(createIndex, { - tenantID: 1, - role: 1, - }); - - // Suspension based User Connection pagination. - await variants(createIndex, { - tenantID: 1, - "status.suspension.history.from.start": 1, - "status.suspension.history.from.finish": 1, - }); - - // Ban based User Connection pagination. - await variants(createIndex, { - tenantID: 1, - "status.ban.active": 1, - }); -} - -function hashPassword(password: string): Promise { - return bcrypt.hash(password, 10); -} - -export type InsertUserInput = Omit< - User, - | "id" - | "tenantID" - | "tokens" - | "status" - | "ignoredUsers" - | "emailVerificationID" - | "createdAt" ->; - -export async function insertUser( - mongo: Db, - tenantID: string, - input: InsertUserInput, - now = new Date() -) { - // Create a new ID for the user. - const id = uuid.v4(); - - // default are the properties set by the application when a new user is - // created. - const defaults: Sub = { - id, - tenantID, - tokens: [], - ignoredUsers: [], - status: { - suspension: { history: [] }, - ban: { active: false, history: [] }, - }, - createdAt: now, - }; - - // Guard against empty login profiles (they need some way to login). - if (input.profiles.length === 0) { - throw new Error("users require at least one profile"); - } - - // Mutate the profiles to ensure we mask handle any secrets. - const profiles: Profile[] = []; - for (let profile of input.profiles) { - switch (profile.type) { - case "local": - // Hash the user's password with bcrypt. - const password = await hashPassword(profile.password); - profile = { - ...profile, - password, - }; - break; - } - // Save a copy. - profiles.push(profile); - } - - // Merge the defaults and the input together. - const user: Readonly = { - ...defaults, - ...input, - profiles, - }; - - try { - // Insert it into the database. This may throw an error. - await collection(mongo).insert(user); - } catch (err) { - // Evaluate the error, if it is in regards to violating the unique index, - // then return a duplicate Story error. - if (err instanceof MongoError && err.code === 11000) { - // Check if duplicate index was about the email. - if (err.errmsg && err.errmsg.includes("tenantID_1_email_1")) { - throw new DuplicateEmailError(input.email!); - } - throw new DuplicateUserError(); - } - - throw err; - } - - return user; -} - -export async function retrieveUser(mongo: Db, tenantID: string, id: string) { - return collection(mongo).findOne({ tenantID, id }); -} - -export async function retrieveManyUsers( - mongo: Db, - tenantID: string, - ids: string[] -) { - const cursor = await collection(mongo).find({ - id: { - $in: ids, - }, - tenantID, - }); - - const users = await cursor.toArray(); - - return ids.map(id => users.find(comment => comment.id === id) || null); -} - -export async function retrieveUserWithProfile( - mongo: Db, - tenantID: string, - profile: Partial> -) { - return collection(mongo).findOne({ - tenantID, - profiles: { - $elemMatch: profile, - }, - }); -} - -export async function retrieveUserWithEmail( - mongo: Db, - tenantID: string, - email: string -) { - return collection(mongo).findOne({ - tenantID, - $or: [ - { - profiles: { - $elemMatch: { id: email, type: "local" }, - }, - }, - { email }, - ], - }); -} - -/** - * updateUserRole updates a given User's role. - * - * @param mongo mongodb database to interact with - * @param tenantID Tenant ID where the User resides - * @param id ID of the User that we are updating - * @param role new role to set to the User - */ -export async function updateUserRole( - mongo: Db, - tenantID: string, - id: string, - role: GQLUSER_ROLE -) { - const result = await collection(mongo).findOneAndUpdate( - { id, tenantID }, - { $set: { role } }, - { - // False to return the updated document instead of the original - // document. - returnOriginal: false, - } - ); - if (!result.value) { - throw new UserNotFoundError(id); - } - - return result.value; -} - -export async function verifyUserPassword( - user: Pick, - password: string -) { - const profile = getLocalProfile(user); - if (!profile) { - throw new LocalProfileNotSetError(); - } - - return bcrypt.compare(password, profile.password); -} - -export async function updateUserPassword( - mongo: Db, - tenantID: string, - id: string, - password: string -) { - // Hash the password. - const hashedPassword = await hashPassword(password); - - // Update the user with the new password. - const result = await collection(mongo).findOneAndUpdate( - { - tenantID, - id, - // This ensures that the document we're updating already has a local - // profile associated with them. - "profiles.type": "local", - }, - { - $set: { - "profiles.$[profiles].password": hashedPassword, - }, - }, - { - arrayFilters: [{ "profiles.type": "local" }], - // False to return the updated document instead of the original - // document. - returnOriginal: false, - } - ); - if (!result.value) { - const user = await retrieveUser(mongo, tenantID, id); - if (!user) { - throw new UserNotFoundError(id); - } - - if (!hasLocalProfile(user)) { - throw new LocalProfileNotSetError(); - } - - throw new Error("an unexpected error occurred"); - } - - return result.value || null; -} - -/** - * setUserUsername will set the username of the User if the username hasn't - * already been used before. - * - * @param mongo the database handle - * @param tenantID the ID to the Tenant - * @param id the ID of the User where we are setting the username on - * @param username the username that we want to set - */ -export async function setUserUsername( - mongo: Db, - tenantID: string, - id: string, - username: string -) { - // TODO: (wyattjoh) investigate adding the username previously used to an array. - - // The username wasn't found, so add it to the user. - const result = await collection(mongo).findOneAndUpdate( - { - tenantID, - id, - username: null, - }, - { - $set: { - username, - }, - }, - { - // False to return the updated document instead of the original - // document. - returnOriginal: false, - } - ); - if (!result.value) { - // Try to get the current user to discover what happened. - const user = await retrieveUser(mongo, tenantID, id); - if (!user) { - throw new UserNotFoundError(id); - } - - if (user.username) { - throw new UsernameAlreadySetError(); - } - - throw new Error("an unexpected error occurred"); - } - - return result.value; -} - -/** - * updateUserUsername will set the username of the User. - * - * @param mongo the database handle - * @param tenantID the ID to the Tenant - * @param id the ID of the User where we are setting the username on - * @param username the username that we want to set - */ -export async function updateUserUsername( - mongo: Db, - tenantID: string, - id: string, - username: string -) { - // TODO: (wyattjoh) investigate adding the username previously used to an array. - - // The username wasn't found, so add it to the user. - const result = await collection(mongo).findOneAndUpdate( - { - tenantID, - id, - }, - { - $set: { - username, - }, - }, - { - // False to return the updated document instead of the original - // document. - returnOriginal: false, - } - ); - if (!result.value) { - // Try to get the current user to discover what happened. - const user = await retrieveUser(mongo, tenantID, id); - if (!user) { - throw new UserNotFoundError(id); - } - - throw new Error("an unexpected error occurred"); - } - - return result.value; -} - -/** - * setUserEmail will set the email address of the User if they don't already - * have one associated with them, and it hasn't been used before. - * - * @param mongo the database handle - * @param tenantID the ID to the Tenant - * @param id the ID of the User where we are setting the email address on - * @param emailAddress the email address we want to set - */ -export async function setUserEmail( - mongo: Db, - tenantID: string, - id: string, - emailAddress: string -) { - // Lowercase the email address. - const email = emailAddress.toLowerCase(); - - // Search to see if this email has been used before. - let user = await collection(mongo).findOne({ - tenantID, - email, - }); - if (user) { - throw new DuplicateEmailError(email); - } - - // The email wasn't found, so try to update the User. - const result = await collection(mongo).findOneAndUpdate( - { - tenantID, - id, - email: null, - }, - { - $set: { - email, - }, - }, - { - // False to return the updated document instead of the original - // document. - returnOriginal: false, - } - ); - if (!result.value) { - // Try to get the current user to discover what happened. - user = await retrieveUser(mongo, tenantID, id); - if (!user) { - throw new UserNotFoundError(id); - } - - if (user.email) { - throw new UsernameAlreadySetError(); - } - - throw new Error("an unexpected error occurred"); - } - - return result.value; -} - -/** - * updateUserEmail will update a given User's email address to the one provided. - * - * @param mongo the database that we are interacting with - * @param tenantID the Tenant ID of the Tenant where the User exists - * @param id the User ID that we are updating - * @param emailAddress email address that we are setting on the User - */ -export async function updateUserEmail( - mongo: Db, - tenantID: string, - id: string, - emailAddress: string -) { - // Lowercase the email address. - const email = emailAddress.toLowerCase(); - - // Search to see if this email has been used before. - let user = await collection(mongo).findOne({ - tenantID, - email, - }); - if (user) { - throw new DuplicateEmailError(email); - } - - // The email wasn't found, so try to update the User. - const result = await collection(mongo).findOneAndUpdate( - { - tenantID, - id, - }, - { - $set: { - email, - }, - }, - { - // False to return the updated document instead of the original - // document. - returnOriginal: false, - } - ); - if (!result.value) { - // Try to get the current user to discover what happened. - user = await retrieveUser(mongo, tenantID, id); - if (!user) { - throw new UserNotFoundError(id); - } - - throw new Error("an unexpected error occurred"); - } - - return result.value; -} - -/** - * updateUserAvatar will update the avatar associated with a User. If the avatar - * is not provided, it will be unset. - * - * @param mongo the database that we are interacting with - * @param tenantID the Tenant ID of the Tenant where the User exists - * @param id the User ID that we are updating - * @param avatar URL that the avatar exists at - */ -export async function updateUserAvatar( - mongo: Db, - tenantID: string, - id: string, - avatar?: string -) { - // The email wasn't found, so try to update the User. - const result = await collection(mongo).findOneAndUpdate( - { - tenantID, - id, - }, - { - // This will ensure that if the avatar isn't provided, it will unset the - // avatar on the User. - [avatar ? "$set" : "$unset"]: { - avatar: avatar ? avatar : 1, - }, - }, - { - // False to return the updated document instead of the original - // document. - returnOriginal: false, - } - ); - if (!result.value) { - // Try to get the current user to discover what happened. - const user = await retrieveUser(mongo, tenantID, id); - if (!user) { - throw new UserNotFoundError(id); - } - - throw new Error("an unexpected error occurred"); - } - - return result.value; -} - -/** - * setUserLocalProfile will set the local profile for a User if they don't - * already have one associated with them and the profile doesn't exist on any - * other User already. - * - * @param mongo the database handle - * @param tenantID the ID to the Tenant - * @param id the ID of the User where we are setting the local profile on - * @param emailAddress the email address we want to set - * @param password the password we want to set - */ -export async function setUserLocalProfile( - mongo: Db, - tenantID: string, - id: string, - emailAddress: string, - password: string -) { - // Lowercase the email address. - const email = emailAddress.toLowerCase(); - - // Try to see if this local profile already exists on a User. - let user = await retrieveUserWithProfile(mongo, tenantID, { - type: "local", - id: email, - }); - if (user) { - throw new DuplicateEmailError(email); - } - - // Hash the password. - const hashedPassword = await hashPassword(password); - - // Create the profile that we'll use. - const profile: LocalProfile = { - type: "local", - id: email, - password: hashedPassword, - }; - - // The profile wasn't found, so add it to the User. - const result = await collection(mongo).findOneAndUpdate( - { - tenantID, - id, - // This ensures that the document we're updating does not contain a local - // profile. - "profiles.type": { $ne: "local" }, - }, - { - $push: { - profiles: profile, - }, - }, - { - // False to return the updated document instead of the original - // document. - returnOriginal: false, - } - ); - if (!result.value) { - // Try to get the current user to discover what happened. - user = await retrieveUser(mongo, tenantID, id); - if (!user) { - throw new UserNotFoundError(id); - } - - // Check to see if the user has any local profile (not just a local profile - // with a specific email). - if (hasLocalProfile(user)) { - throw new LocalProfileAlreadySetError(); - } - - throw new Error("an unexpected error occurred"); - } - - return result.value; -} - -export async function createUserToken( - mongo: Db, - tenantID: string, - userID: string, - name: string, - now = new Date() -) { - // Create the Token that we'll be adding to the User. - const token: Readonly = { - id: uuid.v4(), - name, - createdAt: now, - }; - - const result = await collection(mongo).findOneAndUpdate( - { - id: userID, - tenantID, - }, - { - $push: { tokens: token }, - }, - { - // False to return the updated document instead of the original - // document. - returnOriginal: false, - } - ); - if (!result.value) { - throw new UserNotFoundError(userID); - } - - return { - user: result.value, - token, - }; -} - -export async function deactivateUserToken( - mongo: Db, - tenantID: string, - userID: string, - id: string -) { - // Try to remove the Token from the User. - const result = await collection(mongo).findOneAndUpdate( - { - id: userID, - tenantID, - "tokens.id": id, - }, - { - $pull: { tokens: { id } }, - }, - { - // True to return the original document instead of the updated - // document. - returnOriginal: true, - } - ); - if (!result.value) { - const user = await retrieveUser(mongo, tenantID, userID); - if (!user) { - throw new UserNotFoundError(id); - } - - // Check to see if the User had that Token in the first place. - if (!user.tokens.find(t => t.id === id)) { - throw new TokenNotFoundError(); - } - - throw new Error("an unexpected error occurred"); - } - - // We have to typecast here because we know at this point that the record does - // contain the Token. - const token: Token = result.value.tokens.find(t => t.id === id) as Token; - - // Mutate the user in order to remove the Token from the list of Token's. - const updatedUser: Readonly = { - ...result.value, - tokens: result.value.tokens.filter(t => t.id !== id), - }; - - return { - user: updatedUser, - token, - }; -} - -export type UserConnectionInput = ConnectionInput; - -export async function retrieveUserConnection( - mongo: Db, - tenantID: string, - input: UserConnectionInput -): Promise>>> { - // Create the query. - const query = new Query(collection(mongo)).where({ tenantID }); - - // If a filter is being applied, filter it as well. - if (input.filter) { - query.where(input.filter); - } - - return retrieveConnection(input, query); -} - -async function retrieveConnection( - input: UserConnectionInput, - query: Query -): Promise>>> { - // Apply the pagination arguments to the query. - query.orderBy({ createdAt: -1 }); - if (input.after) { - query.where({ createdAt: { $lt: input.after as Date } }); - } - - // Return a connection. - return resolveConnection(query, input, user => user.createdAt); -} - -/** - * banUser will ban a specific user from interacting with the site. - * - * @param mongo the mongo database handle - * @param tenantID the Tenant's ID where the User exists - * @param id the ID of the user being banned - * @param createdBy the ID of the user banning the above mentioned user - * @param now the current date - */ -export async function banUser( - mongo: Db, - tenantID: string, - id: string, - createdBy: string, - now = new Date() -) { - // Create the new ban. - const banHistory: BanStatusHistory = { - id: uuid(), - active: true, - createdBy, - createdAt: now, - }; - - // Try to update the user if the user isn't already banned. - const result = await collection(mongo).findOneAndUpdate( - { - id, - tenantID, - "status.ban.active": { - $ne: true, - }, - }, - { - $set: { - "status.ban.active": true, - }, - $push: { - "status.ban.history": banHistory, - }, - }, - { - // False to return the updated document instead of the original - // document. - returnOriginal: false, - } - ); - if (!result.value) { - // Get the user so we can figure out why the ban operation failed. - const user = await retrieveUser(mongo, tenantID, id); - if (!user) { - throw new UserNotFoundError(id); - } - - // Check to see if the user is already banned. - const ban = consolidateUserBanStatus(user.status.ban); - if (ban.active) { - throw new UserAlreadyBannedError(); - } - - throw new Error("an unexpected error occurred"); - } - - return result.value; -} - -/** - * removeUserBan will lift a user ban from a User allowing them to interact with - * the site again. - * - * @param mongo the mongo database handle - * @param tenantID the Tenant's ID where the User exists - * @param id the ID of the user having their ban lifted - * @param createdBy the ID of the user lifting the ban - * @param now the current date - */ -export async function removeUserBan( - mongo: Db, - tenantID: string, - id: string, - createdBy: string, - now = new Date() -) { - // Create the new ban. - const ban: BanStatusHistory = { - id: uuid(), - active: false, - createdBy, - createdAt: now, - }; - - // Try to update the user if the user isn't already banned. - const result = await collection(mongo).findOneAndUpdate( - { - id, - tenantID, - $or: [ - { - "status.ban.active": { - $ne: false, - }, - }, - { - "status.ban.history": { - $size: 0, - }, - }, - ], - }, - { - $set: { - "status.ban.active": false, - }, - $push: { - "status.ban.history": ban, - }, - }, - { - // False to return the updated document instead of the original - // document. - returnOriginal: false, - } - ); - if (!result.value) { - // Get the user so we can figure out why the ban operation failed. - const user = await retrieveUser(mongo, tenantID, id); - if (!user) { - throw new UserNotFoundError(id); - } - - // The user wasn't banned already, so nothing needs to be done! - return user; - } - - return result.value; -} - -/** - * suspendUser will suspend a user for a specific time range from interacting - * with the site. - * - * @param mongo the mongo database handle - * @param tenantID the Tenant's ID where the User exists - * @param id the ID of the user being suspended - * @param createdBy the ID of the user banning the above mentioned user - * @param from the range of time that the user is being banned for - * @param now the current date - */ -export async function suspendUser( - mongo: Db, - tenantID: string, - id: string, - createdBy: string, - finish: Date, - now = new Date() -) { - // Create the new suspension. - const suspension: SuspensionStatusHistory = { - id: uuid(), - from: { - start: now, - finish, - }, - createdBy, - createdAt: now, - }; - - // Try to update the user if the user isn't already suspended. - const result = await collection(mongo).findOneAndUpdate( - { - id, - tenantID, - "status.suspension.history": { - $not: { - $elemMatch: { - "from.start": { - $lte: now, - }, - "from.finish": { - $gt: now, - }, - }, - }, - }, - }, - { - $push: { - "status.suspension.history": suspension, - }, - }, - { - // False to return the updated document instead of the original - // document. - returnOriginal: false, - } - ); - if (!result.value) { - // Get the user so we can figure out why the suspend operation failed. - const user = await retrieveUser(mongo, tenantID, id); - if (!user) { - throw new UserNotFoundError(id); - } - - // Check to see if the user is already suspended. - const suspended = consolidateUserSuspensionStatus( - user.status.suspension, - now - ); - if (suspended.active && suspended.until) { - throw new UserAlreadySuspendedError(suspended.until); - } - - throw new Error("an unexpected error occurred"); - } - - return result.value; -} - -/** - * removeUserSuspensions will lift any active suspensions. - * - * @param mongo the mongo database handle - * @param tenantID the Tenant's ID where the User exists - * @param id the ID of the User having their suspension lifted - * @param modifiedBy the ID of the User lifting the suspension - * @param now the current date - */ -export async function removeActiveUserSuspensions( - mongo: Db, - tenantID: string, - id: string, - modifiedBy: string, - now = new Date() -) { - // Prepare the update payload. - const update: DeepPartial = { - from: { - finish: now, - }, - modifiedAt: now, - modifiedBy, - }; - - // Try to update the user suspension times. - const result = await collection(mongo).findOneAndUpdate( - { tenantID, id }, - { - $set: dotize({ - "status.suspension.history.$[active]": update, - }), - }, - { - arrayFilters: [ - // Change the finish date on all suspension records that indicate their - // active time within our current range. - { - "active.from.start": { $lte: now }, - "active.from.finish": { $gt: now }, - }, - ], - // False to return the updated document instead of the original - // document. - returnOriginal: false, - } - ); - if (!result.value) { - // Get the user so we can figure out why the suspend operation failed. - const user = await retrieveUser(mongo, tenantID, id); - if (!user) { - throw new UserNotFoundError(id); - } - - // The user wasn't already suspended, so nothing needs to be done! - return user; - } - - logger.debug({ result }, "finished update operation"); - - return result.value; -} - -export type ConsolidatedBanStatus = Omit & - Pick; - -export function consolidateUserBanStatus( - ban: User["status"]["ban"] -): ConsolidatedBanStatus { - return ban; -} - -export type ConsolidatedSuspensionStatus = Omit< - GQLSuspensionStatus, - "history" -> & - Pick; - -export function consolidateUserSuspensionStatus( - suspension: User["status"]["suspension"], - now = new Date() -): ConsolidatedSuspensionStatus { - return suspension.history.reduce( - (status: ConsolidatedSuspensionStatus, history) => { - // Check to see if we're currently suspended. - if (history.from.start <= now && history.from.finish > now) { - status.active = true; - - // Ensure that we have the furthest suspension finish time. - if (!status.until || status.until < history.from.finish) { - status.until = history.from.finish; - } - } - - return status; - }, - { - active: false, - history: suspension.history, - } - ); -} - -export interface ConsolidatedUserStatus { - suspension: ConsolidatedSuspensionStatus; - ban: ConsolidatedBanStatus; -} - -export function consolidateUserStatus( - status: User["status"], - now = new Date() -): ConsolidatedUserStatus { - // Return the status. - return { - suspension: consolidateUserSuspensionStatus(status.suspension, now), - ban: consolidateUserBanStatus(status.ban), - }; -} - -/** - * createOrRetrieveUserPasswordResetID will create/retrieve a password reset ID - * on the User. - * - * @param mongo MongoDB instance to interact with - * @param tenantID Tenant ID that the User exists on - * @param id ID of the User that we are creating a reset ID with - */ -export async function createOrRetrieveUserPasswordResetID( - mongo: Db, - tenantID: string, - id: string -): Promise { - // Create the ID. - const resetID = uuid.v4(); - - // Associate the resetID with the user. - const result = await collection(mongo).findOneAndUpdate( - { - tenantID, - id, - // This ensures that the document we're updating already has a local - // profile associated with them and also doesn't have a resetID. - profiles: { - $elemMatch: { - type: "local", - resetID: null, - }, - }, - }, - { - $set: { - "profiles.$[profiles].resetID": resetID, - }, - }, - { - arrayFilters: [{ "profiles.type": "local" }], - // False to return the updated document instead of the original - // document. - returnOriginal: false, - } - ); - if (!result.value) { - const user = await retrieveUser(mongo, tenantID, id); - if (!user) { - throw new UserNotFoundError(id); - } - - const localProfile = getLocalProfile(user); - if (!localProfile) { - throw new LocalProfileNotSetError(); - } - - if (localProfile.resetID) { - return localProfile.resetID; - } - - throw new Error("an unexpected error occurred"); - } - - return resetID; -} - -export async function createOrRetrieveUserEmailVerificationID( - mongo: Db, - tenantID: string, - id: string -): Promise { - // Create the ID. - const emailVerificationID = uuid.v4(); - - // Associate the resetID with the user. - const result = await collection(mongo).findOneAndUpdate( - { - tenantID, - id, - // This ensures that we don't set a emailVerificationID when there is one - // already. - emailVerificationID: null, - $or: [{ emailVerified: false }, { emailVerified: null }], - }, - { - $set: { - emailVerificationID, - }, - }, - { - // False to return the updated document instead of the original - // document. - returnOriginal: false, - } - ); - if (!result.value) { - const user = await retrieveUser(mongo, tenantID, id); - if (!user) { - throw new UserNotFoundError(id); - } - - if (user.emailVerified) { - throw new Error("email address has already been verified"); - } - - if (user.emailVerificationID) { - return user.emailVerificationID; - } - - throw new Error("an unexpected error occurred"); - } - - return emailVerificationID; -} - -export async function resetUserPassword( - mongo: Db, - tenantID: string, - id: string, - password: string, - resetID: string -) { - // Hash the password. - const hashedPassword = await hashPassword(password); - - // Update the user with the new password. - const result = await collection(mongo).findOneAndUpdate( - { - tenantID, - id, - // This ensures that the document we're updating already has a local - // profile associated with them and also matches the resetID specified. - profiles: { - $elemMatch: { - type: "local", - resetID, - }, - }, - }, - { - $set: { - "profiles.$[profiles].password": hashedPassword, - }, - $unset: { - "profiles.$[profiles].resetID": "", - }, - }, - { - arrayFilters: [{ "profiles.type": "local", "profiles.resetID": resetID }], - // False to return the updated document instead of the original - // document. - returnOriginal: false, - } - ); - if (!result.value) { - const user = await retrieveUser(mongo, tenantID, id); - if (!user) { - throw new UserNotFoundError(id); - } - - const profile = getLocalProfile(user); - if (!profile) { - throw new LocalProfileNotSetError(); - } - - if (profile.resetID !== resetID) { - throw new PasswordResetTokenExpired("reset id mismatch"); - } - - throw new Error("an unexpected error occurred"); - } - - return result.value || null; -} - -export async function confirmUserEmail( - mongo: Db, - tenantID: string, - id: string, - email: string, - emailVerificationID: string -) { - // Update the user with a confirmed email address. - const result = await collection(mongo).findOneAndUpdate( - { - tenantID, - id, - email, - emailVerificationID, - }, - { - $set: { - emailVerified: true, - }, - $unset: { - emailVerificationID: "", - }, - }, - { - // False to return the updated document instead of the original - // document. - returnOriginal: false, - } - ); - if (!result.value) { - const user = await retrieveUser(mongo, tenantID, id); - if (!user) { - throw new UserNotFoundError(id); - } - - if (user.email !== email) { - throw new ConfirmEmailTokenExpired("email mismatch"); - } - - if (user.emailVerificationID !== emailVerificationID) { - throw new ConfirmEmailTokenExpired("email verification id mismatch"); - } - - throw new Error("an unexpected error occurred"); - } - - return result.value || null; -} - -export async function ignoreUser( - mongo: Db, - tenantID: string, - id: string, - ignoreUserID: string, - now = new Date() -) { - const ignoredUser: IgnoredUser = { - id: ignoreUserID, - createdAt: now, - }; - - const result = await collection(mongo).findOneAndUpdate( - { - id, - tenantID, - ignoredUsers: { - $not: { - $eq: { - id: ignoreUserID, - }, - }, - }, - }, - { - $push: { ignoredUsers: ignoredUser }, - }, - { - // False to return the updated document instead of the original - // document. - returnOriginal: false, - } - ); - if (!result.value) { - // Get the user so we can figure out why the ignore operation failed. - const user = await retrieveUser(mongo, tenantID, id); - if (!user) { - throw new UserNotFoundError(id); - } - - // TODO: extract function - if ( - user.ignoredUsers && - user.ignoredUsers.some(u => u.id === ignoreUserID) - ) { - // TODO: improve error - throw new Error("user already ignored"); - } - - throw new Error("an unexpected error occurred"); - } - - return result.value; -} - -export async function removeUserIgnore( - mongo: Db, - tenantID: string, - id: string, - ignoreUserID: string -) { - const result = await collection(mongo).findOneAndUpdate( - { - id, - tenantID, - "ignoredUsers.id": ignoreUserID, - }, - { - $pull: { ignoredUsers: { id: ignoreUserID } }, - }, - { - // False to return the updated document instead of the original - // document. - returnOriginal: false, - } - ); - if (!result.value) { - // Get the user so we can figure out why the ignore operation failed. - const user = await retrieveUser(mongo, tenantID, id); - if (!user) { - throw new UserNotFoundError(id); - } - - // TODO: extract function - if ( - user.ignoredUsers && - user.ignoredUsers.every(u => u.id !== ignoreUserID) - ) { - // TODO: improve error - throw new Error("user already not ignored"); - } - - throw new Error("an unexpected error occurred"); - } - - return result.value; -} +export * from "./user"; diff --git a/src/core/server/models/user/user.ts b/src/core/server/models/user/user.ts new file mode 100644 index 000000000..ca7d5b119 --- /dev/null +++ b/src/core/server/models/user/user.ts @@ -0,0 +1,1756 @@ +import bcrypt from "bcryptjs"; +import { Db, MongoError } from "mongodb"; +import uuid from "uuid"; + +import { DeepPartial, Omit, Sub } from "coral-common/types"; +import { dotize } from "coral-common/utils/dotize"; +import { + ConfirmEmailTokenExpired, + DuplicateEmailError, + DuplicateUserError, + LocalProfileAlreadySetError, + LocalProfileNotSetError, + PasswordResetTokenExpired, + TokenNotFoundError, + UserAlreadyBannedError, + UserAlreadySuspendedError, + UsernameAlreadySetError, + UserNotFoundError, +} from "coral-server/errors"; +import { + GQLBanStatus, + GQLSuspensionStatus, + GQLTimeRange, + GQLUSER_ROLE, +} from "coral-server/graph/tenant/schema/__generated__/types"; +import logger from "coral-server/logger"; +import { + Connection, + ConnectionInput, + resolveConnection, +} from "coral-server/models/helpers/connection"; +import { + createConnectionOrderVariants, + createIndexFactory, +} from "coral-server/models/helpers/indexing"; +import Query from "coral-server/models/helpers/query"; +import { TenantResource } from "coral-server/models/tenant"; + +import { getLocalProfile, hasLocalProfile } from "./helpers"; + +function collection(mongo: Db) { + return mongo.collection>("users"); +} + +export interface LocalProfile { + type: "local"; + id: string; + password: string; + + /** + * resetID is used during a password reset process to prevent replay attacks. + * When a password reset email is sent, a resetID is associated with the + * account and the token. When a given reset token is used, it is cleared from + * the user, preventing the same reset URL from being used multiple times. + */ + resetID?: string; +} + +export interface OIDCProfile { + type: "oidc"; + id: string; + issuer: string; + audience: string; +} + +export interface SSOProfile { + type: "sso"; + id: string; + lastIssuedAt: Date; +} + +export interface FacebookProfile { + type: "facebook"; + id: string; +} + +export interface GoogleProfile { + type: "google"; + id: string; +} + +/** + * Profile is all the different profiles that a given User may have associated + * with their account. + */ +export type Profile = + | LocalProfile + | OIDCProfile + | SSOProfile + | FacebookProfile + | GoogleProfile; + +export interface Token { + readonly id: string; + name: string; + createdAt: Date; +} + +/** + * SuspensionStatusHistory SuspensionStatusHistory is the list of all suspension + * events against a specific User. + */ +export interface SuspensionStatusHistory { + /** + * id is a specific reference for a particular suspension status that will be + * used internally to update suspension records. + */ + id: string; + + /** + * from represents a range of time where a user suspension applies. + */ + from: GQLTimeRange; + + /** + * createdBy is the ID for the User that suspended the User. If `null`, the + * suspension was created by the system. + */ + createdBy?: string; + + /** + * createdAt is the time that the given suspension time frame was created. + */ + createdAt: Date; + + /** + * modifiedBy is the ID for the User that modified the suspension for this + * User. If `null`, the suspension has not been edited, or has been edited by + * the system. + */ + modifiedBy?: string; + + /** + * modifiedAt is the time that the date that the given suspension time frame + * was edited at. + */ + modifiedAt?: Date; +} + +/** + * SuspensionStatus stores the user suspension status as well as the history of + * changes. + */ +export interface SuspensionStatus { + /** + * history is the list of all suspension events against a specific User. + */ + history: SuspensionStatusHistory[]; +} + +/** + * BanStatusHistory is the list of all ban events against a specific User. + */ +export interface BanStatusHistory { + /** + * id is a specific reference for a particular banned status that will be + * used internally to update banned records. + */ + id: string; + + /** + * active, when true, indicates that the user is banned from this status. + */ + active: boolean; + + /** + * createdBy is the ID for the User that banned the User. If `null`, the ban + * was created by the system. + */ + createdBy?: string; + + /** + * createdAt is the time that the given ban was added. + */ + createdAt: Date; +} + +/** + * BanStatus contains information about a ban for a given User. + */ +export interface BanStatus { + /** + * active when true, indicates that the given user is banned. + */ + active: boolean; + + /** + * history is the list of all ban events against a specific User. + */ + history: BanStatusHistory[]; +} + +/** + * UserStatus stores the user status information regarding moderation state. + */ +export interface UserStatus { + /** + * suspension stores the user suspension status as well as the history of + * changes. + */ + suspension: SuspensionStatus; + + /** + * ban stores the user ban status as well as the history of changes. + */ + ban: BanStatus; +} + +/** + * IgnoredUser is the entry describing a User being ignored. + */ +export interface IgnoredUser { + /** + * id is the ID of the User that was ignored. + */ + id: string; + + /** + * createdAt is the date that the User was ignored on. + */ + createdAt: Date; +} + +/** + * User is someone that leaves Comments, and logs in. + */ +export interface User extends TenantResource { + /** + * id is the identifier of the User. + */ + readonly id: string; + + /** + * username is the name of the User visible to other Users. + */ + username?: string; + + /** + * avatar is the url to the avatar for a specific User. + */ + avatar?: string; + + /** + * email is the current email address for the User. + */ + email?: string; + + /** + * emailVerificationID is used to store state regarding the verification state + * of an email address to prevent replay attacks. + */ + emailVerificationID?: string; + + /** + * emailVerified when true indicates that the given email address has been verified. + */ + emailVerified?: boolean; + + /** + * profiles is the array of profiles assigned to the user. + */ + profiles: Profile[]; + + /** + * tokens lists the access tokens associated with the account. + */ + tokens: Token[]; + + /** + * role is the current role of the User. + */ + role: GQLUSER_ROLE; + + /** + * status stores the user status information regarding moderation state. + */ + status: UserStatus; + + /** + * ignoredUsers stores the users that have been ignored by this User. + */ + ignoredUsers: IgnoredUser[]; + + /** + * createdAt is the time that the User was created at. + */ + createdAt: Date; +} + +export async function createUserIndexes(mongo: Db) { + const createIndex = createIndexFactory(collection(mongo)); + + // UNIQUE { id } + await createIndex({ tenantID: 1, id: 1 }, { unique: true }); + + // UNIQUE - PARTIAL { email } + await createIndex( + { tenantID: 1, email: 1 }, + { unique: true, partialFilterExpression: { email: { $exists: true } } } + ); + + // UNIQUE { profiles.type, profiles.id } + await createIndex( + { tenantID: 1, "profiles.type": 1, "profiles.id": 1 }, + // TODO: (wyattjoh) change the `partialFilterExpression` to `{ "profiles.id": { $exists: true } }` + { unique: true, partialFilterExpression: { profiles: { $exists: true } } } + ); + + // { profiles } + await createIndex( + { tenantID: 1, profiles: 1, email: 1 }, + { + partialFilterExpression: { profiles: { $exists: true } }, + background: true, + } + ); + + // TEXT { id, username, email, createdAt } + await createIndex( + { + tenantID: 1, + id: "text", + username: "text", + email: "text", + createdAt: -1, + }, + { background: true } + ); + + const variants = createConnectionOrderVariants>( + [{ createdAt: -1 }], + { background: true } + ); + + // User Connection pagination. + // { ...connectionParams } + await variants(createIndex, { + tenantID: 1, + }); + + // Role based User Connection pagination. + // { role, ...connectionParams } + await variants(createIndex, { + tenantID: 1, + role: 1, + }); + + // Suspension based User Connection pagination. + await variants(createIndex, { + tenantID: 1, + "status.suspension.history.from.start": 1, + "status.suspension.history.from.finish": 1, + }); + + // Ban based User Connection pagination. + await variants(createIndex, { + tenantID: 1, + "status.ban.active": 1, + }); +} + +function hashPassword(password: string): Promise { + return bcrypt.hash(password, 10); +} + +export type InsertUserInput = Omit< + User, + | "id" + | "tenantID" + | "tokens" + | "status" + | "ignoredUsers" + | "emailVerificationID" + | "createdAt" +>; + +export async function insertUser( + mongo: Db, + tenantID: string, + input: InsertUserInput, + now = new Date() +) { + // Create a new ID for the user. + const id = uuid.v4(); + + // default are the properties set by the application when a new user is + // created. + const defaults: Sub = { + id, + tenantID, + tokens: [], + ignoredUsers: [], + status: { + suspension: { history: [] }, + ban: { active: false, history: [] }, + }, + createdAt: now, + }; + + // Guard against empty login profiles (they need some way to login). + if (input.profiles.length === 0) { + throw new Error("users require at least one profile"); + } + + // Mutate the profiles to ensure we mask handle any secrets. + const profiles: Profile[] = []; + for (let profile of input.profiles) { + switch (profile.type) { + case "local": + // Hash the user's password with bcrypt. + const password = await hashPassword(profile.password); + profile = { + ...profile, + password, + }; + break; + } + // Save a copy. + profiles.push(profile); + } + + // Merge the defaults and the input together. + const user: Readonly = { + ...defaults, + ...input, + profiles, + }; + + try { + // Insert it into the database. This may throw an error. + await collection(mongo).insert(user); + } catch (err) { + // Evaluate the error, if it is in regards to violating the unique index, + // then return a duplicate Story error. + if (err instanceof MongoError && err.code === 11000) { + // Check if duplicate index was about the email. + if (err.errmsg && err.errmsg.includes("tenantID_1_email_1")) { + throw new DuplicateEmailError(input.email!); + } + throw new DuplicateUserError(); + } + + throw err; + } + + return user; +} + +export async function retrieveUser(mongo: Db, tenantID: string, id: string) { + return collection(mongo).findOne({ tenantID, id }); +} + +export async function retrieveManyUsers( + mongo: Db, + tenantID: string, + ids: string[] +) { + const cursor = await collection(mongo).find({ + id: { + $in: ids, + }, + tenantID, + }); + + const users = await cursor.toArray(); + + return ids.map(id => users.find(comment => comment.id === id) || null); +} + +export async function retrieveUserWithProfile( + mongo: Db, + tenantID: string, + profile: Partial> +) { + return collection(mongo).findOne({ + tenantID, + profiles: { + $elemMatch: profile, + }, + }); +} + +export async function retrieveUserWithEmail( + mongo: Db, + tenantID: string, + email: string +) { + return collection(mongo).findOne({ + tenantID, + $or: [ + { + profiles: { + $elemMatch: { id: email, type: "local" }, + }, + }, + { email }, + ], + }); +} + +/** + * updateUserRole updates a given User's role. + * + * @param mongo mongodb database to interact with + * @param tenantID Tenant ID where the User resides + * @param id ID of the User that we are updating + * @param role new role to set to the User + */ +export async function updateUserRole( + mongo: Db, + tenantID: string, + id: string, + role: GQLUSER_ROLE +) { + const result = await collection(mongo).findOneAndUpdate( + { id, tenantID }, + { $set: { role } }, + { + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + if (!result.value) { + throw new UserNotFoundError(id); + } + + return result.value; +} + +export async function verifyUserPassword( + user: Pick, + password: string +) { + const profile = getLocalProfile(user); + if (!profile) { + throw new LocalProfileNotSetError(); + } + + return bcrypt.compare(password, profile.password); +} + +export async function updateUserPassword( + mongo: Db, + tenantID: string, + id: string, + password: string +) { + // Hash the password. + const hashedPassword = await hashPassword(password); + + // Update the user with the new password. + const result = await collection(mongo).findOneAndUpdate( + { + tenantID, + id, + // This ensures that the document we're updating already has a local + // profile associated with them. + "profiles.type": "local", + }, + { + $set: { + "profiles.$[profiles].password": hashedPassword, + }, + }, + { + arrayFilters: [{ "profiles.type": "local" }], + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + if (!result.value) { + const user = await retrieveUser(mongo, tenantID, id); + if (!user) { + throw new UserNotFoundError(id); + } + + if (!hasLocalProfile(user)) { + throw new LocalProfileNotSetError(); + } + + throw new Error("an unexpected error occurred"); + } + + return result.value || null; +} + +export interface UpdateUserInput { + email?: string; + username?: string; +} + +export async function updateUserFromSSO( + mongo: Db, + tenantID: string, + id: string, + update: UpdateUserInput, + lastIssuedAt: Date +) { + // Update the user with the new password. + const result = await collection(mongo).findOneAndUpdate( + { + tenantID, + id, + // This ensures that the document we're updating already has a sso + // profile associated with them. + "profiles.type": "sso", + }, + { + $set: { + "profiles.$[profiles].lastIssuedAt": lastIssuedAt, + ...update, + }, + }, + { + arrayFilters: [{ "profiles.type": "sso" }], + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + if (!result.value) { + const user = await retrieveUserWithProfile(mongo, tenantID, { + type: "sso", + id, + }); + if (!user) { + throw new UserNotFoundError(id); + } + + throw new Error("an unexpected error occurred"); + } + + return result.value || null; +} + +/** + * setUserUsername will set the username of the User if the username hasn't + * already been used before. + * + * @param mongo the database handle + * @param tenantID the ID to the Tenant + * @param id the ID of the User where we are setting the username on + * @param username the username that we want to set + */ +export async function setUserUsername( + mongo: Db, + tenantID: string, + id: string, + username: string +) { + // TODO: (wyattjoh) investigate adding the username previously used to an array. + + // The username wasn't found, so add it to the user. + const result = await collection(mongo).findOneAndUpdate( + { + tenantID, + id, + username: null, + }, + { + $set: { + username, + }, + }, + { + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + if (!result.value) { + // Try to get the current user to discover what happened. + const user = await retrieveUser(mongo, tenantID, id); + if (!user) { + throw new UserNotFoundError(id); + } + + if (user.username) { + throw new UsernameAlreadySetError(); + } + + throw new Error("an unexpected error occurred"); + } + + return result.value; +} + +/** + * updateUserUsername will set the username of the User. + * + * @param mongo the database handle + * @param tenantID the ID to the Tenant + * @param id the ID of the User where we are setting the username on + * @param username the username that we want to set + */ +export async function updateUserUsername( + mongo: Db, + tenantID: string, + id: string, + username: string +) { + // TODO: (wyattjoh) investigate adding the username previously used to an array. + + // The username wasn't found, so add it to the user. + const result = await collection(mongo).findOneAndUpdate( + { + tenantID, + id, + }, + { + $set: { + username, + }, + }, + { + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + if (!result.value) { + // Try to get the current user to discover what happened. + const user = await retrieveUser(mongo, tenantID, id); + if (!user) { + throw new UserNotFoundError(id); + } + + throw new Error("an unexpected error occurred"); + } + + return result.value; +} + +/** + * setUserEmail will set the email address of the User if they don't already + * have one associated with them, and it hasn't been used before. + * + * @param mongo the database handle + * @param tenantID the ID to the Tenant + * @param id the ID of the User where we are setting the email address on + * @param emailAddress the email address we want to set + */ +export async function setUserEmail( + mongo: Db, + tenantID: string, + id: string, + emailAddress: string +) { + // Lowercase the email address. + const email = emailAddress.toLowerCase(); + + // Search to see if this email has been used before. + let user = await collection(mongo).findOne({ + tenantID, + email, + }); + if (user) { + throw new DuplicateEmailError(email); + } + + // The email wasn't found, so try to update the User. + const result = await collection(mongo).findOneAndUpdate( + { + tenantID, + id, + email: null, + }, + { + $set: { + email, + }, + }, + { + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + if (!result.value) { + // Try to get the current user to discover what happened. + user = await retrieveUser(mongo, tenantID, id); + if (!user) { + throw new UserNotFoundError(id); + } + + if (user.email) { + throw new UsernameAlreadySetError(); + } + + throw new Error("an unexpected error occurred"); + } + + return result.value; +} + +/** + * updateUserEmail will update a given User's email address to the one provided. + * + * @param mongo the database that we are interacting with + * @param tenantID the Tenant ID of the Tenant where the User exists + * @param id the User ID that we are updating + * @param emailAddress email address that we are setting on the User + */ +export async function updateUserEmail( + mongo: Db, + tenantID: string, + id: string, + emailAddress: string +) { + // Lowercase the email address. + const email = emailAddress.toLowerCase(); + + // Search to see if this email has been used before. + let user = await collection(mongo).findOne({ + tenantID, + email, + }); + if (user) { + throw new DuplicateEmailError(email); + } + + // The email wasn't found, so try to update the User. + const result = await collection(mongo).findOneAndUpdate( + { + tenantID, + id, + }, + { + $set: { + email, + }, + }, + { + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + if (!result.value) { + // Try to get the current user to discover what happened. + user = await retrieveUser(mongo, tenantID, id); + if (!user) { + throw new UserNotFoundError(id); + } + + throw new Error("an unexpected error occurred"); + } + + return result.value; +} + +/** + * updateUserAvatar will update the avatar associated with a User. If the avatar + * is not provided, it will be unset. + * + * @param mongo the database that we are interacting with + * @param tenantID the Tenant ID of the Tenant where the User exists + * @param id the User ID that we are updating + * @param avatar URL that the avatar exists at + */ +export async function updateUserAvatar( + mongo: Db, + tenantID: string, + id: string, + avatar?: string +) { + // The email wasn't found, so try to update the User. + const result = await collection(mongo).findOneAndUpdate( + { + tenantID, + id, + }, + { + // This will ensure that if the avatar isn't provided, it will unset the + // avatar on the User. + [avatar ? "$set" : "$unset"]: { + avatar: avatar ? avatar : 1, + }, + }, + { + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + if (!result.value) { + // Try to get the current user to discover what happened. + const user = await retrieveUser(mongo, tenantID, id); + if (!user) { + throw new UserNotFoundError(id); + } + + throw new Error("an unexpected error occurred"); + } + + return result.value; +} + +/** + * setUserLocalProfile will set the local profile for a User if they don't + * already have one associated with them and the profile doesn't exist on any + * other User already. + * + * @param mongo the database handle + * @param tenantID the ID to the Tenant + * @param id the ID of the User where we are setting the local profile on + * @param emailAddress the email address we want to set + * @param password the password we want to set + */ +export async function setUserLocalProfile( + mongo: Db, + tenantID: string, + id: string, + emailAddress: string, + password: string +) { + // Lowercase the email address. + const email = emailAddress.toLowerCase(); + + // Try to see if this local profile already exists on a User. + let user = await retrieveUserWithProfile(mongo, tenantID, { + type: "local", + id: email, + }); + if (user) { + throw new DuplicateEmailError(email); + } + + // Hash the password. + const hashedPassword = await hashPassword(password); + + // Create the profile that we'll use. + const profile: LocalProfile = { + type: "local", + id: email, + password: hashedPassword, + }; + + // The profile wasn't found, so add it to the User. + const result = await collection(mongo).findOneAndUpdate( + { + tenantID, + id, + // This ensures that the document we're updating does not contain a local + // profile. + "profiles.type": { $ne: "local" }, + }, + { + $push: { + profiles: profile, + }, + }, + { + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + if (!result.value) { + // Try to get the current user to discover what happened. + user = await retrieveUser(mongo, tenantID, id); + if (!user) { + throw new UserNotFoundError(id); + } + + // Check to see if the user has any local profile (not just a local profile + // with a specific email). + if (hasLocalProfile(user)) { + throw new LocalProfileAlreadySetError(); + } + + throw new Error("an unexpected error occurred"); + } + + return result.value; +} + +export async function createUserToken( + mongo: Db, + tenantID: string, + userID: string, + name: string, + now = new Date() +) { + // Create the Token that we'll be adding to the User. + const token: Readonly = { + id: uuid.v4(), + name, + createdAt: now, + }; + + const result = await collection(mongo).findOneAndUpdate( + { + id: userID, + tenantID, + }, + { + $push: { tokens: token }, + }, + { + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + if (!result.value) { + throw new UserNotFoundError(userID); + } + + return { + user: result.value, + token, + }; +} + +export async function deactivateUserToken( + mongo: Db, + tenantID: string, + userID: string, + id: string +) { + // Try to remove the Token from the User. + const result = await collection(mongo).findOneAndUpdate( + { + id: userID, + tenantID, + "tokens.id": id, + }, + { + $pull: { tokens: { id } }, + }, + { + // True to return the original document instead of the updated + // document. + returnOriginal: true, + } + ); + if (!result.value) { + const user = await retrieveUser(mongo, tenantID, userID); + if (!user) { + throw new UserNotFoundError(id); + } + + // Check to see if the User had that Token in the first place. + if (!user.tokens.find(t => t.id === id)) { + throw new TokenNotFoundError(); + } + + throw new Error("an unexpected error occurred"); + } + + // We have to typecast here because we know at this point that the record does + // contain the Token. + const token: Token = result.value.tokens.find(t => t.id === id) as Token; + + // Mutate the user in order to remove the Token from the list of Token's. + const updatedUser: Readonly = { + ...result.value, + tokens: result.value.tokens.filter(t => t.id !== id), + }; + + return { + user: updatedUser, + token, + }; +} + +export type UserConnectionInput = ConnectionInput; + +export async function retrieveUserConnection( + mongo: Db, + tenantID: string, + input: UserConnectionInput +): Promise>>> { + // Create the query. + const query = new Query(collection(mongo)).where({ tenantID }); + + // If a filter is being applied, filter it as well. + if (input.filter) { + query.where(input.filter); + } + + return retrieveConnection(input, query); +} + +async function retrieveConnection( + input: UserConnectionInput, + query: Query +): Promise>>> { + // Apply the pagination arguments to the query. + query.orderBy({ createdAt: -1 }); + if (input.after) { + query.where({ createdAt: { $lt: input.after as Date } }); + } + + // Return a connection. + return resolveConnection(query, input, user => user.createdAt); +} + +/** + * banUser will ban a specific user from interacting with the site. + * + * @param mongo the mongo database handle + * @param tenantID the Tenant's ID where the User exists + * @param id the ID of the user being banned + * @param createdBy the ID of the user banning the above mentioned user + * @param now the current date + */ +export async function banUser( + mongo: Db, + tenantID: string, + id: string, + createdBy: string, + now = new Date() +) { + // Create the new ban. + const banHistory: BanStatusHistory = { + id: uuid(), + active: true, + createdBy, + createdAt: now, + }; + + // Try to update the user if the user isn't already banned. + const result = await collection(mongo).findOneAndUpdate( + { + id, + tenantID, + "status.ban.active": { + $ne: true, + }, + }, + { + $set: { + "status.ban.active": true, + }, + $push: { + "status.ban.history": banHistory, + }, + }, + { + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + if (!result.value) { + // Get the user so we can figure out why the ban operation failed. + const user = await retrieveUser(mongo, tenantID, id); + if (!user) { + throw new UserNotFoundError(id); + } + + // Check to see if the user is already banned. + const ban = consolidateUserBanStatus(user.status.ban); + if (ban.active) { + throw new UserAlreadyBannedError(); + } + + throw new Error("an unexpected error occurred"); + } + + return result.value; +} + +/** + * removeUserBan will lift a user ban from a User allowing them to interact with + * the site again. + * + * @param mongo the mongo database handle + * @param tenantID the Tenant's ID where the User exists + * @param id the ID of the user having their ban lifted + * @param createdBy the ID of the user lifting the ban + * @param now the current date + */ +export async function removeUserBan( + mongo: Db, + tenantID: string, + id: string, + createdBy: string, + now = new Date() +) { + // Create the new ban. + const ban: BanStatusHistory = { + id: uuid(), + active: false, + createdBy, + createdAt: now, + }; + + // Try to update the user if the user isn't already banned. + const result = await collection(mongo).findOneAndUpdate( + { + id, + tenantID, + $or: [ + { + "status.ban.active": { + $ne: false, + }, + }, + { + "status.ban.history": { + $size: 0, + }, + }, + ], + }, + { + $set: { + "status.ban.active": false, + }, + $push: { + "status.ban.history": ban, + }, + }, + { + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + if (!result.value) { + // Get the user so we can figure out why the ban operation failed. + const user = await retrieveUser(mongo, tenantID, id); + if (!user) { + throw new UserNotFoundError(id); + } + + // The user wasn't banned already, so nothing needs to be done! + return user; + } + + return result.value; +} + +/** + * suspendUser will suspend a user for a specific time range from interacting + * with the site. + * + * @param mongo the mongo database handle + * @param tenantID the Tenant's ID where the User exists + * @param id the ID of the user being suspended + * @param createdBy the ID of the user banning the above mentioned user + * @param from the range of time that the user is being banned for + * @param now the current date + */ +export async function suspendUser( + mongo: Db, + tenantID: string, + id: string, + createdBy: string, + finish: Date, + now = new Date() +) { + // Create the new suspension. + const suspension: SuspensionStatusHistory = { + id: uuid(), + from: { + start: now, + finish, + }, + createdBy, + createdAt: now, + }; + + // Try to update the user if the user isn't already suspended. + const result = await collection(mongo).findOneAndUpdate( + { + id, + tenantID, + "status.suspension.history": { + $not: { + $elemMatch: { + "from.start": { + $lte: now, + }, + "from.finish": { + $gt: now, + }, + }, + }, + }, + }, + { + $push: { + "status.suspension.history": suspension, + }, + }, + { + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + if (!result.value) { + // Get the user so we can figure out why the suspend operation failed. + const user = await retrieveUser(mongo, tenantID, id); + if (!user) { + throw new UserNotFoundError(id); + } + + // Check to see if the user is already suspended. + const suspended = consolidateUserSuspensionStatus( + user.status.suspension, + now + ); + if (suspended.active && suspended.until) { + throw new UserAlreadySuspendedError(suspended.until); + } + + throw new Error("an unexpected error occurred"); + } + + return result.value; +} + +/** + * removeUserSuspensions will lift any active suspensions. + * + * @param mongo the mongo database handle + * @param tenantID the Tenant's ID where the User exists + * @param id the ID of the User having their suspension lifted + * @param modifiedBy the ID of the User lifting the suspension + * @param now the current date + */ +export async function removeActiveUserSuspensions( + mongo: Db, + tenantID: string, + id: string, + modifiedBy: string, + now = new Date() +) { + // Prepare the update payload. + const update: DeepPartial = { + from: { + finish: now, + }, + modifiedAt: now, + modifiedBy, + }; + + // Try to update the user suspension times. + const result = await collection(mongo).findOneAndUpdate( + { tenantID, id }, + { + $set: dotize({ + "status.suspension.history.$[active]": update, + }), + }, + { + arrayFilters: [ + // Change the finish date on all suspension records that indicate their + // active time within our current range. + { + "active.from.start": { $lte: now }, + "active.from.finish": { $gt: now }, + }, + ], + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + if (!result.value) { + // Get the user so we can figure out why the suspend operation failed. + const user = await retrieveUser(mongo, tenantID, id); + if (!user) { + throw new UserNotFoundError(id); + } + + // The user wasn't already suspended, so nothing needs to be done! + return user; + } + + logger.debug({ result }, "finished update operation"); + + return result.value; +} + +export type ConsolidatedBanStatus = Omit & + Pick; + +export function consolidateUserBanStatus( + ban: User["status"]["ban"] +): ConsolidatedBanStatus { + return ban; +} + +export type ConsolidatedSuspensionStatus = Omit< + GQLSuspensionStatus, + "history" +> & + Pick; + +export function consolidateUserSuspensionStatus( + suspension: User["status"]["suspension"], + now = new Date() +): ConsolidatedSuspensionStatus { + return suspension.history.reduce( + (status: ConsolidatedSuspensionStatus, history) => { + // Check to see if we're currently suspended. + if (history.from.start <= now && history.from.finish > now) { + status.active = true; + + // Ensure that we have the furthest suspension finish time. + if (!status.until || status.until < history.from.finish) { + status.until = history.from.finish; + } + } + + return status; + }, + { + active: false, + history: suspension.history, + } + ); +} + +export interface ConsolidatedUserStatus { + suspension: ConsolidatedSuspensionStatus; + ban: ConsolidatedBanStatus; +} + +export function consolidateUserStatus( + status: User["status"], + now = new Date() +): ConsolidatedUserStatus { + // Return the status. + return { + suspension: consolidateUserSuspensionStatus(status.suspension, now), + ban: consolidateUserBanStatus(status.ban), + }; +} + +/** + * createOrRetrieveUserPasswordResetID will create/retrieve a password reset ID + * on the User. + * + * @param mongo MongoDB instance to interact with + * @param tenantID Tenant ID that the User exists on + * @param id ID of the User that we are creating a reset ID with + */ +export async function createOrRetrieveUserPasswordResetID( + mongo: Db, + tenantID: string, + id: string +): Promise { + // Create the ID. + const resetID = uuid.v4(); + + // Associate the resetID with the user. + const result = await collection(mongo).findOneAndUpdate( + { + tenantID, + id, + // This ensures that the document we're updating already has a local + // profile associated with them and also doesn't have a resetID. + profiles: { + $elemMatch: { + type: "local", + resetID: null, + }, + }, + }, + { + $set: { + "profiles.$[profiles].resetID": resetID, + }, + }, + { + arrayFilters: [{ "profiles.type": "local" }], + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + if (!result.value) { + const user = await retrieveUser(mongo, tenantID, id); + if (!user) { + throw new UserNotFoundError(id); + } + + const localProfile = getLocalProfile(user); + if (!localProfile) { + throw new LocalProfileNotSetError(); + } + + if (localProfile.resetID) { + return localProfile.resetID; + } + + throw new Error("an unexpected error occurred"); + } + + return resetID; +} + +export async function createOrRetrieveUserEmailVerificationID( + mongo: Db, + tenantID: string, + id: string +): Promise { + // Create the ID. + const emailVerificationID = uuid.v4(); + + // Associate the resetID with the user. + const result = await collection(mongo).findOneAndUpdate( + { + tenantID, + id, + // This ensures that we don't set a emailVerificationID when there is one + // already. + emailVerificationID: null, + $or: [{ emailVerified: false }, { emailVerified: null }], + }, + { + $set: { + emailVerificationID, + }, + }, + { + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + if (!result.value) { + const user = await retrieveUser(mongo, tenantID, id); + if (!user) { + throw new UserNotFoundError(id); + } + + if (user.emailVerified) { + throw new Error("email address has already been verified"); + } + + if (user.emailVerificationID) { + return user.emailVerificationID; + } + + throw new Error("an unexpected error occurred"); + } + + return emailVerificationID; +} + +export async function resetUserPassword( + mongo: Db, + tenantID: string, + id: string, + password: string, + resetID: string +) { + // Hash the password. + const hashedPassword = await hashPassword(password); + + // Update the user with the new password. + const result = await collection(mongo).findOneAndUpdate( + { + tenantID, + id, + // This ensures that the document we're updating already has a local + // profile associated with them and also matches the resetID specified. + profiles: { + $elemMatch: { + type: "local", + resetID, + }, + }, + }, + { + $set: { + "profiles.$[profiles].password": hashedPassword, + }, + $unset: { + "profiles.$[profiles].resetID": "", + }, + }, + { + arrayFilters: [{ "profiles.type": "local", "profiles.resetID": resetID }], + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + if (!result.value) { + const user = await retrieveUser(mongo, tenantID, id); + if (!user) { + throw new UserNotFoundError(id); + } + + const profile = getLocalProfile(user); + if (!profile) { + throw new LocalProfileNotSetError(); + } + + if (profile.resetID !== resetID) { + throw new PasswordResetTokenExpired("reset id mismatch"); + } + + throw new Error("an unexpected error occurred"); + } + + return result.value || null; +} + +export async function confirmUserEmail( + mongo: Db, + tenantID: string, + id: string, + email: string, + emailVerificationID: string +) { + // Update the user with a confirmed email address. + const result = await collection(mongo).findOneAndUpdate( + { + tenantID, + id, + email, + emailVerificationID, + }, + { + $set: { + emailVerified: true, + }, + $unset: { + emailVerificationID: "", + }, + }, + { + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + if (!result.value) { + const user = await retrieveUser(mongo, tenantID, id); + if (!user) { + throw new UserNotFoundError(id); + } + + if (user.email !== email) { + throw new ConfirmEmailTokenExpired("email mismatch"); + } + + if (user.emailVerificationID !== emailVerificationID) { + throw new ConfirmEmailTokenExpired("email verification id mismatch"); + } + + throw new Error("an unexpected error occurred"); + } + + return result.value || null; +} + +export async function ignoreUser( + mongo: Db, + tenantID: string, + id: string, + ignoreUserID: string, + now = new Date() +) { + const ignoredUser: IgnoredUser = { + id: ignoreUserID, + createdAt: now, + }; + + const result = await collection(mongo).findOneAndUpdate( + { + id, + tenantID, + ignoredUsers: { + $not: { + $eq: { + id: ignoreUserID, + }, + }, + }, + }, + { + $push: { ignoredUsers: ignoredUser }, + }, + { + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + if (!result.value) { + // Get the user so we can figure out why the ignore operation failed. + const user = await retrieveUser(mongo, tenantID, id); + if (!user) { + throw new UserNotFoundError(id); + } + + // TODO: extract function + if ( + user.ignoredUsers && + user.ignoredUsers.some(u => u.id === ignoreUserID) + ) { + // TODO: improve error + throw new Error("user already ignored"); + } + + throw new Error("an unexpected error occurred"); + } + + return result.value; +} + +export async function removeUserIgnore( + mongo: Db, + tenantID: string, + id: string, + ignoreUserID: string +) { + const result = await collection(mongo).findOneAndUpdate( + { + id, + tenantID, + "ignoredUsers.id": ignoreUserID, + }, + { + $pull: { ignoredUsers: { id: ignoreUserID } }, + }, + { + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + if (!result.value) { + // Get the user so we can figure out why the ignore operation failed. + const user = await retrieveUser(mongo, tenantID, id); + if (!user) { + throw new UserNotFoundError(id); + } + + // TODO: extract function + if ( + user.ignoredUsers && + user.ignoredUsers.every(u => u.id !== ignoreUserID) + ) { + // TODO: improve error + throw new Error("user already not ignored"); + } + + throw new Error("an unexpected error occurred"); + } + + return result.value; +} diff --git a/src/core/server/services/users/auth/reset.ts b/src/core/server/services/users/auth/reset.ts index 610b108c6..1f8f9ba8a 100644 --- a/src/core/server/services/users/auth/reset.ts +++ b/src/core/server/services/users/auth/reset.ts @@ -10,7 +10,6 @@ import { TokenInvalidError, UserNotFoundError, } from "coral-server/errors"; -import { getLocalProfile } from "coral-server/helpers/users"; import { Tenant } from "coral-server/models/tenant"; import { createOrRetrieveUserPasswordResetID, @@ -18,6 +17,7 @@ import { retrieveUser, User, } from "coral-server/models/user"; +import { getLocalProfile } from "coral-server/models/user/helpers"; import { JWTSigningConfig, signString, diff --git a/src/core/server/services/users/index.ts b/src/core/server/services/users/index.ts index bb43ae47f..e8d2cdecb 100644 --- a/src/core/server/services/users/index.ts +++ b/src/core/server/services/users/index.ts @@ -14,7 +14,6 @@ import { UserNotFoundError, } from "coral-server/errors"; import { GQLUSER_ROLE } from "coral-server/graph/tenant/schema/__generated__/types"; -import { getLocalProfile, hasLocalProfile } from "coral-server/helpers/users"; import { Tenant } from "coral-server/models/tenant"; import { banUser, @@ -40,6 +39,10 @@ import { updateUserUsername, User, } from "coral-server/models/user"; +import { + getLocalProfile, + hasLocalProfile, +} from "coral-server/models/user/helpers"; import { userIsStaff } from "coral-server/models/user/helpers"; import { MailerQueue } from "coral-server/queue/tasks/mailer"; import { JWTSigningConfig, signPATString } from "coral-server/services/jwt";