mirror of
https://github.com/wassname/talk.git
synced 2026-07-15 11:26:58 +08:00
[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:
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user