From 98a8c8dc7195c985fca0829b222a28c9fde997fd Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 20 Dec 2018 22:42:44 +0000 Subject: [PATCH] [next] User Mutations (#2133) * feat: added support for Token's * feat: added mutations - updateUserUsername - updateUserDisplayName - updateUserAvatar - updateUserEmail - updateUserRole * lint: removed old commented out code * fix: linting --- .../server/app/middleware/context/tenant.ts | 4 + .../passport/strategies/verifiers/jwt.ts | 83 +++- src/core/server/graph/tenant/context.ts | 7 +- src/core/server/graph/tenant/middleware.ts | 1 - src/core/server/graph/tenant/mutators/User.ts | 35 ++ .../server/graph/tenant/resolvers/Mutation.ts | 28 ++ .../server/graph/tenant/schema/schema.graphql | 338 ++++++++++++++-- src/core/server/models/user.ts | 379 +++++++++++++++--- src/core/server/services/jwt/index.ts | 23 +- src/core/server/services/users/index.ts | 177 ++++++++ 10 files changed, 964 insertions(+), 111 deletions(-) diff --git a/src/core/server/app/middleware/context/tenant.ts b/src/core/server/app/middleware/context/tenant.ts index b642d6aa2..67f18f46f 100644 --- a/src/core/server/app/middleware/context/tenant.ts +++ b/src/core/server/app/middleware/context/tenant.ts @@ -4,6 +4,7 @@ import { Db } from "mongodb"; import { Config } from "talk-server/config"; import TenantContext from "talk-server/graph/tenant/context"; import { TaskQueue } from "talk-server/queue"; +import { JWTSigningConfig } from "talk-server/services/jwt"; import { AugmentedRedis } from "talk-server/services/redis"; import { Request } from "talk-server/types/express"; @@ -12,6 +13,7 @@ export interface TenantContextMiddlewareOptions { redis: AugmentedRedis; queue: TaskQueue; config: Config; + signingConfig: JWTSigningConfig; } export const tenantContext = ({ @@ -19,6 +21,7 @@ export const tenantContext = ({ redis, queue, config, + signingConfig, }: TenantContextMiddlewareOptions): RequestHandler => ( req: Request, res, @@ -48,6 +51,7 @@ export const tenantContext = ({ user: req.user, tenantCache: cache.tenant, queue, + signingConfig, }), }; diff --git a/src/core/server/app/middleware/passport/strategies/verifiers/jwt.ts b/src/core/server/app/middleware/passport/strategies/verifiers/jwt.ts index 904955bfb..a4e44c07e 100644 --- a/src/core/server/app/middleware/passport/strategies/verifiers/jwt.ts +++ b/src/core/server/app/middleware/passport/strategies/verifiers/jwt.ts @@ -9,21 +9,61 @@ import { retrieveUser } from "talk-server/models/user"; import { checkBlacklistJWT, JWTSigningConfig } from "talk-server/services/jwt"; export interface JWTToken { - // aud: string; + /** + * jti is the Token identifier. With normal login tokens, this is a randomly + * generated uuid, which is added to a blacklist when the User "logs out". For + * Personal Access Tokens, this is the Token identifier. + */ jti: string; + + /** + * sub is the ID of the User that this Token is associated with. + */ sub: string; - exp: number; + + /** + * iss is the ID of the Tenant that this Token is associated with. + */ iss: string; + + /** + * exp is the optional expiry for the tokens. Personal Access Token's do not + * have an expiry associated with them, hence why it's optional. + */ + exp?: number; + + /** + * pat, when true, indicates that this Token is a Personal Access Token, and + * it's `jti` claim should be treated as the Token ID. These tokens cannot be + * logged out and instead must be deactivated. + */ + pat?: boolean; } export function isJWTToken(token: JWTToken | object): token is JWTToken { - return ( - // typeof (token as JWTToken).aud === "string" && - typeof (token as JWTToken).jti === "string" && - typeof (token as JWTToken).sub === "string" && - typeof (token as JWTToken).exp === "number" && - typeof (token as JWTToken).iss === "string" - ); + if ( + typeof (token as JWTToken).jti !== "string" || + typeof (token as JWTToken).sub !== "string" || + typeof (token as JWTToken).iss !== "string" + ) { + return false; + } + + if ( + typeof (token as JWTToken).exp !== "undefined" && + typeof (token as JWTToken).exp !== "number" + ) { + return false; + } + + if ( + typeof (token as JWTToken).pat !== "undefined" && + typeof (token as JWTToken).pat !== "boolean" + ) { + return false; + } + + return true; } export interface JWTVerifierOptions { @@ -61,12 +101,31 @@ export class JWTVerifier { logger.trace({ responseTime }, "jwt verification complete"); - // Check to see if the token has been blacklisted, as these tokens can be - // revoked. - await checkBlacklistJWT(this.redis, token.jti); + // Check to see if this is a Personal Access Token, these tokens cannot be + // blacklisted. + if (!token.pat) { + // Check to see if the token has been blacklisted, as these tokens can be + // revoked. + await checkBlacklistJWT(this.redis, token.jti); + } // Find the user. const user = await retrieveUser(this.mongo, tenant.id, token.sub); + if (token.pat) { + // As this is a Personal Access Token, ensure that the Token is valid by + // checking it against the User's tokens. + if (!user) { + throw new Error( + "personal access token referenced user that wasn't found" + ); + } + + // Ensure that the token exists on the User. + const foundToken = user.tokens.find(({ id }) => id === token.jti); + if (!foundToken) { + throw new Error("personal access token does not exist"); + } + } // Return the user now that we have found them!. return user; diff --git a/src/core/server/graph/tenant/context.ts b/src/core/server/graph/tenant/context.ts index 08c66f247..97e96961d 100644 --- a/src/core/server/graph/tenant/context.ts +++ b/src/core/server/graph/tenant/context.ts @@ -5,6 +5,7 @@ import CommonContext from "talk-server/graph/common/context"; import { Tenant } from "talk-server/models/tenant"; import { User } from "talk-server/models/user"; import { TaskQueue } from "talk-server/queue"; +import { JWTSigningConfig } from "talk-server/services/jwt"; import { AugmentedRedis } from "talk-server/services/redis"; import TenantCache from "talk-server/services/tenant/cache"; import { Request } from "talk-server/types/express"; @@ -19,6 +20,7 @@ export interface TenantContextOptions { tenantCache: TenantCache; queue: TaskQueue; config: Config; + signingConfig?: JWTSigningConfig; req?: Request; user?: User; } @@ -26,12 +28,13 @@ export interface TenantContextOptions { export default class TenantContext extends CommonContext { public readonly tenant: Tenant; public readonly tenantCache: TenantCache; - public readonly user?: User; public readonly mongo: Db; public readonly redis: AugmentedRedis; public readonly queue: TaskQueue; public readonly loaders: ReturnType; public readonly mutators: ReturnType; + public readonly user?: User; + public readonly signingConfig?: JWTSigningConfig; constructor({ req, @@ -42,6 +45,7 @@ export default class TenantContext extends CommonContext { config, tenantCache, queue, + signingConfig, }: TenantContextOptions) { super({ user, req, config }); @@ -51,6 +55,7 @@ export default class TenantContext extends CommonContext { this.mongo = mongo; this.redis = redis; this.queue = queue; + this.signingConfig = signingConfig; this.loaders = loaders(this); this.mutators = mutators(this); } diff --git a/src/core/server/graph/tenant/middleware.ts b/src/core/server/graph/tenant/middleware.ts index 69cf11978..81eb304df 100644 --- a/src/core/server/graph/tenant/middleware.ts +++ b/src/core/server/graph/tenant/middleware.ts @@ -1,5 +1,4 @@ import { GraphQLSchema } from "graphql"; -// import { graphqlBatchHTTPWrapper } from "react-relay-network-layer"; import { graphqlBatchMiddleware } from "talk-server/app/middleware/graphqlBatch"; import { Config } from "talk-server/config"; diff --git a/src/core/server/graph/tenant/mutators/User.ts b/src/core/server/graph/tenant/mutators/User.ts index 04b594033..71b534d46 100644 --- a/src/core/server/graph/tenant/mutators/User.ts +++ b/src/core/server/graph/tenant/mutators/User.ts @@ -1,16 +1,30 @@ import TenantContext from "talk-server/graph/tenant/context"; import * as user from "talk-server/models/user"; import { + createToken, + deactivateToken, setEmail, setPassword, setUsername, + updateAvatar, + updateDisplayName, + updateEmail, updatePassword, + updateRole, + updateUsername, } from "talk-server/services/users"; import { + GQLCreateTokenInput, + GQLDeactivateTokenInput, GQLSetEmailInput, GQLSetPasswordInput, GQLSetUsernameInput, GQLUpdatePasswordInput, + GQLUpdateUserAvatarInput, + GQLUpdateUserDisplayNameInput, + GQLUpdateUserEmailInput, + GQLUpdateUserRoleInput, + GQLUpdateUserUsernameInput, } from "../schema/__generated__/types"; export const User = (ctx: TenantContext) => ({ @@ -30,4 +44,25 @@ export const User = (ctx: TenantContext) => ({ input: GQLUpdatePasswordInput ): Promise | null> => updatePassword(ctx.mongo, ctx.tenant, ctx.user!, input.password), + createToken: async (input: GQLCreateTokenInput) => + createToken( + ctx.mongo, + ctx.tenant, + // NOTE: (wyattjoh) this will error if not provided. + ctx.signingConfig!, + ctx.user!, + input.name + ), + deactivateToken: async (input: GQLDeactivateTokenInput) => + deactivateToken(ctx.mongo, ctx.tenant, ctx.user!, input.id), + updateUserUsername: async (input: GQLUpdateUserUsernameInput) => + updateUsername(ctx.mongo, ctx.tenant, input.userID, input.username), + updateUserDisplayName: async (input: GQLUpdateUserDisplayNameInput) => + updateDisplayName(ctx.mongo, ctx.tenant, input.userID, input.displayName), + updateUserEmail: async (input: GQLUpdateUserEmailInput) => + updateEmail(ctx.mongo, ctx.tenant, input.userID, input.email), + updateUserAvatar: async (input: GQLUpdateUserAvatarInput) => + updateAvatar(ctx.mongo, ctx.tenant, input.userID, input.avatar), + updateUserRole: async (input: GQLUpdateUserRoleInput) => + updateRole(ctx.mongo, ctx.tenant, input.userID, input.role), }); diff --git a/src/core/server/graph/tenant/resolvers/Mutation.ts b/src/core/server/graph/tenant/resolvers/Mutation.ts index 29a02856a..e3bde46f2 100644 --- a/src/core/server/graph/tenant/resolvers/Mutation.ts +++ b/src/core/server/graph/tenant/resolvers/Mutation.ts @@ -97,4 +97,32 @@ export const Mutation: Required> = { user: await ctx.mutators.User.updatePassword(input), clientMutationId: input.clientMutationId, }), + createToken: async (source, { input }, ctx) => ({ + ...(await ctx.mutators.User.createToken(input)), + clientMutationId: input.clientMutationId, + }), + deactivateToken: async (source, { input }, ctx) => ({ + ...(await ctx.mutators.User.deactivateToken(input)), + clientMutationId: input.clientMutationId, + }), + updateUserUsername: async (source, { input }, ctx) => ({ + user: await ctx.mutators.User.updateUserUsername(input), + clientMutationId: input.clientMutationId, + }), + updateUserDisplayName: async (source, { input }, ctx) => ({ + user: await ctx.mutators.User.updateUserDisplayName(input), + clientMutationId: input.clientMutationId, + }), + updateUserEmail: async (source, { input }, ctx) => ({ + user: await ctx.mutators.User.updateUserEmail(input), + clientMutationId: input.clientMutationId, + }), + updateUserAvatar: async (source, { input }, ctx) => ({ + user: await ctx.mutators.User.updateUserAvatar(input), + clientMutationId: input.clientMutationId, + }), + updateUserRole: async (source, { input }, ctx) => ({ + user: await ctx.mutators.User.updateUserRole(input), + clientMutationId: input.clientMutationId, + }), }; diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index aaed707d1..9430abf4b 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -984,41 +984,6 @@ enum USER_ROLE { ADMIN } -enum USER_USERNAME_STATUS { - """ - UNSET is used when the username can be changed, and does not necessarily - require moderator action to become active. This can be used when the user - signs up with a social login and has the option of setting their own - username. - """ - UNSET - - """ - SET is used when the username has been set for the first time, but cannot - change without the username being rejected by a moderator and that moderator - agreeing that the username should be allowed to change. - """ - SET - - """ - APPROVED is used when the username was changed, and subsequently approved by - said moderator. - """ - APPROVED - - """ - REJECTED is used when the username was changed, and subsequently rejected by - said moderator. - """ - REJECTED - - """ - CHANGED is used after a user has changed their username after it was - rejected. - """ - CHANGED -} - type LocalProfile { id: String! } @@ -1047,6 +1012,12 @@ union Profile = | FacebookProfile | GoogleProfile +type Token { + id: ID! + name: String! + createdAt: Time! +} + """ User is someone that leaves Comments, and logs in. """ @@ -1108,6 +1079,11 @@ type User { first: Int = 10 after: Cursor ): CommentModerationActionConnection! @auth(role: [MODERATOR, ADMIN]) + + """ + tokens lists the access tokens associated with the account. + """ + tokens: [Token!]! @auth(role: [ADMIN], userIDField: "id") } ################################################################################ @@ -2943,6 +2919,244 @@ type UpdatePasswordPayload { clientMutationId: String! } +################## +# createToken +################## + +input CreateTokenInput { + """ + name is the desired name for the Token. + """ + name: String! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type CreateTokenPayload { + """ + user is the possibly modified User. + """ + user: User! + + """ + token is the Token that was created. + """ + token: Token! + + """ + signedToken is the signed Token associated with the account. + """ + signedToken: String! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +################## +# deactivateToken +################## + +input DeactivateTokenInput { + """ + id is the ID of the Token that should be deactivated. + """ + id: ID! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type DeactivateTokenPayload { + """ + user is the possibly modified User. + """ + user: User! + + """ + token is the Token that was deleted. + """ + token: Token + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +################## +# updateUserUsername +################## + +input UpdateUserUsernameInput { + """ + userID is the ID of the User that should have their username updated. + """ + userID: ID! + + """ + username is the desired username to set for the User. + """ + username: String! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type UpdateUserUsernamePayload { + """ + user is the possibly modified User. + """ + user: User! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +################## +# updateUserDisplayName +################## + +input UpdateUserDisplayNameInput { + """ + userID is the ID of the User that should have their display name updated. + """ + userID: ID! + + """ + displayName is the desired display name to set for the User. If set to `null` + or not provided, it will be unset. + """ + displayName: String + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type UpdateUserDisplayNamePayload { + """ + user is the possibly modified User. + """ + user: User! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +################## +# updateUserEmail +################## + +input UpdateUserEmailInput { + """ + userID is the ID of the User that should have their email address updated. + """ + userID: ID! + + """ + email is the email address to set for the User. + """ + email: String! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type UpdateUserEmailPayload { + """ + user is the possibly modified User. + """ + user: User! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +################## +# updateUserAvatar +################## + +input UpdateUserAvatarInput { + """ + userID is the ID of the User that should have their avatar updated. + """ + userID: ID! + + """ + avatar is the URL to the avatar to set for the User. If set to `null` or not + provided, it will be unset. + """ + avatar: String + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type UpdateUserAvatarPayload { + """ + user is the possibly modified User. + """ + user: User! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +################## +# updateUserRole +################## + +input UpdateUserRoleInput { + """ + userID is the ID of the User that should have their avatar updated. + """ + userID: ID! + + """ + role is the `USER_ROLE` that the User should be set to. + """ + role: USER_ROLE! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type UpdateUserRolePayload { + """ + user is the possibly modified User. + """ + user: User! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + ################## ## Mutation ################## @@ -3085,12 +3299,54 @@ type Mutation { they already have one associated with them. """ updatePassword(input: UpdatePasswordInput!): UpdatePasswordPayload @auth -} -################################################################################ -## Subscriptions -################################################################################ + """ + createToken allows an administrator to create a Token based on the current + logged in User. + """ + createToken(input: CreateTokenInput!): CreateTokenPayload! + @auth(roles: [ADMIN]) -type Subscription { - commentCreated(storyID: ID!): Comment + """ + deactivateToken will deactivate the current logged in User's Token based on + the input. + """ + deactivateToken(input: DeactivateTokenInput!): DeactivateTokenPayload! + @auth(roles: [ADMIN]) + + """ + updateUserUsername allows administrators to update a given User's username to + the one provided. + """ + updateUserUsername( + input: UpdateUserUsernameInput! + ): UpdateUserUsernamePayload! @auth(roles: [ADMIN]) + + """ + updateUserDisplayName allows administrators to update a given User's display + name to the one provided. + """ + updateUserDisplayName( + input: UpdateUserDisplayNameInput! + ): UpdateUserDisplayNamePayload @auth(roles: [ADMIN]) + + """ + updateUserEmail allows administrators to update a given User's email address + to the one provided. + """ + updateUserEmail(input: UpdateUserEmailInput!): UpdateUserEmailPayload! + @auth(roles: [ADMIN]) + + """ + updateUserAvatar allows administrators to update a given User's avatar to the + one provided. + """ + updateUserAvatar(input: UpdateUserAvatarInput!): UpdateUserAvatarPayload! + @auth(roles: [ADMIN]) + + """ + updateUserRole will update a given User's role. + """ + updateUserRole(input: UpdateUserRoleInput!): UpdateUserRolePayload! + @auth(roles: [ADMIN]) } diff --git a/src/core/server/models/user.ts b/src/core/server/models/user.ts index 5d80c28b3..feb6bf5fb 100644 --- a/src/core/server/models/user.ts +++ b/src/core/server/models/user.ts @@ -3,10 +3,7 @@ import { Db } from "mongodb"; import uuid from "uuid"; import { Omit, Sub } from "talk-common/types"; -import { - GQLUSER_ROLE, - GQLUSER_USERNAME_STATUS, -} from "talk-server/graph/tenant/schema/__generated__/types"; +import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; import { FilterQuery } from "talk-server/models/query"; import { TenantResource } from "talk-server/models/tenant"; @@ -52,27 +49,9 @@ export type Profile = export interface Token { readonly id: string; name: string; - active: boolean; -} - -export interface UserStatusHistory { - status: T; - assignedBy?: string; - reason?: string; createdAt: Date; } -export interface UserStatusItem { - status: T; - history: Array>; -} - -export interface UserStatus { - username: UserStatusItem; - banned: UserStatusItem; - suspension: UserStatusItem; -} - export interface User extends TenantResource { readonly id: string; username?: string; @@ -84,8 +63,6 @@ export interface User extends TenantResource { profiles: Profile[]; tokens: Token[]; role: GQLUSER_ROLE; - status: UserStatus; - ignoredUserIDs: string[]; createdAt: Date; } @@ -95,13 +72,7 @@ function hashPassword(password: string): Promise { export type UpsertUserInput = Omit< User, - | "id" - | "tenantID" - | "tokens" - | "status" - | "ignoredUserIDs" - | "createdAt" - | "lowercaseUsername" + "id" | "tenantID" | "tokens" | "createdAt" | "lowercaseUsername" >; export async function upsertUser( @@ -120,23 +91,6 @@ export async function upsertUser( id, tenantID, tokens: [], - ignoredUserIDs: [], - status: { - banned: { - status: false, - history: [], - }, - suspension: { - status: null, - history: [], - }, - username: { - status: input.username - ? GQLUSER_USERNAME_STATUS.SET - : GQLUSER_USERNAME_STATUS.UNSET, - history: [], - }, - }, createdAt: now, }; @@ -250,13 +204,21 @@ export async function retrieveUserWithProfile( }); } +/** + * 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( - db: Db, + mongo: Db, tenantID: string, id: string, role: GQLUSER_ROLE ) { - const result = await collection(db).findOneAndUpdate( + const result = await collection(mongo).findOneAndUpdate( { id, tenantID }, { $set: { role } }, { @@ -265,8 +227,12 @@ export async function updateUserRole( returnOriginal: false, } ); + if (!result.value) { + // TODO: (wyattjoh) return better error + throw new Error("user not found"); + } - return result.value || null; + return result.value; } export async function verifyUserPassword(user: User, password: string) { @@ -372,7 +338,6 @@ export async function setUserUsername( $set: { username, lowercaseUsername, - "status.username.status": GQLUSER_USERNAME_STATUS.SET, }, }, { @@ -401,6 +366,115 @@ export async function setUserUsername( 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 +) { + // Lowercase the username. + const lowercaseUsername = username.toLowerCase(); + + // Search to see if this username has been used before. + let user = await collection(mongo).findOne({ + tenantID, + lowercaseUsername, + }); + if (user) { + // TODO: (wyattjoh) return better error + throw new Error("duplicate username found"); + } + + // The username wasn't found, so add it to the user. + const result = await collection(mongo).findOneAndUpdate( + { + tenantID, + id, + }, + { + $set: { + username, + lowercaseUsername, + }, + }, + { + // 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) { + // TODO: (wyattjoh) return better error + throw new Error("user not found"); + } + + // TODO: (wyattjoh) return better error + throw new Error("unexpected error occurred"); + } + + return result.value; +} + +/** + * updateUserDisplayName will set the displayName of the User. If the display + * name is not provided, it will be unset. + * + * @param mongo the database handle + * @param tenantID the ID to the Tenant + * @param id the ID of the User where we are setting the displayName on + * @param displayName the displayName that we want to set + */ +export async function updateUserDisplayName( + mongo: Db, + tenantID: string, + id: string, + displayName?: string +) { + // The username wasn't found, so add it to the user. + const result = await collection(mongo).findOneAndUpdate( + { + tenantID, + id, + }, + { + // This will ensure that if the display name isn't provided, it will unset + // the display name on the User. + [displayName ? "$set" : "$unset"]: { + displayName: displayName ? displayName : 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) { + // TODO: (wyattjoh) return better error + throw new Error("user not found"); + } + + // TODO: (wyattjoh) return better error + throw new Error("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. @@ -467,6 +541,114 @@ export async function setUserEmail( 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) { + // TODO: (wyattjoh) return better error + throw new Error("duplicate email found"); + } + + // 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) { + // TODO: (wyattjoh) return better error + throw new Error("user not found"); + } + + // TODO: (wyattjoh) return better error + throw new Error("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) { + // TODO: (wyattjoh) return better error + throw new Error("user not found"); + } + + // TODO: (wyattjoh) return better error + throw new Error("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 @@ -547,3 +729,96 @@ export async function setUserLocalProfile( return result.value; } + +export async function createUserToken( + mongo: Db, + tenantID: string, + userID: string, + name: string +) { + // Create the Token that we'll be adding to the User. + const token: Readonly = { + id: uuid.v4(), + name, + createdAt: new Date(), + }; + + 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) { + // TODO: (wyattjoh) return better error + throw new Error("user not found"); + } + + 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) { + // TODO: (wyattjoh) return better error + throw new Error("user not found"); + } + + // Check to see if the User had that Token in the first place. + if (!user.tokens.find(t => t.id === id)) { + // TODO: (wyattjoh) return better error + throw new Error("token not found on user"); + } + + // TODO: (wyattjoh) return better error + throw new Error("could not remove the token for an unknown reason"); + } + + // 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, + }; +} diff --git a/src/core/server/services/jwt/index.ts b/src/core/server/services/jwt/index.ts index 8adefb966..92d5fe588 100644 --- a/src/core/server/services/jwt/index.ts +++ b/src/core/server/services/jwt/index.ts @@ -82,7 +82,10 @@ export function createJWTSigningConfig(config: Config): JWTSigningConfig { throw new Error("invalid algorithm specified"); } -export type SigningTokenOptions = Pick; +export type SigningTokenOptions = Pick< + SignOptions, + "jwtid" | "audience" | "issuer" | "expiresIn" | "notBefore" +>; export const signTokenString = async ( { algorithm, secret }: JWTSigningConfig, @@ -90,11 +93,23 @@ export const signTokenString = async ( options: SigningTokenOptions ) => jwt.sign({}, secret, { - ...options, jwtid: uuid(), - algorithm, - expiresIn: "1 day", // TODO: (wyattjoh) evaluate allowing configuration? + // TODO: (wyattjoh) evaluate allowing configuration? + expiresIn: "1 day", + ...options, subject: user.id, + algorithm, + }); + +export const signPATString = async ( + { algorithm, secret }: JWTSigningConfig, + user: User, + options: SigningTokenOptions +) => + jwt.sign({ pat: true }, secret, { + ...options, + subject: user.id, + algorithm, }); export function extractJWTFromRequest(req: Request) { diff --git a/src/core/server/services/users/index.ts b/src/core/server/services/users/index.ts index 9393bdb19..541764f1b 100644 --- a/src/core/server/services/users/index.ts +++ b/src/core/server/services/users/index.ts @@ -7,17 +7,26 @@ import { USERNAME_MIN_LENGTH, USERNAME_REGEX, } from "talk-common/helpers/validate"; +import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; import { Tenant } from "talk-server/models/tenant"; import { + createUserToken, + deactivateUserToken, LocalProfile, setUserEmail, setUserLocalProfile, setUserUsername, + updateUserAvatar, + updateUserDisplayName, + updateUserEmail, updateUserPassword, + updateUserRole, + updateUserUsername, upsertUser, UpsertUserInput, User, } from "talk-server/models/user"; +import { JWTSigningConfig, signPATString } from "../jwt"; /** * validateUsername will validate that the username is valid. Current @@ -43,6 +52,22 @@ function validateUsername(tenant: Tenant, username: string) { } } +const DISPLAY_NAME_MAX_LENGTH = USERNAME_MAX_LENGTH; + +/** + * validateDisplayName will validate that the username is valid. + * + * @param tenant tenant where the User is associated with + * @param displayName the display name to be tested + */ +function validateDisplayName(tenant: Tenant, displayName: string) { + // TODO: replace these static regex/length with database options in the Tenant eventually + + if (displayName.length > DISPLAY_NAME_MAX_LENGTH) { + throw new Error("displayName exceeded maximum length"); + } +} + /** * validatePassword will validate that the password is valid. Current * implementation uses a length statically, future versions will expose this as @@ -227,3 +252,155 @@ export async function updatePassword( return updateUserPassword(mongo, tenant.id, user.id, password); } + +/** + * createToken will create a Token for the User as well as return a signed Token + * that can be used to authenticate. + * + * @param mongo mongo database to interact with + * @param tenant Tenant where the User will be interacted with + * @param config signing configuration to create the signed token + * @param user User that should get updated + * @param name name of the Token + */ +export async function createToken( + mongo: Db, + tenant: Tenant, + config: JWTSigningConfig, + user: User, + name: string +) { + // Create the token for the User! + const result = await createUserToken(mongo, tenant.id, user.id, name); + + // Sign the token! + const signedToken = await signPATString(config, user, { + // Tokens are issued with the token ID as their JWT ID. + jwtid: result.token.id, + + // Tokens are issued with the tenant ID. + issuer: tenant.id, + }); + + return { ...result, signedToken }; +} + +/** + * deactivateToken will disable the given Token so that it can not be used to + * authenticate any more. + * + * @param mongo mongo database to interact with + * @param tenant Tenant where the User will be interacted with + * @param config signing configuration to create the signed token + * @param user User that should get updated + * @param id of the Token to be deactivated + */ +export async function deactivateToken( + mongo: Db, + tenant: Tenant, + user: User, + id: string +) { + if (!user.tokens.find(t => t.id === id)) { + // TODO: (wyattjoh) return better error + throw new Error("token not found on user"); + } + + return deactivateUserToken(mongo, tenant.id, user.id, id); +} + +/** + * updateUsername will update a given User's username. + * + * @param mongo mongo database to interact with + * @param tenant Tenant where the User will be interacted with + * @param userID the User's ID that we are updating + * @param username the username that we are setting on the User + */ +export async function updateUsername( + mongo: Db, + tenant: Tenant, + userID: string, + username: string +) { + // Validate the username. + validateUsername(tenant, username); + + return updateUserUsername(mongo, tenant.id, userID, username); +} + +/** + * updateDisplayName will update a given User's display name. + * + * @param mongo mongo database to interact with + * @param tenant Tenant where the User will be interacted with + * @param userID the User's ID that we are updating + * @param displayName the display name that we are setting on the User + */ +export async function updateDisplayName( + mongo: Db, + tenant: Tenant, + userID: string, + displayName?: string +) { + if (displayName) { + // Validate the display name. + validateDisplayName(tenant, displayName); + } + + return updateUserDisplayName(mongo, tenant.id, userID, displayName); +} + +/** + * updateRole will update the given User to the specified role. + * + * @param mongo mongo database to interact with + * @param tenant Tenant where the User will be interacted with + * @param userID the User's ID that we are updating + * @param role the role that we are setting on the User + */ +export async function updateRole( + mongo: Db, + tenant: Tenant, + userID: string, + role: GQLUSER_ROLE +) { + return updateUserRole(mongo, tenant.id, userID, role); +} + +/** + * updateEmail will update the given User's email address. + * + * @param mongo mongo database to interact with + * @param tenant Tenant where the User will be interacted with + * @param userID the User's ID that we are updating + * @param email the email address that we are setting on the User + */ +export async function updateEmail( + mongo: Db, + tenant: Tenant, + userID: string, + email: string +) { + // Validate the email address. + validateEmail(tenant, email); + + return updateUserEmail(mongo, tenant.id, userID, email); +} + +/** + * updateAvatar will update the given User's avatar. + * + * @param mongo mongo database to interact with + * @param tenant Tenant where the User will be interacted with + * @param userID the User's ID that we are updating + * @param avatar the avatar that we are setting on the User + */ +export async function updateAvatar( + mongo: Db, + tenant: Tenant, + userID: string, + avatar?: string +) { + return updateUserAvatar(mongo, tenant.id, userID, avatar); +}