[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
This commit is contained in:
Wyatt Johnson
2018-12-20 22:42:44 +00:00
committed by GitHub
parent 01a2ace3f0
commit 98a8c8dc71
10 changed files with 964 additions and 111 deletions
@@ -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,
}),
};
@@ -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;
+6 -1
View File
@@ -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<typeof loaders>;
public readonly mutators: ReturnType<typeof mutators>;
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);
}
@@ -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";
@@ -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<Readonly<user.User> | 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),
});
@@ -97,4 +97,32 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
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,
}),
};
@@ -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])
}
+327 -52
View File
@@ -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<T> {
status: T;
assignedBy?: string;
reason?: string;
createdAt: Date;
}
export interface UserStatusItem<T> {
status: T;
history: Array<UserStatusHistory<T>>;
}
export interface UserStatus {
username: UserStatusItem<GQLUSER_USERNAME_STATUS>;
banned: UserStatusItem<boolean>;
suspension: UserStatusItem<Date | null>;
}
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<string> {
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<Token> = {
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<User> = {
...result.value,
tokens: result.value.tokens.filter(t => t.id !== id),
};
return {
user: updatedUser,
token,
};
}
+19 -4
View File
@@ -82,7 +82,10 @@ export function createJWTSigningConfig(config: Config): JWTSigningConfig {
throw new Error("invalid algorithm specified");
}
export type SigningTokenOptions = Pick<SignOptions, "audience" | "issuer">;
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) {
+177
View File
@@ -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);
}