From 986c92598d761c78a8d5a8fb6c7bec4015b58478 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 28 Jun 2018 17:06:45 -0600 Subject: [PATCH 01/45] feature: added generated graph types --- package-lock.json | 9 ++ package.json | 4 +- scripts/types.js | 96 +++++++++++++++++++ .../graph/management/resolvers/index.ts | 8 +- .../server/graph/management/schema/index.ts | 4 +- .../graph/management/schema/schema.graphql | 25 ++--- .../server/graph/tenant/loaders/comments.ts | 10 +- .../server/graph/tenant/mutators/comment.ts | 6 +- .../server/graph/tenant/resolvers/asset.ts | 9 +- .../server/graph/tenant/resolvers/comment.ts | 12 ++- .../server/graph/tenant/resolvers/index.ts | 8 +- .../server/graph/tenant/resolvers/mutation.ts | 22 +---- .../server/graph/tenant/resolvers/query.ts | 14 ++- src/core/server/graph/tenant/schema/index.ts | 4 +- .../server/graph/tenant/schema/schema.graphql | 27 ++++-- src/core/server/logger.ts | 5 +- src/core/server/models/comment.ts | 29 +++--- 17 files changed, 201 insertions(+), 91 deletions(-) create mode 100644 scripts/types.js diff --git a/package-lock.json b/package-lock.json index c61ddd7fb..0d6ef44fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9125,6 +9125,15 @@ "cross-fetch": "2.0.0" } }, + "graphql-schema-typescript": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/graphql-schema-typescript/-/graphql-schema-typescript-1.2.1.tgz", + "integrity": "sha512-ipZh3Epm/Kqcy6MF5FM6uxwCMFok07q+6qyxFOa7ViRufcjzH9Y3nECmECH5WgqRGl2wR6TmskbZd5qJJrGpoA==", + "dev": true, + "requires": { + "yargs": "^11.0.0" + } + }, "graphql-subscriptions": { "version": "0.5.8", "resolved": "https://registry.npmjs.org/graphql-subscriptions/-/graphql-subscriptions-0.5.8.tgz", diff --git a/package.json b/package.json index 2ccf6c47c..86ecfe387 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "lint": "npm-run-all --parallel lint:*", "lint:server": "tslint --project ./tsconfig.json", "lint:client": "tslint --project ./src/core/client/tsconfig.json", - "docz:watch": "docz dev" + "docz:watch": "docz dev", + "postinstall": "node ./scripts/types.js" }, "author": "", "license": "Apache-2.0", @@ -95,6 +96,7 @@ "fluent-langneg": "^0.1.0", "fluent-react": "^0.7.0", "graphql-playground-middleware-express": "^1.7.0", + "graphql-schema-typescript": "^1.2.1", "html-webpack-plugin": "^3.2.0", "jest": "^23.2.0", "loader-utils": "^1.1.0", diff --git a/scripts/types.js b/scripts/types.js new file mode 100644 index 000000000..3825548e7 --- /dev/null +++ b/scripts/types.js @@ -0,0 +1,96 @@ +const { Linter, Configuration } = require("tslint"); +const { generateTSTypesAsString } = require("graphql-schema-typescript"); +const { getGraphQLConfig } = require("graphql-config"); +const path = require("path"); +const fs = require("fs"); + +function lint(files) { + const linter = new Linter({ fix: true }); + + for (const { fileName, types } of files) { + const configuration = Configuration.findConfiguration(null, fileName) + .results; + linter.lint(fileName, types, configuration); + } +} + +function getFileName(name) { + return path.join( + __dirname, + "..", + "src", + "core", + "server", + "graph", + name, + "schema", + "__generated__", + "types.ts" + ); +} + +async function main() { + const config = getGraphQLConfig(__dirname); + const projects = config.getProjects(); + + const files = [ + { + name: "tenant", + fileName: getFileName("tenant"), + config: { + contextType: "TenantContext", + importStatements: [ + 'import { Cursor } from "talk-server/models/connection";', + 'import TenantContext from "talk-server/graph/tenant/context";', + ], + customScalarType: { Cursor: "Cursor", Time: "string" }, + }, + }, + { + name: "management", + fileName: getFileName("management"), + config: { + contextType: "ManagementContext", + importStatements: [ + 'import ManagementContext from "talk-server/graph/management/context";', + ], + }, + }, + ]; + + for (const file of files) { + // Load the graph schema. + const schema = projects[file.name].getSchema(); + + // Create the generated directory. + const dir = path.dirname(file.fileName); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir); + } + + // Create the types for this file. + file.types = await generateTSTypesAsString(schema, { + tabSpaces: 2, + typePrefix: "GQL", + contextType: "any", + strictNulls: false, + minimizeInterfaceImplementation: true, + ...file.config, + }); + } + + // Send the files off to the linter to be linted and written. + lint(files); + + return files; +} + +main() + .then(files => { + for (const { fileName } of files) { + console.log(`Generated ${fileName}`); + } + }) + .catch(err => { + console.error(err); + }); diff --git a/src/core/server/graph/management/resolvers/index.ts b/src/core/server/graph/management/resolvers/index.ts index 42b27b820..b1bcbcef8 100644 --- a/src/core/server/graph/management/resolvers/index.ts +++ b/src/core/server/graph/management/resolvers/index.ts @@ -1,5 +1,5 @@ -import Cursor from "../../common/scalars/cursor"; +import { GQLResolver } from "talk-server/graph/management/schema/__generated__/types"; -export default { - Cursor, -}; +const Resolvers: GQLResolver = {}; + +export default Resolvers; diff --git a/src/core/server/graph/management/schema/index.ts b/src/core/server/graph/management/schema/index.ts index ab7cd2856..2f094a807 100644 --- a/src/core/server/graph/management/schema/index.ts +++ b/src/core/server/graph/management/schema/index.ts @@ -1,6 +1,8 @@ +import { IResolvers } from "graphql-tools"; + import loadSchema from "talk-server/graph/common/schema"; import resolvers from "talk-server/graph/management/resolvers"; export default function getManagementSchema() { - return loadSchema("management", resolvers); + return loadSchema("management", resolvers as IResolvers); } diff --git a/src/core/server/graph/management/schema/schema.graphql b/src/core/server/graph/management/schema/schema.graphql index 67655516d..e07ccbdd9 100644 --- a/src/core/server/graph/management/schema/schema.graphql +++ b/src/core/server/graph/management/schema/schema.graphql @@ -7,27 +7,22 @@ Time represented as an ISO8601 string. """ scalar Time -""" -Cursor represents a paginating cursor. -""" -scalar Cursor - ################################################################################ ## Tenant ################################################################################ type Tenant { - id: ID! + id: ID! - """ - organizationName is the name of the organization. - """ - organizationName: String + """ + organizationName is the name of the organization. + """ + organizationName: String - """ - organizationContactEmail is the email of the organization. - """ - organizationContactEmail: String + """ + organizationContactEmail is the email of the organization. + """ + organizationContactEmail: String } ################################################################################ @@ -35,5 +30,5 @@ type Tenant { ################################################################################ type Query { - tenant(id: ID!): Tenant + tenant(id: ID!): Tenant } diff --git a/src/core/server/graph/tenant/loaders/comments.ts b/src/core/server/graph/tenant/loaders/comments.ts index 6649a50ab..0e68876e2 100644 --- a/src/core/server/graph/tenant/loaders/comments.ts +++ b/src/core/server/graph/tenant/loaders/comments.ts @@ -1,7 +1,11 @@ import DataLoader from "dataloader"; + import Context from "talk-server/graph/tenant/context"; import { - ConnectionInput, + AssetToCommentsArgs, + CommentToRepliesArgs, +} from "talk-server/graph/tenant/schema/__generated__/types"; +import { retrieveAssetConnection, retrieveMany, retrieveRepliesConnection, @@ -11,8 +15,8 @@ export default (ctx: Context) => ({ comment: new DataLoader((ids: string[]) => retrieveMany(ctx.db, ctx.tenant.id, ids) ), - forAsset: (assetID: string, input: ConnectionInput) => + forAsset: (assetID: string, input: AssetToCommentsArgs) => retrieveAssetConnection(ctx.db, ctx.tenant.id, assetID, input), - forParent: (assetID: string, parentID: string, input: ConnectionInput) => + forParent: (assetID: string, parentID: string, input: CommentToRepliesArgs) => retrieveRepliesConnection(ctx.db, ctx.tenant.id, assetID, parentID, input), }); diff --git a/src/core/server/graph/tenant/mutators/comment.ts b/src/core/server/graph/tenant/mutators/comment.ts index 1046930b9..9025d3cef 100644 --- a/src/core/server/graph/tenant/mutators/comment.ts +++ b/src/core/server/graph/tenant/mutators/comment.ts @@ -1,12 +1,12 @@ import TenantContext from "talk-server/graph/tenant/context"; -import { CreateCommentInput } from "talk-server/graph/tenant/resolvers/mutation"; +import { GQLCreateCommentInput } from "talk-server/graph/tenant/schema/__generated__/types"; import { Comment } from "talk-server/models/comment"; import { create } from "talk-server/services/comments"; export default (ctx: TenantContext) => ({ - create: (input: CreateCommentInput): Promise => { + create: (input: GQLCreateCommentInput): Promise => { // FIXME: remove tenant + user ! - return create(ctx.db, ctx.tenant!.id, { + return create(ctx.db, ctx.tenant.id, { author_id: ctx.user!.id, asset_id: input.assetID, body: input.body, diff --git a/src/core/server/graph/tenant/resolvers/asset.ts b/src/core/server/graph/tenant/resolvers/asset.ts index 7f17e8861..e5487ba08 100644 --- a/src/core/server/graph/tenant/resolvers/asset.ts +++ b/src/core/server/graph/tenant/resolvers/asset.ts @@ -1,10 +1,11 @@ -import Context from "talk-server/graph/tenant/context"; +import { GQLAssetTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; import { Asset } from "talk-server/models/asset"; -import { ConnectionInput } from "talk-server/models/comment"; -export default { - comments: async (asset: Asset, input: ConnectionInput, ctx: Context) => +const Asset: GQLAssetTypeResolver = { + comments: (asset, input, ctx) => ctx.loaders.Comments.forAsset(asset.id, input), // TODO: implement this. isClosed: () => false, }; + +export default Asset; diff --git a/src/core/server/graph/tenant/resolvers/comment.ts b/src/core/server/graph/tenant/resolvers/comment.ts index 22f72b5e5..4b7f67023 100644 --- a/src/core/server/graph/tenant/resolvers/comment.ts +++ b/src/core/server/graph/tenant/resolvers/comment.ts @@ -1,9 +1,11 @@ -import Context from "talk-server/graph/tenant/context"; -import { Comment, ConnectionInput } from "talk-server/models/comment"; +import { GQLCommentTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; +import { Comment } from "talk-server/models/comment"; -export default { - author: async (comment: Comment, _: any, ctx: Context) => +const Comment: GQLCommentTypeResolver = { + author: async (comment, args, ctx) => ctx.loaders.Users.user.load(comment.author_id), - replies: async (comment: Comment, input: ConnectionInput, ctx: Context) => + replies: async (comment, input, ctx) => ctx.loaders.Comments.forParent(comment.asset_id, comment.id, input), }; + +export default Comment; diff --git a/src/core/server/graph/tenant/resolvers/index.ts b/src/core/server/graph/tenant/resolvers/index.ts index d1143f1ff..e8d33540d 100644 --- a/src/core/server/graph/tenant/resolvers/index.ts +++ b/src/core/server/graph/tenant/resolvers/index.ts @@ -1,13 +1,17 @@ -import Cursor from "../../common/scalars/cursor"; +import Cursor from "talk-server/graph/common/scalars/cursor"; +import { GQLResolver } from "talk-server/graph/tenant/schema/__generated__/types"; + import Asset from "./asset"; import Comment from "./comment"; import Mutation from "./mutation"; import Query from "./query"; -export default { +const Resolvers: GQLResolver = { Asset, Comment, Cursor, Query, Mutation, }; + +export default Resolvers; diff --git a/src/core/server/graph/tenant/resolvers/mutation.ts b/src/core/server/graph/tenant/resolvers/mutation.ts index 4d3b82a76..f51f0fc1b 100644 --- a/src/core/server/graph/tenant/resolvers/mutation.ts +++ b/src/core/server/graph/tenant/resolvers/mutation.ts @@ -1,23 +1,7 @@ -import { ClientMutationProps } from "talk-server/graph/common/resolvers/mutation"; -import TenantContext from "talk-server/graph/tenant/context"; -import { Comment } from "talk-server/models/comment"; +import { GQLMutationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; -export interface CreateCommentInput extends ClientMutationProps { - assetID: string; - parentID?: string; - body: string; -} - -export interface CreateCommentPayload extends ClientMutationProps { - comment: Comment; -} - -const Mutation = { - createComment: async ( - source: void, - input: CreateCommentInput, - ctx: TenantContext - ): Promise => ({ +const Mutation: GQLMutationTypeResolver = { + createComment: async (source, { input }, ctx) => ({ comment: await ctx.mutators.Comment.create(input), clientMutationId: input.clientMutationId, }), diff --git a/src/core/server/graph/tenant/resolvers/query.ts b/src/core/server/graph/tenant/resolvers/query.ts index ae76679a9..caf5e0a3b 100644 --- a/src/core/server/graph/tenant/resolvers/query.ts +++ b/src/core/server/graph/tenant/resolvers/query.ts @@ -1,10 +1,8 @@ -import TenantContext from "talk-server/graph/tenant/context"; +import { GQLQueryTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; -export default { - asset: async ( - source: void, - { id }: { id: string; url: string }, - ctx: TenantContext - ) => ctx.loaders.Assets.asset.load(id), - settings: async (parent: any, args: any, ctx: TenantContext) => ctx.tenant, +const Query: GQLQueryTypeResolver = { + asset: (source, args, ctx) => ctx.loaders.Assets.asset.load(args.id), + settings: (parent, args, ctx) => ctx.tenant, }; + +export default Query; diff --git a/src/core/server/graph/tenant/schema/index.ts b/src/core/server/graph/tenant/schema/index.ts index 93641ac66..1105c358e 100644 --- a/src/core/server/graph/tenant/schema/index.ts +++ b/src/core/server/graph/tenant/schema/index.ts @@ -1,6 +1,8 @@ +import { IResolvers } from "graphql-tools"; + import loadSchema from "talk-server/graph/common/schema"; import resolvers from "talk-server/graph/tenant/resolvers"; export default function getTenantSchema() { - return loadSchema("tenant", resolvers); + return loadSchema("tenant", resolvers as IResolvers); } diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index eb060b6a4..96657351d 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -31,9 +31,9 @@ enum MODERATION_MODE { } """ -Wordlist describes all the available wordlists. +WordlistSettings describes all the available wordlists. """ -type Wordlist { +type WordlistSettings { """ banned words will by default reject the comment if it is found. """ @@ -46,7 +46,20 @@ type Wordlist { } # Settings stores the global settings for a given installation. + +################################################################################ +## Settings +################################################################################ + +""" +Settings stores the global settings for a given Tenant. +""" type Settings { + """ + domain is the domain that is associated with this Tenant. + """ + domain: String! + """ moderation is the moderation mode for all Asset's on the site. """ @@ -152,7 +165,7 @@ type Settings { """ wordlist will return a given list of words. """ - wordlist: Wordlist! + wordlist: WordlistSettings! """ domains will return a given list of whitelisted domains. @@ -222,8 +235,8 @@ type Comment { replies will return the replies to this comment. """ replies( - first: Int = 10 - orderBy: COMMENT_SORT = CREATED_AT_DESC + first: Int! = 10 + orderBy: COMMENT_SORT! = CREATED_AT_DESC after: Cursor ): CommentsConnection } @@ -299,8 +312,8 @@ type Asset { comments are the comments on the Asset. """ comments( - first: Int = 10 - orderBy: COMMENT_SORT = CREATED_AT_DESC + first: Int! = 10 + orderBy: COMMENT_SORT! = CREATED_AT_DESC after: Cursor ): CommentsConnection diff --git a/src/core/server/logger.ts b/src/core/server/logger.ts index c3b50c355..7a9ee886e 100644 --- a/src/core/server/logger.ts +++ b/src/core/server/logger.ts @@ -1,5 +1,8 @@ import bunyan from "bunyan"; -const logger = bunyan.createLogger({ name: "talk" }); +const logger = bunyan.createLogger({ + name: "talk", + serializers: bunyan.stdSerializers, +}); export default logger; diff --git a/src/core/server/models/comment.ts b/src/core/server/models/comment.ts index 81d046159..5c0726669 100644 --- a/src/core/server/models/comment.ts +++ b/src/core/server/models/comment.ts @@ -1,11 +1,13 @@ import { merge } from "lodash"; import { Db } from "mongodb"; +import uuid from "uuid"; + import { Omit, Sub } from "talk-common/types"; +import { GQLCOMMENT_SORT } from "talk-server/graph/tenant/schema/__generated__/types"; import { ActionCounts } from "talk-server/models/actions"; import { Connection, Cursor } from "talk-server/models/connection"; import Query from "talk-server/models/query"; import { TenantResource } from "talk-server/models/tenant"; -import uuid from "uuid"; function collection(db: Db) { return db.collection>("comments"); @@ -121,16 +123,9 @@ export async function retrieveMany(db: Db, tenantID: string, ids: string[]) { return ids.map(id => comments.find(comment => comment.id === id) || null); } -export enum CommentSort { - CREATED_AT_DESC = "CREATED_AT_DESC", - CREATED_AT_ASC = "CREATED_AT_ASC", - REPLIES_DESC = "REPLIES_DESC", - RESPECT_DESC = "RESPECT_DESC", -} - export interface ConnectionInput { first: number; - orderBy: CommentSort; + orderBy: GQLCOMMENT_SORT; after?: Cursor; } @@ -144,12 +139,12 @@ export interface ConnectionInput { function nodesToEdge(input: ConnectionInput, nodes: Comment[]) { let getCursor: (comment: Comment, index: number) => Cursor; switch (input.orderBy) { - case CommentSort.CREATED_AT_DESC: - case CommentSort.CREATED_AT_ASC: + case GQLCOMMENT_SORT.CREATED_AT_DESC: + case GQLCOMMENT_SORT.CREATED_AT_ASC: getCursor = comment => comment.created_at; break; - case CommentSort.REPLIES_DESC: - case CommentSort.RESPECT_DESC: + case GQLCOMMENT_SORT.REPLIES_DESC: + case GQLCOMMENT_SORT.RESPECT_DESC: getCursor = (_, index) => (input.after ? (input.after as number) : 0) + index + 1; break; @@ -226,25 +221,25 @@ async function retrieveConnection( ) { // Apply some sorting options. switch (input.orderBy) { - case CommentSort.CREATED_AT_DESC: + case GQLCOMMENT_SORT.CREATED_AT_DESC: query.orderBy({ created_at: -1 }); if (input.after) { query.where({ created_at: { $lt: input.after as Date } }); } break; - case CommentSort.CREATED_AT_ASC: + case GQLCOMMENT_SORT.CREATED_AT_ASC: query.orderBy({ created_at: 1 }); if (input.after) { query.where({ created_at: { $gt: input.after as Date } }); } break; - case CommentSort.REPLIES_DESC: + case GQLCOMMENT_SORT.REPLIES_DESC: query.orderBy({ reply_count: -1, created_at: -1 }); if (input.after) { query.after(input.after as number); } break; - case CommentSort.RESPECT_DESC: + case GQLCOMMENT_SORT.RESPECT_DESC: query.orderBy({ "action_counts.respect": -1, created_at: -1 }); if (input.after) { query.after(input.after as number); From 1236946312b6f060c3e6919edc05ec75aa38be01 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 29 Jun 2018 16:11:32 -0600 Subject: [PATCH 02/45] initial oidc support --- config/nodemon/types.json | 7 + package-lock.json | 346 +++++++++++++----- package.json | 11 +- src/core/server/app/index.ts | 6 +- .../server/app/middleware/passport/index.ts | 78 +++- .../server/app/middleware/passport/oidc.ts | 223 +++++++++++ .../server/app/middleware/passport/sso.ts | 9 + src/core/server/app/router.ts | 40 +- src/core/server/app/url.ts | 13 + .../server/graph/common/directives/auth.ts | 12 + .../graph/tenant/resolvers/auth_settings.ts | 14 + .../resolvers/facebook_auth_integration.ts | 10 + .../resolvers/google_auth_integration.ts | 10 + .../resolvers/local_auth_integration.ts | 8 + .../tenant/resolvers/oidc_auth_integration.ts | 10 + .../tenant/resolvers/sso_auth_integration.ts | 10 + src/core/server/graph/tenant/schema/index.ts | 10 +- .../server/graph/tenant/schema/schema.graphql | 117 +++++- src/core/server/models/tenant.ts | 98 ++++- src/core/server/models/user.ts | 42 ++- src/types/jsonwebtoken.d.ts | 19 + src/types/webfinger.d.ts | 16 + 22 files changed, 968 insertions(+), 141 deletions(-) create mode 100644 config/nodemon/types.json create mode 100644 src/core/server/app/middleware/passport/oidc.ts create mode 100644 src/core/server/app/middleware/passport/sso.ts create mode 100644 src/core/server/app/url.ts create mode 100644 src/core/server/graph/common/directives/auth.ts create mode 100644 src/core/server/graph/tenant/resolvers/auth_settings.ts create mode 100644 src/core/server/graph/tenant/resolvers/facebook_auth_integration.ts create mode 100644 src/core/server/graph/tenant/resolvers/google_auth_integration.ts create mode 100644 src/core/server/graph/tenant/resolvers/local_auth_integration.ts create mode 100644 src/core/server/graph/tenant/resolvers/oidc_auth_integration.ts create mode 100644 src/core/server/graph/tenant/resolvers/sso_auth_integration.ts create mode 100644 src/types/jsonwebtoken.d.ts create mode 100644 src/types/webfinger.d.ts diff --git a/config/nodemon/types.json b/config/nodemon/types.json new file mode 100644 index 000000000..73203fd0a --- /dev/null +++ b/config/nodemon/types.json @@ -0,0 +1,7 @@ +{ + "exec": "npm run compile:server:types", + "ext": "graphql", + "watch": [ + "./src/core/server/graph" + ] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index ff1c5e30f..f0f773f88 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1413,7 +1413,6 @@ "version": "1.17.0", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.17.0.tgz", "integrity": "sha512-a2+YeUjPkztKJu5aIF2yArYFQQp8d51wZ7DavSHjFuY1mqVgidGyzEQ41JIVNy82fXj8yPgy2vJmfIywgESW6w==", - "dev": true, "requires": { "@types/connect": "*", "@types/node": "*" @@ -1448,7 +1447,6 @@ "version": "3.4.32", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.32.tgz", "integrity": "sha512-4r8qa0quOvh7lGD0pre62CAb1oni1OO6ecJLGCezTmhQ8Fz50Arx9RUszryR8KlgK6avuSXvviL6yWyViQABOg==", - "dev": true, "requires": { "@types/node": "*" } @@ -1471,31 +1469,45 @@ "@types/events": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@types/events/-/events-1.2.0.tgz", - "integrity": "sha512-KEIlhXnIutzKwRbQkGWb/I4HFqBuUykAdHgDED6xqwXJfONCjF5VoE0cXEiurh3XauygxzeDzgtXUqvLkxFzzA==", - "dev": true + "integrity": "sha512-KEIlhXnIutzKwRbQkGWb/I4HFqBuUykAdHgDED6xqwXJfONCjF5VoE0cXEiurh3XauygxzeDzgtXUqvLkxFzzA==" }, "@types/express": { "version": "4.16.0", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.16.0.tgz", "integrity": "sha512-TtPEYumsmSTtTetAPXlJVf3kEqb6wZK0bZojpJQrnD/djV4q1oB6QQ8aKvKqwNPACoe02GNiy5zDzcYivR5Z2w==", - "dev": true, "requires": { "@types/body-parser": "*", "@types/express-serve-static-core": "*", "@types/serve-static": "*" } }, + "@types/express-jwt": { + "version": "0.0.34", + "resolved": "https://registry.npmjs.org/@types/express-jwt/-/express-jwt-0.0.34.tgz", + "integrity": "sha1-/b7kxq9cCiRu8qkz9VGZc8dxfwI=", + "requires": { + "@types/express": "*", + "@types/express-unless": "*" + } + }, "@types/express-serve-static-core": { "version": "4.16.0", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.16.0.tgz", "integrity": "sha512-lTeoCu5NxJU4OD9moCgm0ESZzweAx0YqsAcab6OB0EB3+As1OaHtKnaGJvcngQxYsi9UNv0abn4/DRavrRxt4w==", - "dev": true, "requires": { "@types/events": "*", "@types/node": "*", "@types/range-parser": "*" } }, + "@types/express-unless": { + "version": "0.0.32", + "resolved": "https://registry.npmjs.org/@types/express-unless/-/express-unless-0.0.32.tgz", + "integrity": "sha512-6YpJyFNlDDnPnRjMOvJCoDYlSDDmG/OEEUsPk7yhNkL4G9hUYtgab6vi1CcWsGSSSM0CsvNlWTG+ywAGnvF03g==", + "requires": { + "@types/express": "*" + } + }, "@types/graphql": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/@types/graphql/-/graphql-0.13.1.tgz", @@ -1524,6 +1536,15 @@ "integrity": "sha512-GXYdIVpwBP5ZBOlHitSYfQdH+vWXVahhkeQwalX0LkoX7Mx0D3L3tg4vXXhr6nYHkEpWlAzWuEjgWEBtcp5NZA==", "dev": true }, + "@types/jsonwebtoken": { + "version": "7.2.7", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-7.2.7.tgz", + "integrity": "sha512-lq9X76APpxGJDUe1VptL1P5GrogqhPCH+SDy94+gaBJw7Hhj6hwrVC6zuxAx2GrgktkBuwydESZBvPfrdBoOEg==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/lodash": { "version": "4.14.109", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.109.tgz", @@ -1539,8 +1560,7 @@ "@types/mime": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.0.tgz", - "integrity": "sha512-A2TAGbTFdBw9azHbpVd+/FkdW2T6msN1uct1O9bH3vTerEHKZhTXJUQXy+hNq1B0RagfU8U+KBdqiZpxjhOUQA==", - "dev": true + "integrity": "sha512-A2TAGbTFdBw9azHbpVd+/FkdW2T6msN1uct1O9bH3vTerEHKZhTXJUQXy+hNq1B0RagfU8U+KBdqiZpxjhOUQA==" }, "@types/mongodb": { "version": "3.0.19", @@ -1556,8 +1576,16 @@ "@types/node": { "version": "10.3.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-10.3.1.tgz", - "integrity": "sha512-IsX9aDHDzJohkm3VCDB8tkzl5RQ34E/PFA29TQk6uDGb7Oc869ZBtmdKVDBzY3+h9GnXB8ssrRXEPVZrlIOPOw==", - "dev": true + "integrity": "sha512-IsX9aDHDzJohkm3VCDB8tkzl5RQ34E/PFA29TQk6uDGb7Oc869ZBtmdKVDBzY3+h9GnXB8ssrRXEPVZrlIOPOw==" + }, + "@types/oauth": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.0.tgz", + "integrity": "sha512-1oouefxKPGiDkb5m6lNxDkFry3PItCOJ+tlNtEn/gRvWShb2Rb3y0pccOIGwN/AwHUpwsuwlRwSpg7aoCN3bQQ==", + "dev": true, + "requires": { + "@types/node": "*" + } }, "@types/passport": { "version": "0.4.5", @@ -1568,6 +1596,27 @@ "@types/express": "*" } }, + "@types/passport-oauth2": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.4.5.tgz", + "integrity": "sha512-q/pT4RKkiHU1W20P2qUAtVmua3bVF1b8Tulag/niDISS+8CGrRthmQxvPhkIVtJb68ssCxn8ck+eagInGuKHDQ==", + "dev": true, + "requires": { + "@types/express": "*", + "@types/oauth": "*", + "@types/passport": "*" + } + }, + "@types/passport-strategy": { + "version": "0.2.33", + "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.33.tgz", + "integrity": "sha512-tmj//XbNqCWmD+PJ/KnxAouircAmMGLN9IHBO3utH5DXuHHHYN4ZG53DRrQBjlZMiS/1b5IP38U2ay1GfbcQrQ==", + "dev": true, + "requires": { + "@types/express": "*", + "@types/passport": "*" + } + }, "@types/query-string": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@types/query-string/-/query-string-6.1.0.tgz", @@ -1577,8 +1626,7 @@ "@types/range-parser": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.2.tgz", - "integrity": "sha512-HtKGu+qG1NPvYe1z7ezLsyIaXYyi8SoAVqWDZgDQ8dLrsZvSzUNCwZyfX33uhWxL/SU0ZDQZ3nwZ0nimt507Kw==", - "dev": true + "integrity": "sha512-HtKGu+qG1NPvYe1z7ezLsyIaXYyi8SoAVqWDZgDQ8dLrsZvSzUNCwZyfX33uhWxL/SU0ZDQZ3nwZ0nimt507Kw==" }, "@types/react": { "version": "16.4.2", @@ -1627,7 +1675,6 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.2.tgz", "integrity": "sha512-/BZ4QRLpH/bNYgZgwhKEh+5AsboDBcUdlBYgzoLX0fpj3Y2gp6EApyOlM3bK53wQS/OE1SrdSYBAbux2D1528Q==", - "dev": true, "requires": { "@types/express-serve-static-core": "*", "@types/mime": "*" @@ -2341,8 +2388,7 @@ "asn1": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz", - "integrity": "sha1-2sh4dxPJlmhJ/IGAd36+nB3fO4Y=", - "dev": true + "integrity": "sha1-2sh4dxPJlmhJ/IGAd36+nB3fO4Y=" }, "asn1.js": { "version": "4.10.1", @@ -2384,8 +2430,7 @@ "assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", - "dev": true + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" }, "assign-symbols": { "version": "1.0.0", @@ -2428,8 +2473,7 @@ "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", - "dev": true + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" }, "atob": { "version": "2.1.1", @@ -2454,14 +2498,12 @@ "aws-sign2": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=", - "dev": true + "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=" }, "aws4": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.7.0.tgz", - "integrity": "sha512-32NDda82rhwD9/JBCCkB+MRYDp0oSvlo2IL6rQWA10PQi7tDUM3eqMSltXmY+Oyl/7N3P3qNtAlv7X0d9bI28w==", - "dev": true + "integrity": "sha512-32NDda82rhwD9/JBCCkB+MRYDp0oSvlo2IL6rQWA10PQi7tDUM3eqMSltXmY+Oyl/7N3P3qNtAlv7X0d9bI28w==" }, "b3b": { "version": "0.0.1", @@ -4305,7 +4347,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.1.tgz", "integrity": "sha1-Y7xdy2EzG5K8Bf1SiVPDNGKgb40=", - "dev": true, "optional": true, "requires": { "tweetnacl": "^0.14.3" @@ -4649,6 +4690,11 @@ "isarray": "^1.0.0" } }, + "buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" + }, "buffer-from": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.0.tgz", @@ -4855,8 +4901,7 @@ "caseless": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=", - "dev": true + "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" }, "ccount": { "version": "1.0.3", @@ -5210,8 +5255,7 @@ "co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=", - "dev": true + "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=" }, "coa": { "version": "1.0.4", @@ -5300,7 +5344,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.6.tgz", "integrity": "sha1-cj599ugBrFYTETp+RFqbactjKBg=", - "dev": true, "requires": { "delayed-stream": "~1.0.0" } @@ -5543,8 +5586,7 @@ "core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", - "dev": true + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" }, "cosmiconfig": { "version": "3.1.0", @@ -6231,7 +6273,6 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", - "dev": true, "requires": { "assert-plus": "^1.0.0" } @@ -6440,8 +6481,7 @@ "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", - "dev": true + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" }, "delegate": { "version": "3.2.0", @@ -7102,12 +7142,19 @@ "version": "0.1.1", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz", "integrity": "sha1-D8c6ntXw1Tw4GTOYUj735UN3dQU=", - "dev": true, "optional": true, "requires": { "jsbn": "~0.1.0" } }, + "ecdsa-sig-formatter": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.10.tgz", + "integrity": "sha1-HFlQAPBKiJffuFAAiSoPTDOvhsM=", + "requires": { + "safe-buffer": "^5.0.1" + } + }, "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -7639,8 +7686,7 @@ "extend": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.1.tgz", - "integrity": "sha1-p1Xqe8Gt/MWjHOfnYtuq3F5jZEQ=", - "dev": true + "integrity": "sha1-p1Xqe8Gt/MWjHOfnYtuq3F5jZEQ=" }, "extend-shallow": { "version": "3.0.2", @@ -7754,8 +7800,7 @@ "extsprintf": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=", - "dev": true + "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" }, "fast-deep-equal": { "version": "2.0.1", @@ -8105,14 +8150,12 @@ "forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=", - "dev": true + "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" }, "form-data": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.2.tgz", "integrity": "sha1-SXBJi+YEwgwAXU9cI67NIda0kJk=", - "dev": true, "requires": { "asynckit": "^0.4.0", "combined-stream": "1.0.6", @@ -8255,14 +8298,12 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, - "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -8277,20 +8318,17 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "core-util-is": { "version": "1.0.2", @@ -8407,8 +8445,7 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "ini": { "version": "1.3.5", @@ -8420,7 +8457,6 @@ "version": "1.0.0", "bundled": true, "dev": true, - "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -8435,7 +8471,6 @@ "version": "3.0.4", "bundled": true, "dev": true, - "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -8443,14 +8478,12 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "minipass": { "version": "2.2.4", "bundled": true, "dev": true, - "optional": true, "requires": { "safe-buffer": "^5.1.1", "yallist": "^3.0.0" @@ -8469,7 +8502,6 @@ "version": "0.5.1", "bundled": true, "dev": true, - "optional": true, "requires": { "minimist": "0.0.8" } @@ -8550,8 +8582,7 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "object-assign": { "version": "4.1.1", @@ -8563,7 +8594,6 @@ "version": "1.4.0", "bundled": true, "dev": true, - "optional": true, "requires": { "wrappy": "1" } @@ -8685,7 +8715,6 @@ "version": "1.0.2", "bundled": true, "dev": true, - "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -8797,7 +8826,6 @@ "version": "0.1.7", "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", - "dev": true, "requires": { "assert-plus": "^1.0.0" } @@ -9301,14 +9329,12 @@ "har-schema": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", - "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=", - "dev": true + "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=" }, "har-validator": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.0.3.tgz", "integrity": "sha1-ukAsJmGU8VlW7xXg/PJCmT9qff0=", - "dev": true, "requires": { "ajv": "^5.1.0", "har-schema": "^2.0.0" @@ -9318,7 +9344,6 @@ "version": "5.5.2", "resolved": "https://registry.npmjs.org/ajv/-/ajv-5.5.2.tgz", "integrity": "sha1-c7Xuyj+rZT49P5Qis0GtQiBdyWU=", - "dev": true, "requires": { "co": "^4.6.0", "fast-deep-equal": "^1.0.0", @@ -9329,14 +9354,12 @@ "fast-deep-equal": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz", - "integrity": "sha1-wFNHeBfIa1HaqFPIHgWbcz0CNhQ=", - "dev": true + "integrity": "sha1-wFNHeBfIa1HaqFPIHgWbcz0CNhQ=" }, "json-schema-traverse": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz", - "integrity": "sha1-NJptRMU6Ud6JtAgFxdXlm0F9M0A=", - "dev": true + "integrity": "sha1-NJptRMU6Ud6JtAgFxdXlm0F9M0A=" } } }, @@ -9707,7 +9730,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", - "dev": true, "requires": { "assert-plus": "^1.0.0", "jsprim": "^1.2.2", @@ -10534,8 +10556,7 @@ "is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", - "dev": true + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" }, "is-utf8": { "version": "0.2.1", @@ -10630,8 +10651,7 @@ "isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=", - "dev": true + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" }, "istanbul-api": { "version": "1.3.1", @@ -11265,7 +11285,6 @@ "version": "0.1.1", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", - "dev": true, "optional": true }, "jsdom": { @@ -11329,8 +11348,7 @@ "json-schema": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", - "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=", - "dev": true + "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=" }, "json-schema-traverse": { "version": "0.4.1", @@ -11341,8 +11359,7 @@ "json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", - "dev": true + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" }, "json3": { "version": "3.3.2", @@ -11372,11 +11389,33 @@ "integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=", "dev": true }, + "jsonwebtoken": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.3.0.tgz", + "integrity": "sha512-oge/hvlmeJCH+iIz1DwcO7vKPkNGJHhgkspk8OH3VKlw+mbi42WtD4ig1+VXRln765vxptAv+xT26Fd3cteqag==", + "requires": { + "jws": "^3.1.5", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1" + }, + "dependencies": { + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" + } + } + }, "jsprim": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", - "dev": true, "requires": { "assert-plus": "1.0.0", "extsprintf": "1.3.0", @@ -11384,6 +11423,38 @@ "verror": "1.10.0" } }, + "jwa": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.1.6.tgz", + "integrity": "sha512-tBO/cf++BUsJkYql/kBbJroKOgHWEigTKBAjjBEmrMGYd1QMBC74Hr4Wo2zCZw6ZrVhlJPvoMrkcOnlWR/DJfw==", + "requires": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.10", + "safe-buffer": "^5.0.1" + } + }, + "jwks-rsa": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-1.3.0.tgz", + "integrity": "sha512-9q+d5VffK/FvFAjuXoddrq7zQybFSINV4mcwJJExGKXGyjWWpTt3vsn/aX33aB0heY02LK0qSyicdtRK0gVTig==", + "requires": { + "@types/express-jwt": "0.0.34", + "debug": "^2.2.0", + "limiter": "^1.1.0", + "lru-memoizer": "^1.6.0", + "ms": "^2.0.0", + "request": "^2.73.0" + } + }, + "jws": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.1.5.tgz", + "integrity": "sha512-GsCSexFADNQUr8T5HPJvayTjvPIfoyJPtLQBwn5a4WZQchcrPMPMAWcC1AzJVRDKyD6ZPROPAxgv6rfHViO4uQ==", + "requires": { + "jwa": "^1.1.5", + "safe-buffer": "^5.0.1" + } + }, "keygrip": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.0.2.tgz", @@ -11643,6 +11714,11 @@ "type-check": "~0.3.2" } }, + "limiter": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.3.tgz", + "integrity": "sha512-zrycnIMsLw/3ZxTbW7HCez56rcFGecWTx5OZNplzcXUUmJLmoYArC6qdJzmAN5BWiNXGcpjhF9RQ1HSv5zebEw==" + }, "load-cfg": { "version": "0.2.8", "resolved": "https://registry.npmjs.org/load-cfg/-/load-cfg-0.2.8.tgz", @@ -11768,6 +11844,11 @@ "path-exists": "^3.0.0" } }, + "lock": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/lock/-/lock-0.1.4.tgz", + "integrity": "sha1-/sfervF+fDoKVeHaBCgD4l2RdF0=" + }, "lodash": { "version": "4.17.10", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.10.tgz", @@ -11843,16 +11924,41 @@ "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=", "dev": true }, + "lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=" + }, + "lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=" + }, "lodash.isempty": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.isempty/-/lodash.isempty-4.4.0.tgz", "integrity": "sha1-b4bL7di+TsmHvpqvM8loTbGzHn4=" }, + "lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=" + }, + "lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=" + }, "lodash.isobject": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/lodash.isobject/-/lodash.isobject-3.0.2.tgz", "integrity": "sha1-PI+41bW/S/kK4G4U8qUwpO2TXh0=" }, + "lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=" + }, "lodash.isstring": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", @@ -11880,6 +11986,11 @@ "resolved": "https://registry.npmjs.org/lodash.noop/-/lodash.noop-3.0.1.tgz", "integrity": "sha1-OBiPTWUKOkdCWEObluxFsyYXEzw=" }, + "lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" + }, "lodash.partial": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/lodash.partial/-/lodash.partial-4.2.1.tgz", @@ -12031,6 +12142,28 @@ "yallist": "^2.1.2" } }, + "lru-memoizer": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-1.12.0.tgz", + "integrity": "sha1-7+ZXBsyKnMZT+A8NWm6jitlQ41I=", + "requires": { + "lock": "~0.1.2", + "lodash": "^4.17.4", + "lru-cache": "~4.0.0", + "very-fast-args": "^1.1.0" + }, + "dependencies": { + "lru-cache": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.0.2.tgz", + "integrity": "sha1-HRdnnAac2l0ECZGgnbwsDbN35V4=", + "requires": { + "pseudomap": "^1.0.1", + "yallist": "^2.0.0" + } + } + } + }, "luxon": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/luxon/-/luxon-1.2.1.tgz", @@ -12918,11 +13051,15 @@ "integrity": "sha512-Zt6HRR6RcJkuj5/N9zeE7FN6YitRW//hK2wTOwX274IBphbY3Zf5+yn5mZ9v/SzAOTMjQNxZf9KkmPLWn0cV4g==", "dev": true }, + "oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha1-vR/vr2hslrdUda7VGWQS/2DPucE=" + }, "oauth-sign": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz", - "integrity": "sha1-Rqarfwrq2N6unsBWV4C31O/rnUM=", - "dev": true + "integrity": "sha1-Rqarfwrq2N6unsBWV4C31O/rnUM=" }, "object-assign": { "version": "4.1.1", @@ -13316,6 +13453,17 @@ "pause": "0.0.1" } }, + "passport-oauth2": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.4.0.tgz", + "integrity": "sha1-9i+BWDy+EmCb585vFguTlaJ7hq0=", + "requires": { + "oauth": "0.9.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + } + }, "passport-strategy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", @@ -15874,8 +16022,7 @@ "pseudomap": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", - "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=", - "dev": true + "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=" }, "psl": { "version": "1.1.28", @@ -17215,7 +17362,6 @@ "version": "2.87.0", "resolved": "https://registry.npmjs.org/request/-/request-2.87.0.tgz", "integrity": "sha512-fcogkm7Az5bsS6Sl0sibkbhcKsnyon/jV1kF3ajGmF0c8HrttdKTPRT9hieOaQHA5HEq6r8OyWOo/o781C1tNw==", - "dev": true, "requires": { "aws-sign2": "~0.7.0", "aws4": "^1.6.0", @@ -17242,14 +17388,12 @@ "punycode": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", - "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=", - "dev": true + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=" }, "tough-cookie": { "version": "2.3.4", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.4.tgz", "integrity": "sha512-TZ6TTfI5NtZnuyy/Kecv+CnoROnyXn2DN97LontgQpCwsX2XyLYCC0ENhYkehSOwAp8rTQKc/NUIF7BkQ5rKLA==", - "dev": true, "requires": { "punycode": "^1.4.1" } @@ -17495,8 +17639,7 @@ "safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "sane": { "version": "2.5.2", @@ -18067,7 +18210,6 @@ "version": "1.14.2", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.14.2.tgz", "integrity": "sha1-xvxhZIo9nE52T9P8306hBeSSupg=", - "dev": true, "requires": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", @@ -19325,7 +19467,6 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", - "dev": true, "requires": { "safe-buffer": "^5.0.1" } @@ -19334,7 +19475,6 @@ "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", - "dev": true, "optional": true }, "type-check": { @@ -19619,6 +19759,11 @@ } } }, + "uid2": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.3.tgz", + "integrity": "sha1-SDEm4Rd03y9xuLY53NeZw3YWK4I=" + }, "ulid": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/ulid/-/ulid-2.3.0.tgz", @@ -20152,13 +20297,17 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", - "dev": true, "requires": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, + "very-fast-args": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/very-fast-args/-/very-fast-args-1.1.0.tgz", + "integrity": "sha1-4W0dH6+KbllqJGQh/ZCneWPQs5Y=" + }, "vfile": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/vfile/-/vfile-2.3.0.tgz", @@ -20961,8 +21110,7 @@ "yallist": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", - "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=", - "dev": true + "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=" }, "yargs": { "version": "11.0.0", diff --git a/package.json b/package.json index 64e1cfa9f..b4da13f68 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,10 @@ "watch:css-types": "tcm src/core/client/ --watch", "watch:relay-stream": "nodemon --config ./config/nodemon/relay-stream.json", "watch:server": "nodemon --config ./config/nodemon/server.json", + "watch:types": "nodemon --config ./config/nodemon/types.json", "compile:css-types": "tcm src/core/client/", "compile:relay-stream": "relay-compiler --src ./src/core/client/stream --schema ./src/core/server/graph/tenant/schema/schema.graphql --language typescript --artifactDirectory ./src/core/client/stream/__generated__ --no-watchman", + "compile:server:types": "node ./scripts/types.js", "start:development": "NODE_ENV=development ts-node -r tsconfig-paths/register src/index.ts", "lint-fix": "npm run lint:server -- --fix && npm run lint:client -- --fix && npm run lint:scripts -- --fix", "lint": "npm-run-all --parallel lint:*", @@ -43,10 +45,14 @@ "graphql-tools": "^3.0.2", "ioredis": "^3.2.2", "joi": "^13.4.0", + "jsonwebtoken": "^8.3.0", + "jwks-rsa": "^1.3.0", "lodash": "^4.17.10", "luxon": "^1.2.1", "mongodb": "^3.0.10", "passport": "^0.4.0", + "passport-oauth2": "^1.4.0", + "passport-strategy": "^1.0.0", "performance-now": "^2.1.0", "subscriptions-transport-ws": "^0.9.11", "uuid": "^3.2.1" @@ -66,11 +72,14 @@ "@types/ioredis": "^3.2.8", "@types/jest": "^23.1.1", "@types/joi": "^13.0.8", + "@types/jsonwebtoken": "^7.2.7", "@types/lodash": "^4.14.109", "@types/luxon": "^0.5.3", "@types/mongodb": "^3.0.19", "@types/node": "^10.3.1", "@types/passport": "^0.4.5", + "@types/passport-oauth2": "^1.4.5", + "@types/passport-strategy": "^0.2.33", "@types/query-string": "^6.1.0", "@types/react-dom": "^16.0.6", "@types/react-relay": "^1.3.6", @@ -140,4 +149,4 @@ "webpack-hot-client": "^4.0.3", "webpack-manifest-plugin": "^2.0.3" } -} \ No newline at end of file +} diff --git a/src/core/server/app/index.ts b/src/core/server/app/index.ts index 3711234e6..ef65f8157 100644 --- a/src/core/server/app/index.ts +++ b/src/core/server/app/index.ts @@ -3,6 +3,7 @@ import http from "http"; import { Redis } from "ioredis"; import { Db } from "mongodb"; +import { createPassport } from "talk-server/app/middleware/passport"; import { Config } from "talk-server/config"; import { handleSubscriptions } from "talk-server/graph/common/subscriptions/middleware"; import { Schemas } from "talk-server/graph/schemas"; @@ -35,8 +36,11 @@ export async function createApp(options: AppOptions): Promise { // Static Files parent.use(serveStatic); + // Create some services for the router. + const passport = createPassport({ db: options.mongo }); + // Mount the router. - parent.use(await createRouter(options)); + parent.use(await createRouter(options, { passport })); // Error Handling parent.use(errorLogger); diff --git a/src/core/server/app/middleware/passport/index.ts b/src/core/server/app/middleware/passport/index.ts index a22e11144..bebc52631 100644 --- a/src/core/server/app/middleware/passport/index.ts +++ b/src/core/server/app/middleware/passport/index.ts @@ -1,13 +1,89 @@ import { Db } from "mongodb"; import passport, { Authenticator } from "passport"; +import { NextFunction, Response } from "express"; +import OIDCStrategy, { + Token, + VerifyCallback, +} from "talk-server/app/middleware/passport/oidc"; +import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; +import { Tenant } from "talk-server/models/tenant"; +import { create, retrieveWithProfile, User } from "talk-server/models/user"; +import { Request } from "talk-server/types/express"; + export interface PassportOptions { db: Db; } -export function createPassport(opts: PassportOptions): passport.Authenticator { +async function verifyOIDC( + db: Db, + tenant: Tenant, + { iss, sub, email, email_verified }: Token, + done: VerifyCallback +) { + try { + // Construct the profile that will be used to query for the user. + const profile = { + type: "oidc", + provider: iss, + id: sub, + }; + + // Try to lookup user given their id provided in the `sub` claim. + let user = await retrieveWithProfile(db, tenant.id, profile); + if (!user) { + // FIXME: implement rules. + + // Create the new user, as one didn't exist before! + user = await create(db, tenant.id, { + username: null, + role: GQLUSER_ROLE.COMMENTER, + email, + email_verified, + profiles: [profile], + }); + } + + return done(null, user); + } catch (err) { + return done(err); + } +} + +export function createPassport({ + db, +}: PassportOptions): passport.Authenticator { // Create the authenticator. const auth = new Authenticator(); + // Process the OIDC Strategy. + auth.use(new OIDCStrategy({ db }, verifyOIDC.bind(null, db))); + return auth; } + +export const authenticate = ( + authenticator: passport.Authenticator, + name: string +) => (req: Request, res: Response, next: NextFunction) => + authenticator.authenticate( + name, + { session: false }, + (err: Error | null, user: User | null) => { + if (err) { + // TODO: wrap error? + return next(err); + } + + // Set the cache control headers. + res.header( + "Cache-Control", + "private, no-cache, no-store, must-revalidate" + ); + res.header("Expires", "-1"); + res.header("Pragma", "no-cache"); + + // Send back the details! + res.json({ user }); + } + )(req, res, next); diff --git a/src/core/server/app/middleware/passport/oidc.ts b/src/core/server/app/middleware/passport/oidc.ts new file mode 100644 index 000000000..ceac0bc37 --- /dev/null +++ b/src/core/server/app/middleware/passport/oidc.ts @@ -0,0 +1,223 @@ +import jwt from "jsonwebtoken"; +import jwks, { JwksClient } from "jwks-rsa"; +import { Strategy as OAuth2Strategy } from "passport-oauth2"; +import { Strategy } from "passport-strategy"; + +import { reconstructURL } from "talk-server/app/url"; +import { OIDCAuthIntegration, Tenant } from "talk-server/models/tenant"; +import { User } from "talk-server/models/user"; +import { Request } from "talk-server/types/express"; + +export type OIDCStrategyOptions = any; + +export interface Params { + id_token?: string; +} + +export type VerifyCallback = ( + err?: Error | null, + user?: User | null, + info?: object +) => void; + +export interface Token { + iss: string; + sub: string; + email: string; + email_verified?: boolean; +} + +export type OIDCStrategyCallback = ( + tenant: Tenant, + token: Token, + done: VerifyCallback +) => void; + +export interface StrategyItem { + strategy: OAuth2Strategy; + jwksClient?: JwksClient; +} + +// FIXME: attach strategy to cache updates of the tenants + +export default class OIDCStrategy extends Strategy { + public name: string; + + private verify: OIDCStrategyCallback; + private cache: Map; + + constructor(options: OIDCStrategyOptions, verify: OIDCStrategyCallback) { + super(); + + this.name = "oidc"; + this.cache = new Map(); + this.verify = verify; + } + + private lookupJWKSClient( + req: Request, + tenantID: string, + oidc: OIDCAuthIntegration + ) { + let entry = this.cache.get(tenantID); + if (!entry) { + const strategy = this.createStrategy(req, oidc); + + // Create the entry. + entry = { + strategy, + }; + + // We don't reset the entry in the cache here because if we just created + // it, we'll be creating the jwksClient anyways, so we'll update it there. + } + + if (!entry.jwksClient) { + // Create the new JWKS client. + const jwksClient = jwks({ + jwksUri: oidc.jwksURI, + }); + + // Set the jwksClient on the entry. + entry.jwksClient = jwksClient; + + // Update the cached entry. + this.cache.set(tenantID, entry); + } + + return entry.jwksClient; + } + + private verifyCallback( + req: Request, + accessToken: string, + refreshToken: string, + params: Params, + profile: any, + done: VerifyCallback + ) { + // Try to lookup user given their id provided in the `sub` claim of the + // `id_token`. + const { id_token } = params; + if (!id_token) { + // TODO: return better error. + return done(new Error("no id_token in params")); + } + + // Grab the tenant out of the request, as we need some more details. + const { tenant } = req; + + // Grab the JWKSClient. + const client = this.lookupJWKSClient(req, tenant!.id, tenant!.auth.oidc!); + + // Verify that the id_token is valid or not. + jwt.verify( + id_token, + ({ kid }, callback) => { + if (!kid) { + // TODO: return better error. + return callback(new Error("no kid in id_token")); + } + + // Get the signing key from the jwks provider. + client.getSigningKey(kid, (err, key) => { + if (err) { + // TODO: wrap error? + return callback(err); + } + + const signingKey = key.publicKey || key.rsaPublicKey; + callback(null, signingKey); + }); + }, + { + issuer: tenant!.auth.oidc!.issuer, + }, + (err, decoded) => { + if (err) { + // TODO: wrap error? + return done(err); + } + + this.verify(tenant!, decoded as Token, done); + } + ); + } + + private createStrategy( + req: Request, + integration: OIDCAuthIntegration + ): OAuth2Strategy { + const { clientID, clientSecret, authorizationURL, tokenURL } = integration; + + // Construct the callbackURL from the request. + const callbackURL = reconstructURL(req, "/api/tenant/auth/oidc/callback"); + + // Create a new OAuth2Strategy, where we pass the verify callback bound to + // this OIDCStrategy instance. + return new OAuth2Strategy( + { + passReqToCallback: true, + clientID, + clientSecret, + authorizationURL, + tokenURL, + callbackURL, + }, + this.verifyCallback.bind(this) + ); + } + + private async lookupStrategy(req: Request) { + const { tenant } = req; + if (!tenant) { + // TODO: return a better error. + throw new Error("tenant not found"); + } + + // Get the integration from the tenant. If needed, it will be used to create + // a new strategy. + const integration = tenant.auth.oidc; + if (!integration) { + // TODO: return a better error. + throw new Error("integration not found"); + } + + // Try to get the Tenant's cached integrations. + let entry = this.cache.get(tenant.id); + if (!entry) { + // Create the strategy. + const strategy = this.createStrategy(req, integration); + + // Reset the entry. + entry = { + strategy, + }; + + // Update the cached integrations value. + this.cache.set(tenant.id, entry); + } + + return entry.strategy; + } + + public async authenticate(req: Request) { + // Lookup the strategy. + const strategy = await this.lookupStrategy(req); + if (!strategy) { + return; + } + + // Augment the strategy with the request method bindings. + strategy.error = this.error.bind(this); + strategy.fail = this.fail.bind(this); + strategy.pass = this.pass.bind(this); + strategy.redirect = this.redirect.bind(this); + strategy.success = this.success.bind(this); + + // Authenticate with the strategy, binding the current context to the method + // to provide it with the augmented passport handlers. We also request the + // 'openid' scope so we can get an id_token back. + strategy.authenticate(req, { scope: "openid email", session: false }); + } +} diff --git a/src/core/server/app/middleware/passport/sso.ts b/src/core/server/app/middleware/passport/sso.ts new file mode 100644 index 000000000..ecc883685 --- /dev/null +++ b/src/core/server/app/middleware/passport/sso.ts @@ -0,0 +1,9 @@ +import { Strategy } from "passport-strategy"; + +import { Request } from "talk-server/types/express"; + +export default class SSOStrategy extends Strategy { + public async authenticate(req: Request) { + return; + } +} diff --git a/src/core/server/app/router.ts b/src/core/server/app/router.ts index fd481ee5c..1ae9934b8 100644 --- a/src/core/server/app/router.ts +++ b/src/core/server/app/router.ts @@ -1,13 +1,15 @@ import express from "express"; +import passport from "passport"; import tenantMiddleware from "talk-server/app/middleware/tenant"; import managementGraphMiddleware from "talk-server/graph/management/middleware"; import tenantGraphMiddleware from "talk-server/graph/tenant/middleware"; +import { authenticate } from "talk-server/app/middleware/passport"; import { AppOptions } from "./index"; import playground from "./middleware/playground"; -async function createManagementRouter(opts: AppOptions) { +async function createManagementRouter(app: AppOptions, options: RouterOptions) { const router = express.Router(); // Management API @@ -15,51 +17,63 @@ async function createManagementRouter(opts: AppOptions) { "/graphql", express.json(), await managementGraphMiddleware( - opts.schemas.management, - opts.config, - opts.mongo + app.schemas.management, + app.config, + app.mongo ) ); return router; } -async function createTenantRouter(opts: AppOptions) { +async function createTenantRouter(app: AppOptions, options: RouterOptions) { const router = express.Router(); // Tenant identification middleware. - router.use(tenantMiddleware({ db: opts.mongo })); + router.use(tenantMiddleware({ db: app.mongo })); + + router.use(options.passport.initialize()); + router.use("/auth/oidc", authenticate(options.passport, "oidc")); + router.use("/auth/oidc/callback", authenticate(options.passport, "oidc")); + // router.use("/auth/google", options.passport.authenticate("google")); + // router.use("/auth/google/callback", options.passport.authenticate("google")); + // router.use("/auth/facebook", options.passport.authenticate("facebook")); + // router.use("/auth/facebook/callback", options.passport.authenticate("facebook")); // Tenant API router.use( "/graphql", express.json(), - await tenantGraphMiddleware(opts.schemas.tenant, opts.config, opts.mongo) + await tenantGraphMiddleware(app.schemas.tenant, app.config, app.mongo) ); return router; } -async function createAPIRouter(opts: AppOptions) { +async function createAPIRouter(app: AppOptions, options: RouterOptions) { // Create a router. const router = express.Router(); // Configure the tenant routes. - router.use("/tenant", await createTenantRouter(opts)); + router.use("/tenant", await createTenantRouter(app, options)); // Configure the management routes. - router.use("/management", await createManagementRouter(opts)); + router.use("/management", await createManagementRouter(app, options)); return router; } -export async function createRouter(opts: AppOptions) { +export interface RouterOptions { + passport: passport.Authenticator; +} + +export async function createRouter(app: AppOptions, options: RouterOptions) { // Create a router. const router = express.Router(); - router.use("/api", await createAPIRouter(opts)); + router.use("/api", await createAPIRouter(app, options)); - if (opts.config.get("env") === "development") { + if (app.config.get("env") === "development") { // Tenant GraphiQL router.get( "/tenant/graphiql", diff --git a/src/core/server/app/url.ts b/src/core/server/app/url.ts new file mode 100644 index 000000000..b9f533f1a --- /dev/null +++ b/src/core/server/app/url.ts @@ -0,0 +1,13 @@ +import { Request } from "talk-server/types/express"; +import { URL } from "url"; + +export function reconstructURL(req: Request, input?: string): string { + const scheme = req.secure ? "https" : "http"; + const host = req.get("host"); + const base = `${scheme}://${host}`; + const path = input || req.originalUrl; + + const url = new URL(path, base); + + return url.href; +} diff --git a/src/core/server/graph/common/directives/auth.ts b/src/core/server/graph/common/directives/auth.ts new file mode 100644 index 000000000..600f5a2c1 --- /dev/null +++ b/src/core/server/graph/common/directives/auth.ts @@ -0,0 +1,12 @@ +import { DirectiveResolverFn } from "graphql-tools"; + +const auth: DirectiveResolverFn = (next, src, args, context) => { + return next().then(str => { + if (typeof str === "string") { + return str.toUpperCase(); + } + return str; + }); +}; + +export default auth; diff --git a/src/core/server/graph/tenant/resolvers/auth_settings.ts b/src/core/server/graph/tenant/resolvers/auth_settings.ts new file mode 100644 index 000000000..1519d24c0 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/auth_settings.ts @@ -0,0 +1,14 @@ +import { GQLAuthSettingsTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; +import { Auth, AuthIntegration } from "talk-server/models/tenant"; + +const disabled: AuthIntegration = { enabled: false }; + +const AuthSettings: GQLAuthSettingsTypeResolver = { + local: auth => auth.local || disabled, + sso: auth => auth.sso || disabled, + oidc: auth => auth.oidc || disabled, + google: auth => auth.google || disabled, + facebook: auth => auth.facebook || disabled, +}; + +export default AuthSettings; diff --git a/src/core/server/graph/tenant/resolvers/facebook_auth_integration.ts b/src/core/server/graph/tenant/resolvers/facebook_auth_integration.ts new file mode 100644 index 000000000..c0a127d82 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/facebook_auth_integration.ts @@ -0,0 +1,10 @@ +import { GQLFacebookAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; +import { FacebookAuthIntegration } from "talk-server/models/tenant"; + +const FacebookAuthIntegration: GQLFacebookAuthIntegrationTypeResolver< + FacebookAuthIntegration +> = { + config: auth => auth, +}; + +export default FacebookAuthIntegration; diff --git a/src/core/server/graph/tenant/resolvers/google_auth_integration.ts b/src/core/server/graph/tenant/resolvers/google_auth_integration.ts new file mode 100644 index 000000000..8edbabcf6 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/google_auth_integration.ts @@ -0,0 +1,10 @@ +import { GQLGoogleAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; +import { GoogleAuthIntegration } from "talk-server/models/tenant"; + +const GoogleAuthIntegration: GQLGoogleAuthIntegrationTypeResolver< + GoogleAuthIntegration +> = { + config: auth => auth, +}; + +export default GoogleAuthIntegration; diff --git a/src/core/server/graph/tenant/resolvers/local_auth_integration.ts b/src/core/server/graph/tenant/resolvers/local_auth_integration.ts new file mode 100644 index 000000000..fe14c7b92 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/local_auth_integration.ts @@ -0,0 +1,8 @@ +import { GQLLocalAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; +import { LocalAuthIntegration } from "talk-server/models/tenant"; + +const LocalAuthIntegration: GQLLocalAuthIntegrationTypeResolver< + LocalAuthIntegration +> = {}; + +export default LocalAuthIntegration; diff --git a/src/core/server/graph/tenant/resolvers/oidc_auth_integration.ts b/src/core/server/graph/tenant/resolvers/oidc_auth_integration.ts new file mode 100644 index 000000000..966001ab9 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/oidc_auth_integration.ts @@ -0,0 +1,10 @@ +import { GQLOIDCAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; +import { OIDCAuthIntegration } from "talk-server/models/tenant"; + +const OIDCAuthIntegration: GQLOIDCAuthIntegrationTypeResolver< + OIDCAuthIntegration +> = { + config: auth => auth, +}; + +export default OIDCAuthIntegration; diff --git a/src/core/server/graph/tenant/resolvers/sso_auth_integration.ts b/src/core/server/graph/tenant/resolvers/sso_auth_integration.ts new file mode 100644 index 000000000..562cad894 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/sso_auth_integration.ts @@ -0,0 +1,10 @@ +import { GQLSSOAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; +import { SSOAuthIntegration } from "talk-server/models/tenant"; + +const SSOAuthIntegration: GQLSSOAuthIntegrationTypeResolver< + SSOAuthIntegration +> = { + config: auth => auth, +}; + +export default SSOAuthIntegration; diff --git a/src/core/server/graph/tenant/schema/index.ts b/src/core/server/graph/tenant/schema/index.ts index 1105c358e..1a27fdb3c 100644 --- a/src/core/server/graph/tenant/schema/index.ts +++ b/src/core/server/graph/tenant/schema/index.ts @@ -1,8 +1,14 @@ -import { IResolvers } from "graphql-tools"; +import { attachDirectiveResolvers, IResolvers } from "graphql-tools"; +import auth from "talk-server/graph/common/directives/auth"; import loadSchema from "talk-server/graph/common/schema"; import resolvers from "talk-server/graph/tenant/resolvers"; export default function getTenantSchema() { - return loadSchema("tenant", resolvers as IResolvers); + const schema = loadSchema("tenant", resolvers as IResolvers); + + // Attach the directive resolvers. + attachDirectiveResolvers(schema, { auth }); + + return schema; } diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 96657351d..9f5c0fdcd 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -1,3 +1,9 @@ +################################################################################ +## Custom Directives +################################################################################ + +directive @auth(roles: [USER_ROLE!]!) on FIELD_DEFINITION + ################################################################################ ## Custom Scalar Types ################################################################################ @@ -45,7 +51,90 @@ type WordlistSettings { suspect: [String!]! } -# Settings stores the global settings for a given installation. +################################################################################ +## AuthSettings +################################################################################ + +########################## +## LocalAuthIntegration +########################## + +type LocalAuthIntegration { + enabled: Boolean! +} + +########################## +## SSOAuthIntegration +########################## + +type SSOAuthIntegrationConfig { + key: String! +} + +type SSOAuthIntegration { + enabled: Boolean! + config: SSOAuthIntegrationConfig @auth(roles: [ADMIN]) +} + +########################## +## OIDCAuthIntegration +########################## + +type OIDCAuthIntegrationConfig { + clientID: String! + clientSecret: String! + authorizationURL: String! + tokenURL: String! +} + +type OIDCAuthIntegrationOptions { + name: String! +} + +type OIDCAuthIntegration { + enabled: Boolean! + options: OIDCAuthIntegrationOptions + config: SSOAuthIntegrationConfig @auth(roles: [ADMIN]) +} + +########################## +## GoogleAuthIntegration +########################## + +type GoogleAuthIntegrationConfig { + clientID: String! + clientSecret: String! +} + +type GoogleAuthIntegration { + enabled: Boolean! + config: GoogleAuthIntegrationConfig @auth(roles: [ADMIN]) +} + +########################## +## FacebookAuthIntegration +########################## + +type FacebookAuthIntegrationConfig { + clientID: String! + clientSecret: String! +} + +type FacebookAuthIntegration { + enabled: Boolean! + config: FacebookAuthIntegrationConfig @auth(roles: [ADMIN]) +} + +""" +AuthSettings contains all the settings related to authentication and authorization. +""" +type AuthSettings { + local: LocalAuthIntegration! + sso: SSOAuthIntegration! + oidc: OIDCAuthIntegration! + google: GoogleAuthIntegration! + facebook: FacebookAuthIntegration! +} ################################################################################ ## Settings @@ -58,12 +147,12 @@ type Settings { """ domain is the domain that is associated with this Tenant. """ - domain: String! + domain: String @auth(roles: [ADMIN]) """ moderation is the moderation mode for all Asset's on the site. """ - moderation: MODERATION_MODE! + moderation: MODERATION_MODE @auth(roles: [ADMIN]) """ Enables a requirement for email confirmation before a user can login. @@ -100,7 +189,7 @@ type Settings { """ premodLinksEnable will put all comments that contain links into premod. """ - premodLinksEnable: Boolean! + premodLinksEnable: Boolean @auth(roles: [ADMIN]) """ autoCloseStream when true will auto close the stream when the `closeTimeout` @@ -165,18 +254,29 @@ type Settings { """ wordlist will return a given list of words. """ - wordlist: WordlistSettings! + wordlist: WordlistSettings @auth(roles: [ADMIN]) """ domains will return a given list of whitelisted domains. """ - domains: [String!]! + domains: [String!] @auth(roles: [ADMIN]) + + """ + auth contains all the settings related to authentication and authorization. + """ + auth: AuthSettings! } ################################################################################ ## User ################################################################################ +enum USER_ROLE { + COMMENTER + MODERATOR + ADMIN +} + """ User is someone that leaves Comments, and logs in. """ @@ -190,6 +290,11 @@ type User { username is the name of the User visible to other Users. """ username: String! + + """ + role is the current role of the User. + """ + role: USER_ROLE! } ################################################################################ diff --git a/src/core/server/models/tenant.ts b/src/core/server/models/tenant.ts index 8e41a1bf2..123c6b261 100644 --- a/src/core/server/models/tenant.ts +++ b/src/core/server/models/tenant.ts @@ -1,9 +1,11 @@ import dotize from "dotize"; import { merge } from "lodash"; import { Db } from "mongodb"; -import { Sub } from "talk-common/types"; import uuid from "uuid"; +import { Sub } from "talk-common/types"; +import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; + function collection(db: Db) { return db.collection>("tenants"); } @@ -22,6 +24,92 @@ export enum Moderation { POST = "POST", } +// AuthIntegrations. + +export interface EmailDomainRuleCondition { + // emailDomain is the domain name component of the email addresses that should + // match for this condition. + emailDomain: string; + + // emailVerifiedRequired stipulates that this rule only applies when the user + // account has been marked as having their email address already verified. + emailVerifiedRequired: boolean; +} + +// RoleRule describes the role assignment for when a user logs into Talk, how +// they can have their account automatically upgraded to a specific role when +// the domain for their email matches the one provided. +export interface RoleRule extends Partial { + // role is the specific GQLUSER_ROLE that should be assigned to the newly created + // user depending on their email address. + role: GQLUSER_ROLE; +} + +export interface AuthRules { + // roles allow the configuration of automatic role assignment based on the + // user's email address. + roles?: RoleRule[]; + + // restrictTo when populated, will restrict which users can login using this + // integration. If a user successfully logs in using the OIDCStrategy, but + // does not match the following rules, the user will not be created. + restrictTo?: EmailDomainRuleCondition[]; +} + +export interface AuthIntegration { + enabled: boolean; +} + +// SSOAuthIntegration is an AuthIntegration that provides a secret to the admins +// of a tenant, where they can sign a SSO payload with it to provide to the +// embed to allow single sign on. +export interface SSOAuthIntegration extends AuthIntegration { + key: string; +} + +// OIDCAuthIntegration provides a way to store Open ID Connect credentials. This +// will be used in the admin to provide staff logins for users. +export interface OIDCAuthIntegration extends AuthIntegration { + clientID: string; + clientSecret: string; + issuer: string; + authorizationURL: string; + jwksURI: string; + tokenURL: string; +} + +export interface FacebookAuthIntegration extends AuthIntegration { + clientID: string; + clientSecret: string; +} + +export interface GoogleAuthIntegration extends AuthIntegration { + clientID: string; + clientSecret: string; +} + +export type LocalAuthIntegration = AuthIntegration; + +// Auth describes all of the possible auth integration configurations. +export interface Auth { + // local is the auth integration for the local auth. + local: LocalAuthIntegration; + + // sso is the external auth integration for the single sign on auth. + sso?: SSOAuthIntegration; + + // sso is the external auth integration for the OpenID Connect auth. + oidc?: OIDCAuthIntegration; + + // sso is the external auth integration for the Google auth. + google?: GoogleAuthIntegration; + + // sso is the external auth integration for the Facebook auth. + facebook?: FacebookAuthIntegration; +} + +// Tenant definition. + export interface Tenant { readonly id: string; @@ -57,6 +145,9 @@ export interface Tenant { // domains is the set of whitelisted domains. domains: string[]; + + // Set of configured authentication integrations. + auth: Auth; } /** @@ -99,6 +190,11 @@ export async function createTenant(db: Db, input: CreateTenantInput) { suspect: [], banned: [], }, + auth: { + local: { + enabled: true, + }, + }, }; // Create the new Tenant by merging it together with the defaults. diff --git a/src/core/server/models/user.ts b/src/core/server/models/user.ts index 6812fefce..487f7174f 100644 --- a/src/core/server/models/user.ts +++ b/src/core/server/models/user.ts @@ -1,9 +1,11 @@ import { merge } from "lodash"; import { Db } from "mongodb"; +import uuid from "uuid"; + import { Omit, Sub } from "talk-common/types"; +import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; import { ActionCounts } from "talk-server/models/actions"; import { TenantResource } from "talk-server/models/tenant"; -import uuid from "uuid"; function collection(db: Db) { return db.collection>("users"); @@ -11,6 +13,7 @@ function collection(db: Db) { export interface Profile { readonly id: string; + readonly type: string; provider: string; } @@ -45,13 +48,6 @@ export enum UserUsernameStatus { CHANGED = "CHANGED", } -export enum UserRole { - ADMIN = "ADMIN", - MODERATOR = "MODERATOR", - STAFF = "STAFF", - COMMENTER = "COMMENTER", -} - export interface UserStatusHistory { status: T; // TODO: migrate field assigned_by?: string; @@ -72,11 +68,13 @@ export interface UserStatus { export interface User extends TenantResource { readonly id: string; - username: string; + username: string | null; password?: string; + email?: string; + email_verified?: boolean; profiles: Profile[]; tokens: Token[]; - role: UserRole; + role: GQLUSER_ROLE; status: UserStatus; action_counts: ActionCounts; ignored_users: string[]; // TODO: migrate field @@ -89,7 +87,6 @@ export type CreateUserInput = Omit< | "tenant_id" | "tokens" | "status" - | "role" | "action_counts" | "ignored_users" | "created_at" @@ -98,15 +95,11 @@ export type CreateUserInput = Omit< export async function create(db: Db, tenantID: string, input: CreateUserInput) { const now = new Date(); - // // Pull out some useful properties from the input. - // const { body, status } = input; - // default are the properties set by the application when a new user is // created. const defaults: Sub = { id: uuid.v4(), tenant_id: tenantID, - role: UserRole.COMMENTER, tokens: [], action_counts: {}, ignored_users: [], @@ -120,7 +113,9 @@ export async function create(db: Db, tenantID: string, input: CreateUserInput) { history: [], }, username: { - status: UserUsernameStatus.SET, + status: input.username + ? UserUsernameStatus.SET + : UserUsernameStatus.UNSET, history: [], }, }, @@ -153,11 +148,24 @@ export async function retrieveMany(db: Db, tenantID: string, ids: string[]) { return ids.map(id => users.find(comment => comment.id === id) || null); } +export async function retrieveWithProfile( + db: Db, + tenantID: string, + profile: Profile +) { + return collection(db).findOne({ + tenant_id: tenantID, + profiles: { + $elemMatch: profile, + }, + }); +} + export async function updateRole( db: Db, tenantID: string, id: string, - role: UserRole + role: GQLUSER_ROLE ) { const result = await collection(db).findOneAndUpdate( { id, tenant_id: tenantID }, diff --git a/src/types/jsonwebtoken.d.ts b/src/types/jsonwebtoken.d.ts new file mode 100644 index 000000000..de8c9f82f --- /dev/null +++ b/src/types/jsonwebtoken.d.ts @@ -0,0 +1,19 @@ +import { VerifyOptions, VerifyCallback } from "jsonwebtoken"; + +declare module "jsonwebtoken" { + export type KeyFunctionCallback = ( + err: Error | null, + secretOrPublicKey?: string | Buffer + ) => void; + export type KeyFunction = ( + headers: { kid?: string }, + callback: KeyFunctionCallback + ) => void; + + export function verify( + token: string, + secretOrPublicKey: string | Buffer | KeyFunction, + options?: VerifyOptions, + callback?: VerifyCallback + ): void; +} diff --git a/src/types/webfinger.d.ts b/src/types/webfinger.d.ts new file mode 100644 index 000000000..a13bfb998 --- /dev/null +++ b/src/types/webfinger.d.ts @@ -0,0 +1,16 @@ +declare module "webfinger" { + export interface WebfingerOptions { + webfingerOnly?: boolean; + } + + export interface WebfingerCallback { + (err: Error, jrd: { [key: string]: any }): void; + } + + export function webfinger( + resource: string, + res: string, + options: WebfingerOptions, + callback: WebfingerCallback + ): void; +} From 679ad01a7dbb1fa0e3cbab2e5d463e0d5d17cb5b Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 4 Jul 2018 10:17:48 -0600 Subject: [PATCH 03/45] feat: supported watcher --- config/nodemon/relay-stream.json | 8 ------ config/nodemon/server.json | 10 -------- config/nodemon/types.json | 7 ------ config/watcher.ts | 6 +++++ package.json | 21 ++++++++-------- scripts/types.js | 6 +++-- src/core/client/tsconfig.json | 43 ++++++++------------------------ src/tsconfig.json | 25 +++++-------------- tsconfig.json | 18 +++---------- 9 files changed, 41 insertions(+), 103 deletions(-) delete mode 100644 config/nodemon/relay-stream.json delete mode 100644 config/nodemon/server.json delete mode 100644 config/nodemon/types.json diff --git a/config/nodemon/relay-stream.json b/config/nodemon/relay-stream.json deleted file mode 100644 index dc9556823..000000000 --- a/config/nodemon/relay-stream.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "exec": "npm-run-all compile:relay-stream", - "ext": "ts,tsx,graphql", - "watch": [ - "./src/core/client/stream", - "./src/core/**/*.graphql" - ] -} diff --git a/config/nodemon/server.json b/config/nodemon/server.json deleted file mode 100644 index 08472086e..000000000 --- a/config/nodemon/server.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "exec": "npm run start:development", - "ext": "ts,graphql", - "watch": [ - "./src" - ], - "ignore": [ - "./src/client" - ] -} diff --git a/config/nodemon/types.json b/config/nodemon/types.json deleted file mode 100644 index 73203fd0a..000000000 --- a/config/nodemon/types.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "exec": "npm run compile:server:types", - "ext": "graphql", - "watch": [ - "./src/core/server/graph" - ] -} \ No newline at end of file diff --git a/config/watcher.ts b/config/watcher.ts index 305193045..0831d698f 100644 --- a/config/watcher.ts +++ b/config/watcher.ts @@ -8,6 +8,12 @@ import { const config: Config = { rootDir: path.resolve(__dirname, "../src"), watchers: { + compileGraphQLTypes: { + paths: ["core/server/graph/**/*.graphql"], + executor: new CommandExecutor("npm run compile:server:types", { + runOnInit: true, + }), + }, compileRelayStream: { paths: [ "core/client/stream/**/*.ts", diff --git a/package.json b/package.json index 576662b45..97f04b404 100644 --- a/package.json +++ b/package.json @@ -3,23 +3,24 @@ "version": "5.0.0", "description": "A better commenting experience from Mozilla, The Washington Post, and The New York Times.", "scripts": { - "start": "node dist/index.js", - "test": "node scripts/test.js --env=jsdom", - "build": "npm-run-all --parallel compile:* --parallel build:*", "build:client": "node ./scripts/build.js", "build:server": "tsc -p ./src/tsconfig.json", - "watch": "NODE_ENV=development ts-node ./scripts/watcher/bin/watcher.ts ./config/watcher.ts", + "build": "npm-run-all --parallel compile:* --parallel build:*", "compile:css-types": "tcm src/core/client/", - "watch:types": "nodemon --config ./config/nodemon/types.json", + "compile:graphql": "node ./scripts/types.js", "compile:relay-stream": "relay-compiler --src ./src/core/client/stream --schema $(ts-node ./scripts/schemaPath.ts tenant) --language typescript --artifactDirectory ./src/core/client/stream/__generated__ --no-watchman", - "start:development": "NODE_ENV=development ts-node --project ./src/tsconfig.json -r tsconfig-paths/register ./src/index.ts", - "start:webpackDevServer": "node ./scripts/start.js", + "docz:watch": "docz dev", "lint-fix": "npm run lint:server -- --fix && npm run lint:client -- --fix && npm run lint:scripts -- --fix", - "lint": "npm-run-all --parallel lint:*", - "lint:server": "tslint --project ./src/tsconfig.json", "lint:client": "tslint --project ./src/core/client/tsconfig.json", "lint:scripts": "tslint --project ./tsconfig.json", - "docz:watch": "docz dev" + "lint:server": "tslint --project ./src/tsconfig.json", + "lint": "npm-run-all --parallel lint:*", + "start:development": "NODE_ENV=development ts-node --project ./src/tsconfig.json -r tsconfig-paths/register ./src/index.ts", + "start:webpackDevServer": "node ./scripts/start.js", + "start": "node dist/index.js", + "test": "node scripts/test.js --env=jsdom", + "watch:types": "nodemon --config ./config/nodemon/types.json", + "watch": "NODE_ENV=development ts-node ./scripts/watcher/bin/watcher.ts ./config/watcher.ts" }, "author": "", "license": "Apache-2.0", diff --git a/scripts/types.js b/scripts/types.js index 3825548e7..0bf026bd8 100644 --- a/scripts/types.js +++ b/scripts/types.js @@ -4,7 +4,7 @@ const { getGraphQLConfig } = require("graphql-config"); const path = require("path"); const fs = require("fs"); -function lint(files) { +function lintAndWrite(files) { const linter = new Linter({ fix: true }); for (const { fileName, types } of files) { @@ -80,7 +80,7 @@ async function main() { } // Send the files off to the linter to be linted and written. - lint(files); + lintAndWrite(files); return files; } @@ -88,9 +88,11 @@ async function main() { main() .then(files => { for (const { fileName } of files) { + // tslint:disable-next-line:no-console console.log(`Generated ${fileName}`); } }) .catch(err => { + // tslint:disable-next-line:no-console console.error(err); }); diff --git a/src/core/client/tsconfig.json b/src/core/client/tsconfig.json index ffb2142b6..e8fb8eeab 100644 --- a/src/core/client/tsconfig.json +++ b/src/core/client/tsconfig.json @@ -5,40 +5,17 @@ "module": "esnext", "jsx": "preserve", "allowJs": false, - "lib": [ - "dom", - "es7", - "scripthost", - "es2015", - "esnext.asynciterable" - ], + "lib": ["dom", "es7", "scripthost", "es2015", "esnext.asynciterable"], "baseUrl": "./", "paths": { - "talk-admin/*": [ - "./admin/*" - ], - "talk-stream/*": [ - "./stream/*" - ], - "talk-framework/*": [ - "./framework/*" - ], - "talk-ui/*": [ - "./ui/*" - ], - "talk-common/*": [ - "../common/*" - ], - "talk-locales/*": [ - "../../locales/*" - ] + "talk-admin/*": ["./admin/*"], + "talk-stream/*": ["./stream/*"], + "talk-framework/*": ["./framework/*"], + "talk-ui/*": ["./ui/*"], + "talk-common/*": ["../common/*"], + "talk-locales/*": ["../../locales/*"] } }, - "include": [ - "./**/*", - "../../types/**/*.d.ts" - ], - "exclude": [ - "node_modules" - ] -} \ No newline at end of file + "include": ["./**/*", "../../types/**/*.d.ts"], + "exclude": ["node_modules"] +} diff --git a/src/tsconfig.json b/src/tsconfig.json index eddf98d29..ac216dbaa 100644 --- a/src/tsconfig.json +++ b/src/tsconfig.json @@ -9,26 +9,13 @@ "outDir": "../dist", // See https://github.com/prismagraphql/graphql-request/issues/26 for why we // have to include "dom" here. - "lib": [ - "es6", - "esnext.asynciterable", - "dom" - ], + "lib": ["es2017", "es6", "esnext.asynciterable", "dom"], "baseUrl": "./", "paths": { - "talk-server/*": [ - "./core/server/*" - ], - "talk-common/*": [ - "./core/common/*" - ] + "talk-server/*": ["./core/server/*"], + "talk-common/*": ["./core/common/*"] } }, - "include": [ - "./**/*" - ], - "exclude": [ - "node_modules", - "./core/client" - ] -} \ No newline at end of file + "include": ["./**/*"], + "exclude": ["node_modules", "./core/client"] +} diff --git a/tsconfig.json b/tsconfig.json index 8e15c0223..bee79d2d0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,18 +13,8 @@ "noImplicitAny": true, "strictNullChecks": true, "noErrorTruncation": true, - "lib": [ - "es6", - "esnext.asynciterable" - ] + "lib": ["es6", "esnext.asynciterable"] }, - "include": [ - "./src/**/.*.js", - "./scripts/**/*", - "./config/**/*", - "*.js" - ], - "exclude": [ - "node_modules" - ] -} \ No newline at end of file + "include": ["./src/**/.*.js", "./scripts/**/*", "./config/**/*", "*.js"], + "exclude": ["node_modules"] +} From ec6955715f2ccf103c24dad201c2470b298351fa Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 4 Jul 2018 10:21:29 -0600 Subject: [PATCH 04/45] fixed config --- config/watcher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/watcher.ts b/config/watcher.ts index 0831d698f..a6b643e2c 100644 --- a/config/watcher.ts +++ b/config/watcher.ts @@ -10,7 +10,7 @@ const config: Config = { watchers: { compileGraphQLTypes: { paths: ["core/server/graph/**/*.graphql"], - executor: new CommandExecutor("npm run compile:server:types", { + executor: new CommandExecutor("npm run compile:graphql", { runOnInit: true, }), }, From eb7ddd7310b5f44ce2018d53495ee1c79101d96f Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Tue, 3 Jul 2018 15:49:35 -0300 Subject: [PATCH 05/45] Do not rerun relay compiler on its own changes --- config/watcher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/watcher.ts b/config/watcher.ts index a6b643e2c..d407e7272 100644 --- a/config/watcher.ts +++ b/config/watcher.ts @@ -21,7 +21,7 @@ const config: Config = { "core/client/stream/**/*.graphql", "core/client/server/**/*.graphql", ], - ignore: ["core/**/*.d.ts"], + ignore: ["core/**/*.d.ts", "core/**/*.graphql.ts"], executor: new CommandExecutor("npm run compile:relay-stream", { runOnInit: true, }), From 6a185e587e69d99b8a785d90395adbacf9e51720 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 4 Jul 2018 10:50:10 -0600 Subject: [PATCH 06/45] feat: added support for --only flag --- scripts/watcher/bin/watcher.ts | 12 ++++++++++-- scripts/watcher/types.ts | 4 ++++ scripts/watcher/watch.ts | 17 +++++++++++++++-- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/scripts/watcher/bin/watcher.ts b/scripts/watcher/bin/watcher.ts index 32209c064..a3a50e6c3 100644 --- a/scripts/watcher/bin/watcher.ts +++ b/scripts/watcher/bin/watcher.ts @@ -4,16 +4,24 @@ import program from "commander"; import path from "path"; import watch from "../"; +function list(val: string) { + return val.split(","); +} + program .version("0.1.0") .usage("") + .option("-o, --only ", "only run the specified watcher", list) .arguments("") .description("Run watchers defined in ") - .action(configFile => { + .action((configFile, cmd) => { + const { only = [] } = cmd; + let config: any = require(path.resolve(configFile)); if (config.__esModule) { config = config.default; } - watch(config); + + watch(config, { only }); }) .parse(process.argv); diff --git a/scripts/watcher/types.ts b/scripts/watcher/types.ts index f78daee24..15bd6090b 100644 --- a/scripts/watcher/types.ts +++ b/scripts/watcher/types.ts @@ -17,6 +17,10 @@ export interface Executor { execute(filePath: string): void; } +export interface Options { + only?: string[]; +} + export interface Config { rootDir?: string; backend?: Watcher; diff --git a/scripts/watcher/watch.ts b/scripts/watcher/watch.ts index 572f7107b..8a3feac75 100644 --- a/scripts/watcher/watch.ts +++ b/scripts/watcher/watch.ts @@ -2,7 +2,7 @@ import Joi from "joi"; import path from "path"; import ChokidarWatcher from "./ChokidarWatcher"; -import { Config, configSchema, WatchConfig, Watcher } from "./types"; +import { Config, configSchema, Options, WatchConfig, Watcher } from "./types"; // Polyfill the asyncIterator symbol. if (Symbol.asyncIterator === undefined) { @@ -43,9 +43,22 @@ function setupCleanup(config: Config) { ); } -export default async function watch(config: Config) { +function filterOnly(config: Config, only: string[]) { + for (const key of Object.keys(config.watchers)) { + if (only.indexOf(key) === -1) { + // tslint:disable-next-line:no-console + console.log(`Disabled watcher "${key}"`); + delete config.watchers[key]; + } + } +} + +export default async function watch(config: Config, options?: Options) { Joi.assert(config, configSchema); const watcher = config.backend || new ChokidarWatcher(); + if (options && options.only && options.only.length > 0) { + filterOnly(config, options.only); + } setupCleanup(config); for (const key of Object.keys(config.watchers)) { // tslint:disable-next-line:no-console From e450fa05e0835afffe44accfe51ec85440bfaeac Mon Sep 17 00:00:00 2001 From: Kiwi Date: Wed, 4 Jul 2018 14:01:08 -0300 Subject: [PATCH 07/45] Use JSDocs comments (#1727) --- scripts/watcher/CommandExecutor.ts | 6 +++--- scripts/watcher/LongRunningExecutor.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/watcher/CommandExecutor.ts b/scripts/watcher/CommandExecutor.ts index 60cf15e6a..fddba0b8b 100644 --- a/scripts/watcher/CommandExecutor.ts +++ b/scripts/watcher/CommandExecutor.ts @@ -5,13 +5,13 @@ import { Executor } from "./types"; interface CommandExecutorOptions { args?: ReadonlyArray; - // If true, allow spawning multiple processes. + /** If true, allow spawning multiple processes. */ spawnMutiple?: boolean; - // Specify the period in which the process is started at max once. + /** Specify the period in which the process is started at max once. */ debounce?: number | false; - // If true, will run command upon initialization. + /** If true, will run command upon initialization. */ runOnInit?: boolean; } diff --git a/scripts/watcher/LongRunningExecutor.ts b/scripts/watcher/LongRunningExecutor.ts index cffe6f7db..7019c1fb3 100644 --- a/scripts/watcher/LongRunningExecutor.ts +++ b/scripts/watcher/LongRunningExecutor.ts @@ -6,7 +6,7 @@ import { Executor } from "./types"; interface LongRunningExecutorOptions { args?: ReadonlyArray; - // Specify the period in which the process is restarted at max once. + /** Specify the period in which the process is restarted at max once. */ debounce?: number; } From 3e90877012406acb9c08e28e89b2aa342edc9b4b Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 4 Jul 2018 14:44:26 -0600 Subject: [PATCH 08/45] feat: added @auth directive --- package.json | 2 - src/core/server/graph/common/context.ts | 13 +++++++ .../server/graph/common/directives/auth.ts | 39 ++++++++++++++++--- src/core/server/graph/management/context.ts | 5 ++- src/core/server/graph/tenant/context.ts | 7 ++-- .../server/graph/tenant/schema/schema.graphql | 19 ++++++--- 6 files changed, 67 insertions(+), 18 deletions(-) create mode 100644 src/core/server/graph/common/context.ts diff --git a/package.json b/package.json index 97f04b404..cbe872d79 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ "start:webpackDevServer": "node ./scripts/start.js", "start": "node dist/index.js", "test": "node scripts/test.js --env=jsdom", - "watch:types": "nodemon --config ./config/nodemon/types.json", "watch": "NODE_ENV=development ts-node ./scripts/watcher/bin/watcher.ts ./config/watcher.ts" }, "author": "", @@ -112,7 +111,6 @@ "html-webpack-plugin": "^3.2.0", "jest": "^23.2.0", "loader-utils": "^1.1.0", - "nodemon": "^1.17.5", "npm-run-all": "^4.1.3", "postcss-flexbugs-fixes": "^3.3.1", "postcss-font-magician": "^2.2.1", diff --git a/src/core/server/graph/common/context.ts b/src/core/server/graph/common/context.ts new file mode 100644 index 000000000..d939d7b64 --- /dev/null +++ b/src/core/server/graph/common/context.ts @@ -0,0 +1,13 @@ +import { User } from "talk-server/models/user"; + +export interface CommonContextOptions { + user?: User; +} + +export default class CommonContext { + public user?: User; + + constructor({ user }: CommonContextOptions) { + this.user = user; + } +} diff --git a/src/core/server/graph/common/directives/auth.ts b/src/core/server/graph/common/directives/auth.ts index 600f5a2c1..2b21a6366 100644 --- a/src/core/server/graph/common/directives/auth.ts +++ b/src/core/server/graph/common/directives/auth.ts @@ -1,12 +1,39 @@ import { DirectiveResolverFn } from "graphql-tools"; -const auth: DirectiveResolverFn = (next, src, args, context) => { - return next().then(str => { - if (typeof str === "string") { - return str.toUpperCase(); +import CommonContext from "talk-server/graph/common/context"; +import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; + +export interface AuthDirectiveArgs { + roles?: GQLUSER_ROLE[]; + userIDField?: string; +} + +const auth: DirectiveResolverFn< + Record, + CommonContext +> = (next, src, { roles, userIDField }: AuthDirectiveArgs, { user }) => { + // If there is a user on the request. + if (user) { + // If the role and user owner checks are disabled, then allow them based on + // their authenticated status. + if (!roles && !userIDField) { + return next(); } - return str; - }); + + // And the user has the expected role. + if (roles && roles.includes(user.role)) { + // Let the request continue. + return next(); + } + + // Or the item is owned by the specific user. + if (userIDField && src[userIDField] && src[userIDField] === user.id) { + return next(); + } + } + + // TODO: return better error. + throw new Error("not authorized"); }; export default auth; diff --git a/src/core/server/graph/management/context.ts b/src/core/server/graph/management/context.ts index dda52cb5b..c77f1a207 100644 --- a/src/core/server/graph/management/context.ts +++ b/src/core/server/graph/management/context.ts @@ -1,13 +1,16 @@ import { Db } from "mongodb"; +import CommonContext from "talk-server/graph/common/context"; export interface ManagementContextOptions { db: Db; } -export default class ManagementContext { +export default class ManagementContext extends CommonContext { public db: Db; constructor({ db }: ManagementContextOptions) { + super({}); + this.db = db; } } diff --git a/src/core/server/graph/tenant/context.ts b/src/core/server/graph/tenant/context.ts index c49b3d49a..21e2dc9b9 100644 --- a/src/core/server/graph/tenant/context.ts +++ b/src/core/server/graph/tenant/context.ts @@ -1,4 +1,5 @@ import { Db } from "mongodb"; +import CommonContext from "talk-server/graph/common/context"; import { Tenant } from "talk-server/models/tenant"; import { User } from "talk-server/models/user"; import loaders from "./loaders"; @@ -10,16 +11,16 @@ export interface TenantContextOptions { user?: User; } -export default class TenantContext { +export default class TenantContext extends CommonContext { public loaders: ReturnType; public mutators: ReturnType; public db: Db; public tenant: Tenant; - public user?: User; constructor({ user, tenant, db }: TenantContextOptions) { + super({ user }); + this.tenant = tenant; - this.user = user; this.loaders = loaders(this); this.mutators = mutators(this); this.db = db; diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 9f5c0fdcd..bf6e5d6f8 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -2,7 +2,14 @@ ## Custom Directives ################################################################################ -directive @auth(roles: [USER_ROLE!]!) on FIELD_DEFINITION +""" +auth is a directive that will enforce authorization rules on the schema +definition. It will restrict the viewer of the field based on roles or if the +`userIDField` is specified, it will see if the current users ID equals the field +specified. This allows users that own a specific resource (like a comment, or a +flag) see their own content, but restrict it to everyone else. +""" +directive @auth(roles: [USER_ROLE!], userIDField: String) on FIELD_DEFINITION ################################################################################ ## Custom Scalar Types @@ -195,7 +202,7 @@ type Settings { autoCloseStream when true will auto close the stream when the `closeTimeout` amount of seconds have been reached. """ - autoCloseStream: Boolean! + autoCloseStream: Boolean! @auth(roles: [ADMIN]) """ customCssUrl is the URL of the custom CSS used to display on the frontend. @@ -254,12 +261,12 @@ type Settings { """ wordlist will return a given list of words. """ - wordlist: WordlistSettings @auth(roles: [ADMIN]) + wordlist: WordlistSettings @auth(roles: [ADMIN, MODERATOR]) """ domains will return a given list of whitelisted domains. """ - domains: [String!] @auth(roles: [ADMIN]) + domains: [String!] @auth(roles: [ADMIN]) @auth(roles: [ADMIN]) """ auth contains all the settings related to authentication and authorization. @@ -294,7 +301,7 @@ type User { """ role is the current role of the User. """ - role: USER_ROLE! + role: USER_ROLE! @auth(roles: [ADMIN, MODERATOR], userIDField: "id") } ################################################################################ @@ -561,7 +568,7 @@ type Mutation { """ createComment will create a Comment as the current logged in User. """ - createComment(input: CreateCommentInput!): CreateCommentPayload + createComment(input: CreateCommentInput!): CreateCommentPayload @auth } ################################################################################ From 87ce65755c2840f6bb89b55a0dad31e141a412d1 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 5 Jul 2018 15:58:19 -0600 Subject: [PATCH 09/45] feat: initial local passport strategy --- package-lock.json | 107 ++++---------- package.json | 6 +- scripts/types.js | 4 +- .../server/app/middleware/passport/index.ts | 60 ++------ .../server/app/middleware/passport/local.ts | 61 ++++++++ .../server/app/middleware/passport/oidc.ts | 133 +++++++++++++----- src/core/server/app/router.ts | 9 ++ src/core/server/app/url.ts | 3 +- .../server/graph/tenant/loaders/comments.ts | 18 ++- src/core/server/graph/tenant/loaders/users.ts | 4 +- .../server/graph/tenant/schema/schema.graphql | 35 +++++ src/core/server/models/comment.ts | 20 +-- src/core/server/models/tenant.ts | 2 +- src/core/server/models/user.ts | 65 ++++----- src/core/server/services/comments/index.ts | 2 +- 15 files changed, 312 insertions(+), 217 deletions(-) create mode 100644 src/core/server/app/middleware/passport/local.ts diff --git a/package-lock.json b/package-lock.json index 4d7d90884..6b61ad34c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1403,6 +1403,12 @@ "lodash.deburr": "^4.1.0" } }, + "@types/bcryptjs": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.1.tgz", + "integrity": "sha512-CVJ8ExtzUQJzLJbEk/lWrHD3MTvstTodjWidcH23gCii5WSD0z1TPSLqSdtbn5eCDw+DxfKgoUALi+loe8ftXA==", + "dev": true + }, "@types/bluebird": { "version": "3.5.20", "resolved": "https://registry.npmjs.org/@types/bluebird/-/bluebird-3.5.20.tgz", @@ -1624,6 +1630,17 @@ "@types/express": "*" } }, + "@types/passport-local": { + "version": "1.0.33", + "resolved": "https://registry.npmjs.org/@types/passport-local/-/passport-local-1.0.33.tgz", + "integrity": "sha512-+rn6ZIxje0jZ2+DAiWFI8vGG7ZFKB0hXx2cUdMmudSWsigSq6ES7Emso46r4HJk0qCgrZVfI8sJiM7HIYf4SbA==", + "dev": true, + "requires": { + "@types/express": "*", + "@types/passport": "*", + "@types/passport-strategy": "*" + } + }, "@types/passport-oauth2": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.4.5.tgz", @@ -4380,6 +4397,11 @@ "tweetnacl": "^0.14.3" } }, + "bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha1-mrVie5PmBiH/fNrF2pczAn3x0Ms=" + }, "big.js": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/big.js/-/big.js-3.2.0.tgz", @@ -9865,12 +9887,6 @@ "integrity": "sha512-Pgs951kaMm5GXP7MOvxERINe3gsaVjUWFm+UZPSq9xYriQAksyhg0csnS0KXSNRD5NmNdapXEpjxG49+AKh/ug==", "dev": true }, - "ignore-by-default": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", - "integrity": "sha1-SMptcvbGo68Aqa1K5odr44ieKwk=", - "dev": true - }, "immutable": { "version": "3.7.6", "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.7.6.tgz", @@ -12946,50 +12962,6 @@ "which": "^1.3.0" } }, - "nodemon": { - "version": "1.17.5", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-1.17.5.tgz", - "integrity": "sha512-FG2mWJU1Y58a9ktgMJ/RZpsiPz3b7ge77t/okZHEa4NbrlXGKZ8s1A6Q+C7+JPXohAfcPALRwvxcAn8S874pmw==", - "dev": true, - "requires": { - "chokidar": "^2.0.2", - "debug": "^3.1.0", - "ignore-by-default": "^1.0.1", - "minimatch": "^3.0.4", - "pstree.remy": "^1.1.0", - "semver": "^5.5.0", - "supports-color": "^5.2.0", - "touch": "^3.1.0", - "undefsafe": "^2.0.2", - "update-notifier": "^2.3.0" - }, - "dependencies": { - "debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", - "dev": true, - "requires": { - "ms": "2.0.0" - } - }, - "has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", - "dev": true - }, - "supports-color": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz", - "integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==", - "dev": true, - "requires": { - "has-flag": "^3.0.0" - } - } - } - }, "nopt": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", @@ -13529,6 +13501,14 @@ "pause": "0.0.1" } }, + "passport-local": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz", + "integrity": "sha1-H+YyaMkudWBmJkN+O5BmYsFbpu4=", + "requires": { + "passport-strategy": "1.x.x" + } + }, "passport-oauth2": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.4.0.tgz", @@ -16106,15 +16086,6 @@ "integrity": "sha512-+AqO1Ae+N/4r7Rvchrdm432afjT9hqJRyBN3DQv9At0tPz4hIFSGKbq64fN9dVoCow4oggIIax5/iONx0r9hZw==", "dev": true }, - "pstree.remy": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.0.tgz", - "integrity": "sha512-q5I5vLRMVtdWa8n/3UEzZX7Lfghzrg9eG2IKk2ENLSofKRCXVqMvMUHxCKgXNaqH/8ebhBxrqftHWnyTFweJ5Q==", - "dev": true, - "requires": { - "ps-tree": "^1.1.0" - } - }, "public-encrypt": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.2.tgz", @@ -18966,15 +18937,6 @@ "integrity": "sha1-LmhELZ9k7HILjMieZEOsbKqVACk=", "dev": true }, - "touch": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", - "integrity": "sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==", - "dev": true, - "requires": { - "nopt": "~1.0.10" - } - }, "tough-cookie": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.4.3.tgz", @@ -19852,15 +19814,6 @@ "integrity": "sha512-keqHubrlpvT6G2wH0OEfSW4mquYRcbe/J8NMmveoQOjUqmo+hXtO+ORCpWhdbZ7k72UtY61BL7haGxW6enBnjw==", "dev": true }, - "undefsafe": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.2.tgz", - "integrity": "sha1-Il9rngM3Zj4Njnz9aG/Cg2zKznY=", - "dev": true, - "requires": { - "debug": "^2.2.0" - } - }, "unherit": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/unherit/-/unherit-1.1.1.tgz", diff --git a/package.json b/package.json index cbe872d79..76e1cb87f 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "license": "Apache-2.0", "dependencies": { "apollo-server-express": "^1.3.6", + "bcryptjs": "^2.4.3", "bunyan": "^1.8.12", "convict": "^4.3.0", "dataloader": "^1.4.0", @@ -46,6 +47,7 @@ "luxon": "^1.2.1", "mongodb": "^3.0.10", "passport": "^0.4.0", + "passport-local": "^1.0.0", "passport-oauth2": "^1.4.0", "passport-strategy": "^1.0.0", "performance-now": "^2.1.0", @@ -58,6 +60,7 @@ "@babel/polyfill": "7.0.0-beta.49", "@babel/preset-env": "7.0.0-beta.49", "@babel/preset-react": "7.0.0-beta.49", + "@types/bcryptjs": "^2.4.1", "@types/bunyan": "^1.8.4", "@types/chokidar": "^1.7.5", "@types/classnames": "^2.2.4", @@ -76,6 +79,7 @@ "@types/mongodb": "^3.0.19", "@types/node": "^10.3.1", "@types/passport": "^0.4.5", + "@types/passport-local": "^1.0.33", "@types/passport-oauth2": "^1.4.5", "@types/passport-strategy": "^0.2.33", "@types/query-string": "^6.1.0", @@ -149,4 +153,4 @@ "webpack-hot-client": "^4.0.3", "webpack-manifest-plugin": "^2.0.3" } -} \ No newline at end of file +} diff --git a/scripts/types.js b/scripts/types.js index 0bf026bd8..be1449842 100644 --- a/scripts/types.js +++ b/scripts/types.js @@ -72,9 +72,7 @@ async function main() { file.types = await generateTSTypesAsString(schema, { tabSpaces: 2, typePrefix: "GQL", - contextType: "any", - strictNulls: false, - minimizeInterfaceImplementation: true, + strictNulls: true, ...file.config, }); } diff --git a/src/core/server/app/middleware/passport/index.ts b/src/core/server/app/middleware/passport/index.ts index bebc52631..1b6968564 100644 --- a/src/core/server/app/middleware/passport/index.ts +++ b/src/core/server/app/middleware/passport/index.ts @@ -1,63 +1,33 @@ +import { NextFunction, Response } from "express"; import { Db } from "mongodb"; import passport, { Authenticator } from "passport"; -import { NextFunction, Response } from "express"; -import OIDCStrategy, { - Token, - VerifyCallback, -} from "talk-server/app/middleware/passport/oidc"; -import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; -import { Tenant } from "talk-server/models/tenant"; -import { create, retrieveWithProfile, User } from "talk-server/models/user"; +import { createLocalStrategy } from "talk-server/app/middleware/passport/local"; +import { createOIDCStrategy } from "talk-server/app/middleware/passport/oidc"; +import { User } from "talk-server/models/user"; import { Request } from "talk-server/types/express"; +export type VerifyCallback = ( + err?: Error | null, + user?: User | null, + info?: object +) => void; + export interface PassportOptions { db: Db; } -async function verifyOIDC( - db: Db, - tenant: Tenant, - { iss, sub, email, email_verified }: Token, - done: VerifyCallback -) { - try { - // Construct the profile that will be used to query for the user. - const profile = { - type: "oidc", - provider: iss, - id: sub, - }; - - // Try to lookup user given their id provided in the `sub` claim. - let user = await retrieveWithProfile(db, tenant.id, profile); - if (!user) { - // FIXME: implement rules. - - // Create the new user, as one didn't exist before! - user = await create(db, tenant.id, { - username: null, - role: GQLUSER_ROLE.COMMENTER, - email, - email_verified, - profiles: [profile], - }); - } - - return done(null, user); - } catch (err) { - return done(err); - } -} - export function createPassport({ db, }: PassportOptions): passport.Authenticator { // Create the authenticator. const auth = new Authenticator(); - // Process the OIDC Strategy. - auth.use(new OIDCStrategy({ db }, verifyOIDC.bind(null, db))); + // Use the OIDC Strategy. + auth.use(createOIDCStrategy({ db })); + + // Use the LocalStrategy. + auth.use(createLocalStrategy({ db })); return auth; } diff --git a/src/core/server/app/middleware/passport/local.ts b/src/core/server/app/middleware/passport/local.ts new file mode 100644 index 000000000..4aa707153 --- /dev/null +++ b/src/core/server/app/middleware/passport/local.ts @@ -0,0 +1,61 @@ +import { Db } from "mongodb"; +import { Strategy as LocalStrategy } from "passport-local"; + +import { VerifyCallback } from "talk-server/app/middleware/passport"; +import { + retrieveUserWithProfile, + verifyUserPassword, +} from "talk-server/models/user"; +import { Request } from "talk-server/types/express"; + +async function verify( + db: Db, + req: Request, + email: string, + password: string, + done: VerifyCallback +) { + try { + // The tenant is guaranteed at this point. + const tenant = req.tenant!; + + // TODO: rate limit the ip address + + // Get the user from the database. + const user = await retrieveUserWithProfile(db, tenant.id, { + id: email, + type: "local", + }); + if (!user) { + // The user didn't exist. + return done(null, null); + } + + // Verify the password. + const passwordVerified = await verifyUserPassword(user, password); + if (!passwordVerified) { + // TODO: return better error + return done(new Error("invalid password")); + } + + return done(null, user); + } catch (err) { + return done(err); + } +} + +export interface LocalStrategyOptions { + db: Db; +} + +export function createLocalStrategy({ db }: LocalStrategyOptions) { + return new LocalStrategy( + { + usernameField: "email", + passwordField: "password", + session: false, + passReqToCallback: true, + }, + verify.bind(null, db) + ); +} diff --git a/src/core/server/app/middleware/passport/oidc.ts b/src/core/server/app/middleware/passport/oidc.ts index ceac0bc37..f75fe43b3 100644 --- a/src/core/server/app/middleware/passport/oidc.ts +++ b/src/core/server/app/middleware/passport/oidc.ts @@ -1,57 +1,106 @@ import jwt from "jsonwebtoken"; import jwks, { JwksClient } from "jwks-rsa"; +import { Db } from "mongodb"; import { Strategy as OAuth2Strategy } from "passport-oauth2"; import { Strategy } from "passport-strategy"; import { reconstructURL } from "talk-server/app/url"; +import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; import { OIDCAuthIntegration, Tenant } from "talk-server/models/tenant"; -import { User } from "talk-server/models/user"; +import { createUser, retrieveUserWithProfile } from "talk-server/models/user"; import { Request } from "talk-server/types/express"; -export type OIDCStrategyOptions = any; +import { VerifyCallback } from "./index"; export interface Params { id_token?: string; } -export type VerifyCallback = ( - err?: Error | null, - user?: User | null, - info?: object -) => void; - -export interface Token { +export interface OIDCIDToken { iss: string; sub: string; email: string; email_verified?: boolean; } -export type OIDCStrategyCallback = ( - tenant: Tenant, - token: Token, - done: VerifyCallback -) => void; - export interface StrategyItem { strategy: OAuth2Strategy; jwksClient?: JwksClient; } +export interface OIDCStrategyOptions { + db: Db; +} + +export function isOIDCToken(token: OIDCIDToken | object): token is OIDCIDToken { + if ( + (token as OIDCIDToken).iss && + (token as OIDCIDToken).sub && + (token as OIDCIDToken).email + ) { + return true; + } + + return false; +} + +export async function findOrCreateOIDCUser( + db: Db, + tenant: Tenant, + { iss, sub, email, email_verified }: OIDCIDToken +) { + // Construct the profile that will be used to query for the user. + const profile = { + type: "oidc", + provider: iss, + id: sub, + }; + + // Try to lookup user given their id provided in the `sub` claim. + let user = await retrieveUserWithProfile(db, tenant.id, profile); + if (!user) { + // FIXME: implement rules. + + // Create the new user, as one didn't exist before! + user = await createUser(db, tenant.id, { + username: null, + role: GQLUSER_ROLE.COMMENTER, + email, + email_verified, + profiles: [profile], + }); + } + + return user; +} + // FIXME: attach strategy to cache updates of the tenants export default class OIDCStrategy extends Strategy { public name: string; - private verify: OIDCStrategyCallback; + private db: Db; private cache: Map; - constructor(options: OIDCStrategyOptions, verify: OIDCStrategyCallback) { + constructor({ db }: OIDCStrategyOptions) { super(); this.name = "oidc"; this.cache = new Map(); - this.verify = verify; + this.db = db; + } + + private async verify( + tenant: Tenant, + token: OIDCIDToken, + done: VerifyCallback + ) { + try { + const user = await findOrCreateOIDCUser(this.db, tenant, token); + return done(null, user); + } catch (err) { + return done(err); + } } private lookupJWKSClient( @@ -139,7 +188,7 @@ export default class OIDCStrategy extends Strategy { return done(err); } - this.verify(tenant!, decoded as Token, done); + this.verify(tenant!, decoded as OIDCIDToken, done); } ); } @@ -183,6 +232,12 @@ export default class OIDCStrategy extends Strategy { throw new Error("integration not found"); } + // Handle when the integration is enabled/disabled. + if (!integration.enabled) { + // TODO: return a better error. + throw new Error("integration not enabled"); + } + // Try to get the Tenant's cached integrations. let entry = this.cache.get(tenant.id); if (!entry) { @@ -202,22 +257,30 @@ export default class OIDCStrategy extends Strategy { } public async authenticate(req: Request) { - // Lookup the strategy. - const strategy = await this.lookupStrategy(req); - if (!strategy) { - return; + try { + // Lookup the strategy. + const strategy = await this.lookupStrategy(req); + if (!strategy) { + throw new Error("strategy not found"); + } + + // Augment the strategy with the request method bindings. + strategy.error = this.error.bind(this); + strategy.fail = this.fail.bind(this); + strategy.pass = this.pass.bind(this); + strategy.redirect = this.redirect.bind(this); + strategy.success = this.success.bind(this); + + // Authenticate with the strategy, binding the current context to the method + // to provide it with the augmented passport handlers. We also request the + // 'openid' scope so we can get an id_token back. + strategy.authenticate(req, { scope: "openid email", session: false }); + } catch (err) { + return this.error(err); } - - // Augment the strategy with the request method bindings. - strategy.error = this.error.bind(this); - strategy.fail = this.fail.bind(this); - strategy.pass = this.pass.bind(this); - strategy.redirect = this.redirect.bind(this); - strategy.success = this.success.bind(this); - - // Authenticate with the strategy, binding the current context to the method - // to provide it with the augmented passport handlers. We also request the - // 'openid' scope so we can get an id_token back. - strategy.authenticate(req, { scope: "openid email", session: false }); } } + +export function createOIDCStrategy({ db }: OIDCStrategyOptions) { + return new OIDCStrategy({ db }); +} diff --git a/src/core/server/app/router.ts b/src/core/server/app/router.ts index 1ae9934b8..a1a668b51 100644 --- a/src/core/server/app/router.ts +++ b/src/core/server/app/router.ts @@ -33,6 +33,11 @@ async function createTenantRouter(app: AppOptions, options: RouterOptions) { router.use(tenantMiddleware({ db: app.mongo })); router.use(options.passport.initialize()); + router.use( + "/auth/local", + express.json(), + authenticate(options.passport, "local") + ); router.use("/auth/oidc", authenticate(options.passport, "oidc")); router.use("/auth/oidc/callback", authenticate(options.passport, "oidc")); // router.use("/auth/google", options.passport.authenticate("google")); @@ -64,6 +69,10 @@ async function createAPIRouter(app: AppOptions, options: RouterOptions) { } export interface RouterOptions { + /** + * passport is the instance of the Authenticator that can be used to create + * and mount new authentication middleware. + */ passport: passport.Authenticator; } diff --git a/src/core/server/app/url.ts b/src/core/server/app/url.ts index b9f533f1a..b656e4421 100644 --- a/src/core/server/app/url.ts +++ b/src/core/server/app/url.ts @@ -1,11 +1,10 @@ import { Request } from "talk-server/types/express"; import { URL } from "url"; -export function reconstructURL(req: Request, input?: string): string { +export function reconstructURL(req: Request, path: string = "/"): string { const scheme = req.secure ? "https" : "http"; const host = req.get("host"); const base = `${scheme}://${host}`; - const path = input || req.originalUrl; const url = new URL(path, base); diff --git a/src/core/server/graph/tenant/loaders/comments.ts b/src/core/server/graph/tenant/loaders/comments.ts index 0e68876e2..765ed6768 100644 --- a/src/core/server/graph/tenant/loaders/comments.ts +++ b/src/core/server/graph/tenant/loaders/comments.ts @@ -6,17 +6,23 @@ import { CommentToRepliesArgs, } from "talk-server/graph/tenant/schema/__generated__/types"; import { - retrieveAssetConnection, - retrieveMany, - retrieveRepliesConnection, + retrieveCommentAssetConnection, + retrieveManyComments, + retrieveCommentRepliesConnection, } from "talk-server/models/comment"; export default (ctx: Context) => ({ comment: new DataLoader((ids: string[]) => - retrieveMany(ctx.db, ctx.tenant.id, ids) + retrieveManyComments(ctx.db, ctx.tenant.id, ids) ), forAsset: (assetID: string, input: AssetToCommentsArgs) => - retrieveAssetConnection(ctx.db, ctx.tenant.id, assetID, input), + retrieveCommentAssetConnection(ctx.db, ctx.tenant.id, assetID, input), forParent: (assetID: string, parentID: string, input: CommentToRepliesArgs) => - retrieveRepliesConnection(ctx.db, ctx.tenant.id, assetID, parentID, input), + retrieveCommentRepliesConnection( + ctx.db, + ctx.tenant.id, + assetID, + parentID, + input + ), }); diff --git a/src/core/server/graph/tenant/loaders/users.ts b/src/core/server/graph/tenant/loaders/users.ts index d875bc6b4..34bbebc10 100644 --- a/src/core/server/graph/tenant/loaders/users.ts +++ b/src/core/server/graph/tenant/loaders/users.ts @@ -1,9 +1,9 @@ import DataLoader from "dataloader"; import Context from "talk-server/graph/tenant/context"; -import { retrieveMany, User } from "talk-server/models/user"; +import { retrieveManyUsers, User } from "talk-server/models/user"; export default (ctx: Context) => ({ user: new DataLoader(ids => - retrieveMany(ctx.db, ctx.tenant.id, ids) + retrieveManyUsers(ctx.db, ctx.tenant.id, ids) ), }); diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index bf6e5d6f8..2e3ae43af 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -284,6 +284,41 @@ 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 +} + """ User is someone that leaves Comments, and logs in. """ diff --git a/src/core/server/models/comment.ts b/src/core/server/models/comment.ts index 5c0726669..df5d1f877 100644 --- a/src/core/server/models/comment.ts +++ b/src/core/server/models/comment.ts @@ -34,7 +34,7 @@ export enum CommentStatus { export interface Comment extends TenantResource { readonly id: string; - parent_id?: string; + parent_id: string | null; author_id: string; asset_id: string; body: string; @@ -45,9 +45,7 @@ export interface Comment extends TenantResource { reply_count: number; created_at: Date; deleted_at?: Date; - metadata?: { - [_: string]: any; - }; + metadata?: Record; } export type CreateCommentInput = Omit< @@ -60,7 +58,7 @@ export type CreateCommentInput = Omit< | "status_history" >; -export async function create( +export async function createComment( db: Db, tenantID: string, input: CreateCommentInput @@ -106,11 +104,15 @@ export async function create( return comment; } -export async function retrieve(db: Db, tenantID: string, id: string) { +export async function retrieveComment(db: Db, tenantID: string, id: string) { return collection(db).findOne({ id, tenant_id: tenantID }); } -export async function retrieveMany(db: Db, tenantID: string, ids: string[]) { +export async function retrieveManyComments( + db: Db, + tenantID: string, + ids: string[] +) { const cursor = await collection(db).find({ id: { $in: ids, @@ -164,7 +166,7 @@ function nodesToEdge(input: ConnectionInput, nodes: Comment[]) { * @param parentID the parent id for the comment to retrieve * @param input connection configuration */ -export async function retrieveRepliesConnection( +export async function retrieveCommentRepliesConnection( db: Db, tenantID: string, assetID: string, @@ -190,7 +192,7 @@ export async function retrieveRepliesConnection( * @param assetID the Asset id for the comment to retrieve * @param input connection configuration */ -export async function retrieveAssetConnection( +export async function retrieveCommentAssetConnection( db: Db, tenantID: string, assetID: string, diff --git a/src/core/server/models/tenant.ts b/src/core/server/models/tenant.ts index 123c6b261..e18ab7ec0 100644 --- a/src/core/server/models/tenant.ts +++ b/src/core/server/models/tenant.ts @@ -210,7 +210,7 @@ export async function retrieveTenantByDomain(db: Db, domain: string) { return collection(db).findOne({ domain }); } -export async function retrieve(db: Db, id: string) { +export async function retrieveTenant(db: Db, id: string) { return collection(db).findOne({ id }); } diff --git a/src/core/server/models/user.ts b/src/core/server/models/user.ts index 487f7174f..1fbcbb031 100644 --- a/src/core/server/models/user.ts +++ b/src/core/server/models/user.ts @@ -1,9 +1,13 @@ +import bcrypt from "bcryptjs"; import { merge } from "lodash"; import { Db } from "mongodb"; import uuid from "uuid"; import { Omit, Sub } from "talk-common/types"; -import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; +import { + GQLUSER_ROLE, + GQLUSER_USERNAME_STATUS, +} from "talk-server/graph/tenant/schema/__generated__/types"; import { ActionCounts } from "talk-server/models/actions"; import { TenantResource } from "talk-server/models/tenant"; @@ -14,7 +18,7 @@ function collection(db: Db) { export interface Profile { readonly id: string; readonly type: string; - provider: string; + provider?: string; } export interface Token { @@ -23,31 +27,6 @@ export interface Token { active: boolean; } -export enum UserUsernameStatus { - // 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 = "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 = "SET", - - // APPROVED is used when the username was changed, and subsequently approved by - // said moderator. - APPROVED = "APPROVED", - - // REJECTED is used when the username was changed, and subsequently rejected by - // said moderator. - REJECTED = "REJECTED", - - // CHANGED is used after a user has changed their username after it was - // rejected. - CHANGED = "CHANGED", -} - export interface UserStatusHistory { status: T; // TODO: migrate field assigned_by?: string; @@ -61,7 +40,7 @@ export interface UserStatusItem { } export interface UserStatus { - username: UserStatusItem; + username: UserStatusItem; banned: UserStatusItem; suspension: UserStatusItem; } @@ -92,7 +71,11 @@ export type CreateUserInput = Omit< | "created_at" >; -export async function create(db: Db, tenantID: string, input: CreateUserInput) { +export async function createUser( + db: Db, + tenantID: string, + input: CreateUserInput +) { const now = new Date(); // default are the properties set by the application when a new user is @@ -114,8 +97,8 @@ export async function create(db: Db, tenantID: string, input: CreateUserInput) { }, username: { status: input.username - ? UserUsernameStatus.SET - : UserUsernameStatus.UNSET, + ? GQLUSER_USERNAME_STATUS.SET + : GQLUSER_USERNAME_STATUS.UNSET, history: [], }, }, @@ -131,11 +114,15 @@ export async function create(db: Db, tenantID: string, input: CreateUserInput) { return user; } -export async function retrieve(db: Db, tenantID: string, id: string) { +export async function retrieveUser(db: Db, tenantID: string, id: string) { return collection(db).findOne({ id, tenant_id: tenantID }); } -export async function retrieveMany(db: Db, tenantID: string, ids: string[]) { +export async function retrieveManyUsers( + db: Db, + tenantID: string, + ids: string[] +) { const cursor = await collection(db).find({ id: { $in: ids, @@ -148,7 +135,7 @@ export async function retrieveMany(db: Db, tenantID: string, ids: string[]) { return ids.map(id => users.find(comment => comment.id === id) || null); } -export async function retrieveWithProfile( +export async function retrieveUserWithProfile( db: Db, tenantID: string, profile: Profile @@ -161,7 +148,7 @@ export async function retrieveWithProfile( }); } -export async function updateRole( +export async function updateUserRole( db: Db, tenantID: string, id: string, @@ -175,3 +162,11 @@ export async function updateRole( return result.value || null; } + +export async function verifyUserPassword(user: User, password: string) { + if (user.password) { + return bcrypt.compare(user.password, password); + } + + return false; +} diff --git a/src/core/server/services/comments/index.ts b/src/core/server/services/comments/index.ts index 95a9001cb..f13852dfa 100644 --- a/src/core/server/services/comments/index.ts +++ b/src/core/server/services/comments/index.ts @@ -4,7 +4,7 @@ import { Omit } from "talk-common/types"; import { Comment, CommentStatus, - create as createComment, + createComment, CreateCommentInput, } from "talk-server/models/comment"; From 15478fe54f24b8533823b3c16b3b122021255150 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 5 Jul 2018 16:03:34 -0600 Subject: [PATCH 10/45] fix: linting --- src/core/server/graph/tenant/loaders/comments.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/server/graph/tenant/loaders/comments.ts b/src/core/server/graph/tenant/loaders/comments.ts index 765ed6768..df43c823c 100644 --- a/src/core/server/graph/tenant/loaders/comments.ts +++ b/src/core/server/graph/tenant/loaders/comments.ts @@ -7,8 +7,8 @@ import { } from "talk-server/graph/tenant/schema/__generated__/types"; import { retrieveCommentAssetConnection, - retrieveManyComments, retrieveCommentRepliesConnection, + retrieveManyComments, } from "talk-server/models/comment"; export default (ctx: Context) => ({ From 093e4fd736273392eb5fea2e8040724a0ceebad1 Mon Sep 17 00:00:00 2001 From: Kiwi Date: Fri, 6 Jul 2018 16:08:10 -0300 Subject: [PATCH 11/45] Adapt files.exclude (#1736) --- .prettierignore | 1 + .vscode/settings.json | 8 +++----- 2 files changed, 4 insertions(+), 5 deletions(-) create mode 100644 .prettierignore diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..3c3629e64 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +node_modules diff --git a/.vscode/settings.json b/.vscode/settings.json index 195b73de5..4dd5d1b72 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,12 +9,10 @@ "**/.hg": true, "**/CVS": true, "**/.DS_Store": true, - "node_modules": true, - "dist": true, - ".vscode": true, - "package-lock.json": true + ".vs": true }, + "tslint.exclude": "**/node_modules/**", "tslint.autoFixOnSave": true, "tslint.jsEnable": true, "typescript.tsdk": "node_modules/typescript/lib" -} \ No newline at end of file +} From c0da4e97aa64b2b4e6da25c373e16e0409f2e23a Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 10 Jul 2018 13:11:32 -0600 Subject: [PATCH 12/45] feat: asset url query --- .../server/graph/tenant/loaders/assets.ts | 9 +- .../server/graph/tenant/loaders/comments.ts | 40 ++++-- .../server/graph/tenant/resolvers/query.ts | 2 +- .../server/graph/tenant/schema/schema.graphql | 10 +- src/core/server/models/asset.ts | 130 ++++++++++++++---- 5 files changed, 144 insertions(+), 47 deletions(-) diff --git a/src/core/server/graph/tenant/loaders/assets.ts b/src/core/server/graph/tenant/loaders/assets.ts index 55c50a0dc..cd7db4b57 100644 --- a/src/core/server/graph/tenant/loaders/assets.ts +++ b/src/core/server/graph/tenant/loaders/assets.ts @@ -1,8 +1,15 @@ import DataLoader from "dataloader"; import TenantContext from "talk-server/graph/tenant/context"; -import { Asset, retrieveManyAssets } from "talk-server/models/asset"; +import { + Asset, + findOrCreateAsset, + FindOrCreateAssetInput, + retrieveManyAssets, +} from "talk-server/models/asset"; export default (ctx: TenantContext) => ({ + findOrCreate: (input: FindOrCreateAssetInput) => + findOrCreateAsset(ctx.db, ctx.tenant.id, input), asset: new DataLoader(ids => retrieveManyAssets(ctx.db, ctx.tenant.id, ids) ), diff --git a/src/core/server/graph/tenant/loaders/comments.ts b/src/core/server/graph/tenant/loaders/comments.ts index df43c823c..719046a83 100644 --- a/src/core/server/graph/tenant/loaders/comments.ts +++ b/src/core/server/graph/tenant/loaders/comments.ts @@ -4,6 +4,7 @@ import Context from "talk-server/graph/tenant/context"; import { AssetToCommentsArgs, CommentToRepliesArgs, + GQLCOMMENT_SORT, } from "talk-server/graph/tenant/schema/__generated__/types"; import { retrieveCommentAssetConnection, @@ -15,14 +16,33 @@ export default (ctx: Context) => ({ comment: new DataLoader((ids: string[]) => retrieveManyComments(ctx.db, ctx.tenant.id, ids) ), - forAsset: (assetID: string, input: AssetToCommentsArgs) => - retrieveCommentAssetConnection(ctx.db, ctx.tenant.id, assetID, input), - forParent: (assetID: string, parentID: string, input: CommentToRepliesArgs) => - retrieveCommentRepliesConnection( - ctx.db, - ctx.tenant.id, - assetID, - parentID, - input - ), + forAsset: ( + assetID: string, + // Apply the graph schema defaults at the loader. + { + first = 10, + orderBy = GQLCOMMENT_SORT.CREATED_AT_DESC, + after, + }: AssetToCommentsArgs + ) => + retrieveCommentAssetConnection(ctx.db, ctx.tenant.id, assetID, { + first, + orderBy, + after, + }), + forParent: ( + assetID: string, + parentID: string, + // Apply the graph schema defaults at the loader. + { + first = 10, + orderBy = GQLCOMMENT_SORT.CREATED_AT_DESC, + after, + }: CommentToRepliesArgs + ) => + retrieveCommentRepliesConnection(ctx.db, ctx.tenant.id, assetID, parentID, { + first, + orderBy, + after, + }), }); diff --git a/src/core/server/graph/tenant/resolvers/query.ts b/src/core/server/graph/tenant/resolvers/query.ts index caf5e0a3b..56d99e1fe 100644 --- a/src/core/server/graph/tenant/resolvers/query.ts +++ b/src/core/server/graph/tenant/resolvers/query.ts @@ -1,7 +1,7 @@ import { GQLQueryTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; const Query: GQLQueryTypeResolver = { - asset: (source, args, ctx) => ctx.loaders.Assets.asset.load(args.id), + asset: (source, args, ctx) => ctx.loaders.Assets.findOrCreate(args), settings: (parent, args, ctx) => ctx.tenant, }; diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 2e3ae43af..9e57502d5 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -382,8 +382,8 @@ type Comment { replies will return the replies to this comment. """ replies( - first: Int! = 10 - orderBy: COMMENT_SORT! = CREATED_AT_DESC + first: Int = 10 + orderBy: COMMENT_SORT = CREATED_AT_DESC after: Cursor ): CommentsConnection } @@ -459,8 +459,8 @@ type Asset { comments are the comments on the Asset. """ comments( - first: Int! = 10 - orderBy: COMMENT_SORT! = CREATED_AT_DESC + first: Int = 10 + orderBy: COMMENT_SORT = CREATED_AT_DESC after: Cursor ): CommentsConnection @@ -533,7 +533,7 @@ type Query { """ asset is the Asset specified by its ID. """ - asset(id: ID!): Asset + asset(id: ID, url: String!): Asset """ me is the current logged in User. diff --git a/src/core/server/models/asset.ts b/src/core/server/models/asset.ts index e00dd2a56..b09583d9c 100644 --- a/src/core/server/models/asset.ts +++ b/src/core/server/models/asset.ts @@ -1,10 +1,10 @@ import dotize from "dotize"; import { defaults } from "lodash"; import { Db } from "mongodb"; +import uuid from "uuid"; + import { Omit } from "talk-common/types"; import { TenantResource } from "talk-server/models/tenant"; -import uuid from "uuid"; -import Query from "./query"; function collection(db: Db) { return db.collection>("assets"); @@ -27,48 +27,103 @@ export interface Asset extends TenantResource { created_at: Date; } -export type CreateAssetInput = Pick; +export interface UpsertAssetInput { + id?: string; + url: string; +} -export async function createAsset( +export async function upsertAsset( db: Db, tenantID: string, - input: CreateAssetInput + { id, url }: UpsertAssetInput ) { const now = new Date(); - // Construct the filter. - const query = new Query(collection(db)).where({ - tenant_id: tenantID, - }); - if (input.id) { - query.where({ id: input.id }); - } else { - query.where({ url: input.url }); - } + // TODO: verify that the url for the given Asset is whitelisted by the tenant. - // Craft the update object. + // Create the asset, optionally sourcing the id from the input, additionally + // porting in the tenant_id. const update: { $setOnInsert: Asset } = { - $setOnInsert: defaults(input, { - id: uuid.v4(), - tenant_id: tenantID, - created_at: now, - }), + $setOnInsert: defaults( + { + url, + tenant_id: tenantID, + created_at: now, + }, + { id }, + { + id: uuid.v4(), + } + ), }; - // Perform the upsert operation. - const result = await collection(db).findOneAndUpdate(query.filter, update, { - // Create the object if it doesn't already exist. - upsert: true, - // False to return the updated document instead of the original - // document. - returnOriginal: false, - }); + // Perform the find and update operation to try and find and or create the + // asset. + const { value: asset } = await collection(db).findOneAndUpdate( + { url }, + update, + { + // Create the object if it doesn't already exist. + upsert: true, - return result.value || null; + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + if (!asset) { + return null; + } + + if (!asset.scraped) { + // TODO: create scrape job to collect asset metadata + } + + return asset; +} + +export interface FindOrCreateAssetInput { + id?: string; + url?: string; +} + +export async function findOrCreateAsset( + db: Db, + tenantID: string, + { id, url }: FindOrCreateAssetInput +) { + if (id) { + if (url) { + // The URL was specified, this is an upsert operation. + return upsertAsset(db, tenantID, { + id, + url, + }); + } + + // The URL was not specified, this is a lookup operation. + return retrieveAsset(db, tenantID, id); + } + + // The ID was not specified, this is an upsert operation. Check to see that + // the URL exists. + if (!url) { + throw new Error("cannot upsert an asset without the url"); + } + + return upsertAsset(db, tenantID, { url }); +} + +export async function retrieveAssetByURL( + db: Db, + tenantID: string, + url: string +) { + return collection(db).findOne({ url, tenant_id: tenantID }); } export async function retrieveAsset(db: Db, tenantID: string, id: string) { - return await collection(db).findOne({ id, tenant_id: tenantID }); + return collection(db).findOne({ id, tenant_id: tenantID }); } export async function retrieveManyAssets( @@ -86,6 +141,21 @@ export async function retrieveManyAssets( return ids.map(id => assets.find(asset => asset.id === id) || null); } +export async function retrieveManyAssetsByURL( + db: Db, + tenantID: string, + urls: string[] +) { + const cursor = await collection(db).find({ + url: { $in: urls }, + tenant_id: tenantID, + }); + + const assets = await cursor.toArray(); + + return urls.map(url => assets.find(asset => asset.url === url) || null); +} + export type UpdateAssetInput = Omit< Partial, "id" | "tenant_id" | "url" | "created_at" From c5dbe4659b71a1a7b3c8ebd0347d435c8106848f Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 10 Jul 2018 13:56:20 -0600 Subject: [PATCH 13/45] feat: initial signup route --- src/core/server/app/handlers/auth/local.ts | 8 +++ .../server/app/middleware/passport/index.ts | 49 ++++++++++--------- .../server/app/middleware/passport/local.ts | 9 ++-- src/core/server/app/router.ts | 2 + 4 files changed, 41 insertions(+), 27 deletions(-) create mode 100644 src/core/server/app/handlers/auth/local.ts diff --git a/src/core/server/app/handlers/auth/local.ts b/src/core/server/app/handlers/auth/local.ts new file mode 100644 index 000000000..dbd47bb50 --- /dev/null +++ b/src/core/server/app/handlers/auth/local.ts @@ -0,0 +1,8 @@ +import { RequestHandler } from "express"; + +import { Request } from "talk-server/types/express"; + +export const signup: RequestHandler = async (req: Request, res, next) => { + // TODO: implement + res.send("ok"); +}; diff --git a/src/core/server/app/middleware/passport/index.ts b/src/core/server/app/middleware/passport/index.ts index 1b6968564..cd857a172 100644 --- a/src/core/server/app/middleware/passport/index.ts +++ b/src/core/server/app/middleware/passport/index.ts @@ -1,4 +1,4 @@ -import { NextFunction, Response } from "express"; +import { NextFunction, RequestHandler, Response } from "express"; import { Db } from "mongodb"; import passport, { Authenticator } from "passport"; @@ -10,7 +10,7 @@ import { Request } from "talk-server/types/express"; export type VerifyCallback = ( err?: Error | null, user?: User | null, - info?: object + info?: { message: string } ) => void; export interface PassportOptions { @@ -32,28 +32,33 @@ export function createPassport({ return auth; } +export const handle = ( + err: Error | null, + user: User | null +): RequestHandler => (req: Request, res, next) => { + if (err) { + // TODO: wrap error? + return next(err); + } + + // Set the cache control headers. + res.header("Cache-Control", "private, no-cache, no-store, must-revalidate"); + res.header("Expires", "-1"); + res.header("Pragma", "no-cache"); + + // Send back the details! + + // TODO: return the token instead of the user. + res.json({ user }); +}; + export const authenticate = ( authenticator: passport.Authenticator, - name: string -) => (req: Request, res: Response, next: NextFunction) => + name: string, + options?: any +): RequestHandler => (req: Request, res, next) => authenticator.authenticate( name, - { session: false }, - (err: Error | null, user: User | null) => { - if (err) { - // TODO: wrap error? - return next(err); - } - - // Set the cache control headers. - res.header( - "Cache-Control", - "private, no-cache, no-store, must-revalidate" - ); - res.header("Expires", "-1"); - res.header("Pragma", "no-cache"); - - // Send back the details! - res.json({ user }); - } + { ...options, session: false }, + (err: Error | null, user: User | null) => handle(err, user)(req, res, next) )(req, res, next); diff --git a/src/core/server/app/middleware/passport/local.ts b/src/core/server/app/middleware/passport/local.ts index 4aa707153..67dfe3b02 100644 --- a/src/core/server/app/middleware/passport/local.ts +++ b/src/core/server/app/middleware/passport/local.ts @@ -8,13 +8,12 @@ import { } from "talk-server/models/user"; import { Request } from "talk-server/types/express"; -async function verify( - db: Db, +const verifyFactory = (db: Db) => async ( req: Request, email: string, password: string, done: VerifyCallback -) { +) => { try { // The tenant is guaranteed at this point. const tenant = req.tenant!; @@ -42,7 +41,7 @@ async function verify( } catch (err) { return done(err); } -} +}; export interface LocalStrategyOptions { db: Db; @@ -56,6 +55,6 @@ export function createLocalStrategy({ db }: LocalStrategyOptions) { session: false, passReqToCallback: true, }, - verify.bind(null, db) + verifyFactory(db) ); } diff --git a/src/core/server/app/router.ts b/src/core/server/app/router.ts index a1a668b51..ba7c9e5da 100644 --- a/src/core/server/app/router.ts +++ b/src/core/server/app/router.ts @@ -5,6 +5,7 @@ import tenantMiddleware from "talk-server/app/middleware/tenant"; import managementGraphMiddleware from "talk-server/graph/management/middleware"; import tenantGraphMiddleware from "talk-server/graph/tenant/middleware"; +import { signup } from "talk-server/app/handlers/auth/local"; import { authenticate } from "talk-server/app/middleware/passport"; import { AppOptions } from "./index"; import playground from "./middleware/playground"; @@ -38,6 +39,7 @@ async function createTenantRouter(app: AppOptions, options: RouterOptions) { express.json(), authenticate(options.passport, "local") ); + router.use("/auth/local/signup", express.json(), signup); router.use("/auth/oidc", authenticate(options.passport, "oidc")); router.use("/auth/oidc/callback", authenticate(options.passport, "oidc")); // router.use("/auth/google", options.passport.authenticate("google")); From d46145f04c012da4f899f9e716df0264fc44e80f Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 10 Jul 2018 13:58:31 -0600 Subject: [PATCH 14/45] fix: linting --- src/core/server/app/middleware/passport/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/server/app/middleware/passport/index.ts b/src/core/server/app/middleware/passport/index.ts index cd857a172..5b5fee739 100644 --- a/src/core/server/app/middleware/passport/index.ts +++ b/src/core/server/app/middleware/passport/index.ts @@ -1,4 +1,4 @@ -import { NextFunction, RequestHandler, Response } from "express"; +import { RequestHandler } from "express"; import { Db } from "mongodb"; import passport, { Authenticator } from "passport"; From 6efe36ceaa2562debf99d542bcd3dd760728534b Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 10 Jul 2018 15:54:52 -0600 Subject: [PATCH 15/45] feat: signup enhancements; more extensions to schema --- src/core/server/app/handlers/auth/local.ts | 82 ++++++++++++++++++- .../server/app/middleware/passport/index.ts | 7 +- .../server/app/middleware/passport/local.ts | 4 +- .../server/app/middleware/passport/oidc.ts | 17 ++-- src/core/server/app/request/body.ts | 23 ++++++ src/core/server/app/router.ts | 2 +- .../tenant/resolvers/auth_integrations.ts | 14 ++++ .../graph/tenant/resolvers/auth_settings.ts | 10 +-- .../server/graph/tenant/resolvers/index.ts | 18 +++- .../server/graph/tenant/resolvers/profile.ts | 21 +++++ .../server/graph/tenant/schema/schema.graphql | 48 ++++++++++- src/core/server/models/comment.ts | 17 ++-- src/core/server/models/tenant.ts | 31 ++++--- src/core/server/models/user.ts | 29 +++++-- src/core/server/services/comments/index.ts | 7 +- src/core/server/services/users/index.ts | 11 +++ 16 files changed, 278 insertions(+), 63 deletions(-) create mode 100644 src/core/server/app/request/body.ts create mode 100644 src/core/server/graph/tenant/resolvers/auth_integrations.ts create mode 100644 src/core/server/graph/tenant/resolvers/profile.ts create mode 100644 src/core/server/services/users/index.ts diff --git a/src/core/server/app/handlers/auth/local.ts b/src/core/server/app/handlers/auth/local.ts index dbd47bb50..656b3cb57 100644 --- a/src/core/server/app/handlers/auth/local.ts +++ b/src/core/server/app/handlers/auth/local.ts @@ -1,8 +1,84 @@ import { RequestHandler } from "express"; +import Joi from "joi"; +import { Db } from "mongodb"; +import { handle } from "talk-server/app/middleware/passport"; +import { validate } from "talk-server/app/request/body"; +import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; +import { LocalProfile } from "talk-server/models/user"; +import { create } from "talk-server/services/users"; import { Request } from "talk-server/types/express"; -export const signup: RequestHandler = async (req: Request, res, next) => { - // TODO: implement - res.send("ok"); +export interface SignupBody { + username: string; + password: string; + email: string; + displayName?: string; +} + +const SignupBodySchema = Joi.object().keys({ + username: Joi.string().trim(), + password: Joi.string().trim(), + email: Joi.string().trim(), +}); + +// Extends the default signup body schema with the displayName to allow it to be +// sent. +const SignupDisplayNameBodySchema = SignupBodySchema.keys({ + displayName: Joi.string().trim(), +}); + +export interface SignupOptions { + db: Db; +} + +export const signup = ({ db }: SignupOptions): RequestHandler => async ( + req: Request, + res, + next +) => { + try { + // TODO: rate limit based on the IP address and user agent. + + // Tenant is guaranteed at this point. + const tenant = req.tenant!; + + if (!tenant.auth.integrations.local.enabled) { + // TODO: replace with better error. + return next(new Error("integration is disabled")); + } + + // Get the fields from the body. We condition on the display name being + // enabled to allow the display name to be stripped in the event that the + // display name is not enabled, yielding a displayName being `undefined`, + // which will not be set in the resultant document. Validate will throw an + // error if the body does not conform to the specification. + const { username, password, email, displayName }: SignupBody = validate( + tenant.auth.displayNameEnable + ? SignupDisplayNameBodySchema + : SignupBodySchema, + req.body + ); + + // Configure with profile. + const profile: LocalProfile = { + id: email, + type: "local", + }; + + // Create the new user. + const user = await create(db, tenant.id, { + email, + username, + displayName, + password, + profiles: [profile], + role: GQLUSER_ROLE.COMMENTER, + }); + + // Send off to the passport handler. + return handle(null, user)(req, res, next); + } catch (err) { + return handle(err)(req, res, next); + } }; diff --git a/src/core/server/app/middleware/passport/index.ts b/src/core/server/app/middleware/passport/index.ts index 5b5fee739..0f24bdeea 100644 --- a/src/core/server/app/middleware/passport/index.ts +++ b/src/core/server/app/middleware/passport/index.ts @@ -34,13 +34,18 @@ export function createPassport({ export const handle = ( err: Error | null, - user: User | null + user?: User | null ): RequestHandler => (req: Request, res, next) => { if (err) { // TODO: wrap error? return next(err); } + if (!user) { + // TODO: replace with better error. + return next(new Error("no user on request")); + } + // Set the cache control headers. res.header("Cache-Control", "private, no-cache, no-store, must-revalidate"); res.header("Expires", "-1"); diff --git a/src/core/server/app/middleware/passport/local.ts b/src/core/server/app/middleware/passport/local.ts index 67dfe3b02..b57c4402e 100644 --- a/src/core/server/app/middleware/passport/local.ts +++ b/src/core/server/app/middleware/passport/local.ts @@ -15,11 +15,11 @@ const verifyFactory = (db: Db) => async ( done: VerifyCallback ) => { try { + // TODO: rate limit based on the IP address and user agent. + // The tenant is guaranteed at this point. const tenant = req.tenant!; - // TODO: rate limit the ip address - // Get the user from the database. const user = await retrieveUserWithProfile(db, tenant.id, { id: email, diff --git a/src/core/server/app/middleware/passport/oidc.ts b/src/core/server/app/middleware/passport/oidc.ts index f75fe43b3..c6be6d6c6 100644 --- a/src/core/server/app/middleware/passport/oidc.ts +++ b/src/core/server/app/middleware/passport/oidc.ts @@ -7,7 +7,8 @@ import { Strategy } from "passport-strategy"; import { reconstructURL } from "talk-server/app/url"; import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; import { OIDCAuthIntegration, Tenant } from "talk-server/models/tenant"; -import { createUser, retrieveUserWithProfile } from "talk-server/models/user"; +import { OIDCProfile, retrieveUserWithProfile } from "talk-server/models/user"; +import { create } from "talk-server/services/users"; import { Request } from "talk-server/types/express"; import { VerifyCallback } from "./index"; @@ -50,7 +51,7 @@ export async function findOrCreateOIDCUser( { iss, sub, email, email_verified }: OIDCIDToken ) { // Construct the profile that will be used to query for the user. - const profile = { + const profile: OIDCProfile = { type: "oidc", provider: iss, id: sub, @@ -62,7 +63,7 @@ export async function findOrCreateOIDCUser( // FIXME: implement rules. // Create the new user, as one didn't exist before! - user = await createUser(db, tenant.id, { + user = await create(db, tenant.id, { username: null, role: GQLUSER_ROLE.COMMENTER, email, @@ -157,7 +158,11 @@ export default class OIDCStrategy extends Strategy { const { tenant } = req; // Grab the JWKSClient. - const client = this.lookupJWKSClient(req, tenant!.id, tenant!.auth.oidc!); + const client = this.lookupJWKSClient( + req, + tenant!.id, + tenant!.auth.integrations.oidc! + ); // Verify that the id_token is valid or not. jwt.verify( @@ -180,7 +185,7 @@ export default class OIDCStrategy extends Strategy { }); }, { - issuer: tenant!.auth.oidc!.issuer, + issuer: tenant!.auth.integrations.oidc!.issuer, }, (err, decoded) => { if (err) { @@ -226,7 +231,7 @@ export default class OIDCStrategy extends Strategy { // Get the integration from the tenant. If needed, it will be used to create // a new strategy. - const integration = tenant.auth.oidc; + const integration = tenant.auth.integrations.oidc; if (!integration) { // TODO: return a better error. throw new Error("integration not found"); diff --git a/src/core/server/app/request/body.ts b/src/core/server/app/request/body.ts new file mode 100644 index 000000000..9b7cabb13 --- /dev/null +++ b/src/core/server/app/request/body.ts @@ -0,0 +1,23 @@ +import Joi from "joi"; + +/** + * validate will strip unknown fields and perform validation against it. It will + * throw any error encountered. + * + * @param schema the Joi schema to validate against + * @param body the body to parse and strip of unknown fields + */ +export const validate = (schema: Joi.SchemaLike, body: any) => { + // Extract the schema from the request. + const { value, error: err } = Joi.validate(body, schema, { + stripUnknown: true, + presence: "required", + }); + + if (err) { + // TODO: return better error. + throw new Error("Validation Error"); + } + + return value; +}; diff --git a/src/core/server/app/router.ts b/src/core/server/app/router.ts index ba7c9e5da..7b2338e0f 100644 --- a/src/core/server/app/router.ts +++ b/src/core/server/app/router.ts @@ -39,7 +39,7 @@ async function createTenantRouter(app: AppOptions, options: RouterOptions) { express.json(), authenticate(options.passport, "local") ); - router.use("/auth/local/signup", express.json(), signup); + router.use("/auth/local/signup", express.json(), signup({ db: app.mongo })); router.use("/auth/oidc", authenticate(options.passport, "oidc")); router.use("/auth/oidc/callback", authenticate(options.passport, "oidc")); // router.use("/auth/google", options.passport.authenticate("google")); diff --git a/src/core/server/graph/tenant/resolvers/auth_integrations.ts b/src/core/server/graph/tenant/resolvers/auth_integrations.ts new file mode 100644 index 000000000..6842b891b --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/auth_integrations.ts @@ -0,0 +1,14 @@ +import { GQLAuthIntegrationsTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; +import { AuthIntegration, AuthIntegrations } from "talk-server/models/tenant"; + +const disabled: AuthIntegration = { enabled: false }; + +const AuthIntegrations: GQLAuthIntegrationsTypeResolver = { + local: auth => auth.local || disabled, + sso: auth => auth.sso || disabled, + oidc: auth => auth.oidc || disabled, + google: auth => auth.google || disabled, + facebook: auth => auth.facebook || disabled, +}; + +export default AuthIntegrations; diff --git a/src/core/server/graph/tenant/resolvers/auth_settings.ts b/src/core/server/graph/tenant/resolvers/auth_settings.ts index 1519d24c0..ed8ec8659 100644 --- a/src/core/server/graph/tenant/resolvers/auth_settings.ts +++ b/src/core/server/graph/tenant/resolvers/auth_settings.ts @@ -1,14 +1,8 @@ import { GQLAuthSettingsTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; -import { Auth, AuthIntegration } from "talk-server/models/tenant"; - -const disabled: AuthIntegration = { enabled: false }; +import { Auth } from "talk-server/models/tenant"; const AuthSettings: GQLAuthSettingsTypeResolver = { - local: auth => auth.local || disabled, - sso: auth => auth.sso || disabled, - oidc: auth => auth.oidc || disabled, - google: auth => auth.google || disabled, - facebook: auth => auth.facebook || disabled, + integrations: auth => auth.integrations, }; export default AuthSettings; diff --git a/src/core/server/graph/tenant/resolvers/index.ts b/src/core/server/graph/tenant/resolvers/index.ts index e8d33540d..dd0a09082 100644 --- a/src/core/server/graph/tenant/resolvers/index.ts +++ b/src/core/server/graph/tenant/resolvers/index.ts @@ -2,16 +2,32 @@ import Cursor from "talk-server/graph/common/scalars/cursor"; import { GQLResolver } from "talk-server/graph/tenant/schema/__generated__/types"; import Asset from "./asset"; +import AuthIntegrations from "./auth_integrations"; +import AuthSettings from "./auth_settings"; import Comment from "./comment"; +import FacebookAuthIntegration from "./facebook_auth_integration"; +import GoogleAuthIntegration from "./google_auth_integration"; +import LocalAuthIntegration from "./local_auth_integration"; import Mutation from "./mutation"; +import OIDCAuthIntegration from "./oidc_auth_integration"; +import Profile from "./profile"; import Query from "./query"; +import SSOAuthIntegration from "./sso_auth_integration"; const Resolvers: GQLResolver = { Asset, + AuthIntegrations, + AuthSettings, Comment, + FacebookAuthIntegration, + GoogleAuthIntegration, + LocalAuthIntegration, + OIDCAuthIntegration, + SSOAuthIntegration, Cursor, - Query, Mutation, + Profile, + Query, }; export default Resolvers; diff --git a/src/core/server/graph/tenant/resolvers/profile.ts b/src/core/server/graph/tenant/resolvers/profile.ts new file mode 100644 index 000000000..c1c6ad13b --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/profile.ts @@ -0,0 +1,21 @@ +import { GQLProfileTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; + +import { Profile } from "talk-server/models/user"; + +const resolveType: GQLProfileTypeResolver = profile => { + switch (profile.type) { + case "local": + return "LocalProfile"; + case "oidc": + return "OIDCProfile"; + case "sso": + return "SSOProfile"; + default: + // TODO: replace with better error. + throw new Error("invalid profile type"); + } +}; + +export default { + __resolveType: resolveType, +}; diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 9e57502d5..5ffd1e40b 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -132,10 +132,7 @@ type FacebookAuthIntegration { config: FacebookAuthIntegrationConfig @auth(roles: [ADMIN]) } -""" -AuthSettings contains all the settings related to authentication and authorization. -""" -type AuthSettings { +type AuthIntegrations { local: LocalAuthIntegration! sso: SSOAuthIntegration! oidc: OIDCAuthIntegration! @@ -143,6 +140,24 @@ type AuthSettings { facebook: FacebookAuthIntegration! } +""" +AuthSettings contains all the settings related to authentication and +authorization. +""" +type AuthSettings { + """ + displayNameEnable when enabled, will allow Users to set and view their + displayName's. + """ + displayNameEnable: Boolean! + + """ + integrations are the set of configurations for the variations of + authentication solutions. + """ + integrations: AuthIntegrations! +} + ################################################################################ ## Settings ################################################################################ @@ -319,6 +334,21 @@ enum USER_USERNAME_STATUS { CHANGED } +type LocalProfile { + id: String! +} + +type OIDCProfile { + id: String! + provider: String! +} + +type SSOProfile { + id: String! +} + +union Profile = LocalProfile | OIDCProfile | SSOProfile + """ User is someone that leaves Comments, and logs in. """ @@ -333,6 +363,16 @@ type User { """ username: String! + """ + displayName is provided optionally when enabled and available. + """ + displayName: String + + """ + profiles is the array of profiles assigned to the user. + """ + profiles: [Profile!] @auth(roles: [ADMIN, MODERATOR], userIDField: "id") + """ role is the current role of the User. """ diff --git a/src/core/server/models/comment.ts b/src/core/server/models/comment.ts index df5d1f877..6207addb7 100644 --- a/src/core/server/models/comment.ts +++ b/src/core/server/models/comment.ts @@ -3,7 +3,10 @@ import { Db } from "mongodb"; import uuid from "uuid"; import { Omit, Sub } from "talk-common/types"; -import { GQLCOMMENT_SORT } from "talk-server/graph/tenant/schema/__generated__/types"; +import { + GQLCOMMENT_SORT, + GQLCOMMENT_STATUS, +} from "talk-server/graph/tenant/schema/__generated__/types"; import { ActionCounts } from "talk-server/models/actions"; import { Connection, Cursor } from "talk-server/models/connection"; import Query from "talk-server/models/query"; @@ -19,19 +22,11 @@ export interface BodyHistoryItem { } export interface StatusHistoryItem { - status: CommentStatus; // TODO: migrate field + status: GQLCOMMENT_STATUS; // TODO: migrate field assigned_by?: string; created_at: Date; } -export enum CommentStatus { - ACCEPTED = "ACCEPTED", - REJECTED = "REJECTED", - PREMOD = "PREMOD", - SYSTEM_WITHHELD = "SYSTEM_WITHHELD", - NONE = "NONE", -} - export interface Comment extends TenantResource { readonly id: string; parent_id: string | null; @@ -39,7 +34,7 @@ export interface Comment extends TenantResource { asset_id: string; body: string; body_history: BodyHistoryItem[]; - status: CommentStatus; + status: GQLCOMMENT_STATUS; status_history: StatusHistoryItem[]; action_counts: ActionCounts; reply_count: number; diff --git a/src/core/server/models/tenant.ts b/src/core/server/models/tenant.ts index e18ab7ec0..0b14a46c0 100644 --- a/src/core/server/models/tenant.ts +++ b/src/core/server/models/tenant.ts @@ -4,7 +4,10 @@ import { Db } from "mongodb"; import uuid from "uuid"; import { Sub } from "talk-common/types"; -import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; +import { + GQLMODERATION_MODE, + GQLUSER_ROLE, +} from "talk-server/graph/tenant/schema/__generated__/types"; function collection(db: Db) { return db.collection>("tenants"); @@ -19,11 +22,6 @@ export interface Wordlist { suspect: string[]; } -export enum Moderation { - PRE = "PRE", - POST = "POST", -} - // AuthIntegrations. export interface EmailDomainRuleCondition { @@ -90,8 +88,8 @@ export interface GoogleAuthIntegration extends AuthIntegration { export type LocalAuthIntegration = AuthIntegration; -// Auth describes all of the possible auth integration configurations. -export interface Auth { +// AuthIntegrations describes all of the possible auth integration configurations. +export interface AuthIntegrations { // local is the auth integration for the local auth. local: LocalAuthIntegration; @@ -108,6 +106,11 @@ export interface Auth { facebook?: FacebookAuthIntegration; } +export interface Auth { + integrations: AuthIntegrations; + displayNameEnable: boolean; +} + // Tenant definition. export interface Tenant { @@ -117,7 +120,7 @@ export interface Tenant { // specific tenant that the API request pertains to. domain: string; - moderation: Moderation; + moderation: GQLMODERATION_MODE; requireEmailConfirmation: boolean; infoBoxEnable: boolean; infoBoxContent?: string; @@ -172,7 +175,7 @@ export async function createTenant(db: Db, input: CreateTenantInput) { id: uuid.v4(), // Default to post moderation. - moderation: Moderation.POST, + moderation: GQLMODERATION_MODE.POST, // Email confirmation is default off. requireEmailConfirmation: false, @@ -191,8 +194,12 @@ export async function createTenant(db: Db, input: CreateTenantInput) { banned: [], }, auth: { - local: { - enabled: true, + // Disable the displayName by default. + displayNameEnable: false, + integrations: { + local: { + enabled: true, + }, }, }, }; diff --git a/src/core/server/models/user.ts b/src/core/server/models/user.ts index 1fbcbb031..0631e9e85 100644 --- a/src/core/server/models/user.ts +++ b/src/core/server/models/user.ts @@ -15,12 +15,24 @@ function collection(db: Db) { return db.collection>("users"); } -export interface Profile { - readonly id: string; - readonly type: string; - provider?: string; +export interface LocalProfile { + type: "local"; + id: string; } +export interface OIDCProfile { + type: "oidc"; + id: string; + provider: string; +} + +export interface SSOProfile { + type: "sso"; + id: string; +} + +export type Profile = LocalProfile | OIDCProfile | SSOProfile; + export interface Token { readonly id: string; name: string; @@ -28,14 +40,14 @@ export interface Token { } export interface UserStatusHistory { - status: T; // TODO: migrate field + status: T; assigned_by?: string; - reason?: string; // TODO: migrate field + reason?: string; created_at: Date; } export interface UserStatusItem { - status: T; // TODO: migrate field + status: T; history: Array>; } @@ -48,6 +60,7 @@ export interface UserStatus { export interface User extends TenantResource { readonly id: string; username: string | null; + displayName?: string; password?: string; email?: string; email_verified?: boolean; @@ -56,7 +69,7 @@ export interface User extends TenantResource { role: GQLUSER_ROLE; status: UserStatus; action_counts: ActionCounts; - ignored_users: string[]; // TODO: migrate field + ignored_users: string[]; created_at: Date; } diff --git a/src/core/server/services/comments/index.ts b/src/core/server/services/comments/index.ts index f13852dfa..3d90149b8 100644 --- a/src/core/server/services/comments/index.ts +++ b/src/core/server/services/comments/index.ts @@ -2,7 +2,6 @@ import { Db } from "mongodb"; import { Omit } from "talk-common/types"; import { - Comment, CommentStatus, createComment, CreateCommentInput, @@ -13,11 +12,7 @@ export type CreateComment = Omit< "status" | "action_counts" >; -export async function create( - db: Db, - tenantID: string, - input: CreateComment -): Promise { +export async function create(db: Db, tenantID: string, input: CreateComment) { // TODO: run the comment through the moderation phases. const comment = await createComment(db, tenantID, { status: CommentStatus.ACCEPTED, diff --git a/src/core/server/services/users/index.ts b/src/core/server/services/users/index.ts new file mode 100644 index 000000000..08d18ab94 --- /dev/null +++ b/src/core/server/services/users/index.ts @@ -0,0 +1,11 @@ +import { Db } from "mongodb"; + +import { createUser, CreateUserInput } from "talk-server/models/user"; + +export type CreateUser = CreateUserInput; + +export async function create(db: Db, tenantID: string, input: CreateUser) { + const user = await createUser(db, tenantID, input); + + return user; +} From 35ba69c8ed33ade3e233fe0d407ff80f5a15e375 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 11 Jul 2018 16:30:57 -0600 Subject: [PATCH 16/45] fix: allow url to not be provided --- src/core/server/graph/tenant/schema/schema.graphql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 086eb34ed..0d317b963 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -586,9 +586,9 @@ type Query { assets(cursor: Cursor, limit: Int = 10): AssetsConnection """ - asset is the Asset specified by its ID. + asset is the Asset specified by its ID/URL. """ - asset(id: ID, url: String!): Asset + asset(id: ID, url: String): Asset """ me is the current logged in User. From 871206740ff58e8a7c99e2362ac8a898c4e22ee3 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 11 Jul 2018 16:35:20 -0600 Subject: [PATCH 17/45] fix: cleanups for asset --- .../server/graph/tenant/loaders/assets.ts | 5 +++-- .../server/graph/tenant/mutators/comment.ts | 2 +- src/core/server/services/assets/index.ts | 21 +++++++++++++++++++ src/core/server/services/comments/index.ts | 14 ++++++------- 4 files changed, 31 insertions(+), 11 deletions(-) create mode 100644 src/core/server/services/assets/index.ts diff --git a/src/core/server/graph/tenant/loaders/assets.ts b/src/core/server/graph/tenant/loaders/assets.ts index cd7db4b57..b747a1be2 100644 --- a/src/core/server/graph/tenant/loaders/assets.ts +++ b/src/core/server/graph/tenant/loaders/assets.ts @@ -1,15 +1,16 @@ import DataLoader from "dataloader"; + import TenantContext from "talk-server/graph/tenant/context"; import { Asset, - findOrCreateAsset, FindOrCreateAssetInput, retrieveManyAssets, } from "talk-server/models/asset"; +import { findOrCreate } from "talk-server/services/assets"; export default (ctx: TenantContext) => ({ findOrCreate: (input: FindOrCreateAssetInput) => - findOrCreateAsset(ctx.db, ctx.tenant.id, input), + findOrCreate(ctx.db, ctx.tenant, input), asset: new DataLoader(ids => retrieveManyAssets(ctx.db, ctx.tenant.id, ids) ), diff --git a/src/core/server/graph/tenant/mutators/comment.ts b/src/core/server/graph/tenant/mutators/comment.ts index 9025d3cef..1ad380a78 100644 --- a/src/core/server/graph/tenant/mutators/comment.ts +++ b/src/core/server/graph/tenant/mutators/comment.ts @@ -6,7 +6,7 @@ import { create } from "talk-server/services/comments"; export default (ctx: TenantContext) => ({ create: (input: GQLCreateCommentInput): Promise => { // FIXME: remove tenant + user ! - return create(ctx.db, ctx.tenant.id, { + return create(ctx.db, ctx.tenant, { author_id: ctx.user!.id, asset_id: input.assetID, body: input.body, diff --git a/src/core/server/services/assets/index.ts b/src/core/server/services/assets/index.ts new file mode 100644 index 000000000..8c43269d9 --- /dev/null +++ b/src/core/server/services/assets/index.ts @@ -0,0 +1,21 @@ +import { Db } from "mongodb"; + +import { + findOrCreateAsset, + FindOrCreateAssetInput, +} from "talk-server/models/asset"; +import { Tenant } from "talk-server/models/tenant"; + +export type FindOrCreateAsset = FindOrCreateAssetInput; + +export async function findOrCreate( + db: Db, + tenant: Tenant, + input: FindOrCreateAsset +) { + // TODO: check to see if the tenant has enabled lazy asset creation. + + const asset = await findOrCreateAsset(db, tenant.id, input); + + return asset; +} diff --git a/src/core/server/services/comments/index.ts b/src/core/server/services/comments/index.ts index 3d90149b8..dfba035d4 100644 --- a/src/core/server/services/comments/index.ts +++ b/src/core/server/services/comments/index.ts @@ -1,21 +1,19 @@ import { Db } from "mongodb"; import { Omit } from "talk-common/types"; -import { - CommentStatus, - createComment, - CreateCommentInput, -} from "talk-server/models/comment"; +import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__/types"; +import { createComment, CreateCommentInput } from "talk-server/models/comment"; +import { Tenant } from "talk-server/models/tenant"; export type CreateComment = Omit< CreateCommentInput, "status" | "action_counts" >; -export async function create(db: Db, tenantID: string, input: CreateComment) { +export async function create(db: Db, tenant: Tenant, input: CreateComment) { // TODO: run the comment through the moderation phases. - const comment = await createComment(db, tenantID, { - status: CommentStatus.ACCEPTED, + const comment = await createComment(db, tenant.id, { + status: GQLCOMMENT_STATUS.ACCEPTED, action_counts: {}, ...input, }); From 70ff49ec4f01fda771185d106b2e8ba7e750fa92 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 11 Jul 2018 16:49:54 -0600 Subject: [PATCH 18/45] fix: missed users service --- src/core/server/app/handlers/auth/local.ts | 2 +- src/core/server/app/middleware/passport/oidc.ts | 2 +- src/core/server/services/users/index.ts | 5 +++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/core/server/app/handlers/auth/local.ts b/src/core/server/app/handlers/auth/local.ts index 656b3cb57..3bd3d2eb7 100644 --- a/src/core/server/app/handlers/auth/local.ts +++ b/src/core/server/app/handlers/auth/local.ts @@ -67,7 +67,7 @@ export const signup = ({ db }: SignupOptions): RequestHandler => async ( }; // Create the new user. - const user = await create(db, tenant.id, { + const user = await create(db, tenant, { email, username, displayName, diff --git a/src/core/server/app/middleware/passport/oidc.ts b/src/core/server/app/middleware/passport/oidc.ts index c6be6d6c6..4892cd918 100644 --- a/src/core/server/app/middleware/passport/oidc.ts +++ b/src/core/server/app/middleware/passport/oidc.ts @@ -63,7 +63,7 @@ export async function findOrCreateOIDCUser( // FIXME: implement rules. // Create the new user, as one didn't exist before! - user = await create(db, tenant.id, { + user = await create(db, tenant, { username: null, role: GQLUSER_ROLE.COMMENTER, email, diff --git a/src/core/server/services/users/index.ts b/src/core/server/services/users/index.ts index 08d18ab94..6906a7d3b 100644 --- a/src/core/server/services/users/index.ts +++ b/src/core/server/services/users/index.ts @@ -1,11 +1,12 @@ import { Db } from "mongodb"; +import { Tenant } from "talk-server/models/tenant"; import { createUser, CreateUserInput } from "talk-server/models/user"; export type CreateUser = CreateUserInput; -export async function create(db: Db, tenantID: string, input: CreateUser) { - const user = await createUser(db, tenantID, input); +export async function create(db: Db, tenant: Tenant, input: CreateUser) { + const user = await createUser(db, tenant.id, input); return user; } From a2a49b8e45c4aed3d2e41d57ee79c32d527e92fa Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 13 Jul 2018 14:36:54 -0600 Subject: [PATCH 19/45] feat: improved user creation --- src/core/server/app/handlers/auth/local.ts | 6 +- src/core/server/app/index.ts | 5 +- src/core/server/app/middleware/error.ts | 6 + src/core/server/app/middleware/logging.ts | 6 +- .../server/app/middleware/passport/oidc.ts | 153 +++++++++++------- .../request/__snapshots__/body.spec.ts.snap | 3 + src/core/server/app/request/body.spec.ts | 32 ++++ src/core/server/app/request/body.ts | 5 +- src/core/server/app/router.ts | 41 +++-- src/core/server/models/user.ts | 72 +++++++-- src/core/server/services/users/index.ts | 8 +- 11 files changed, 246 insertions(+), 91 deletions(-) create mode 100644 src/core/server/app/middleware/error.ts create mode 100644 src/core/server/app/request/__snapshots__/body.spec.ts.snap create mode 100644 src/core/server/app/request/body.spec.ts diff --git a/src/core/server/app/handlers/auth/local.ts b/src/core/server/app/handlers/auth/local.ts index 3bd3d2eb7..777d64dc8 100644 --- a/src/core/server/app/handlers/auth/local.ts +++ b/src/core/server/app/handlers/auth/local.ts @@ -6,7 +6,7 @@ import { handle } from "talk-server/app/middleware/passport"; import { validate } from "talk-server/app/request/body"; import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; import { LocalProfile } from "talk-server/models/user"; -import { create } from "talk-server/services/users"; +import { upsert } from "talk-server/services/users"; import { Request } from "talk-server/types/express"; export interface SignupBody { @@ -67,12 +67,14 @@ export const signup = ({ db }: SignupOptions): RequestHandler => async ( }; // Create the new user. - const user = await create(db, tenant, { + const user = await upsert(db, tenant, { email, username, displayName, password, profiles: [profile], + // New users signing up via local auth will have the commenter role to + // start with. role: GQLUSER_ROLE.COMMENTER, }); diff --git a/src/core/server/app/index.ts b/src/core/server/app/index.ts index ef65f8157..0fefb561f 100644 --- a/src/core/server/app/index.ts +++ b/src/core/server/app/index.ts @@ -8,10 +8,7 @@ import { Config } from "talk-server/config"; import { handleSubscriptions } from "talk-server/graph/common/subscriptions/middleware"; import { Schemas } from "talk-server/graph/schemas"; -import { - access as accessLogger, - error as errorLogger, -} from "./middleware/logging"; +import { accessLogger, errorLogger } from "./middleware/logging"; import serveStatic from "./middleware/serveStatic"; import { createRouter } from "./router"; diff --git a/src/core/server/app/middleware/error.ts b/src/core/server/app/middleware/error.ts new file mode 100644 index 000000000..2f666260b --- /dev/null +++ b/src/core/server/app/middleware/error.ts @@ -0,0 +1,6 @@ +import { ErrorRequestHandler } from "express"; + +export const apiErrorHandler: ErrorRequestHandler = (err, req, res, next) => { + // TODO: handle better when we improve errors. + res.status(500).json({ error: err.message }); +}; diff --git a/src/core/server/app/middleware/logging.ts b/src/core/server/app/middleware/logging.ts index 9db48fa31..ebe1fe53e 100644 --- a/src/core/server/app/middleware/logging.ts +++ b/src/core/server/app/middleware/logging.ts @@ -2,7 +2,7 @@ import { ErrorRequestHandler, RequestHandler } from "express"; import now from "performance-now"; import logger from "../../logger"; -export const access: RequestHandler = (req, res, next) => { +export const accessLogger: RequestHandler = (req, res, next) => { const startTime = now(); const end = res.end; res.end = (chunk: any, encodingOrCb?: any, cb?: any) => { @@ -37,7 +37,7 @@ export const access: RequestHandler = (req, res, next) => { next(); }; -export const error: ErrorRequestHandler = (err, req, res, next) => { - logger.error({ err }, "http error"); +export const errorLogger: ErrorRequestHandler = (err, req, res, next) => { + logger.error(err, "http error"); next(err); }; diff --git a/src/core/server/app/middleware/passport/oidc.ts b/src/core/server/app/middleware/passport/oidc.ts index 4892cd918..a26ff32dd 100644 --- a/src/core/server/app/middleware/passport/oidc.ts +++ b/src/core/server/app/middleware/passport/oidc.ts @@ -1,27 +1,33 @@ import jwt from "jsonwebtoken"; import jwks, { JwksClient } from "jwks-rsa"; import { Db } from "mongodb"; -import { Strategy as OAuth2Strategy } from "passport-oauth2"; +import { Strategy as OAuth2Strategy, VerifyCallback } from "passport-oauth2"; import { Strategy } from "passport-strategy"; import { reconstructURL } from "talk-server/app/url"; import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; import { OIDCAuthIntegration, Tenant } from "talk-server/models/tenant"; import { OIDCProfile, retrieveUserWithProfile } from "talk-server/models/user"; -import { create } from "talk-server/services/users"; +import { upsert } from "talk-server/services/users"; import { Request } from "talk-server/types/express"; -import { VerifyCallback } from "./index"; - export interface Params { id_token?: string; } +/** + * OIDCIDToken describes the set of claims that are present in a ID Token. This + * interface confirms with the ID Token specification as defined: + * https://openid.net/specs/openid-connect-core-1_0.html#IDToken + */ export interface OIDCIDToken { + aud: string; iss: string; sub: string; - email: string; + exp: number; // TODO: use this as the source for how long an OIDC user can be logged in for + email?: string; email_verified?: boolean; + picture?: string; } export interface StrategyItem { @@ -48,13 +54,14 @@ export function isOIDCToken(token: OIDCIDToken | object): token is OIDCIDToken { export async function findOrCreateOIDCUser( db: Db, tenant: Tenant, - { iss, sub, email, email_verified }: OIDCIDToken + token: OIDCIDToken ) { // Construct the profile that will be used to query for the user. const profile: OIDCProfile = { type: "oidc", - provider: iss, - id: sub, + id: token.sub, + issuer: token.iss, + audience: token.aud, }; // Try to lookup user given their id provided in the `sub` claim. @@ -63,11 +70,12 @@ export async function findOrCreateOIDCUser( // FIXME: implement rules. // Create the new user, as one didn't exist before! - user = await create(db, tenant, { + user = await upsert(db, tenant, { username: null, role: GQLUSER_ROLE.COMMENTER, - email, - email_verified, + email: token.email, + email_verified: token.email_verified, + avatar: token.picture, profiles: [profile], }); } @@ -75,6 +83,11 @@ export async function findOrCreateOIDCUser( return user; } +/** + * OIDC_SCOPE is the set of scopes requested for users signing up via OIDC. + */ +const OIDC_SCOPE = "openid email profile"; + // FIXME: attach strategy to cache updates of the tenants export default class OIDCStrategy extends Strategy { @@ -138,14 +151,14 @@ export default class OIDCStrategy extends Strategy { return entry.jwksClient; } - private verifyCallback( + private verifyCallback = ( req: Request, - accessToken: string, - refreshToken: string, + accessToken: string, // ignore the access token, we don't use it. + refreshToken: string, // ignore the refresh token, we don't use it. params: Params, - profile: any, + profile: any, // we don't look inside the profile (yet). done: VerifyCallback - ) { + ) => { // Try to lookup user given their id provided in the `sub` claim of the // `id_token`. const { id_token } = params; @@ -156,36 +169,30 @@ export default class OIDCStrategy extends Strategy { // Grab the tenant out of the request, as we need some more details. const { tenant } = req; + if (!tenant) { + // TODO: return a better error. + return done(new Error("tenant not found")); + } + + // Get the integration from the tenant. If needed, it will be used to create + // a new strategy. + let integration: OIDCAuthIntegration; + try { + integration = getEnabledIntegration(tenant); + } catch (err) { + // TODO: wrap error? + return done(err); + } // Grab the JWKSClient. - const client = this.lookupJWKSClient( - req, - tenant!.id, - tenant!.auth.integrations.oidc! - ); + const client = this.lookupJWKSClient(req, tenant.id, integration); // Verify that the id_token is valid or not. jwt.verify( id_token, - ({ kid }, callback) => { - if (!kid) { - // TODO: return better error. - return callback(new Error("no kid in id_token")); - } - - // Get the signing key from the jwks provider. - client.getSigningKey(kid, (err, key) => { - if (err) { - // TODO: wrap error? - return callback(err); - } - - const signingKey = key.publicKey || key.rsaPublicKey; - callback(null, signingKey); - }); - }, + this.keyFunc(client), { - issuer: tenant!.auth.integrations.oidc!.issuer, + issuer: integration.issuer, }, (err, decoded) => { if (err) { @@ -193,10 +200,39 @@ export default class OIDCStrategy extends Strategy { return done(err); } - this.verify(tenant!, decoded as OIDCIDToken, done); + // Delegate the verify method off to the passed in verify method. + this.verify(tenant, decoded as OIDCIDToken, done); } ); - } + }; + + /** + * keyFunc will provide the secret based on the given jwkw client. + * + * @param client the jwks client for the specific request being made + */ + private keyFunc = (client: jwks.JwksClient): jwt.KeyFunction => ( + { kid }, + callback + ) => { + if (!kid) { + // TODO: return better error. + return callback(new Error("no kid in id_token")); + } + + // Get the signing key from the jwks provider. + client.getSigningKey(kid, (err, key) => { + if (err) { + // TODO: wrap error? + return callback(err); + } + + // Grab the signingKey out of the provided key. + const signingKey = key.publicKey || key.rsaPublicKey; + + callback(null, signingKey); + }); + }; private createStrategy( req: Request, @@ -218,7 +254,7 @@ export default class OIDCStrategy extends Strategy { tokenURL, callbackURL, }, - this.verifyCallback.bind(this) + this.verifyCallback ); } @@ -231,17 +267,7 @@ export default class OIDCStrategy extends Strategy { // Get the integration from the tenant. If needed, it will be used to create // a new strategy. - const integration = tenant.auth.integrations.oidc; - if (!integration) { - // TODO: return a better error. - throw new Error("integration not found"); - } - - // Handle when the integration is enabled/disabled. - if (!integration.enabled) { - // TODO: return a better error. - throw new Error("integration not enabled"); - } + const integration = getEnabledIntegration(tenant); // Try to get the Tenant's cached integrations. let entry = this.cache.get(tenant.id); @@ -279,13 +305,32 @@ export default class OIDCStrategy extends Strategy { // Authenticate with the strategy, binding the current context to the method // to provide it with the augmented passport handlers. We also request the // 'openid' scope so we can get an id_token back. - strategy.authenticate(req, { scope: "openid email", session: false }); + strategy.authenticate(req, { + scope: OIDC_SCOPE, + session: false, + }); } catch (err) { return this.error(err); } } } +function getEnabledIntegration(tenant: Tenant) { + const integration = tenant.auth.integrations.oidc; + if (!integration) { + // TODO: return a better error. + throw new Error("integration not found"); + } + + // Handle when the integration is enabled/disabled. + if (!integration.enabled) { + // TODO: return a better error. + throw new Error("integration not enabled"); + } + + return integration; +} + export function createOIDCStrategy({ db }: OIDCStrategyOptions) { return new OIDCStrategy({ db }); } diff --git a/src/core/server/app/request/__snapshots__/body.spec.ts.snap b/src/core/server/app/request/__snapshots__/body.spec.ts.snap new file mode 100644 index 000000000..823d9cb68 --- /dev/null +++ b/src/core/server/app/request/__snapshots__/body.spec.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`throws an error for missing fields 1`] = `"child \\"d\\" fails because [\\"d\\" is required]"`; diff --git a/src/core/server/app/request/body.spec.ts b/src/core/server/app/request/body.spec.ts new file mode 100644 index 000000000..98fd1376d --- /dev/null +++ b/src/core/server/app/request/body.spec.ts @@ -0,0 +1,32 @@ +import Joi from "joi"; + +import { validate } from "talk-server/app/request/body"; + +it("strips out unknown fields", () => { + const payload = { a: 1, b: 2, c: 3 }; + const schema = Joi.object().keys({}); + + expect(validate(schema, payload)).toEqual({}); +}); + +it("allows valid fields", () => { + const payload = { a: 1, b: 2, c: 3 }; + const schema = Joi.object().keys({ a: Joi.number() }); + + expect(validate(schema, payload)).toEqual({ a: 1 }); +}); + +it("allows valid fields from extended schema", () => { + const payload = { a: 1, b: 2, c: 3 }; + const schema = Joi.object().keys({ a: Joi.number() }); + const extendedSchema = schema.keys({ b: Joi.number() }); + + expect(validate(extendedSchema, payload)).toEqual({ a: 1, b: 2 }); +}); + +it("throws an error for missing fields", () => { + const payload = { a: 1, b: 2, c: 3 }; + const schema = Joi.object().keys({ d: Joi.number() }); + + expect(() => validate(schema, payload)).toThrowErrorMatchingSnapshot(); +}); diff --git a/src/core/server/app/request/body.ts b/src/core/server/app/request/body.ts index 9b7cabb13..a8e34412c 100644 --- a/src/core/server/app/request/body.ts +++ b/src/core/server/app/request/body.ts @@ -12,11 +12,12 @@ export const validate = (schema: Joi.SchemaLike, body: any) => { const { value, error: err } = Joi.validate(body, schema, { stripUnknown: true, presence: "required", + abortEarly: false, }); if (err) { - // TODO: return better error. - throw new Error("Validation Error"); + // TODO: wrap error? + throw err; } return value; diff --git a/src/core/server/app/router.ts b/src/core/server/app/router.ts index 7b2338e0f..1011e532f 100644 --- a/src/core/server/app/router.ts +++ b/src/core/server/app/router.ts @@ -6,6 +6,8 @@ import managementGraphMiddleware from "talk-server/graph/management/middleware"; import tenantGraphMiddleware from "talk-server/graph/tenant/middleware"; import { signup } from "talk-server/app/handlers/auth/local"; +import { apiErrorHandler } from "talk-server/app/middleware/error"; +import { errorLogger } from "talk-server/app/middleware/logging"; import { authenticate } from "talk-server/app/middleware/passport"; import { AppOptions } from "./index"; import playground from "./middleware/playground"; @@ -33,19 +35,11 @@ async function createTenantRouter(app: AppOptions, options: RouterOptions) { // Tenant identification middleware. router.use(tenantMiddleware({ db: app.mongo })); + // Setup Passport middleware. router.use(options.passport.initialize()); - router.use( - "/auth/local", - express.json(), - authenticate(options.passport, "local") - ); - router.use("/auth/local/signup", express.json(), signup({ db: app.mongo })); - router.use("/auth/oidc", authenticate(options.passport, "oidc")); - router.use("/auth/oidc/callback", authenticate(options.passport, "oidc")); - // router.use("/auth/google", options.passport.authenticate("google")); - // router.use("/auth/google/callback", options.passport.authenticate("google")); - // router.use("/auth/facebook", options.passport.authenticate("facebook")); - // router.use("/auth/facebook/callback", options.passport.authenticate("facebook")); + + // Setup auth routes. + router.use("/auth", createNewAuthRouter(app, options)); // Tenant API router.use( @@ -57,6 +51,25 @@ async function createTenantRouter(app: AppOptions, options: RouterOptions) { return router; } +function createNewAuthRouter(app: AppOptions, options: RouterOptions) { + const router = express.Router(); + + router.post( + "/local", + express.json(), + authenticate(options.passport, "local") + ); + router.post("/local/signup", express.json(), signup({ db: app.mongo })); + router.get("/oidc", authenticate(options.passport, "oidc")); + router.get("/oidc/callback", authenticate(options.passport, "oidc")); + // router.get("/google", options.passport.authenticate("google")); + // router.get("/google/callback", options.passport.authenticate("google")); + // router.get("/facebook", options.passport.authenticate("facebook")); + // router.get("/facebook/callback", options.passport.authenticate("facebook")); + + return router; +} + async function createAPIRouter(app: AppOptions, options: RouterOptions) { // Create a router. const router = express.Router(); @@ -67,6 +80,10 @@ async function createAPIRouter(app: AppOptions, options: RouterOptions) { // Configure the management routes. router.use("/management", await createManagementRouter(app, options)); + // General API error handler. + router.use(errorLogger); + router.use(apiErrorHandler); + return router; } diff --git a/src/core/server/models/user.ts b/src/core/server/models/user.ts index 0631e9e85..04da3214d 100644 --- a/src/core/server/models/user.ts +++ b/src/core/server/models/user.ts @@ -9,6 +9,7 @@ import { GQLUSER_USERNAME_STATUS, } from "talk-server/graph/tenant/schema/__generated__/types"; import { ActionCounts } from "talk-server/models/actions"; +import { FilterQuery } from "talk-server/models/query"; import { TenantResource } from "talk-server/models/tenant"; function collection(db: Db) { @@ -23,7 +24,8 @@ export interface LocalProfile { export interface OIDCProfile { type: "oidc"; id: string; - provider: string; + issuer: string; + audience: string; } export interface SSOProfile { @@ -62,6 +64,7 @@ export interface User extends TenantResource { username: string | null; displayName?: string; password?: string; + avatar?: string; email?: string; email_verified?: boolean; profiles: Profile[]; @@ -73,7 +76,7 @@ export interface User extends TenantResource { created_at: Date; } -export type CreateUserInput = Omit< +export type UpsertUserInput = Omit< User, | "id" | "tenant_id" @@ -84,17 +87,20 @@ export type CreateUserInput = Omit< | "created_at" >; -export async function createUser( +export async function upsertUser( db: Db, tenantID: string, - input: CreateUserInput + input: UpsertUserInput ) { const now = new Date(); + // Create a new ID for the user. + const id = uuid.v4(); + // default are the properties set by the application when a new user is // created. - const defaults: Sub = { - id: uuid.v4(), + const defaults: Sub = { + id, tenant_id: tenantID, tokens: [], action_counts: {}, @@ -118,15 +124,61 @@ export async function createUser( created_at: now, }; + if (input.password) { + // Hash the user's password with bcrypt. + input.password = await bcrypt.hash(input.password, 10); + } + // Merge the defaults and the input together. const user: Readonly = merge({}, defaults, input); - // Insert it into the database. - await collection(db).insertOne(user); + // Create a query that will utilize a findOneAndUpdate to facilitate an upsert + // operation to ensure no user has the same profile and/or email address. If + // any user is found to have the same profile as any of the profiles specified + // in the new user object, then we should error here. + const filter = createUpsertUserFilter(user); - return user; + // Create the upsert/update operation. + const update: { $setOnInsert: Readonly } = { + $setOnInsert: user, + }; + + // Insert it into the database. This may throw an error. + const result = await collection(db).findOneAndUpdate(filter, update, { + // We are using this to create a user, so we need to upsert it. + upsert: true, + + // False to return the updated document instead of the original document. + // This lets us detect if the document was updated or not. + returnOriginal: false, + }); + + // Check to see if this was a new user that was upserted, or one was found + // that matched existing records. We are sure here that the record exists + // because we're returning the updated document and performing an upsert + // operation. + if (result.value!.id !== id) { + // TODO: return better error. + throw new Error("user already found"); + } + + return result.value!; } +const createUpsertUserFilter = (user: Readonly) => { + const query: FilterQuery = { + // Query by the profiles if the user is being created with one. + $or: user.profiles.map(profile => ({ profiles: { $elemMatch: profile } })), + }; + + if (user.email) { + // Query by the email address if the user is being created with one. + query.$or.push({ email: user.email }); + } + + return query; +}; + export async function retrieveUser(db: Db, tenantID: string, id: string) { return collection(db).findOne({ id, tenant_id: tenantID }); } @@ -178,7 +230,7 @@ export async function updateUserRole( export async function verifyUserPassword(user: User, password: string) { if (user.password) { - return bcrypt.compare(user.password, password); + return bcrypt.compare(password, user.password); } return false; diff --git a/src/core/server/services/users/index.ts b/src/core/server/services/users/index.ts index 6906a7d3b..1a0a62b4f 100644 --- a/src/core/server/services/users/index.ts +++ b/src/core/server/services/users/index.ts @@ -1,12 +1,12 @@ import { Db } from "mongodb"; import { Tenant } from "talk-server/models/tenant"; -import { createUser, CreateUserInput } from "talk-server/models/user"; +import { upsertUser, UpsertUserInput } from "talk-server/models/user"; -export type CreateUser = CreateUserInput; +export type UpsertUser = UpsertUserInput; -export async function create(db: Db, tenant: Tenant, input: CreateUser) { - const user = await createUser(db, tenant.id, input); +export async function upsert(db: Db, tenant: Tenant, input: UpsertUser) { + const user = await upsertUser(db, tenant.id, input); return user; } From 2a7fa08bd6a56c22fd320d596233b8a423edfe1f Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 13 Jul 2018 14:52:10 -0600 Subject: [PATCH 20/45] fix: renamed type file --- src/core/server/types/{express.ts => express.d.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/core/server/types/{express.ts => express.d.ts} (100%) diff --git a/src/core/server/types/express.ts b/src/core/server/types/express.d.ts similarity index 100% rename from src/core/server/types/express.ts rename to src/core/server/types/express.d.ts From f5ef551fb449bc824146ca8ada7ed4e14f435645 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 16 Jul 2018 10:49:16 -0600 Subject: [PATCH 21/45] review: input.password -> hashedPassword --- src/core/server/models/user.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/core/server/models/user.ts b/src/core/server/models/user.ts index 04da3214d..5adc4bc53 100644 --- a/src/core/server/models/user.ts +++ b/src/core/server/models/user.ts @@ -124,13 +124,18 @@ export async function upsertUser( created_at: now, }; + let hashedPassword; if (input.password) { // Hash the user's password with bcrypt. - input.password = await bcrypt.hash(input.password, 10); + hashedPassword = await bcrypt.hash(input.password, 10); } // Merge the defaults and the input together. - const user: Readonly = merge({}, defaults, input); + const user: Readonly = merge({}, defaults, input, { + // Specified last in the merge call, it will override any existing password + // entry if it is defined. + password: hashedPassword, + }); // Create a query that will utilize a findOneAndUpdate to facilitate an upsert // operation to ensure no user has the same profile and/or email address. If From 7e98580264d9c9baa6b025d0b65b7961c945b11e Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 16 Jul 2018 11:38:28 -0600 Subject: [PATCH 22/45] review: changes for review --- config/watcher.ts | 4 +- package.json | 4 +- scripts/{types.js => generateSchemaTypes.js} | 10 +- src/core/client/tsconfig.json | 3 +- src/core/server/app/handlers/auth/local.ts | 3 +- .../server/app/middleware/passport/oidc.ts | 121 +++++++++--------- 6 files changed, 66 insertions(+), 79 deletions(-) rename scripts/{types.js => generateSchemaTypes.js} (95%) diff --git a/config/watcher.ts b/config/watcher.ts index b4c1470dd..c441f09a2 100644 --- a/config/watcher.ts +++ b/config/watcher.ts @@ -9,8 +9,8 @@ const config: Config = { rootDir: path.resolve(__dirname, "../src"), watchers: { compileGraphQLTypes: { - paths: ["core/server/graph/**/*.graphql"], - executor: new CommandExecutor("npm run compile:graphql", { + paths: ["core/server/**/*.graphql"], + executor: new CommandExecutor("npm run compile:schema", { runOnInit: true, }), }, diff --git a/package.json b/package.json index aa5f888b1..33ae5e838 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "build:server": "tsc -p ./src/tsconfig.json", "build": "npm-run-all compile --parallel build:*", "compile:css-types": "tcm src/core/client/", - "compile:graphql": "node ./scripts/types.js", + "compile:schema": "node ./scripts/generateSchemaTypes.js", "compile:relay-stream": "relay-compiler --src ./src/core/client/stream --schema $(ts-node ./scripts/schemaPath.ts tenant) --language typescript --artifactDirectory ./src/core/client/stream/__generated__ --no-watchman", "compile": "npm-run-all --parallel compile:*", "docz:watch": "docz dev", @@ -175,4 +175,4 @@ "webpack-hot-client": "^4.0.3", "webpack-manifest-plugin": "^2.0.3" } -} +} \ No newline at end of file diff --git a/scripts/types.js b/scripts/generateSchemaTypes.js similarity index 95% rename from scripts/types.js rename to scripts/generateSchemaTypes.js index be1449842..3344eb33f 100644 --- a/scripts/types.js +++ b/scripts/generateSchemaTypes.js @@ -17,15 +17,9 @@ function lintAndWrite(files) { function getFileName(name) { return path.join( __dirname, - "..", - "src", - "core", - "server", - "graph", + "../src/core/server/graph", name, - "schema", - "__generated__", - "types.ts" + "schema/__generated__/types.ts" ); } diff --git a/src/core/client/tsconfig.json b/src/core/client/tsconfig.json index e8fb8eeab..a62cea6c5 100644 --- a/src/core/client/tsconfig.json +++ b/src/core/client/tsconfig.json @@ -12,8 +12,7 @@ "talk-stream/*": ["./stream/*"], "talk-framework/*": ["./framework/*"], "talk-ui/*": ["./ui/*"], - "talk-common/*": ["../common/*"], - "talk-locales/*": ["../../locales/*"] + "talk-common/*": ["../common/*"] } }, "include": ["./**/*", "../../types/**/*.d.ts"], diff --git a/src/core/server/app/handlers/auth/local.ts b/src/core/server/app/handlers/auth/local.ts index 777d64dc8..a5e483d08 100644 --- a/src/core/server/app/handlers/auth/local.ts +++ b/src/core/server/app/handlers/auth/local.ts @@ -22,8 +22,7 @@ const SignupBodySchema = Joi.object().keys({ email: Joi.string().trim(), }); -// Extends the default signup body schema with the displayName to allow it to be -// sent. +// Extends the default signup body schema to allow the displayName to be set. const SignupDisplayNameBodySchema = SignupBodySchema.keys({ displayName: Joi.string().trim(), }); diff --git a/src/core/server/app/middleware/passport/oidc.ts b/src/core/server/app/middleware/passport/oidc.ts index a26ff32dd..07dd55543 100644 --- a/src/core/server/app/middleware/passport/oidc.ts +++ b/src/core/server/app/middleware/passport/oidc.ts @@ -51,6 +51,50 @@ export function isOIDCToken(token: OIDCIDToken | object): token is OIDCIDToken { return false; } +/** + * keyFunc will provide the secret based on the given jwkw client. + * + * @param client the jwks client for the specific request being made + */ +const signingKeyFactory = (client: jwks.JwksClient): jwt.KeyFunction => ( + { kid }, + callback +) => { + if (!kid) { + // TODO: return better error. + return callback(new Error("no kid in id_token")); + } + + // Get the signing key from the jwks provider. + client.getSigningKey(kid, (err, key) => { + if (err) { + // TODO: wrap error? + return callback(err); + } + + // Grab the signingKey out of the provided key. + const signingKey = key.publicKey || key.rsaPublicKey; + + callback(null, signingKey); + }); +}; + +function getEnabledIntegration(tenant: Tenant) { + const integration = tenant.auth.integrations.oidc; + if (!integration) { + // TODO: return a better error. + throw new Error("integration not found"); + } + + // Handle when the integration is enabled/disabled. + if (!integration.enabled) { + // TODO: return a better error. + throw new Error("integration not enabled"); + } + + return integration; +} + export async function findOrCreateOIDCUser( db: Db, tenant: Tenant, @@ -104,19 +148,6 @@ export default class OIDCStrategy extends Strategy { this.db = db; } - private async verify( - tenant: Tenant, - token: OIDCIDToken, - done: VerifyCallback - ) { - try { - const user = await findOrCreateOIDCUser(this.db, tenant, token); - return done(null, user); - } catch (err) { - return done(err); - } - } - private lookupJWKSClient( req: Request, tenantID: string, @@ -151,7 +182,7 @@ export default class OIDCStrategy extends Strategy { return entry.jwksClient; } - private verifyCallback = ( + private userAuthenticatedCallback = ( req: Request, accessToken: string, // ignore the access token, we don't use it. refreshToken: string, // ignore the refresh token, we don't use it. @@ -190,50 +221,30 @@ export default class OIDCStrategy extends Strategy { // Verify that the id_token is valid or not. jwt.verify( id_token, - this.keyFunc(client), + signingKeyFactory(client), { issuer: integration.issuer, }, - (err, decoded) => { + async (err, decoded) => { if (err) { // TODO: wrap error? return done(err); } - // Delegate the verify method off to the passed in verify method. - this.verify(tenant, decoded as OIDCIDToken, done); + try { + const user = await findOrCreateOIDCUser( + this.db, + tenant, + decoded as OIDCIDToken + ); + return done(null, user); + } catch (err) { + return done(err); + } } ); }; - /** - * keyFunc will provide the secret based on the given jwkw client. - * - * @param client the jwks client for the specific request being made - */ - private keyFunc = (client: jwks.JwksClient): jwt.KeyFunction => ( - { kid }, - callback - ) => { - if (!kid) { - // TODO: return better error. - return callback(new Error("no kid in id_token")); - } - - // Get the signing key from the jwks provider. - client.getSigningKey(kid, (err, key) => { - if (err) { - // TODO: wrap error? - return callback(err); - } - - // Grab the signingKey out of the provided key. - const signingKey = key.publicKey || key.rsaPublicKey; - - callback(null, signingKey); - }); - }; - private createStrategy( req: Request, integration: OIDCAuthIntegration @@ -254,7 +265,7 @@ export default class OIDCStrategy extends Strategy { tokenURL, callbackURL, }, - this.verifyCallback + this.userAuthenticatedCallback ); } @@ -315,22 +326,6 @@ export default class OIDCStrategy extends Strategy { } } -function getEnabledIntegration(tenant: Tenant) { - const integration = tenant.auth.integrations.oidc; - if (!integration) { - // TODO: return a better error. - throw new Error("integration not found"); - } - - // Handle when the integration is enabled/disabled. - if (!integration.enabled) { - // TODO: return a better error. - throw new Error("integration not enabled"); - } - - return integration; -} - export function createOIDCStrategy({ db }: OIDCStrategyOptions) { return new OIDCStrategy({ db }); } From b07073b882479c16d8ba6661aa7ea6af063ac2be Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 16 Jul 2018 13:14:38 -0600 Subject: [PATCH 23/45] review: tests --- .../graph/common/scalars/cursor.spec.ts | 136 ++++++++++++++++++ .../server/graph/common/scalars/cursor.ts | 4 + .../server/types/{express.d.ts => express.ts} | 0 3 files changed, 140 insertions(+) create mode 100644 src/core/server/graph/common/scalars/cursor.spec.ts rename src/core/server/types/{express.d.ts => express.ts} (100%) diff --git a/src/core/server/graph/common/scalars/cursor.spec.ts b/src/core/server/graph/common/scalars/cursor.spec.ts new file mode 100644 index 000000000..6d3b807f2 --- /dev/null +++ b/src/core/server/graph/common/scalars/cursor.spec.ts @@ -0,0 +1,136 @@ +import { Kind } from "graphql"; +import { DateTime } from "luxon"; + +import Cursor from "./cursor"; + +describe("parseLiteral", () => { + it("parses a date from a string", () => { + expect( + Cursor.parseLiteral({ + kind: Kind.STRING, + value: "2018-07-16T18:34:26.744Z", + }) + ).toBeInstanceOf(Date); + + expect( + Cursor.parseLiteral({ + kind: Kind.STRING, + value: "this-should-fail", + }) + ).toEqual(null); + + expect( + Cursor.parseLiteral({ + kind: Kind.STRING, + value: "", + }) + ).toEqual(null); + }); + + it("parses a number from a string", () => { + expect( + Cursor.parseLiteral({ + kind: Kind.STRING, + value: "20", + }) + ).toEqual(20); + + expect( + Cursor.parseLiteral({ + kind: Kind.STRING, + value: "0", + }) + ).toEqual(0); + + expect( + Cursor.parseLiteral({ + kind: Kind.STRING, + value: "null", + }) + ).toEqual(null); + + expect( + Cursor.parseLiteral({ + kind: Kind.STRING, + value: "0", + }) + ).toEqual(0); + }); + + it("parses a number from a number", () => { + expect( + Cursor.parseLiteral({ + kind: Kind.INT, + value: "20", + }) + ).toEqual(20); + + expect( + Cursor.parseLiteral({ + kind: Kind.INT, + value: "0", + }) + ).toEqual(0); + + expect( + Cursor.parseLiteral({ + kind: Kind.INT, + value: "", + }) + ).toEqual(null); + }); + + it("does not parse unknown kinds", () => { + expect( + Cursor.parseLiteral({ + kind: Kind.FLOAT, + value: "0.0", + }) + ).toEqual(null); + }); +}); + +describe("serialize", () => { + it("renders native dates correctly", () => { + const date = new Date(); + const expected = date.toISOString(); + expect(Cursor.serialize(date)).toEqual(expected); + + expect(Cursor.serialize({})).toEqual(null); + }); + + it("renders luxon dates correctly", () => { + const date = DateTime.fromJSDate(new Date()); + const expected = date.toISO(); + expect(Cursor.serialize(date)).toEqual(expected); + }); + + it("renders numbers correctly", () => { + let value = 50; + let expected = "50"; + expect(Cursor.serialize(value)).toEqual(expected); + + value = 0; + expected = "0"; + expect(Cursor.serialize(value)).toEqual(expected); + + expect(Cursor.serialize(null)).toEqual(null); + }); +}); + +describe("parseValue", () => { + it("parses the string value of a Date", () => { + const date = new Date(); + const expected = date.toISOString(); + expect(Cursor.parseValue(expected)).toBeInstanceOf(Date); + }); + + it("parses the string value of a number", () => { + expect(Cursor.parseValue("0")).toEqual(0); + }); + + it("handles invalid properties", () => { + expect(Cursor.parseValue(null)).toEqual(null); + expect(Cursor.parseValue(2)).toEqual(null); + }); +}); diff --git a/src/core/server/graph/common/scalars/cursor.ts b/src/core/server/graph/common/scalars/cursor.ts index 23649e10b..e4aeaab74 100644 --- a/src/core/server/graph/common/scalars/cursor.ts +++ b/src/core/server/graph/common/scalars/cursor.ts @@ -1,11 +1,15 @@ import { GraphQLScalarType } from "graphql"; import { Kind } from "graphql/language"; import { DateTime } from "luxon"; + import { Cursor } from "talk-server/models/connection"; function parseIntegerCursor(value: string): number | null { try { const cursor = parseInt(value, 10); + if (isNaN(cursor)) { + return null; + } return cursor; } catch (err) { diff --git a/src/core/server/types/express.d.ts b/src/core/server/types/express.ts similarity index 100% rename from src/core/server/types/express.d.ts rename to src/core/server/types/express.ts From 34f42c29913a362c32259ee0d67595ce23f912f7 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 16 Jul 2018 13:24:02 -0600 Subject: [PATCH 24/45] feat: support graphql schema type compilation for watcher --- config/watcher.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/config/watcher.ts b/config/watcher.ts index b59928503..277ec34ec 100644 --- a/config/watcher.ts +++ b/config/watcher.ts @@ -8,7 +8,7 @@ import { const config: Config = { rootDir: path.resolve(__dirname, "../src"), watchers: { - compileGraphQLTypes: { + compileSchema: { paths: ["core/server/**/*.graphql"], executor: new CommandExecutor("npm run compile:schema", { runOnInit: true, @@ -53,7 +53,7 @@ const config: Config = { }, defaultSet: "client", sets: { - server: ["runServer"], + server: ["compileSchema", "runServer"], client: [ "runServer", "runWebpackDevServer", @@ -61,7 +61,7 @@ const config: Config = { "compileRelayStream", ], docz: ["runDocz", "compileCSSTypes"], - compile: ["compileCSSTypes", "compileRelayStream"], + compile: ["compileSchema", "compileCSSTypes", "compileRelayStream"], }, }; From 6f6bedb07acfb0ec402366eb9c64dbfb284da6b2 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 16 Jul 2018 13:26:41 -0600 Subject: [PATCH 25/45] feat: production updates --- config/paths.js | 2 +- config/webpack.config.prod.js | 10 +++++----- src/core/server/app/handlers/auth/local.ts | 3 ++- src/core/server/app/index.ts | 8 +++++--- src/core/server/app/middleware/notFound.ts | 5 +++++ src/core/server/app/router.ts | 14 +++++++++----- src/core/server/config.ts | 5 ----- src/index.ts | 6 ++++++ 8 files changed, 33 insertions(+), 20 deletions(-) create mode 100644 src/core/server/app/middleware/notFound.ts diff --git a/config/paths.js b/config/paths.js index 7d98ac998..73cc43370 100644 --- a/config/paths.js +++ b/config/paths.js @@ -46,7 +46,7 @@ module.exports = { appPostCssConfig: resolveApp("config/postcss.config.js"), appJestConfig: resolveApp("config/jest.config.js"), appLoaders: resolveApp("loaders"), - appDist: resolveApp("dist"), + appDist: resolveApp("dist/static"), appPublic: resolveApp("public"), appPackageJson: resolveApp("package.json"), appSrc: resolveApp("src"), diff --git a/config/webpack.config.prod.js b/config/webpack.config.prod.js index 4980d73be..d1ad40d3b 100644 --- a/config/webpack.config.prod.js +++ b/config/webpack.config.prod.js @@ -39,7 +39,7 @@ if (env.stringified["process.env"].NODE_ENV !== '"production"') { // because of this bug https://github.com/webpack-contrib/extract-text-webpack-plugin/issues/763. // TODO: Repalce with mini-css-extract-plugin once it supports HMR. // https://github.com/webpack-contrib/mini-css-extract-plugin -const cssFilename = "static/css/[name].[md5:contenthash:hex:20].css"; +const cssFilename = "assets/css/[name].[md5:contenthash:hex:20].css"; // ExtractTextPlugin expects the build output to be flat. // (See https://github.com/webpack-contrib/extract-text-webpack-plugin/issues/27) @@ -72,8 +72,8 @@ module.exports = { // Generated JS file names (with nested folders). // There will be one main bundle, and one file per asynchronous chunk. // We don't currently advertise code splitting but Webpack supports it. - filename: "static/js/[name].[chunkhash:8].js", - chunkFilename: "static/js/[name].[chunkhash:8].chunk.js", + filename: "assets/js/[name].[chunkhash:8].js", + chunkFilename: "assets/js/[name].[chunkhash:8].chunk.js", // We inferred the "public path" (such as / or /my-project) from homepage. publicPath: publicPath, // Point sourcemap entries to original disk location (format as URL on Windows) @@ -210,7 +210,7 @@ module.exports = { loader: require.resolve("url-loader"), options: { limit: 10000, - name: "static/media/[name].[hash:8].[ext]", + name: "assets/media/[name].[hash:8].[ext]", }, }, // Process JS with Babel. @@ -299,7 +299,7 @@ module.exports = { exclude: [/\.(js|jsx|mjs|ts|tsx)$/, /\.html$/, /\.json$/], loader: require.resolve("file-loader"), options: { - name: "static/media/[name].[hash:8].[ext]", + name: "assets/media/[name].[hash:8].[ext]", }, }, ], diff --git a/src/core/server/app/handlers/auth/local.ts b/src/core/server/app/handlers/auth/local.ts index a5e483d08..b1cfd11be 100644 --- a/src/core/server/app/handlers/auth/local.ts +++ b/src/core/server/app/handlers/auth/local.ts @@ -31,7 +31,7 @@ export interface SignupOptions { db: Db; } -export const signup = ({ db }: SignupOptions): RequestHandler => async ( +export const signupHandler = ({ db }: SignupOptions): RequestHandler => async ( req: Request, res, next @@ -42,6 +42,7 @@ export const signup = ({ db }: SignupOptions): RequestHandler => async ( // Tenant is guaranteed at this point. const tenant = req.tenant!; + // Check to ensure that the local integration has been enabled. if (!tenant.auth.integrations.local.enabled) { // TODO: replace with better error. return next(new Error("integration is disabled")); diff --git a/src/core/server/app/index.ts b/src/core/server/app/index.ts index 0fefb561f..c5dbd81a1 100644 --- a/src/core/server/app/index.ts +++ b/src/core/server/app/index.ts @@ -3,6 +3,7 @@ import http from "http"; import { Redis } from "ioredis"; import { Db } from "mongodb"; +import { notFoundMiddleware } from "talk-server/app/middleware/notFound"; import { createPassport } from "talk-server/app/middleware/passport"; import { Config } from "talk-server/config"; import { handleSubscriptions } from "talk-server/graph/common/subscriptions/middleware"; @@ -30,16 +31,17 @@ export async function createApp(options: AppOptions): Promise { // Logging parent.use(accessLogger); - // Static Files - parent.use(serveStatic); - // Create some services for the router. const passport = createPassport({ db: options.mongo }); // Mount the router. parent.use(await createRouter(options, { passport })); + // Static Files + parent.use(serveStatic); + // Error Handling + parent.use(notFoundMiddleware); parent.use(errorLogger); return parent; diff --git a/src/core/server/app/middleware/notFound.ts b/src/core/server/app/middleware/notFound.ts new file mode 100644 index 000000000..1d5dbde5c --- /dev/null +++ b/src/core/server/app/middleware/notFound.ts @@ -0,0 +1,5 @@ +import { RequestHandler } from "express"; + +export const notFoundMiddleware: RequestHandler = (req, res, next) => { + next(new Error("not found")); +}; diff --git a/src/core/server/app/router.ts b/src/core/server/app/router.ts index 1011e532f..ad862d574 100644 --- a/src/core/server/app/router.ts +++ b/src/core/server/app/router.ts @@ -1,14 +1,14 @@ import express from "express"; import passport from "passport"; +import { signupHandler } from "talk-server/app/handlers/auth/local"; +import { apiErrorHandler } from "talk-server/app/middleware/error"; +import { errorLogger } from "talk-server/app/middleware/logging"; +import { authenticate } from "talk-server/app/middleware/passport"; import tenantMiddleware from "talk-server/app/middleware/tenant"; import managementGraphMiddleware from "talk-server/graph/management/middleware"; import tenantGraphMiddleware from "talk-server/graph/tenant/middleware"; -import { signup } from "talk-server/app/handlers/auth/local"; -import { apiErrorHandler } from "talk-server/app/middleware/error"; -import { errorLogger } from "talk-server/app/middleware/logging"; -import { authenticate } from "talk-server/app/middleware/passport"; import { AppOptions } from "./index"; import playground from "./middleware/playground"; @@ -59,7 +59,11 @@ function createNewAuthRouter(app: AppOptions, options: RouterOptions) { express.json(), authenticate(options.passport, "local") ); - router.post("/local/signup", express.json(), signup({ db: app.mongo })); + router.post( + "/local/signup", + express.json(), + signupHandler({ db: app.mongo }) + ); router.get("/oidc", authenticate(options.passport, "oidc")); router.get("/oidc/callback", authenticate(options.passport, "oidc")); // router.get("/google", options.passport.authenticate("google")); diff --git a/src/core/server/config.ts b/src/core/server/config.ts index f82bb46b8..1954de907 100644 --- a/src/core/server/config.ts +++ b/src/core/server/config.ts @@ -1,11 +1,6 @@ import convict from "convict"; -import dotenv from "dotenv"; import Joi from "joi"; -// Apply all the configuration provided in the .env file if it isn't already in -// the environment. -dotenv.config(); - // Add custom format for the mongo uri scheme. convict.addFormat({ name: "mongo-uri", diff --git a/src/index.ts b/src/index.ts index ec3479156..985b3476e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,9 @@ +import dotenv from "dotenv"; + +// Apply all the configuration provided in the .env file if it isn't already in +// the environment. +dotenv.config(); + import express from "express"; import logger from "talk-server/logger"; From caec45a0c68f8be53138885a1cb7480f03432742 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 17 Jul 2018 12:32:43 -0600 Subject: [PATCH 26/45] feat: passport impl --- .../app/middleware/passport/jwt.spec.ts | 65 +++++ .../server/app/middleware/passport/jwt.ts | 32 +++ .../app/middleware/passport/oidc.spec.ts | 87 +++++++ .../server/app/middleware/passport/oidc.ts | 56 ++++- .../app/middleware/passport/sso.spec.ts | 83 +++++++ .../server/app/middleware/passport/sso.ts | 223 +++++++++++++++++- src/core/server/app/router.ts | 1 + 7 files changed, 539 insertions(+), 8 deletions(-) create mode 100644 src/core/server/app/middleware/passport/jwt.spec.ts create mode 100644 src/core/server/app/middleware/passport/jwt.ts create mode 100644 src/core/server/app/middleware/passport/oidc.spec.ts create mode 100644 src/core/server/app/middleware/passport/sso.spec.ts diff --git a/src/core/server/app/middleware/passport/jwt.spec.ts b/src/core/server/app/middleware/passport/jwt.spec.ts new file mode 100644 index 000000000..22ea07405 --- /dev/null +++ b/src/core/server/app/middleware/passport/jwt.spec.ts @@ -0,0 +1,65 @@ +import sinon from "sinon"; + +import { + extractJWTFromRequest, + parseAuthHeader, +} from "talk-server/app/middleware/passport/jwt"; +import { Request } from "talk-server/types/express"; + +describe("parseAuthHeader", () => { + it("parses valid headers", () => { + const parsed = { + scheme: "bearer", + value: "token", + }; + + expect(parseAuthHeader("Bearer token")).toEqual(parsed); + + expect(parseAuthHeader("bearer token")).toEqual(parsed); + + expect(parseAuthHeader("bearer token")).toEqual(parsed); + }); + + it("parses invalid headers", () => { + expect(parseAuthHeader("this-is-a-wrong-header")).toEqual(null); + expect(parseAuthHeader("bearerthis-is-a-wrong-header")).toEqual(null); + }); +}); + +describe("extractJWTFromRequest", () => { + it("extracts the token from header", () => { + const req = { + get: sinon + .stub() + .withArgs("authorization") + .returns("Bearer token"), + }; + + expect(extractJWTFromRequest((req as any) as Request)).toEqual("token"); + expect(req.get.calledOnce).toBeTruthy(); + + req.get.reset(); + req.get.returns(null); + expect(extractJWTFromRequest((req as any) as Request)).toEqual(null); + expect(req.get.calledOnce).toBeTruthy(); + }); + + it("extracts the token from query string", () => { + const req = { + get: sinon + .stub() + .withArgs("authorization") + .returns(null), + query: { access_token: "token" }, + }; + + expect(extractJWTFromRequest((req as any) as Request)).toEqual("token"); + expect(req.get.calledOnce).toBeTruthy(); + + delete req.query.access_token; + + req.get.reset(); + expect(extractJWTFromRequest((req as any) as Request)).toEqual(null); + expect(req.get.calledOnce).toBeTruthy(); + }); +}); diff --git a/src/core/server/app/middleware/passport/jwt.ts b/src/core/server/app/middleware/passport/jwt.ts new file mode 100644 index 000000000..d0ec3d5ea --- /dev/null +++ b/src/core/server/app/middleware/passport/jwt.ts @@ -0,0 +1,32 @@ +import { Request } from "talk-server/types/express"; + +const re = /(\S+)\s+(\S+)/; + +export function parseAuthHeader(header: string) { + const matches = header.match(re); + if (!matches || matches.length < 3) { + return null; + } + + return { + scheme: matches[1].toLowerCase(), + value: matches[2], + }; +} + +export function extractJWTFromRequest(req: Request) { + const header = req.get("authorization"); + if (header) { + const parts = parseAuthHeader(header); + if (parts && parts.scheme === "bearer") { + return parts.value; + } + } + + const token: string | undefined | false = req.query && req.query.access_token; + if (token) { + return token; + } + + return null; +} diff --git a/src/core/server/app/middleware/passport/oidc.spec.ts b/src/core/server/app/middleware/passport/oidc.spec.ts new file mode 100644 index 000000000..6f10b1140 --- /dev/null +++ b/src/core/server/app/middleware/passport/oidc.spec.ts @@ -0,0 +1,87 @@ +import { + OIDCDisplayNameIDTokenSchema, + OIDCIDTokenSchema, +} from "talk-server/app/middleware/passport/oidc"; +import { validate } from "talk-server/app/request/body"; + +describe("OIDCIDTokenSchema", () => { + it("allows a valid payload", () => { + const token = { + sub: "sub", + iss: "iss", + aud: "aud", + email: "email", + email_verified: true, + }; + + expect(validate(OIDCIDTokenSchema, token)).toEqual(token); + }); + + it("allows an empty email_verified", () => { + const token = { + sub: "sub", + iss: "iss", + aud: "aud", + email: "email", + }; + + expect(validate(OIDCIDTokenSchema, token)).toEqual({ + ...token, + email_verified: false, + }); + }); + + it("allows an empty picture", () => { + const token = { + sub: "sub", + iss: "iss", + aud: "aud", + email: "email", + email_verified: true, + }; + + expect(validate(OIDCIDTokenSchema, token)).toEqual(token); + }); +}); + +describe("OIDCDisplayNameIDTokenSchema", () => { + it("allows a valid payload", () => { + const token = { + sub: "sub", + iss: "iss", + aud: "aud", + email: "email", + email_verified: true, + name: "name", + nickname: "nickname", + }; + + expect(validate(OIDCDisplayNameIDTokenSchema, token)).toEqual(token); + }); + + it("allows an empty name", () => { + const token = { + sub: "sub", + iss: "iss", + aud: "aud", + email: "email", + email_verified: false, + nickname: "nickname", + }; + + expect(validate(OIDCDisplayNameIDTokenSchema, token)).toEqual(token); + }); + + it("allows an empty nickname", () => { + const token = { + sub: "sub", + iss: "iss", + aud: "aud", + email: "email", + email_verified: false, + name: "name", + }; + + expect(validate(OIDCDisplayNameIDTokenSchema, token)).toEqual(token); + }); +}); diff --git a/src/core/server/app/middleware/passport/oidc.ts b/src/core/server/app/middleware/passport/oidc.ts index 07dd55543..35982e0a1 100644 --- a/src/core/server/app/middleware/passport/oidc.ts +++ b/src/core/server/app/middleware/passport/oidc.ts @@ -1,9 +1,11 @@ +import Joi from "joi"; import jwt from "jsonwebtoken"; import jwks, { JwksClient } from "jwks-rsa"; import { Db } from "mongodb"; import { Strategy as OAuth2Strategy, VerifyCallback } from "passport-oauth2"; import { Strategy } from "passport-strategy"; +import { validate } from "talk-server/app/request/body"; import { reconstructURL } from "talk-server/app/url"; import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; import { OIDCAuthIntegration, Tenant } from "talk-server/models/tenant"; @@ -28,6 +30,8 @@ export interface OIDCIDToken { email?: string; email_verified?: boolean; picture?: string; + name?: string; + nickname?: string; } export interface StrategyItem { @@ -95,17 +99,50 @@ function getEnabledIntegration(tenant: Tenant) { return integration; } +export const OIDCIDTokenSchema = Joi.object() + .keys({ + sub: Joi.string(), + iss: Joi.string(), + aud: Joi.string(), + email: Joi.string(), + email_verified: Joi.boolean().default(false), + picture: Joi.string().default(undefined), + }) + .optionalKeys(["picture", "email_verified"]); + +export const OIDCDisplayNameIDTokenSchema = OIDCIDTokenSchema.keys({ + name: Joi.string().default(undefined), + nickname: Joi.string().default(undefined), +}).optionalKeys(["name", "nickname"]); + export async function findOrCreateOIDCUser( db: Db, tenant: Tenant, token: OIDCIDToken ) { + // Unpack/validate the token content. + const { + sub, + iss, + aud, + email, + email_verified, + picture, + name, + nickname, + }: OIDCIDToken = validate( + tenant.auth.displayNameEnable + ? OIDCDisplayNameIDTokenSchema + : OIDCIDTokenSchema, + token + ); + // Construct the profile that will be used to query for the user. const profile: OIDCProfile = { type: "oidc", - id: token.sub, - issuer: token.iss, - audience: token.aud, + id: sub, + issuer: iss, + audience: aud, }; // Try to lookup user given their id provided in the `sub` claim. @@ -113,17 +150,24 @@ export async function findOrCreateOIDCUser( if (!user) { // FIXME: implement rules. + // Default the displayName. When it is disabled, Joi will strip the + // displayName fields from the token, so it will fallback to undefined. + const displayName = nickname || name || undefined; + // Create the new user, as one didn't exist before! user = await upsert(db, tenant, { username: null, + displayName, role: GQLUSER_ROLE.COMMENTER, - email: token.email, - email_verified: token.email_verified, - avatar: token.picture, + email, + email_verified, + avatar: picture, profiles: [profile], }); } + // TODO: (wyattjoh) possibly update the user profile if the remaining details mismatch? + return user; } diff --git a/src/core/server/app/middleware/passport/sso.spec.ts b/src/core/server/app/middleware/passport/sso.spec.ts new file mode 100644 index 000000000..f973f45b7 --- /dev/null +++ b/src/core/server/app/middleware/passport/sso.spec.ts @@ -0,0 +1,83 @@ +import { + isSSOToken, + SSODisplayNameUserProfileSchema, + SSOUserProfileSchema, +} from "talk-server/app/middleware/passport/sso"; +import { validate } from "talk-server/app/request/body"; + +describe("isSSOToken", () => { + it("understands valid sso tokens", () => { + const token = { user: { id: "id", email: "email", username: "username" } }; + expect(isSSOToken(token)).toBeTruthy(); + }); + + it("understands invalid sso tokens", () => { + expect(isSSOToken({ user: { id: "id", email: "email" } })).toBeFalsy(); + expect( + isSSOToken({ user: { id: "id", username: "username" } }) + ).toBeFalsy(); + expect( + isSSOToken({ user: { email: "email", username: "username" } }) + ).toBeFalsy(); + expect(isSSOToken({})).toBeFalsy(); + }); +}); + +describe("SSOUserProfileSchema", () => { + it("allows a valid payload", () => { + const profile = { + id: "id", + email: "email", + username: "username", + avatar: "avatar", + }; + + expect(validate(SSOUserProfileSchema, profile)).toEqual(profile); + }); + + it("allows an empty avatar", () => { + const profile = { + id: "id", + email: "email", + username: "username", + }; + + expect(validate(SSOUserProfileSchema, profile)).toEqual(profile); + }); +}); + +describe("SSODisplayNameUserProfileSchema", () => { + it("allows a valid payload", () => { + const profile = { + id: "id", + email: "email", + username: "username", + avatar: "avatar", + displayName: "displayName", + }; + + expect(validate(SSODisplayNameUserProfileSchema, profile)).toEqual(profile); + }); + + it("allows an empty avatar", () => { + const profile = { + id: "id", + email: "email", + username: "username", + displayName: "displayName", + }; + + expect(validate(SSODisplayNameUserProfileSchema, profile)).toEqual(profile); + }); + + it("allows an empty displayName", () => { + const profile = { + id: "id", + email: "email", + username: "username", + avatar: "avatar", + }; + + expect(validate(SSODisplayNameUserProfileSchema, profile)).toEqual(profile); + }); +}); diff --git a/src/core/server/app/middleware/passport/sso.ts b/src/core/server/app/middleware/passport/sso.ts index ecc883685..b5699114d 100644 --- a/src/core/server/app/middleware/passport/sso.ts +++ b/src/core/server/app/middleware/passport/sso.ts @@ -1,9 +1,228 @@ +import Joi from "joi"; +import jwt, { KeyFunctionCallback } from "jsonwebtoken"; +import { Db } from "mongodb"; import { Strategy } from "passport-strategy"; +import { extractJWTFromRequest } from "talk-server/app/middleware/passport/jwt"; +import { + findOrCreateOIDCUser, + isOIDCToken, + OIDCIDToken, +} from "talk-server/app/middleware/passport/oidc"; +import { validate } from "talk-server/app/request/body"; +import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; +import { Tenant } from "talk-server/models/tenant"; +import { retrieveUserWithProfile, SSOProfile } from "talk-server/models/user"; +import { upsert } from "talk-server/services/users"; import { Request } from "talk-server/types/express"; +export interface SSOStrategyOptions { + db: Db; +} + +export interface SSOUserProfile { + id: string; + email: string; + username: string; + avatar?: string; + displayName?: string; +} + +export interface SSOToken { + user: SSOUserProfile; +} + +export const SSOUserProfileSchema = Joi.object() + .keys({ + id: Joi.string(), + email: Joi.string(), + username: Joi.string(), + avatar: Joi.string().default(undefined), + }) + .optionalKeys(["avatar"]); + +export const SSODisplayNameUserProfileSchema = SSOUserProfileSchema.keys({ + displayName: Joi.string().default(undefined), +}).optionalKeys(["displayName"]); + +export async function findOrCreateSSOUser( + db: Db, + tenant: Tenant, + token: SSOToken +) { + if (!token.user) { + // TODO: (wyattjoh) replace with better error. + throw new Error("token is malformed, missing user claim"); + } + + // Unpack/validate the token content. + const { id, email, username, displayName, avatar }: SSOUserProfile = validate( + tenant.auth.displayNameEnable + ? SSODisplayNameUserProfileSchema + : SSOUserProfileSchema, + token.user + ); + + const profile: SSOProfile = { + type: "sso", + id, + }; + + // Try to lookup user given their id provided in the `sub` claim. + let user = await retrieveUserWithProfile(db, tenant.id, profile); + if (!user) { + // FIXME: (wyattjoh) implement rules! Not all users should be able to create an account via this method. + + // Create the new user, as one didn't exist before! + user = await upsert(db, tenant, { + username, + // When the displayName is disabled on the tenant, the displayName will + // never be set (or even stored in the database). + displayName, + role: GQLUSER_ROLE.COMMENTER, + email, + avatar, + profiles: [profile], + }); + } + + // TODO: (wyattjoh) possibly update the user profile if the remaining details mismatch? + + return user; +} + +/** + * isSSOUserProfile will check if the given profile is a SSOUserProfile. + * + * @param profile the profile to check for the type + */ +export function isSSOUserProfile( + profile: SSOUserProfile | object +): profile is SSOUserProfile { + return ( + typeof (profile as SSOUserProfile).id !== "undefined" && + typeof (profile as SSOUserProfile).email !== "undefined" && + typeof (profile as SSOUserProfile).username !== "undefined" + ); +} + +export function isSSOToken(token: SSOToken | object): token is SSOToken { + return ( + typeof (token as SSOToken).user === "object" && + isSSOUserProfile((token as SSOToken).user) + ); +} + export default class SSOStrategy extends Strategy { - public async authenticate(req: Request) { - return; + public name: string; + + private db: Db; + + constructor({ db }: SSOStrategyOptions) { + super(); + + this.name = "sso"; + this.db = db; + } + + /** + * retrieves the integration's secret to be used to verify the token. + */ + private getSigningSecretGetter = (tenant: Tenant) => async ( + headers: { kid?: string }, + done: KeyFunctionCallback + ) => { + const integration = tenant.auth.integrations.sso; + if (!integration) { + // TODO: (wyattjoh) return a better error. + return done(new Error("integration not found")); + } + + if (!integration.enabled) { + // TODO: (wyattjoh) return a better error. + return done(new Error("integration not enabled")); + } + + // TODO: (wyattjoh) do something with the kid... Lookup the secret or verify it matches what we have? + + return done(null, integration.key); + }; + + /** + * findOrCreateUser will interpret the token and use the correct strategy for + * retrieving/creating the user. + * + * @param tenant the tenant for the new/returning user + * @param token the token that was unpacked and validated from the sso strategy + */ + private async findOrCreateUser( + tenant: Tenant, + token: OIDCIDToken | SSOToken + ) { + if (isOIDCToken(token)) { + // The token provided for SSO contains an issuer claim. We're assuming + // that this request is associated with an OpenID Connect provider. + return findOrCreateOIDCUser(this.db, tenant, token); + } + + // Check to see if this token is a SSO Token or not, if it isn't error out. + if (!isSSOToken(token)) { + // TODO: (wyattjoh) return a better error. + throw new Error("token is invalid"); + } + + // The token provided does not confirm to the OpenID Connect provider + // spec, but id does conform to a SSOToken so we should expect the token to + // contain the user profile. + return findOrCreateSSOUser(this.db, tenant, token); + } + + /** + * wrapNewTokenHandler wraps the token handling with a promise. + */ + private wrapNewTokenHandler = (tenant: Tenant) => async ( + err: Error | undefined, + decoded: OIDCIDToken | SSOToken + ) => { + if (err) { + return this.fail(err, 401); + } + + try { + // Find or create the user based on the decoded token. + const user = await this.findOrCreateUser(tenant, decoded); + + // The user was found or created! + return this.success(user, null); + } catch (err) { + return this.error(err); + } + }; + + public authenticate(req: Request) { + const { tenant } = req; + if (!tenant) { + // TODO: (wyattjoh) return a better error. + return this.error(new Error("tenant not found")); + } + + // Lookup the token. + const token = extractJWTFromRequest(req); + if (!token) { + // TODO: (wyattjoh) return a better error. + return this.fail(new Error("no token on request"), 400); + } + + // Perform the JWT validation. + jwt.verify( + token, + this.getSigningSecretGetter(tenant), + { + // Force the use of the HS256 algorithm. We can explore switching this + // out in the future.. + algorithms: ["HS256"], // TODO: (wyattjoh) investigate replacing algorithm. + }, + this.wrapNewTokenHandler(tenant) + ); } } diff --git a/src/core/server/app/router.ts b/src/core/server/app/router.ts index ad862d574..b6af1564f 100644 --- a/src/core/server/app/router.ts +++ b/src/core/server/app/router.ts @@ -64,6 +64,7 @@ function createNewAuthRouter(app: AppOptions, options: RouterOptions) { express.json(), signupHandler({ db: app.mongo }) ); + router.post("/sso", authenticate(options.passport, "sso")); router.get("/oidc", authenticate(options.passport, "oidc")); router.get("/oidc/callback", authenticate(options.passport, "oidc")); // router.get("/google", options.passport.authenticate("google")); From 54e99dec128eac9190f55c1ed5716aea4e2145a1 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 17 Jul 2018 12:42:06 -0600 Subject: [PATCH 27/45] feat: added npm audit --- .circleci/config.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index bd7493f6b..0bf9891c0 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -24,6 +24,9 @@ jobs: - run: name: Install dependencies command: npm install + - run: + name: Audit dependencies + command: npm audit - save_cache: key: dependency-cache-{{ checksum "package-lock.json" }} paths: From 2cf7f1e2efb3e86cd17bd1c8007bcc43aa07b82d Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 17 Jul 2018 12:49:35 -0600 Subject: [PATCH 28/45] fix: update npm --- .circleci/config.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 0bf9891c0..1680a0cca 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -21,6 +21,9 @@ jobs: at: ~/coralproject/talk - restore_cache: key: dependency-cache-{{ checksum "package-lock.json" }} + - run: + name: Update NPM + command: npm update -g npm - run: name: Install dependencies command: npm install From 7c139a37b43a5cdac1d89500404847a79e06b300 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 17 Jul 2018 12:50:21 -0600 Subject: [PATCH 29/45] fix: sudo with global npm update --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 1680a0cca..e65bc925a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -23,7 +23,7 @@ jobs: key: dependency-cache-{{ checksum "package-lock.json" }} - run: name: Update NPM - command: npm update -g npm + command: sudo npm update -g npm - run: name: Install dependencies command: npm install From 189623a367d7e1ca03d1e965874af47bb5f51bcd Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 17 Jul 2018 12:51:24 -0600 Subject: [PATCH 30/45] fix: move audit up --- .circleci/config.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index e65bc925a..45d18b2db 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -24,12 +24,12 @@ jobs: - run: name: Update NPM command: sudo npm update -g npm - - run: - name: Install dependencies - command: npm install - run: name: Audit dependencies command: npm audit + - run: + name: Install dependencies + command: npm install - save_cache: key: dependency-cache-{{ checksum "package-lock.json" }} paths: From c7feaa344f521b28944f715e1e9257fdcf1ccf79 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 17 Jul 2018 13:51:59 -0600 Subject: [PATCH 31/45] fix: runInBand for CI --- .circleci/config.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 45d18b2db..92a18e11c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,6 +1,7 @@ # job_environment will setup the environment for any job being executed. job_environment: &job_environment NODE_ENV: test + CI: true # job_defaults applies all the defaults for each job. job_defaults: &job_defaults @@ -61,7 +62,9 @@ jobs: command: npm run compile - run: name: Perform testing - command: npm run test + # We're running these tests in band to avoid errors where the circleci + # test runner runs out of RAM trying to run them all in parallel. + command: npm run test -- --runInBand # build will build the static assets and server typescript files. build: From 8487f22b8ed19e17e9d004759bad4f2b290a22e4 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 17 Jul 2018 14:09:00 -0600 Subject: [PATCH 32/45] feat: added test results --- .circleci/config.yml | 11 +++++++++-- package-lock.json | 35 +++++++++++++++++++++++++++++++++++ package.json | 21 +++++++++++---------- scripts/test.js | 6 +++++- 4 files changed, 60 insertions(+), 13 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 92a18e11c..dd98d8c41 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,7 +1,6 @@ # job_environment will setup the environment for any job being executed. job_environment: &job_environment NODE_ENV: test - CI: true # job_defaults applies all the defaults for each job. job_defaults: &job_defaults @@ -53,6 +52,10 @@ jobs: # unit_tests will run the unit tests. unit_tests: <<: *job_defaults + environment: + <<: *job_environment + CI: true + JEST_JUNIT_OUTPUT: "reports/junit/js-test-results.xml" steps: - checkout - attach_workspace: @@ -64,7 +67,11 @@ jobs: name: Perform testing # We're running these tests in band to avoid errors where the circleci # test runner runs out of RAM trying to run them all in parallel. - command: npm run test -- --runInBand + command: npm run test -- --ci --runInBand --testResultsProcessor="jest-junit" + - store_test_results: + path: reports/junit + - store_artifacts: + path: reports/junit # build will build the static assets and server typescript files. build: diff --git a/package-lock.json b/package-lock.json index db8d3ddbc..271d5e135 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12486,6 +12486,35 @@ "pretty-format": "^23.2.0" } }, + "jest-junit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/jest-junit/-/jest-junit-5.1.0.tgz", + "integrity": "sha512-3EVf1puv2ox5wybQDfLX3AEn3IKOgDV4E76y4pO2hBu46DEtAFZZAm//X1pzPQpqKji0zqgMIzqzF/K+uGAX9A==", + "dev": true, + "requires": { + "jest-validate": "^23.0.1", + "mkdirp": "^0.5.1", + "strip-ansi": "^4.0.0", + "xml": "^1.0.1" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + } + } + }, "jest-leak-detector": { "version": "23.2.0", "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-23.2.0.tgz", @@ -23340,6 +23369,12 @@ "integrity": "sha1-SWsswQnsqNus/i3HK2A8F8WHCtQ=", "dev": true }, + "xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha1-eLpyAgApxbyHuKgaPPzXS0ovweU=", + "dev": true + }, "xml-name-validator": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", diff --git a/package.json b/package.json index b6a18e136..5f709a7e1 100644 --- a/package.json +++ b/package.json @@ -71,8 +71,8 @@ "@types/convict": "^4.2.0", "@types/cross-spawn": "^6.0.0", "@types/dotenv": "^4.0.3", - "@types/enzyme-adapter-react-16": "^1.0.2", "@types/enzyme": "^3.1.11", + "@types/enzyme-adapter-react-16": "^1.0.2", "@types/express": "^4.16.0", "@types/graphql": "^0.13.3", "@types/ioredis": "^3.2.12", @@ -84,10 +84,10 @@ "@types/luxon": "^0.5.3", "@types/mongodb": "^3.1.1", "@types/node": "^10.5.2", + "@types/passport": "^0.4.5", "@types/passport-local": "^1.0.33", "@types/passport-oauth2": "^1.4.5", "@types/passport-strategy": "^0.2.33", - "@types/passport": "^0.4.5", "@types/query-string": "^6.1.0", "@types/react-dom": "^16.0.6", "@types/react-relay": "github:coralproject/patched#types/react-relay", @@ -113,20 +113,21 @@ "cross-spawn": "^6.0.5", "css-loader": "^0.28.11", "docz": "^0.5.8", + "enzyme": "^3.3.0", "enzyme-adapter-react-16": "^1.1.1", "enzyme-to-json": "^3.3.4", - "enzyme": "^3.3.0", "extract-text-webpack-plugin": "^4.0.0-beta.0", "final-form": "^4.8.1", "flat": "^4.1.0", + "fluent": "^0.6.4", "fluent-intl-polyfill": "^0.1.0", "fluent-langneg": "^0.1.0", "fluent-react": "^0.7.0", - "fluent": "^0.6.4", "graphql-playground-middleware-express": "^1.7.2", "graphql-schema-typescript": "^1.2.1", "html-webpack-plugin": "^3.2.0", "jest": "^23.4.1", + "jest-junit": "^5.1.0", "jsdom": "^11.11.0", "loader-utils": "^1.1.0", "npm-run-all": "^4.1.3", @@ -143,6 +144,7 @@ "pstree.remy": "^1.1.0", "query-string": "^6.1.0", "raw-loader": "^0.5.1", + "react": "^16.4.0", "react-dev-utils": "6.0.0-next.3e165448", "react-dom": "^16.4.0", "react-final-form": "^3.6.4", @@ -150,10 +152,9 @@ "react-responsive": "^4.1.0", "react-test-renderer": "^16.4.1", "react-timeago": "^4.1.9", - "react": "^16.4.0", "recompose": "^0.27.1", - "relay-compiler-language-typescript": "github:coralproject/patched#relay-compiler-language-typescript", "relay-compiler": "github:coralproject/patched#relay-compiler", + "relay-compiler-language-typescript": "github:coralproject/patched#relay-compiler-language-typescript", "relay-local-schema": "^0.7.0", "relay-runtime": "github:coralproject/patched#relay-runtime", "relay-test-utils": "github:coralproject/patched#relay-test-utils", @@ -163,20 +164,20 @@ "ts-jest": "^23.0.0", "ts-loader": "^4.4.2", "ts-node": "^6.2.0", - "tsconfig-paths-webpack-plugin": "^3.1.4", "tsconfig-paths": "^3.4.2", + "tsconfig-paths-webpack-plugin": "^3.1.4", + "tslint": "^5.10.0", "tslint-config-prettier": "^1.13.0", "tslint-loader": "^3.6.0", "tslint-plugin-prettier": "^1.3.0", "tslint-react": "^3.6.0", - "tslint": "^5.10.0", "typed-css-modules": "^0.3.4", "typescript": "^2.9.2", "uglifyjs-webpack-plugin": "^1.2.5", + "webpack": "4.12.0", "webpack-cli": "^3.0.2", "webpack-dev-server": "^3.1.4", "webpack-hot-client": "^4.1.1", - "webpack-manifest-plugin": "^2.0.3", - "webpack": "4.12.0" + "webpack-manifest-plugin": "^2.0.3" } } diff --git a/scripts/test.js b/scripts/test.js index f08b18de6..59c8a93cc 100644 --- a/scripts/test.js +++ b/scripts/test.js @@ -22,7 +22,11 @@ let argv = process.argv.slice(2); argv.push("--config", paths.appJestConfig); // Watch unless on CI or in coverage mode -if (!process.env.CI && argv.indexOf("--coverage") < 0) { +if ( + !process.env.CI && // ensure that the ci env var is not set. + argv.indexOf("--ci") < 0 && // ensure that the ci flag is not passed + argv.indexOf("--coverage") < 0 // ensure that the coverage flag is not passed +) { argv.push("--watch"); } From a2a0cebb06b650120a3c55c3134f65364e0603d2 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 17 Jul 2018 16:57:36 -0600 Subject: [PATCH 33/45] feat: initial token issuing support --- src/core/server/app/handlers/auth/local.ts | 12 ++- .../server/app/middleware/passport/index.ts | 84 ++++++++++----- .../server/app/middleware/passport/jwt.ts | 100 +++++++++++++++++- src/core/server/app/router.ts | 22 ++-- src/core/server/config.ts | 27 ++++- 5 files changed, 200 insertions(+), 45 deletions(-) diff --git a/src/core/server/app/handlers/auth/local.ts b/src/core/server/app/handlers/auth/local.ts index b1cfd11be..baab6dcd6 100644 --- a/src/core/server/app/handlers/auth/local.ts +++ b/src/core/server/app/handlers/auth/local.ts @@ -2,7 +2,8 @@ import { RequestHandler } from "express"; import Joi from "joi"; import { Db } from "mongodb"; -import { handle } from "talk-server/app/middleware/passport"; +import { handleSuccessfulLogin } from "talk-server/app/middleware/passport"; +import { JWTSigningConfig } from "talk-server/app/middleware/passport/jwt"; import { validate } from "talk-server/app/request/body"; import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; import { LocalProfile } from "talk-server/models/user"; @@ -29,9 +30,10 @@ const SignupDisplayNameBodySchema = SignupBodySchema.keys({ export interface SignupOptions { db: Db; + signingConfig: JWTSigningConfig; } -export const signupHandler = ({ db }: SignupOptions): RequestHandler => async ( +export const signupHandler = (options: SignupOptions): RequestHandler => async ( req: Request, res, next @@ -67,7 +69,7 @@ export const signupHandler = ({ db }: SignupOptions): RequestHandler => async ( }; // Create the new user. - const user = await upsert(db, tenant, { + const user = await upsert(options.db, tenant, { email, username, displayName, @@ -79,8 +81,8 @@ export const signupHandler = ({ db }: SignupOptions): RequestHandler => async ( }); // Send off to the passport handler. - return handle(null, user)(req, res, next); + return handleSuccessfulLogin(user, options.signingConfig, req, res, next); } catch (err) { - return handle(err)(req, res, next); + return next(err); } }; diff --git a/src/core/server/app/middleware/passport/index.ts b/src/core/server/app/middleware/passport/index.ts index 0f24bdeea..40c1bda39 100644 --- a/src/core/server/app/middleware/passport/index.ts +++ b/src/core/server/app/middleware/passport/index.ts @@ -1,7 +1,12 @@ -import { RequestHandler } from "express"; +import { NextFunction, RequestHandler, Response } from "express"; import { Db } from "mongodb"; import passport, { Authenticator } from "passport"; +import { + JWTSigningConfig, + SigningTokenOptions, + signTokenString, +} from "talk-server/app/middleware/passport/jwt"; import { createLocalStrategy } from "talk-server/app/middleware/passport/local"; import { createOIDCStrategy } from "talk-server/app/middleware/passport/oidc"; import { User } from "talk-server/models/user"; @@ -32,38 +37,69 @@ export function createPassport({ return auth; } -export const handle = ( - err: Error | null, - user?: User | null -): RequestHandler => (req: Request, res, next) => { - if (err) { - // TODO: wrap error? +export async function handleSuccessfulLogin( + user: User, + signingConfig: JWTSigningConfig, + req: Request, + res: Response, + next: NextFunction +) { + try { + // Grab the tenant from the request. + const { tenant } = req; + + const options: SigningTokenOptions = {}; + + if (tenant) { + // Attach the tenant's id to the issued token as a `iss` claim. + options.issuer = tenant.id; + + // TODO: (wyattjoh) evaluate the possibility when we have multiple + // integrations per type to use the integration id as the audience. + } + + // Grab the token. + const token = await signTokenString(signingConfig, user, options); + + // Set the cache control headers. + res.header("Cache-Control", "private, no-cache, no-store, must-revalidate"); + res.header("Expires", "-1"); + res.header("Pragma", "no-cache"); + + // Send back the details! + res.json({ token }); + } catch (err) { return next(err); } +} - if (!user) { - // TODO: replace with better error. - return next(new Error("no user on request")); - } - - // Set the cache control headers. - res.header("Cache-Control", "private, no-cache, no-store, must-revalidate"); - res.header("Expires", "-1"); - res.header("Pragma", "no-cache"); - - // Send back the details! - - // TODO: return the token instead of the user. - res.json({ user }); -}; - +/** + * authenticate will wrap a authenticators authenticate method with one that + * will return a valid login token for a valid login by a compatible strategy. + * + * @param authenticator the base authenticator instance + * @param signingConfig used to sign the tokens that are issued. + * @param name the name of the authenticator to use + * @param options any options to be passed to the authenticate call + */ export const authenticate = ( authenticator: passport.Authenticator, + signingConfig: JWTSigningConfig, name: string, options?: any ): RequestHandler => (req: Request, res, next) => authenticator.authenticate( name, { ...options, session: false }, - (err: Error | null, user: User | null) => handle(err, user)(req, res, next) + (err: Error | null, user: User | null) => { + if (err) { + return next(err); + } + if (!user) { + // TODO: (wyattjoh) replace with better error. + return next(new Error("no user on request")); + } + + handleSuccessfulLogin(user, signingConfig, req, res, next); + } )(req, res, next); diff --git a/src/core/server/app/middleware/passport/jwt.ts b/src/core/server/app/middleware/passport/jwt.ts index d0ec3d5ea..e8ec1ebfa 100644 --- a/src/core/server/app/middleware/passport/jwt.ts +++ b/src/core/server/app/middleware/passport/jwt.ts @@ -1,9 +1,14 @@ +import jwt, { SignOptions } from "jsonwebtoken"; +import uuid from "uuid"; + +import { Config } from "talk-server/config"; +import { User } from "talk-server/models/user"; import { Request } from "talk-server/types/express"; -const re = /(\S+)\s+(\S+)/; +const authHeaderRegex = /(\S+)\s+(\S+)/; export function parseAuthHeader(header: string) { - const matches = header.match(re); + const matches = header.match(authHeaderRegex); if (!matches || matches.length < 3) { return null; } @@ -30,3 +35,94 @@ export function extractJWTFromRequest(req: Request) { return null; } + +export enum AsymmetricSigningAlgorithm { + RS256 = "RS256", + RS384 = "RS384", + RS512 = "RS512", + ES256 = "ES256", + ES384 = "ES384", + ES512 = "ES512", +} + +export enum SymmetricSigningAlgorithm { + HS256 = "HS256", + HS384 = "HS384", + HS512 = "HS512", +} + +export type JWTSigningAlgorithm = + | AsymmetricSigningAlgorithm + | SymmetricSigningAlgorithm; + +export interface JWTSigningConfig { + secret: Buffer; + algorithm: JWTSigningAlgorithm; +} + +export function createAsymmetricSigningConfig( + algorithm: AsymmetricSigningAlgorithm, + secret: string +): JWTSigningConfig { + return { + // Secrets have their newlines encoded with newline litterals. + secret: Buffer.from(secret.replace(/\\n/g, "\n")), + algorithm, + }; +} + +export function createSymmetricSigningConfig( + algorithm: SymmetricSigningAlgorithm, + secret: string +): JWTSigningConfig { + return { + secret: new Buffer(secret), + algorithm, + }; +} + +function isSymmetricSigningAlgorithm( + algorithm: string | SymmetricSigningAlgorithm +): algorithm is SymmetricSigningAlgorithm { + return algorithm in SymmetricSigningAlgorithm; +} + +function isAsymmetricSigningAlgorithm( + algorithm: string | AsymmetricSigningAlgorithm +): algorithm is AsymmetricSigningAlgorithm { + return algorithm in AsymmetricSigningAlgorithm; +} + +/** + * Parses the config and provides the signing config. + * + * @param config the server configuration + */ +export function createJWTSigningConfig(config: Config): JWTSigningConfig { + const secret = config.get("signing_secret"); + const algorithm = config.get("signing_algorithm"); + if (isSymmetricSigningAlgorithm(algorithm)) { + return createSymmetricSigningConfig(algorithm, secret); + } else if (isAsymmetricSigningAlgorithm(algorithm)) { + return createAsymmetricSigningConfig(algorithm, secret); + } + + // TODO: (wyattjoh) return better error. + throw new Error("invalid algorithm specified"); +} + +export type SigningTokenOptions = Pick; + +export async function signTokenString( + { algorithm, secret }: JWTSigningConfig, + user: User, + options: SigningTokenOptions +) { + return jwt.sign({}, secret, { + ...options, + jwtid: uuid.v4(), + algorithm, + expiresIn: "1 day", // TODO: (wyattjoh) evalue allowing configuration? + subject: user.id, + }); +} diff --git a/src/core/server/app/router.ts b/src/core/server/app/router.ts index b6af1564f..b9914e5a5 100644 --- a/src/core/server/app/router.ts +++ b/src/core/server/app/router.ts @@ -9,6 +9,7 @@ import tenantMiddleware from "talk-server/app/middleware/tenant"; import managementGraphMiddleware from "talk-server/graph/management/middleware"; import tenantGraphMiddleware from "talk-server/graph/tenant/middleware"; +import { createJWTSigningConfig } from "talk-server/app/middleware/passport/jwt"; import { AppOptions } from "./index"; import playground from "./middleware/playground"; @@ -54,23 +55,26 @@ async function createTenantRouter(app: AppOptions, options: RouterOptions) { function createNewAuthRouter(app: AppOptions, options: RouterOptions) { const router = express.Router(); + // Create the signing config. + const signingConfig = createJWTSigningConfig(app.config); + + // Mount the passport routes. router.post( "/local", express.json(), - authenticate(options.passport, "local") + authenticate(options.passport, signingConfig, "local") ); router.post( "/local/signup", express.json(), - signupHandler({ db: app.mongo }) + signupHandler({ db: app.mongo, signingConfig }) + ); + router.post("/sso", authenticate(options.passport, signingConfig, "sso")); + router.get("/oidc", authenticate(options.passport, signingConfig, "oidc")); + router.get( + "/oidc/callback", + authenticate(options.passport, signingConfig, "oidc") ); - router.post("/sso", authenticate(options.passport, "sso")); - router.get("/oidc", authenticate(options.passport, "oidc")); - router.get("/oidc/callback", authenticate(options.passport, "oidc")); - // router.get("/google", options.passport.authenticate("google")); - // router.get("/google/callback", options.passport.authenticate("google")); - // router.get("/facebook", options.passport.authenticate("facebook")); - // router.get("/facebook/callback", options.passport.authenticate("facebook")); return router; } diff --git a/src/core/server/config.ts b/src/core/server/config.ts index 1954de907..d4b5ca3fc 100644 --- a/src/core/server/config.ts +++ b/src/core/server/config.ts @@ -55,12 +55,29 @@ const config = convict({ env: "REDIS", arg: "redis", }, - secret: { - doc: "The secret used to sign and verify JWTs", + signing_secret: { + doc: "", format: "*", - default: null, - env: "SECRET", - arg: "secret", + default: "keyboard cat", // TODO: (wyattjoh) evaluate best solution + env: "SIGNING_SECRET", + arg: "signingSecret", + }, + signing_algorithm: { + doc: "", + format: [ + "HS256", + "HS384", + "HS512", + "RS256", + "RS384", + "RS512", + "ES256", + "ES384", + "ES512", + ], + default: "HS256", + env: "SIGNING_ALGORITHM", + arg: "signingAlgorithm", }, logging_level: { doc: "The logging level to print to the console", From a6bfad0833371f1a3f7edb4ef191188d6c416a20 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 20 Jul 2018 09:58:40 -0600 Subject: [PATCH 34/45] feat: more JWT tests --- src/core/server/app/middleware/logging.ts | 3 +- .../passport/__snapshots__/jwt.spec.ts.snap | 31 +++++++++++++++++++ .../app/middleware/passport/jwt.spec.ts | 19 ++++++++++++ .../server/app/middleware/passport/jwt.ts | 2 +- 4 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 src/core/server/app/middleware/passport/__snapshots__/jwt.spec.ts.snap diff --git a/src/core/server/app/middleware/logging.ts b/src/core/server/app/middleware/logging.ts index ebe1fe53e..134c19df1 100644 --- a/src/core/server/app/middleware/logging.ts +++ b/src/core/server/app/middleware/logging.ts @@ -1,6 +1,7 @@ import { ErrorRequestHandler, RequestHandler } from "express"; import now from "performance-now"; -import logger from "../../logger"; + +import logger from "talk-server/logger"; export const accessLogger: RequestHandler = (req, res, next) => { const startTime = now(); diff --git a/src/core/server/app/middleware/passport/__snapshots__/jwt.spec.ts.snap b/src/core/server/app/middleware/passport/__snapshots__/jwt.spec.ts.snap new file mode 100644 index 000000000..1e61492d7 --- /dev/null +++ b/src/core/server/app/middleware/passport/__snapshots__/jwt.spec.ts.snap @@ -0,0 +1,31 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`createJWTSigningConfig parses a RSA certiciate 1`] = ` +"-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEAyxR2DVlvkQRquggUQTpHN+PxDs2iOiItGgn6u4+faUCdgGEV +EnmG69//3lAZHnEQN9rkZS3/20zc41mTJnO7dslJbB316vWUSIwYcVY/VC9DTbk+ +MHWZd94p5hOB8PoY2vEGA53KiyWLqQC5FWE3u7cz7eYTr9/eRPDTc15IzohLXd5U +C9EbO5ebho2CvWrBfrLozM5Kidp8r3Jp+A0o3kfJ/kRDDn/BmG6pM0TohWZFYMs2 +nQaGg+of9tcafgAs7hZAgBrrcc/jke6+MKxpC8algik79nMk7s7prxF1Z9EbAeQV +1ssL2VgsjvGAHIV+Arckl6QJbVDvQXNAM0PqbQIDAQABAoIBAQCoG6D5vf5P8nMS +2ltB/6cyyfsjgO/45Y+mTXqERwj0DOwUeMkDyRv6KCxb8LxKade+FPIaG7D/7amw +fdcE7qrRUyD3YfnPbUk5oNcfAwFbg+BX969WWBMZmgvfDGj1fWKT4w9ScQ1YkFUD +KrkLzLVhK+/N0Dad0VjiguTXTMZCSDFOY9fO8HRF6EA3aewEPeEY62J6rSjGXvWB +GdW+FNvf/uRr36xGHNqiOP837pdVUppjgDyVsORnMfFtYMyWyxS2XD5r8gRwcRg7 +0nz6bLM53DjKweO+Yl+pIVPFAyXL0pwzQDlnjShsCzyzjA9lJftkQwbcMWopeegJ +kPLmiq4VAoGBAOqDmySNx8vmWWMOaXKFuH6Gqu/Nd7gBHxZ73wvsEmvV52xwa0oi +55h+v6P1YEaNZQWXDFsvILoOUHr2kwZY+Du/MC7tgqpj+Fu3h7UHslulJRE3A+sN +oLbHjZuwm3wwsatpHdyEYOGg0HIGWXi+9pDT/1gy8g3L2Gf0X6rfkBBXAoGBAN2v +lbii0+HvZ2y0D0P6NfUJ6cQDrSyuTe7UW6OVYjBjrVAk8+bhnQ4eKd9edCnUDqu6 +9C8ZSrqR6VBeItbt8y+5ZCRcrigxd2VdH8rL9g6idD9RPnSbHx7Al8DxSUv25xMK +8Z/ZOAvuCmwDfdleycNDoTawKqLtWBzUEntLs5DbAoGAPlTKiJWylAxel8h92HWY +SvDqQCChgGOz6prz9sxBPS42e4kJy0OpwMt3jlGqzDXKswipvRayoSEq3PPqshY1 +rFOtr9trDnTRzzbhuAkaq+ciCghQX0pY/BvgFJCFUyXyIzgmOrVotq+yl4v+fexr +xqTCSqQH2AjlNQQr5VPUi7MCgYEAsNbbMXE6YlXug+lS8CANoM3qm4FvSGA3LNhb +za9hp0YsP+1qXvgEp/lp35RiR+ewWE+HcHbVhOTWYFTnp9ojDyPtfZAtIUTsgIB7 +1vNC8kOnRccSckQ32/k4VSJlHOL1S9yECMZnjiSyTZ2va5HQkyJE3PJE4LlCe6S0 +pYQq1tcCgYEAoJDeSeAPqi5NIu+MWNUWzw4vo5raKyHrJi+cTvKyM/2zJFHvBc5f +RaxkcIAOmIDoVdFgy6APY/0DnDnpqT1kMagUaxZjG9PLFIDds5DRaL99m+S7l8mt +ySX/MbmhQHYWpVf2nL6pmfPuP4Ih6tbKIUUGA3wZXYYZ5r+pZFG1IrA= +-----END RSA PRIVATE KEY-----" +`; diff --git a/src/core/server/app/middleware/passport/jwt.spec.ts b/src/core/server/app/middleware/passport/jwt.spec.ts index 22ea07405..fa7b87d9d 100644 --- a/src/core/server/app/middleware/passport/jwt.spec.ts +++ b/src/core/server/app/middleware/passport/jwt.spec.ts @@ -1,9 +1,11 @@ import sinon from "sinon"; import { + createJWTSigningConfig, extractJWTFromRequest, parseAuthHeader, } from "talk-server/app/middleware/passport/jwt"; +import { Config } from "talk-server/config"; import { Request } from "talk-server/types/express"; describe("parseAuthHeader", () => { @@ -63,3 +65,20 @@ describe("extractJWTFromRequest", () => { expect(req.get.calledOnce).toBeTruthy(); }); }); + +describe("createJWTSigningConfig", () => { + it("parses a RSA certiciate", () => { + const input = `-----BEGIN RSA PRIVATE KEY-----\\nMIIEpQIBAAKCAQEAyxR2DVlvkQRquggUQTpHN+PxDs2iOiItGgn6u4+faUCdgGEV\\nEnmG69//3lAZHnEQN9rkZS3/20zc41mTJnO7dslJbB316vWUSIwYcVY/VC9DTbk+\\nMHWZd94p5hOB8PoY2vEGA53KiyWLqQC5FWE3u7cz7eYTr9/eRPDTc15IzohLXd5U\\nC9EbO5ebho2CvWrBfrLozM5Kidp8r3Jp+A0o3kfJ/kRDDn/BmG6pM0TohWZFYMs2\\nnQaGg+of9tcafgAs7hZAgBrrcc/jke6+MKxpC8algik79nMk7s7prxF1Z9EbAeQV\\n1ssL2VgsjvGAHIV+Arckl6QJbVDvQXNAM0PqbQIDAQABAoIBAQCoG6D5vf5P8nMS\\n2ltB/6cyyfsjgO/45Y+mTXqERwj0DOwUeMkDyRv6KCxb8LxKade+FPIaG7D/7amw\\nfdcE7qrRUyD3YfnPbUk5oNcfAwFbg+BX969WWBMZmgvfDGj1fWKT4w9ScQ1YkFUD\\nKrkLzLVhK+/N0Dad0VjiguTXTMZCSDFOY9fO8HRF6EA3aewEPeEY62J6rSjGXvWB\\nGdW+FNvf/uRr36xGHNqiOP837pdVUppjgDyVsORnMfFtYMyWyxS2XD5r8gRwcRg7\\n0nz6bLM53DjKweO+Yl+pIVPFAyXL0pwzQDlnjShsCzyzjA9lJftkQwbcMWopeegJ\\nkPLmiq4VAoGBAOqDmySNx8vmWWMOaXKFuH6Gqu/Nd7gBHxZ73wvsEmvV52xwa0oi\\n55h+v6P1YEaNZQWXDFsvILoOUHr2kwZY+Du/MC7tgqpj+Fu3h7UHslulJRE3A+sN\\noLbHjZuwm3wwsatpHdyEYOGg0HIGWXi+9pDT/1gy8g3L2Gf0X6rfkBBXAoGBAN2v\\nlbii0+HvZ2y0D0P6NfUJ6cQDrSyuTe7UW6OVYjBjrVAk8+bhnQ4eKd9edCnUDqu6\\n9C8ZSrqR6VBeItbt8y+5ZCRcrigxd2VdH8rL9g6idD9RPnSbHx7Al8DxSUv25xMK\\n8Z/ZOAvuCmwDfdleycNDoTawKqLtWBzUEntLs5DbAoGAPlTKiJWylAxel8h92HWY\\nSvDqQCChgGOz6prz9sxBPS42e4kJy0OpwMt3jlGqzDXKswipvRayoSEq3PPqshY1\\nrFOtr9trDnTRzzbhuAkaq+ciCghQX0pY/BvgFJCFUyXyIzgmOrVotq+yl4v+fexr\\nxqTCSqQH2AjlNQQr5VPUi7MCgYEAsNbbMXE6YlXug+lS8CANoM3qm4FvSGA3LNhb\\nza9hp0YsP+1qXvgEp/lp35RiR+ewWE+HcHbVhOTWYFTnp9ojDyPtfZAtIUTsgIB7\\n1vNC8kOnRccSckQ32/k4VSJlHOL1S9yECMZnjiSyTZ2va5HQkyJE3PJE4LlCe6S0\\npYQq1tcCgYEAoJDeSeAPqi5NIu+MWNUWzw4vo5raKyHrJi+cTvKyM/2zJFHvBc5f\\nRaxkcIAOmIDoVdFgy6APY/0DnDnpqT1kMagUaxZjG9PLFIDds5DRaL99m+S7l8mt\\nySX/MbmhQHYWpVf2nL6pmfPuP4Ih6tbKIUUGA3wZXYYZ5r+pZFG1IrA=\\n-----END RSA PRIVATE KEY-----`; + const config = { + get: sinon.stub(), + }; + + config.get.withArgs("signing_secret").returns(input); + config.get.withArgs("signing_algorithm").returns("RS256"); + + const signingConfig = createJWTSigningConfig((config as any) as Config); + + expect(signingConfig.algorithm).toEqual("RS256"); + expect(signingConfig.secret.toString()).toMatchSnapshot(); + }); +}); diff --git a/src/core/server/app/middleware/passport/jwt.ts b/src/core/server/app/middleware/passport/jwt.ts index e8ec1ebfa..78d8d6c64 100644 --- a/src/core/server/app/middleware/passport/jwt.ts +++ b/src/core/server/app/middleware/passport/jwt.ts @@ -122,7 +122,7 @@ export async function signTokenString( ...options, jwtid: uuid.v4(), algorithm, - expiresIn: "1 day", // TODO: (wyattjoh) evalue allowing configuration? + expiresIn: "1 day", // TODO: (wyattjoh) evaluate allowing configuration? subject: user.id, }); } From bdd4bfc272e5594b75186b5d663021eac3e4d93d Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 20 Jul 2018 14:30:07 -0600 Subject: [PATCH 35/45] feat: initial passport user auth impl --- src/core/server/app/index.ts | 13 +++- .../server/app/middleware/passport/index.ts | 10 ++- .../server/app/middleware/passport/jwt.ts | 77 ++++++++++++++++++- .../server/app/middleware/passport/sso.ts | 4 +- src/core/server/app/router.ts | 19 +++-- src/core/server/graph/tenant/context.ts | 2 + .../server/graph/tenant/resolvers/query.ts | 3 +- src/core/server/index.ts | 6 ++ 8 files changed, 116 insertions(+), 18 deletions(-) diff --git a/src/core/server/app/index.ts b/src/core/server/app/index.ts index c5dbd81a1..4594c9e81 100644 --- a/src/core/server/app/index.ts +++ b/src/core/server/app/index.ts @@ -5,6 +5,7 @@ import { Db } from "mongodb"; import { notFoundMiddleware } from "talk-server/app/middleware/notFound"; import { createPassport } from "talk-server/app/middleware/passport"; +import { JWTSigningConfig } from "talk-server/app/middleware/passport/jwt"; import { Config } from "talk-server/config"; import { handleSubscriptions } from "talk-server/graph/common/subscriptions/middleware"; import { Schemas } from "talk-server/graph/schemas"; @@ -19,6 +20,7 @@ export interface AppOptions { mongo: Db; redis: Redis; schemas: Schemas; + signingConfig: JWTSigningConfig; } /** @@ -32,10 +34,17 @@ export async function createApp(options: AppOptions): Promise { parent.use(accessLogger); // Create some services for the router. - const passport = createPassport({ db: options.mongo }); + const passport = createPassport({ + db: options.mongo, + signingConfig: options.signingConfig, + }); // Mount the router. - parent.use(await createRouter(options, { passport })); + parent.use( + await createRouter(options, { + passport, + }) + ); // Static Files parent.use(serveStatic); diff --git a/src/core/server/app/middleware/passport/index.ts b/src/core/server/app/middleware/passport/index.ts index 40c1bda39..8ef4a7711 100644 --- a/src/core/server/app/middleware/passport/index.ts +++ b/src/core/server/app/middleware/passport/index.ts @@ -3,6 +3,7 @@ import { Db } from "mongodb"; import passport, { Authenticator } from "passport"; import { + createJWTStrategy, JWTSigningConfig, SigningTokenOptions, signTokenString, @@ -20,10 +21,12 @@ export type VerifyCallback = ( export interface PassportOptions { db: Db; + signingConfig: JWTSigningConfig; } export function createPassport({ db, + signingConfig, }: PassportOptions): passport.Authenticator { // Create the authenticator. const auth = new Authenticator(); @@ -34,6 +37,9 @@ export function createPassport({ // Use the LocalStrategy. auth.use(createLocalStrategy({ db })); + // Use the JWTStrategy. + auth.use(createJWTStrategy({ db, signingConfig })); + return auth; } @@ -74,7 +80,7 @@ export async function handleSuccessfulLogin( } /** - * authenticate will wrap a authenticators authenticate method with one that + * wrapAuthz will wrap a authenticators authenticate method with one that * will return a valid login token for a valid login by a compatible strategy. * * @param authenticator the base authenticator instance @@ -82,7 +88,7 @@ export async function handleSuccessfulLogin( * @param name the name of the authenticator to use * @param options any options to be passed to the authenticate call */ -export const authenticate = ( +export const wrapAuthz = ( authenticator: passport.Authenticator, signingConfig: JWTSigningConfig, name: string, diff --git a/src/core/server/app/middleware/passport/jwt.ts b/src/core/server/app/middleware/passport/jwt.ts index 78d8d6c64..be5c492e4 100644 --- a/src/core/server/app/middleware/passport/jwt.ts +++ b/src/core/server/app/middleware/passport/jwt.ts @@ -1,8 +1,10 @@ import jwt, { SignOptions } from "jsonwebtoken"; import uuid from "uuid"; +import { Db } from "mongodb"; +import { Strategy } from "passport-strategy"; import { Config } from "talk-server/config"; -import { User } from "talk-server/models/user"; +import { retrieveUser, User } from "talk-server/models/user"; import { Request } from "talk-server/types/express"; const authHeaderRegex = /(\S+)\s+(\S+)/; @@ -126,3 +128,76 @@ export async function signTokenString( subject: user.id, }); } + +export interface JWTToken { + jti: string; + sub: string; + exp: number; + iss?: string; +} + +export interface JWTStrategyOptions { + signingConfig: JWTSigningConfig; + db: Db; +} + +export class JWTStrategy extends Strategy { + private signingConfig: JWTSigningConfig; + private db: Db; + + public name: string; + + constructor({ signingConfig, db }: JWTStrategyOptions) { + super(); + + this.name = "jwt"; + this.signingConfig = signingConfig; + this.db = db; + } + + public authenticate(req: Request) { + const { tenant } = req; + if (!tenant) { + // TODO: (wyattjoh) return a better error. + return this.error(new Error("tenant not found")); + } + + // Lookup the token. + const token = extractJWTFromRequest(req); + if (!token) { + // TODO: (wyattjoh) return a better error. + return this.fail(new Error("no token on request"), 401); + } + + jwt.verify( + token, + // Use the secret specified in the configuration. + this.signingConfig.secret, + { + // We need to verify that the token is for the specified tenant. + issuer: tenant.id, + // Use the algorithm specified in the configuration. + algorithms: [this.signingConfig.algorithm], + }, + async (err: Error | undefined, { sub }: JWTToken) => { + if (err) { + return this.fail(err, 401); + } + + try { + // Find the user. + const user = await retrieveUser(this.db, tenant.id, sub); + + // Return them! The user may be null, but that's ok here. + this.success(user, null); + } catch (err) { + return this.error(err); + } + } + ); + } +} + +export function createJWTStrategy(options: JWTStrategyOptions) { + return new JWTStrategy(options); +} diff --git a/src/core/server/app/middleware/passport/sso.ts b/src/core/server/app/middleware/passport/sso.ts index b5699114d..e009cf747 100644 --- a/src/core/server/app/middleware/passport/sso.ts +++ b/src/core/server/app/middleware/passport/sso.ts @@ -182,7 +182,7 @@ export default class SSOStrategy extends Strategy { */ private wrapNewTokenHandler = (tenant: Tenant) => async ( err: Error | undefined, - decoded: OIDCIDToken | SSOToken + token: OIDCIDToken | SSOToken ) => { if (err) { return this.fail(err, 401); @@ -190,7 +190,7 @@ export default class SSOStrategy extends Strategy { try { // Find or create the user based on the decoded token. - const user = await this.findOrCreateUser(tenant, decoded); + const user = await this.findOrCreateUser(tenant, token); // The user was found or created! return this.success(user, null); diff --git a/src/core/server/app/router.ts b/src/core/server/app/router.ts index b9914e5a5..9467e4caf 100644 --- a/src/core/server/app/router.ts +++ b/src/core/server/app/router.ts @@ -4,12 +4,11 @@ import passport from "passport"; import { signupHandler } from "talk-server/app/handlers/auth/local"; import { apiErrorHandler } from "talk-server/app/middleware/error"; import { errorLogger } from "talk-server/app/middleware/logging"; -import { authenticate } from "talk-server/app/middleware/passport"; +import { wrapAuthz } from "talk-server/app/middleware/passport"; import tenantMiddleware from "talk-server/app/middleware/tenant"; import managementGraphMiddleware from "talk-server/graph/management/middleware"; import tenantGraphMiddleware from "talk-server/graph/tenant/middleware"; -import { createJWTSigningConfig } from "talk-server/app/middleware/passport/jwt"; import { AppOptions } from "./index"; import playground from "./middleware/playground"; @@ -46,6 +45,9 @@ async function createTenantRouter(app: AppOptions, options: RouterOptions) { router.use( "/graphql", express.json(), + // Any users may submit their GraphQL requests with authentication, this + // middleware will unpack their user into the request. + options.passport.authenticate("jwt", { session: false }), await tenantGraphMiddleware(app.schemas.tenant, app.config, app.mongo) ); @@ -55,25 +57,22 @@ async function createTenantRouter(app: AppOptions, options: RouterOptions) { function createNewAuthRouter(app: AppOptions, options: RouterOptions) { const router = express.Router(); - // Create the signing config. - const signingConfig = createJWTSigningConfig(app.config); - // Mount the passport routes. router.post( "/local", express.json(), - authenticate(options.passport, signingConfig, "local") + wrapAuthz(options.passport, app.signingConfig, "local") ); router.post( "/local/signup", express.json(), - signupHandler({ db: app.mongo, signingConfig }) + signupHandler({ db: app.mongo, signingConfig: app.signingConfig }) ); - router.post("/sso", authenticate(options.passport, signingConfig, "sso")); - router.get("/oidc", authenticate(options.passport, signingConfig, "oidc")); + router.post("/sso", wrapAuthz(options.passport, app.signingConfig, "sso")); + router.get("/oidc", wrapAuthz(options.passport, app.signingConfig, "oidc")); router.get( "/oidc/callback", - authenticate(options.passport, signingConfig, "oidc") + wrapAuthz(options.passport, app.signingConfig, "oidc") ); return router; diff --git a/src/core/server/graph/tenant/context.ts b/src/core/server/graph/tenant/context.ts index 21e2dc9b9..0f51eb529 100644 --- a/src/core/server/graph/tenant/context.ts +++ b/src/core/server/graph/tenant/context.ts @@ -15,12 +15,14 @@ export default class TenantContext extends CommonContext { public loaders: ReturnType; public mutators: ReturnType; public db: Db; + public user?: User; public tenant: Tenant; constructor({ user, tenant, db }: TenantContextOptions) { super({ user }); this.tenant = tenant; + this.user = user; this.loaders = loaders(this); this.mutators = mutators(this); this.db = db; diff --git a/src/core/server/graph/tenant/resolvers/query.ts b/src/core/server/graph/tenant/resolvers/query.ts index 56d99e1fe..5dbd4bccc 100644 --- a/src/core/server/graph/tenant/resolvers/query.ts +++ b/src/core/server/graph/tenant/resolvers/query.ts @@ -2,7 +2,8 @@ import { GQLQueryTypeResolver } from "talk-server/graph/tenant/schema/__generate const Query: GQLQueryTypeResolver = { asset: (source, args, ctx) => ctx.loaders.Assets.findOrCreate(args), - settings: (parent, args, ctx) => ctx.tenant, + settings: (source, args, ctx) => ctx.tenant, + me: (source, args, ctx) => ctx.user, }; export default Query; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index fdf4f3aea..56e92f7f7 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -1,9 +1,11 @@ import express, { Express } from "express"; import http from "http"; +import { createJWTSigningConfig } from "talk-server/app/middleware/passport/jwt"; import getManagementSchema from "talk-server/graph/management/schema"; import { Schemas } from "talk-server/graph/schemas"; import getTenantSchema from "talk-server/graph/tenant/schema"; + import { attachSubscriptionHandlers, createApp, listenAndServe } from "./app"; import config, { Config } from "./config"; import logger from "./logger"; @@ -63,6 +65,9 @@ class Server { // Setup Redis. const redis = await createRedisClient(config); + // Create the signing config. + const signingConfig = createJWTSigningConfig(this.config); + // Create the Talk App, branching off from the parent app. const app: Express = await createApp({ parent, @@ -70,6 +75,7 @@ class Server { redis, config: this.config, schemas: this.schemas, + signingConfig, }); // Start the application and store the resulting http.Server. From 2c1111109a56140c84a2ffa6ee9fd5a8ef1e18dd Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 20 Jul 2018 16:19:33 -0600 Subject: [PATCH 36/45] fix: support not logged in users --- src/core/server/app/middleware/passport/jwt.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/core/server/app/middleware/passport/jwt.ts b/src/core/server/app/middleware/passport/jwt.ts index be5c492e4..b47bd06dd 100644 --- a/src/core/server/app/middleware/passport/jwt.ts +++ b/src/core/server/app/middleware/passport/jwt.ts @@ -156,19 +156,20 @@ export class JWTStrategy extends Strategy { } public authenticate(req: Request) { + // Lookup the token. + const token = extractJWTFromRequest(req); + if (!token) { + // There was no token on the request, so there was no user, so let's mark + // that the strategy was succesfull. + return this.success(null, null); + } + const { tenant } = req; if (!tenant) { // TODO: (wyattjoh) return a better error. return this.error(new Error("tenant not found")); } - // Lookup the token. - const token = extractJWTFromRequest(req); - if (!token) { - // TODO: (wyattjoh) return a better error. - return this.fail(new Error("no token on request"), 401); - } - jwt.verify( token, // Use the secret specified in the configuration. From 0a0f7ce2165bb7c02ed741938bfba148046e790f Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 20 Jul 2018 16:26:35 -0600 Subject: [PATCH 37/45] fix: marked username as nullable --- src/core/server/graph/tenant/schema/schema.graphql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index a3085b32f..f53f642eb 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -361,7 +361,7 @@ type User { """ username is the name of the User visible to other Users. """ - username: String! + username: String """ displayName is provided optionally when enabled and available. From 558f81c9aa24ff25cff534d6434f99aa72a6e207 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 20 Jul 2018 16:38:03 -0600 Subject: [PATCH 38/45] fix: sso strategy --- .../server/app/middleware/passport/index.ts | 4 ++ .../server/app/middleware/passport/sso.ts | 43 +++++++++---------- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/src/core/server/app/middleware/passport/index.ts b/src/core/server/app/middleware/passport/index.ts index 8ef4a7711..755b7f5ed 100644 --- a/src/core/server/app/middleware/passport/index.ts +++ b/src/core/server/app/middleware/passport/index.ts @@ -10,6 +10,7 @@ import { } from "talk-server/app/middleware/passport/jwt"; import { createLocalStrategy } from "talk-server/app/middleware/passport/local"; import { createOIDCStrategy } from "talk-server/app/middleware/passport/oidc"; +import { createSSOStrategy } from "talk-server/app/middleware/passport/sso"; import { User } from "talk-server/models/user"; import { Request } from "talk-server/types/express"; @@ -37,6 +38,9 @@ export function createPassport({ // Use the LocalStrategy. auth.use(createLocalStrategy({ db })); + // Use the SSOStrategy. + auth.use(createSSOStrategy({ db })); + // Use the JWTStrategy. auth.use(createJWTStrategy({ db, signingConfig })); diff --git a/src/core/server/app/middleware/passport/sso.ts b/src/core/server/app/middleware/passport/sso.ts index e009cf747..cd50a67de 100644 --- a/src/core/server/app/middleware/passport/sso.ts +++ b/src/core/server/app/middleware/passport/sso.ts @@ -177,28 +177,6 @@ export default class SSOStrategy extends Strategy { return findOrCreateSSOUser(this.db, tenant, token); } - /** - * wrapNewTokenHandler wraps the token handling with a promise. - */ - private wrapNewTokenHandler = (tenant: Tenant) => async ( - err: Error | undefined, - token: OIDCIDToken | SSOToken - ) => { - if (err) { - return this.fail(err, 401); - } - - try { - // Find or create the user based on the decoded token. - const user = await this.findOrCreateUser(tenant, token); - - // The user was found or created! - return this.success(user, null); - } catch (err) { - return this.error(err); - } - }; - public authenticate(req: Request) { const { tenant } = req; if (!tenant) { @@ -222,7 +200,26 @@ export default class SSOStrategy extends Strategy { // out in the future.. algorithms: ["HS256"], // TODO: (wyattjoh) investigate replacing algorithm. }, - this.wrapNewTokenHandler(tenant) + async (err: Error | undefined, decoded: OIDCIDToken | SSOToken) => { + if (err) { + // TODO: (wyattjoh) wrap error? + return this.error(err); + } + + try { + // Find or create the user based on the decoded token. + const user = await this.findOrCreateUser(tenant, decoded); + + // The user was found or created! + return this.success(user, null); + } catch (err) { + return this.error(err); + } + } ); } } + +export function createSSOStrategy(options: SSOStrategyOptions) { + return new SSOStrategy(options); +} From 81e8f68e62db70ce24084db255c6e8f2148e4f84 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 23 Jul 2018 10:08:12 -0600 Subject: [PATCH 39/45] fix: upgrade @types/passport --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4d50f11b4..417b255dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1793,9 +1793,9 @@ } }, "@types/passport": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/@types/passport/-/passport-0.4.5.tgz", - "integrity": "sha512-Ow5akVXwEZlOPCWGbEGy0GX4ocdwKz7JJH1K+BMd/BSOxmJTo2obH2AKbsgcncQvw5z7AGopdIu1Ap/j9sMRnQ==", + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-0.4.6.tgz", + "integrity": "sha512-P7TxrdpAze3nvHghYPeLlHkYcFDiIkRBbp7xYz2ehX9zmi1yr/qWQMTpXsMxN5w3ESJpMzn917inK4giASaDcQ==", "dev": true, "requires": { "@types/express": "*" diff --git a/package.json b/package.json index 2cc25ebd6..5b017e3e9 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ "@types/luxon": "^0.5.3", "@types/mongodb": "^3.1.1", "@types/node": "^10.5.2", - "@types/passport": "^0.4.5", + "@types/passport": "^0.4.6", "@types/passport-local": "^1.0.33", "@types/passport-oauth2": "^1.4.5", "@types/passport-strategy": "^0.2.33", From 4b5c7c1c7b0887e2fb42bf3687e7b6cda9ccf709 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 23 Jul 2018 13:14:34 -0600 Subject: [PATCH 40/45] fix: move displayName into auth integrations --- src/core/server/app/handlers/auth/local.ts | 19 ++++--------------- .../server/app/middleware/passport/oidc.ts | 2 +- .../server/app/middleware/passport/sso.ts | 2 +- src/core/server/models/tenant.ts | 15 ++++++++++----- 4 files changed, 16 insertions(+), 22 deletions(-) diff --git a/src/core/server/app/handlers/auth/local.ts b/src/core/server/app/handlers/auth/local.ts index baab6dcd6..0c438ccf3 100644 --- a/src/core/server/app/handlers/auth/local.ts +++ b/src/core/server/app/handlers/auth/local.ts @@ -23,11 +23,6 @@ const SignupBodySchema = Joi.object().keys({ email: Joi.string().trim(), }); -// Extends the default signup body schema to allow the displayName to be set. -const SignupDisplayNameBodySchema = SignupBodySchema.keys({ - displayName: Joi.string().trim(), -}); - export interface SignupOptions { db: Db; signingConfig: JWTSigningConfig; @@ -50,15 +45,10 @@ export const signupHandler = (options: SignupOptions): RequestHandler => async ( return next(new Error("integration is disabled")); } - // Get the fields from the body. We condition on the display name being - // enabled to allow the display name to be stripped in the event that the - // display name is not enabled, yielding a displayName being `undefined`, - // which will not be set in the resultant document. Validate will throw an - // error if the body does not conform to the specification. - const { username, password, email, displayName }: SignupBody = validate( - tenant.auth.displayNameEnable - ? SignupDisplayNameBodySchema - : SignupBodySchema, + // Get the fields from the body. Validate will throw an error if the body + // does not conform to the specification. + const { username, password, email }: SignupBody = validate( + SignupBodySchema, req.body ); @@ -72,7 +62,6 @@ export const signupHandler = (options: SignupOptions): RequestHandler => async ( const user = await upsert(options.db, tenant, { email, username, - displayName, password, profiles: [profile], // New users signing up via local auth will have the commenter role to diff --git a/src/core/server/app/middleware/passport/oidc.ts b/src/core/server/app/middleware/passport/oidc.ts index 35982e0a1..2bbe16a30 100644 --- a/src/core/server/app/middleware/passport/oidc.ts +++ b/src/core/server/app/middleware/passport/oidc.ts @@ -131,7 +131,7 @@ export async function findOrCreateOIDCUser( name, nickname, }: OIDCIDToken = validate( - tenant.auth.displayNameEnable + tenant.auth.integrations.oidc!.displayNameEnable ? OIDCDisplayNameIDTokenSchema : OIDCIDTokenSchema, token diff --git a/src/core/server/app/middleware/passport/sso.ts b/src/core/server/app/middleware/passport/sso.ts index cd50a67de..4e6903139 100644 --- a/src/core/server/app/middleware/passport/sso.ts +++ b/src/core/server/app/middleware/passport/sso.ts @@ -57,7 +57,7 @@ export async function findOrCreateSSOUser( // Unpack/validate the token content. const { id, email, username, displayName, avatar }: SSOUserProfile = validate( - tenant.auth.displayNameEnable + tenant.auth.integrations.sso!.displayNameEnable ? SSODisplayNameUserProfileSchema : SSOUserProfileSchema, token.user diff --git a/src/core/server/models/tenant.ts b/src/core/server/models/tenant.ts index 0b14a46c0..bc19c6553 100644 --- a/src/core/server/models/tenant.ts +++ b/src/core/server/models/tenant.ts @@ -58,16 +58,24 @@ export interface AuthIntegration { enabled: boolean; } +export interface DisplayNameAuthIntegration { + displayNameEnable: boolean; +} + // SSOAuthIntegration is an AuthIntegration that provides a secret to the admins // of a tenant, where they can sign a SSO payload with it to provide to the // embed to allow single sign on. -export interface SSOAuthIntegration extends AuthIntegration { +export interface SSOAuthIntegration + extends AuthIntegration, + DisplayNameAuthIntegration { key: string; } // OIDCAuthIntegration provides a way to store Open ID Connect credentials. This // will be used in the admin to provide staff logins for users. -export interface OIDCAuthIntegration extends AuthIntegration { +export interface OIDCAuthIntegration + extends AuthIntegration, + DisplayNameAuthIntegration { clientID: string; clientSecret: string; issuer: string; @@ -108,7 +116,6 @@ export interface AuthIntegrations { export interface Auth { integrations: AuthIntegrations; - displayNameEnable: boolean; } // Tenant definition. @@ -194,8 +201,6 @@ export async function createTenant(db: Db, input: CreateTenantInput) { banned: [], }, auth: { - // Disable the displayName by default. - displayNameEnable: false, integrations: { local: { enabled: true, From 1ec1572efbb2e6b0df397a1f92d332ff0318e8af Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 23 Jul 2018 13:33:44 -0600 Subject: [PATCH 41/45] fix: updated client code to respect missing usernames --- .../client/stream/components/Comment/Comment.tsx | 5 +++-- .../stream/containers/CommentContainer.spec.tsx | 15 +++++++++++++++ .../__snapshots__/CommentContainer.spec.tsx.snap | 12 ++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/core/client/stream/components/Comment/Comment.tsx b/src/core/client/stream/components/Comment/Comment.tsx index f923e4149..03a947a5a 100644 --- a/src/core/client/stream/components/Comment/Comment.tsx +++ b/src/core/client/stream/components/Comment/Comment.tsx @@ -9,7 +9,7 @@ import Username from "./Username"; export interface CommentProps { author: { - username: string; + username: string | null; } | null; body: string | null; createdAt: string; @@ -19,7 +19,8 @@ const Comment: StatelessComponent = props => { return (
- {props.author && {props.author.username}} + {props.author && + props.author.username && {props.author.username}} {props.createdAt} {props.body} diff --git a/src/core/client/stream/containers/CommentContainer.spec.tsx b/src/core/client/stream/containers/CommentContainer.spec.tsx index 98612be38..4836991b8 100644 --- a/src/core/client/stream/containers/CommentContainer.spec.tsx +++ b/src/core/client/stream/containers/CommentContainer.spec.tsx @@ -19,3 +19,18 @@ it("renders username and body", () => { const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); + +it("renders body only", () => { + const props: PropTypesOf = { + data: { + author: { + username: null, + }, + body: "Woof", + createdAt: "1995-12-17T03:24:00.000Z", + }, + }; + + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); +}); diff --git a/src/core/client/stream/containers/__snapshots__/CommentContainer.spec.tsx.snap b/src/core/client/stream/containers/__snapshots__/CommentContainer.spec.tsx.snap index 9e2476f37..f6ec9754b 100644 --- a/src/core/client/stream/containers/__snapshots__/CommentContainer.spec.tsx.snap +++ b/src/core/client/stream/containers/__snapshots__/CommentContainer.spec.tsx.snap @@ -1,5 +1,17 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`renders body only 1`] = ` + +`; + exports[`renders username and body 1`] = ` Date: Wed, 1 Aug 2018 15:52:56 -0600 Subject: [PATCH 42/45] fix: resolved schema error --- .../server/graph/tenant/schema/schema.graphql | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index f53f642eb..d8f082ff7 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -76,6 +76,12 @@ type LocalAuthIntegration { type SSOAuthIntegrationConfig { key: String! + + """ + displayNameEnable when enabled, will allow Users to set and view their + displayName's. + """ + displayNameEnable: Boolean! } type SSOAuthIntegration { @@ -92,6 +98,12 @@ type OIDCAuthIntegrationConfig { clientSecret: String! authorizationURL: String! tokenURL: String! + + """ + displayNameEnable when enabled, will allow Users to set and view their + displayName's. + """ + displayNameEnable: Boolean! } type OIDCAuthIntegrationOptions { @@ -145,12 +157,6 @@ AuthSettings contains all the settings related to authentication and authorization. """ type AuthSettings { - """ - displayNameEnable when enabled, will allow Users to set and view their - displayName's. - """ - displayNameEnable: Boolean! - """ integrations are the set of configurations for the variations of authentication solutions. From 201e5fbcb1bb1529d0ea99339f1b893a4aaa8bcb Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 1 Aug 2018 15:56:04 -0600 Subject: [PATCH 43/45] fix: rename wrapAuthz with wrapAuthn --- src/core/server/app/middleware/passport/index.ts | 4 ++-- src/core/server/app/router.ts | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/core/server/app/middleware/passport/index.ts b/src/core/server/app/middleware/passport/index.ts index 755b7f5ed..d9d6768f3 100644 --- a/src/core/server/app/middleware/passport/index.ts +++ b/src/core/server/app/middleware/passport/index.ts @@ -84,7 +84,7 @@ export async function handleSuccessfulLogin( } /** - * wrapAuthz will wrap a authenticators authenticate method with one that + * wrapAuthn will wrap a authenticators authenticate method with one that * will return a valid login token for a valid login by a compatible strategy. * * @param authenticator the base authenticator instance @@ -92,7 +92,7 @@ export async function handleSuccessfulLogin( * @param name the name of the authenticator to use * @param options any options to be passed to the authenticate call */ -export const wrapAuthz = ( +export const wrapAuthn = ( authenticator: passport.Authenticator, signingConfig: JWTSigningConfig, name: string, diff --git a/src/core/server/app/router.ts b/src/core/server/app/router.ts index 9467e4caf..085b9e4f9 100644 --- a/src/core/server/app/router.ts +++ b/src/core/server/app/router.ts @@ -4,7 +4,7 @@ import passport from "passport"; import { signupHandler } from "talk-server/app/handlers/auth/local"; import { apiErrorHandler } from "talk-server/app/middleware/error"; import { errorLogger } from "talk-server/app/middleware/logging"; -import { wrapAuthz } from "talk-server/app/middleware/passport"; +import { wrapAuthn } from "talk-server/app/middleware/passport"; import tenantMiddleware from "talk-server/app/middleware/tenant"; import managementGraphMiddleware from "talk-server/graph/management/middleware"; import tenantGraphMiddleware from "talk-server/graph/tenant/middleware"; @@ -61,18 +61,18 @@ function createNewAuthRouter(app: AppOptions, options: RouterOptions) { router.post( "/local", express.json(), - wrapAuthz(options.passport, app.signingConfig, "local") + wrapAuthn(options.passport, app.signingConfig, "local") ); router.post( "/local/signup", express.json(), signupHandler({ db: app.mongo, signingConfig: app.signingConfig }) ); - router.post("/sso", wrapAuthz(options.passport, app.signingConfig, "sso")); - router.get("/oidc", wrapAuthz(options.passport, app.signingConfig, "oidc")); + router.post("/sso", wrapAuthn(options.passport, app.signingConfig, "sso")); + router.get("/oidc", wrapAuthn(options.passport, app.signingConfig, "oidc")); router.get( "/oidc/callback", - wrapAuthz(options.passport, app.signingConfig, "oidc") + wrapAuthn(options.passport, app.signingConfig, "oidc") ); return router; From 6d7056d831a29510599ce3cccf19ffcc01829475 Mon Sep 17 00:00:00 2001 From: Kiwi Date: Thu, 2 Aug 2018 17:29:18 +0200 Subject: [PATCH 44/45] [next] Add support for embed (#1762) * Move talk-server/config to talk-common/config * Refactor build into /src/core/build and use common config * Add embed webpack config * Start implementing embed * Implement embed * Add pym types * Add event emitter to Talk Context * Add MatchMedia test for passing values from the context * Add support for click far away * Integrate pym click events to registerClickFarAway * Add tests * Resolve merge issues * Apply PR review --- config/env.js | 93 ---- config/jest.config.js | 4 +- .../client.config.js} | 4 +- .../server.config.js} | 2 +- config/{ => jest}/tsconfig.jest.json | 0 config/paths.js | 66 --- config/paths.ts | 15 + config/watcher.ts | 2 - config/webpack.config.dev.js | 334 ------------- config/webpack.config.prod.js | 388 --------------- ...r.config.js => webpackDevServer.config.ts} | 57 +-- doczrc.js | 8 +- package-lock.json | 354 +++++++++++++- package.json | 38 +- scripts/{build.js => build.ts} | 56 +-- scripts/{start.js => start.ts} | 72 ++- scripts/test.js | 14 +- src/core/build/createWebpackConfig.ts | 447 ++++++++++++++++++ .../core/build/loaders}/locales-loader.js | 0 src/core/build/paths.ts | 33 ++ {config => src/core/build}/polyfills.js | 0 {config => src/core/build}/postcss.config.js | 5 +- src/core/client/embed/PymControl.spec.ts | 55 +++ src/core/client/embed/PymControl.ts | 41 ++ src/core/client/embed/Stream.spec.ts | 70 +++ src/core/client/embed/Stream.ts | 83 ++++ .../__snapshots__/PymControl.spec.ts.snap | 5 + .../embed/__snapshots__/index.spec.ts.snap | 3 + src/core/client/embed/decorators/index.ts | 11 + .../embed/decorators/withAutoHeight.spec.ts | 16 + .../client/embed/decorators/withAutoHeight.ts | 14 + .../embed/decorators/withClickEvent.spec.ts | 19 + .../client/embed/decorators/withClickEvent.ts | 16 + .../embed/decorators/withCommentID.spec.ts | 36 ++ .../client/embed/decorators/withCommentID.ts | 36 ++ .../embed/decorators/withEventEmitter.spec.ts | 21 + .../embed/decorators/withEventEmitter.ts | 13 + .../withIOSSafariWidthWorkaround.spec.ts | 12 + .../withIOSSafariWidthWorkaround.ts | 9 + src/core/client/embed/index.html | 19 + src/core/client/embed/index.spec.ts | 23 + src/core/client/embed/index.ts | 30 ++ src/core/client/embed/tsconfig.json | 14 + src/core/client/embed/utils/buildURL.spec.ts | 18 + src/core/client/embed/utils/buildURL.ts | 17 + .../client/embed/utils/ensureEndSlash.spec.ts | 11 + src/core/client/embed/utils/ensureEndSlash.ts | 3 + src/core/client/embed/utils/index.ts | 2 + .../framework/lib/bootstrap/TalkContext.tsx | 25 +- .../framework/lib/bootstrap/createContext.tsx | 36 +- src/core/client/stream/index.html | 6 +- src/core/client/stream/index.tsx | 2 + src/core/client/tsconfig.json | 2 +- .../ClickOutside/ClickOutside.spec.tsx | 63 ++- .../components/ClickOutside/ClickOutside.tsx | 49 +- .../ui/components/ClickOutside/index.ts | 1 + .../components/MatchMedia/MatchMedia.spec.tsx | 23 +- .../ui/components/UIContext/UIContext.ts | 9 + src/core/{server => common}/config.ts | 4 + src/core/server/app/index.ts | 2 +- .../app/middleware/passport/jwt.spec.ts | 2 +- .../server/app/middleware/passport/jwt.ts | 2 +- .../server/graph/common/middleware/index.ts | 2 +- .../graph/common/subscriptions/pubsub.ts | 2 +- .../server/graph/management/middleware.ts | 2 +- src/core/server/graph/tenant/middleware.ts | 2 +- src/core/server/index.ts | 2 +- src/core/server/services/mongodb/index.ts | 2 +- src/core/server/services/redis/index.ts | 2 +- src/types/pym.d.ts | 174 +++++++ src/types/react-dev-utils.d.ts | 28 ++ tsconfig.json | 10 +- 72 files changed, 1995 insertions(+), 1046 deletions(-) delete mode 100644 config/env.js rename config/{jest-client.config.js => jest/client.config.js} (95%) rename config/{jest-server.config.js => jest/server.config.js} (96%) rename config/{ => jest}/tsconfig.jest.json (100%) delete mode 100644 config/paths.js create mode 100644 config/paths.ts delete mode 100644 config/webpack.config.dev.js delete mode 100644 config/webpack.config.prod.js rename config/{webpackDevServer.config.js => webpackDevServer.config.ts} (61%) rename scripts/{build.js => build.ts} (74%) rename scripts/{start.js => start.ts} (55%) create mode 100644 src/core/build/createWebpackConfig.ts rename {loaders => src/core/build/loaders}/locales-loader.js (100%) create mode 100644 src/core/build/paths.ts rename {config => src/core/build}/polyfills.js (100%) rename {config => src/core/build}/postcss.config.js (96%) create mode 100644 src/core/client/embed/PymControl.spec.ts create mode 100644 src/core/client/embed/PymControl.ts create mode 100644 src/core/client/embed/Stream.spec.ts create mode 100644 src/core/client/embed/Stream.ts create mode 100644 src/core/client/embed/__snapshots__/PymControl.spec.ts.snap create mode 100644 src/core/client/embed/__snapshots__/index.spec.ts.snap create mode 100644 src/core/client/embed/decorators/index.ts create mode 100644 src/core/client/embed/decorators/withAutoHeight.spec.ts create mode 100644 src/core/client/embed/decorators/withAutoHeight.ts create mode 100644 src/core/client/embed/decorators/withClickEvent.spec.ts create mode 100644 src/core/client/embed/decorators/withClickEvent.ts create mode 100644 src/core/client/embed/decorators/withCommentID.spec.ts create mode 100644 src/core/client/embed/decorators/withCommentID.ts create mode 100644 src/core/client/embed/decorators/withEventEmitter.spec.ts create mode 100644 src/core/client/embed/decorators/withEventEmitter.ts create mode 100644 src/core/client/embed/decorators/withIOSSafariWidthWorkaround.spec.ts create mode 100644 src/core/client/embed/decorators/withIOSSafariWidthWorkaround.ts create mode 100644 src/core/client/embed/index.html create mode 100644 src/core/client/embed/index.spec.ts create mode 100644 src/core/client/embed/index.ts create mode 100644 src/core/client/embed/tsconfig.json create mode 100644 src/core/client/embed/utils/buildURL.spec.ts create mode 100644 src/core/client/embed/utils/buildURL.ts create mode 100644 src/core/client/embed/utils/ensureEndSlash.spec.ts create mode 100644 src/core/client/embed/utils/ensureEndSlash.ts create mode 100644 src/core/client/embed/utils/index.ts create mode 100644 src/core/client/ui/components/ClickOutside/index.ts rename src/core/{server => common}/config.ts (95%) create mode 100644 src/types/pym.d.ts create mode 100644 src/types/react-dev-utils.d.ts diff --git a/config/env.js b/config/env.js deleted file mode 100644 index 2240e824f..000000000 --- a/config/env.js +++ /dev/null @@ -1,93 +0,0 @@ -"use strict"; - -const fs = require("fs"); -const path = require("path"); -const paths = require("./paths"); - -// Make sure that including paths.js after env.js will read .env variables. -delete require.cache[require.resolve("./paths")]; - -const NODE_ENV = process.env.NODE_ENV; -if (!NODE_ENV) { - throw new Error( - "The NODE_ENV environment variable is required but was not specified." - ); -} - -// https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use -var dotenvFiles = [ - `${paths.dotenv}.${NODE_ENV}.local`, - `${paths.dotenv}.${NODE_ENV}`, - // Don't include `.env.local` for `test` environment - // since normally you expect tests to produce the same - // results for everyone - NODE_ENV !== "test" && `${paths.dotenv}.local`, - paths.dotenv, -].filter(Boolean); - -// Load environment variables from .env* files. Suppress warnings using silent -// if this file is missing. dotenv will never modify any environment variables -// that have already been set. Variable expansion is supported in .env files. -// https://github.com/motdotla/dotenv -// https://github.com/motdotla/dotenv-expand -dotenvFiles.forEach(dotenvFile => { - if (fs.existsSync(dotenvFile)) { - require("dotenv-expand")( - require("dotenv").config({ - path: dotenvFile, - }) - ); - } -}); - -// We support resolving modules according to `NODE_PATH`. -// This lets you use absolute paths in imports inside large monorepos: -// https://github.com/facebookincubator/create-react-app/issues/253. -// It works similar to `NODE_PATH` in Node itself: -// https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders -// Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored. -// Otherwise, we risk importing Node.js core modules into an app instead of Webpack shims. -// https://github.com/facebookincubator/create-react-app/issues/1023#issuecomment-265344421 -// We also resolve them to make sure all tools using them work consistently. -const appDirectory = fs.realpathSync(process.cwd()); -process.env.NODE_PATH = (process.env.NODE_PATH || "") - .split(path.delimiter) - .filter(folder => folder && !path.isAbsolute(folder)) - .map(folder => path.resolve(appDirectory, folder)) - .join(path.delimiter); - -// Grab NODE_ENV and TALK_* environment variables and prepare them to be -// injected into the application via DefinePlugin in Webpack configuration. -const REACT_APP = /^TALK_/i; - -function getClientEnvironment(publicUrl) { - const raw = Object.keys(process.env) - .filter(key => REACT_APP.test(key)) - .reduce( - (env, key) => { - env[key] = process.env[key]; - return env; - }, - { - // Useful for determining whether we’re running in production mode. - // Most importantly, it switches React into the correct mode. - NODE_ENV: process.env.NODE_ENV || "development", - // Useful for resolving the correct path to static assets in `public`. - // For example, . - // This should only be used as an escape hatch. Normally you would put - // images into the `src` and `import` them in code to get their paths. - PUBLIC_URL: publicUrl, - } - ); - // Stringify all values so we can feed into Webpack DefinePlugin - const stringified = { - "process.env": Object.keys(raw).reduce((env, key) => { - env[key] = JSON.stringify(raw[key]); - return env; - }, {}), - }; - - return { raw, stringified }; -} - -module.exports = getClientEnvironment; diff --git a/config/jest.config.js b/config/jest.config.js index 3e7166a29..3d4bf802b 100644 --- a/config/jest.config.js +++ b/config/jest.config.js @@ -1,6 +1,6 @@ module.exports = { projects: [ - "/jest-client.config.js", - "/jest-server.config.js", + "/jest/client.config.js", + "/jest/server.config.js", ], }; diff --git a/config/jest-client.config.js b/config/jest/client.config.js similarity index 95% rename from config/jest-client.config.js rename to config/jest/client.config.js index 3bfbd195c..d5cc83ce3 100644 --- a/config/jest-client.config.js +++ b/config/jest/client.config.js @@ -2,12 +2,12 @@ const path = require("path"); module.exports = { displayName: "client", - rootDir: "../", + rootDir: "../../", roots: ["/src/core/client"], collectCoverageFrom: ["**/*.{js,jsx,mjs,ts,tsx}"], coveragePathIgnorePatterns: ["/node_modules/"], setupFiles: [ - "/config/polyfills.js", + "/src/core/build/polyfills.js", "/src/core/client/test/setup.ts", ], testMatch: ["**/*.spec.{js,jsx,mjs,ts,tsx}"], diff --git a/config/jest-server.config.js b/config/jest/server.config.js similarity index 96% rename from config/jest-server.config.js rename to config/jest/server.config.js index 6ac9556bf..36376dd06 100644 --- a/config/jest-server.config.js +++ b/config/jest/server.config.js @@ -1,6 +1,6 @@ module.exports = { displayName: "server", - rootDir: "../", + rootDir: "../../", roots: ["/src"], collectCoverageFrom: ["**/*.{js,jsx,mjs,ts,tsx}"], coveragePathIgnorePatterns: ["/node_modules/"], diff --git a/config/tsconfig.jest.json b/config/jest/tsconfig.jest.json similarity index 100% rename from config/tsconfig.jest.json rename to config/jest/tsconfig.jest.json diff --git a/config/paths.js b/config/paths.js deleted file mode 100644 index 73cc43370..000000000 --- a/config/paths.js +++ /dev/null @@ -1,66 +0,0 @@ -"use strict"; - -// A script from `create-react-app` ejected `25.06.2018`. - -const path = require("path"); -const fs = require("fs"); -const url = require("url"); - -// Make sure any symlinks in the project folder are resolved: -// https://github.com/facebookincubator/create-react-app/issues/637 -const appDirectory = fs.realpathSync(process.cwd()); -const resolveApp = relativePath => path.resolve(appDirectory, relativePath); - -const envPublicUrl = process.env.PUBLIC_URL; - -function ensureSlash(p, needsSlash) { - const hasSlash = p.endsWith("/"); - if (hasSlash && !needsSlash) { - return p.substr(p, p.length - 1); - } else if (!hasSlash && needsSlash) { - return `${p}/`; - } else { - return p; - } -} - -const getPublicUrl = appPackageJson => - envPublicUrl || require(appPackageJson).homepage; - -// We use `PUBLIC_URL` environment variable or "homepage" field to infer -// "public path" at which the app is served. -// Webpack needs to know it to put the right + + + diff --git a/src/core/client/embed/index.spec.ts b/src/core/client/embed/index.spec.ts new file mode 100644 index 000000000..66613f1c7 --- /dev/null +++ b/src/core/client/embed/index.spec.ts @@ -0,0 +1,23 @@ +import * as Talk from "./"; + +describe("Basic integration test", () => { + const container: HTMLElement = document.createElement("div"); + let streamInterface: ReturnType; + beforeAll(() => { + container.id = "basic-integration-test-id"; + document.body.appendChild(container); + }); + afterAll(() => { + document.body.removeChild(container); + }); + it("should render iframe", () => { + streamInterface = Talk.render({ + id: "basic-integration-test-id", + }); + expect(container.innerHTML).toMatchSnapshot(); + }); + it("should remove iframe", () => { + streamInterface.remove(); + expect(container.innerHTML).toBe(""); + }); +}); diff --git a/src/core/client/embed/index.ts b/src/core/client/embed/index.ts new file mode 100644 index 000000000..6c6ebc36d --- /dev/null +++ b/src/core/client/embed/index.ts @@ -0,0 +1,30 @@ +import { EventEmitter2 } from "eventemitter2"; +import qs from "query-string"; + +import createStreamInterface from "./Stream"; + +export interface Config { + assetID?: string; + assetURL?: string; + rootURL?: string; + id?: string; + events?: (eventEmitter: EventEmitter2) => void; +} + +export function render(config: Config = {}) { + // Parse query params + const query = qs.parse(location.search); + const eventEmitter = new EventEmitter2({ wildcard: true }); + + if (config.events) { + config.events(eventEmitter); + } + + return createStreamInterface({ + assetID: config.assetID || query.assetID, + assetURL: config.assetURL || query.assetURL, + id: config.id || "talk-embed-stream", + rootURL: config.rootURL || location.origin, + eventEmitter, + }); +} diff --git a/src/core/client/embed/tsconfig.json b/src/core/client/embed/tsconfig.json new file mode 100644 index 000000000..11f8c14ce --- /dev/null +++ b/src/core/client/embed/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "lib": ["dom", "es5"], + "types": ["jest"], + "paths": {} + }, + "include": [ + "./**/*", + "../../../types/pym.d.ts", + "../../../types/simulant.d.ts" + ], + "exclude": ["node_modules"] +} diff --git a/src/core/client/embed/utils/buildURL.spec.ts b/src/core/client/embed/utils/buildURL.spec.ts new file mode 100644 index 000000000..07e4e2ddc --- /dev/null +++ b/src/core/client/embed/utils/buildURL.spec.ts @@ -0,0 +1,18 @@ +import buildURL from "./buildURL"; + +it("should default to window.location", () => { + const url = buildURL(); + expect(url).toBe("http://localhost/"); +}); + +it("should build from parameters", () => { + const url = buildURL({ + protocol: "https", + hostname: "hostname", + port: "8080", + pathname: "/pathname", + search: "search", + hash: "#hash", + }); + expect(url).toBe("https//hostname:8080/pathname?search#hash"); +}); diff --git a/src/core/client/embed/utils/buildURL.ts b/src/core/client/embed/utils/buildURL.ts new file mode 100644 index 000000000..77353427f --- /dev/null +++ b/src/core/client/embed/utils/buildURL.ts @@ -0,0 +1,17 @@ +export default function buildURL({ + protocol = window.location.protocol, + hostname = window.location.hostname, + port = window.location.port, + pathname = window.location.pathname, + search = window.location.search, + hash = window.location.hash, +} = {}) { + if (search && search[0] !== "?") { + search = `?${search}`; + } else if (search === "?") { + search = ""; + } + return `${protocol}//${hostname}${ + port ? `:${port}` : "" + }${pathname}${search}${hash}`; +} diff --git a/src/core/client/embed/utils/ensureEndSlash.spec.ts b/src/core/client/embed/utils/ensureEndSlash.spec.ts new file mode 100644 index 000000000..9ab3f8a94 --- /dev/null +++ b/src/core/client/embed/utils/ensureEndSlash.spec.ts @@ -0,0 +1,11 @@ +import ensureEndSlash from "./ensureEndSlash"; + +it("should add slash to the end", () => { + const path = ensureEndSlash("/test"); + expect(path).toBe("/test/"); +}); + +it("should not add slash to the end if it's already there", () => { + const path = ensureEndSlash("/test/"); + expect(path).toBe("/test/"); +}); diff --git a/src/core/client/embed/utils/ensureEndSlash.ts b/src/core/client/embed/utils/ensureEndSlash.ts new file mode 100644 index 000000000..4adf9a451 --- /dev/null +++ b/src/core/client/embed/utils/ensureEndSlash.ts @@ -0,0 +1,3 @@ +export default function ensureEndSlash(p: string) { + return p.match(/\/$/) ? p : `${p}/`; +} diff --git a/src/core/client/embed/utils/index.ts b/src/core/client/embed/utils/index.ts new file mode 100644 index 000000000..0cf1aaa7f --- /dev/null +++ b/src/core/client/embed/utils/index.ts @@ -0,0 +1,2 @@ +export { default as buildURL } from "./buildURL"; +export { default as ensureEndSlash } from "./ensureEndSlash"; diff --git a/src/core/client/framework/lib/bootstrap/TalkContext.tsx b/src/core/client/framework/lib/bootstrap/TalkContext.tsx index d14c245f5..90ed10530 100644 --- a/src/core/client/framework/lib/bootstrap/TalkContext.tsx +++ b/src/core/client/framework/lib/bootstrap/TalkContext.tsx @@ -1,19 +1,31 @@ import { LocalizationProvider } from "fluent-react/compat"; import { MessageContext } from "fluent/compat"; +import { Child as PymChild } from "pym.js"; import React, { StatelessComponent } from "react"; import { Formatter } from "react-timeago"; import { Environment } from "relay-runtime"; + import { UIContext } from "talk-ui/components"; +import { ClickFarAwayRegister } from "talk-ui/components/ClickOutside"; export interface TalkContext { - // relayEnvironment for our relay framework. + /** relayEnvironment for our relay framework. */ relayEnvironment: Environment; - // localMessages for our i18n framework. + /** localMessages for our i18n framework. */ localeMessages: MessageContext[]; - // formatter for timeago. + /** formatter for timeago. */ timeagoFormatter?: Formatter; + + /** + * A way to listen for clicks that are e.g. outside of the + * current frame for `ClickOutside` + */ + registerClickFarAway?: ClickFarAwayRegister; + + /** A pym child that interacts with the pym parent. */ + pym?: PymChild; } const { Provider, Consumer } = React.createContext({} as any); @@ -32,7 +44,12 @@ export const TalkContextProvider: StatelessComponent<{ }> = ({ value, children }) => ( - + {children} diff --git a/src/core/client/framework/lib/bootstrap/createContext.tsx b/src/core/client/framework/lib/bootstrap/createContext.tsx index 6043eacb9..2e6bd2aff 100644 --- a/src/core/client/framework/lib/bootstrap/createContext.tsx +++ b/src/core/client/framework/lib/bootstrap/createContext.tsx @@ -1,22 +1,32 @@ +import { EventEmitter2 } from "eventemitter2"; import { Localized } from "fluent-react/compat"; import { noop } from "lodash"; +import { Child as PymChild } from "pym.js"; import React from "react"; import { Formatter } from "react-timeago"; import { Environment, Network, RecordSource, Store } from "relay-runtime"; +import { ClickFarAwayRegister } from "talk-ui/components/ClickOutside"; + import { generateMessages, LocalesData, negotiateLanguages } from "../i18n"; import { fetchQuery } from "../network"; import { TalkContext } from "./TalkContext"; interface CreateContextArguments { - // Locales that the user accepts, usually `navigator.languages`. + /** Locales that the user accepts, usually `navigator.languages`. */ userLocales: ReadonlyArray; - // Locales data that is returned by our `locales-loader`. + /** Locales data that is returned by our `locales-loader`. */ localesData: LocalesData; - // Init will be called after the context has been created. + /** Init will be called after the context has been created. */ init?: ((context: TalkContext) => void | Promise); + + /** A pym child that interacts with the pym parent. */ + pym?: PymChild; + + /** Supports emitting and listening to events. */ + eventEmitter?: EventEmitter2; } /** @@ -47,6 +57,8 @@ export default async function createContext({ init = noop, userLocales, localesData, + pym, + eventEmitter = new EventEmitter2({ wildcard: true }), }: CreateContextArguments): Promise { // Initialize Relay. const relayEnvironment = new Environment({ @@ -54,6 +66,21 @@ export default async function createContext({ store: new Store(new RecordSource()), }); + // Listen for outside clicks. + let registerClickFarAway: ClickFarAwayRegister | undefined; + if (pym) { + registerClickFarAway = cb => { + pym.onMessage("click", cb); + // Return unlisten callback. + return () => { + const index = pym.messageHandlers.click.indexOf(cb); + if (index > -1) { + pym.messageHandlers.click.splice(index, 1); + } + }; + }; + } + // Initialize i18n. const locales = negotiateLanguages(userLocales, localesData); @@ -69,6 +96,9 @@ export default async function createContext({ relayEnvironment, localeMessages, timeagoFormatter, + pym, + eventEmitter, + registerClickFarAway, }; // Run custom initializations. diff --git a/src/core/client/stream/index.html b/src/core/client/stream/index.html index 7578f176f..5c2f7b60e 100644 --- a/src/core/client/stream/index.html +++ b/src/core/client/stream/index.html @@ -1,15 +1,15 @@ - + - Relay Experiments + Talk - Stream -
+
diff --git a/src/core/client/stream/index.tsx b/src/core/client/stream/index.tsx index 584e5e230..c6f9059e0 100644 --- a/src/core/client/stream/index.tsx +++ b/src/core/client/stream/index.tsx @@ -1,3 +1,4 @@ +import pym from "pym.js"; import React from "react"; import { StatelessComponent } from "react"; import ReactDOM from "react-dom"; @@ -23,6 +24,7 @@ async function main() { init, localesData, userLocales: navigator.languages, + pym: new pym.Child({ polling: 100 }), }); const Index: StatelessComponent = () => ( diff --git a/src/core/client/tsconfig.json b/src/core/client/tsconfig.json index a62cea6c5..847c9f1c5 100644 --- a/src/core/client/tsconfig.json +++ b/src/core/client/tsconfig.json @@ -16,5 +16,5 @@ } }, "include": ["./**/*", "../../types/**/*.d.ts"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "./embed"] } diff --git a/src/core/client/ui/components/ClickOutside/ClickOutside.spec.tsx b/src/core/client/ui/components/ClickOutside/ClickOutside.spec.tsx index fd5ba04b8..e7da49bda 100644 --- a/src/core/client/ui/components/ClickOutside/ClickOutside.spec.tsx +++ b/src/core/client/ui/components/ClickOutside/ClickOutside.spec.tsx @@ -3,7 +3,14 @@ import React from "react"; import simulant from "simulant"; import sinon from "sinon"; -import ClickOutside from "./ClickOutside"; +import UIContext from "../UIContext"; + +import { + ClickFarAwayCallback, + ClickFarAwayRegister, + ClickOutside, + default as ClickOutsideWithContext, +} from "./ClickOutside"; let container: HTMLElement; @@ -60,3 +67,57 @@ it("should ignore click inside", () => { expect(onClickOutside.calledOnce).toEqual(false); wrapper.unmount(); }); + +it("should detect click far away", () => { + let emitFarAwayClick: ClickFarAwayCallback = Function; + const unlisten = sinon.spy(); + const registerClickFarAway: ClickFarAwayRegister = cb => { + emitFarAwayClick = cb; + return unlisten; + }; + const onClickOutside = sinon.spy(); + const wrapper = mount( + + + , + { + attachTo: container, + } + ); + + expect(onClickOutside.calledOnce).toEqual(false); + emitFarAwayClick(); + expect(onClickOutside.calledOnce).toEqual(true); + expect(unlisten.calledOnce).toEqual(false); + wrapper.unmount(); + expect(unlisten.calledOnce).toEqual(true); +}); + +it("should get registerClickFarAway from context", () => { + const registerClickFarAway: ClickFarAwayRegister = sinon.spy(); + const onClickOutside = sinon.spy(); + const context: any = { + registerClickFarAway, + }; + const wrapper = mount( + + + + + , + { + attachTo: container, + } + ); + + expect(wrapper.find(ClickOutside).prop("registerClickFarAway")).toEqual( + registerClickFarAway + ); + wrapper.unmount(); +}); diff --git a/src/core/client/ui/components/ClickOutside/ClickOutside.tsx b/src/core/client/ui/components/ClickOutside/ClickOutside.tsx index cf13762f0..2d79994ad 100644 --- a/src/core/client/ui/components/ClickOutside/ClickOutside.tsx +++ b/src/core/client/ui/components/ClickOutside/ClickOutside.tsx @@ -1,13 +1,30 @@ -import React from "react"; +import React, { StatelessComponent } from "react"; import { findDOMNode } from "react-dom"; +import UIContext from "../UIContext"; + +export type ClickFarAwayCallback = () => void; +export type ClickFarAwayUnlistenCallback = () => void; + +export type ClickFarAwayRegister = ( + callback: ClickFarAwayCallback +) => ClickFarAwayUnlistenCallback; + interface Props { onClickOutside: () => void; + + /** + * A way to listen for clicks that are e.g. outside of the + * current frame for `ClickOutside` + */ + registerClickFarAway?: ClickFarAwayRegister; + children: React.ReactNode; } -class ClickOutside extends React.Component { +export class ClickOutside extends React.Component { public domNode: Element | null = null; + private unlisten?: ClickFarAwayUnlistenCallback; public handleClick = (e: MouseEvent) => { const { onClickOutside } = this.props; @@ -17,17 +34,43 @@ class ClickOutside extends React.Component { } }; + public handleClickFarAway = () => { + const { onClickOutside } = this.props; + // tslint:disable-next-line:no-unused-expression + onClickOutside && onClickOutside(); + }; + public componentDidMount() { this.domNode = findDOMNode(this) as Element; document.addEventListener("click", this.handleClick, true); + + // Listen to far away clicks. + if (this.props.registerClickFarAway) { + this.unlisten = this.props.registerClickFarAway(this.handleClickFarAway); + } } public componentWillUnmount() { document.removeEventListener("click", this.handleClick, true); + + // Unlisten to far away clicks. + if (this.unlisten) { + this.unlisten(); + this.unlisten = undefined; + } } public render() { return this.props.children; } } -export default ClickOutside; + +const ClickOutsideWithContext: StatelessComponent = props => ( + + {({ registerClickFarAway }) => ( + + )} + +); + +export default ClickOutsideWithContext; diff --git a/src/core/client/ui/components/ClickOutside/index.ts b/src/core/client/ui/components/ClickOutside/index.ts new file mode 100644 index 000000000..b70132125 --- /dev/null +++ b/src/core/client/ui/components/ClickOutside/index.ts @@ -0,0 +1 @@ +export { default as ClickOutside, ClickFarAwayRegister } from "./ClickOutside"; diff --git a/src/core/client/ui/components/MatchMedia/MatchMedia.spec.tsx b/src/core/client/ui/components/MatchMedia/MatchMedia.spec.tsx index 1e600dd76..6f53df511 100644 --- a/src/core/client/ui/components/MatchMedia/MatchMedia.spec.tsx +++ b/src/core/client/ui/components/MatchMedia/MatchMedia.spec.tsx @@ -1,9 +1,11 @@ -import { shallow } from "enzyme"; +import { mount, shallow } from "enzyme"; import React from "react"; +import { MediaQueryMatchers } from "react-responsive"; import { PropTypesOf } from "talk-ui/types"; -import { MatchMedia } from "./MatchMedia"; +import UIContext from "../UIContext"; +import { default as MatchMediaWithContext, MatchMedia } from "./MatchMedia"; it("renders correctly", () => { const props: PropTypesOf = { @@ -25,3 +27,20 @@ it("map new speech prop to older aural prop", () => { const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); + +it("should get mediaQueryValues from context", () => { + const mediaQueryValues: Partial = { + width: 100, + }; + const context: any = { + mediaQueryValues, + }; + const wrapper = mount( + + + Hello World + + + ); + expect(wrapper.find(MatchMedia).prop("values")).toEqual(mediaQueryValues); +}); diff --git a/src/core/client/ui/components/UIContext/UIContext.ts b/src/core/client/ui/components/UIContext/UIContext.ts index b8372d1f3..6e734d277 100644 --- a/src/core/client/ui/components/UIContext/UIContext.ts +++ b/src/core/client/ui/components/UIContext/UIContext.ts @@ -2,9 +2,18 @@ import React from "react"; import { MediaQueryMatchers } from "react-responsive"; import { Formatter } from "react-timeago"; +import { ClickFarAwayRegister } from "../ClickOutside"; + export interface UIContextProps { + /** Allows to integrate translated strings into `RelativeTime` Component */ timeagoFormatter?: Formatter | null; + /** Allows testing `MatchMedia` by setting media query values */ mediaQueryValues?: Partial; + /** + * A way to listen for clicks that are e.g. outside of the + * current frame for `ClickOutside` + */ + registerClickFarAway?: ClickFarAwayRegister; } const UIContext = React.createContext({} as any); diff --git a/src/core/server/config.ts b/src/core/common/config.ts similarity index 95% rename from src/core/server/config.ts rename to src/core/common/config.ts index d4b5ca3fc..099bcc4b2 100644 --- a/src/core/server/config.ts +++ b/src/core/common/config.ts @@ -90,5 +90,9 @@ const config = convict({ export type Config = typeof config; +export const createClientEnv = (c: Config) => ({ + NODE_ENV: c.get("env"), +}); + // Setup the base configuration. export default config; diff --git a/src/core/server/app/index.ts b/src/core/server/app/index.ts index 4594c9e81..d1310eb41 100644 --- a/src/core/server/app/index.ts +++ b/src/core/server/app/index.ts @@ -3,10 +3,10 @@ import http from "http"; import { Redis } from "ioredis"; import { Db } from "mongodb"; +import { Config } from "talk-common/config"; import { notFoundMiddleware } from "talk-server/app/middleware/notFound"; import { createPassport } from "talk-server/app/middleware/passport"; import { JWTSigningConfig } from "talk-server/app/middleware/passport/jwt"; -import { Config } from "talk-server/config"; import { handleSubscriptions } from "talk-server/graph/common/subscriptions/middleware"; import { Schemas } from "talk-server/graph/schemas"; diff --git a/src/core/server/app/middleware/passport/jwt.spec.ts b/src/core/server/app/middleware/passport/jwt.spec.ts index fa7b87d9d..bede65426 100644 --- a/src/core/server/app/middleware/passport/jwt.spec.ts +++ b/src/core/server/app/middleware/passport/jwt.spec.ts @@ -1,11 +1,11 @@ import sinon from "sinon"; +import { Config } from "talk-common/config"; import { createJWTSigningConfig, extractJWTFromRequest, parseAuthHeader, } from "talk-server/app/middleware/passport/jwt"; -import { Config } from "talk-server/config"; import { Request } from "talk-server/types/express"; describe("parseAuthHeader", () => { diff --git a/src/core/server/app/middleware/passport/jwt.ts b/src/core/server/app/middleware/passport/jwt.ts index b47bd06dd..569c005b5 100644 --- a/src/core/server/app/middleware/passport/jwt.ts +++ b/src/core/server/app/middleware/passport/jwt.ts @@ -3,7 +3,7 @@ import uuid from "uuid"; import { Db } from "mongodb"; import { Strategy } from "passport-strategy"; -import { Config } from "talk-server/config"; +import { Config } from "talk-common/config"; import { retrieveUser, User } from "talk-server/models/user"; import { Request } from "talk-server/types/express"; diff --git a/src/core/server/graph/common/middleware/index.ts b/src/core/server/graph/common/middleware/index.ts index 361bc7073..4d094f59a 100644 --- a/src/core/server/graph/common/middleware/index.ts +++ b/src/core/server/graph/common/middleware/index.ts @@ -5,7 +5,7 @@ import { GraphQLOptions, } from "apollo-server-express"; import { FieldDefinitionNode, GraphQLError, ValidationContext } from "graphql"; -import { Config } from "talk-server/config"; +import { Config } from "talk-common/config"; // Sourced from: https://github.com/apollographql/apollo-server/blob/958846887598491fadea57b3f9373d129300f250/packages/apollo-server-core/src/ApolloServer.ts#L46-L57 const NoIntrospection = (context: ValidationContext) => ({ diff --git a/src/core/server/graph/common/subscriptions/pubsub.ts b/src/core/server/graph/common/subscriptions/pubsub.ts index 89ede4771..ae716a556 100644 --- a/src/core/server/graph/common/subscriptions/pubsub.ts +++ b/src/core/server/graph/common/subscriptions/pubsub.ts @@ -1,5 +1,5 @@ import { RedisPubSub } from "graphql-redis-subscriptions"; -import { Config } from "talk-server/config"; +import { Config } from "talk-common/config"; import { createRedisClient } from "talk-server/services/redis"; export async function createPubSub(config: Config): Promise { diff --git a/src/core/server/graph/management/middleware.ts b/src/core/server/graph/management/middleware.ts index def73c2e9..8a62eed63 100644 --- a/src/core/server/graph/management/middleware.ts +++ b/src/core/server/graph/management/middleware.ts @@ -1,7 +1,7 @@ import { GraphQLSchema } from "graphql"; import { Db } from "mongodb"; -import { Config } from "talk-server/config"; +import { Config } from "talk-common/config"; import { graphqlMiddleware } from "talk-server/graph/common/middleware"; import Context from "./context"; diff --git a/src/core/server/graph/tenant/middleware.ts b/src/core/server/graph/tenant/middleware.ts index f583c6d7a..f27e2430e 100644 --- a/src/core/server/graph/tenant/middleware.ts +++ b/src/core/server/graph/tenant/middleware.ts @@ -1,7 +1,7 @@ import { GraphQLSchema } from "graphql"; import { Db } from "mongodb"; -import { Config } from "talk-server/config"; +import { Config } from "talk-common/config"; import { graphqlMiddleware } from "talk-server/graph/common/middleware"; import { Request } from "talk-server/types/express"; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 56e92f7f7..3ef89bde4 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -1,13 +1,13 @@ import express, { Express } from "express"; import http from "http"; +import config, { Config } from "talk-common/config"; import { createJWTSigningConfig } from "talk-server/app/middleware/passport/jwt"; import getManagementSchema from "talk-server/graph/management/schema"; import { Schemas } from "talk-server/graph/schemas"; import getTenantSchema from "talk-server/graph/tenant/schema"; import { attachSubscriptionHandlers, createApp, listenAndServe } from "./app"; -import config, { Config } from "./config"; import logger from "./logger"; import { createMongoDB } from "./services/mongodb"; import { createRedisClient } from "./services/redis"; diff --git a/src/core/server/services/mongodb/index.ts b/src/core/server/services/mongodb/index.ts index 03f4d21a6..c5fe7c1df 100644 --- a/src/core/server/services/mongodb/index.ts +++ b/src/core/server/services/mongodb/index.ts @@ -1,5 +1,5 @@ import { Db, MongoClient } from "mongodb"; -import { Config } from "talk-server/config"; +import { Config } from "talk-common/config"; /** * create will connect to the MongoDB instance identified in the configuration. diff --git a/src/core/server/services/redis/index.ts b/src/core/server/services/redis/index.ts index 14b977b59..8ec7fbb65 100644 --- a/src/core/server/services/redis/index.ts +++ b/src/core/server/services/redis/index.ts @@ -1,5 +1,5 @@ import RedisClient, { Redis } from "ioredis"; -import { Config } from "talk-server/config"; +import { Config } from "talk-common/config"; /** * create will connect to the Redis instance identified in the configuration. diff --git a/src/types/pym.d.ts b/src/types/pym.d.ts new file mode 100644 index 000000000..4c3285192 --- /dev/null +++ b/src/types/pym.d.ts @@ -0,0 +1,174 @@ +declare module "pym.js" { + export interface ChildSettings { + /** + * Callback invoked after receiving a resize event from the parent, + * sets module:pym.Child#settings.renderCallback + */ + renderCallback?: Function; + /** xdomain to validate messages received */ + xdomain?: string; + /** polling frequency in milliseconds to send height to parent */ + polling?: number; + /** + * parent container id used when navigating the child + * iframe to a new page but we want to keep it responsive. + */ + id?: string; + /** + * if passed it will be override the default parentUrl query string + * parameter name expected on the iframe src + */ + parenturlparam?: string; + } + + /** The Child half of a responsive iframe. */ + export class Child { + /** The id of the parent container */ + id: string; + + /** The timerId in order to be able to stop when polling is enabled */ + timerId: string; + + /** The initial width of the parent page */ + parentWidth: string; + + /** The URL of the parent page from window.location.href. */ + parentUrl: string; + + /** The title of the parent page from document.title. */ + parentTitle: string; + + /** Stores the registered messageHandlers for each messageType */ + messageHandlers: Record void>>; + + /** RegularExpression to validate the received messages */ + messageRegex: RegExp; + + constructor(config: ChildSettings); + + /** Navigate parent to a given url. */ + navigateParentTo(url: string): void; + + /** + * Bind a callback to a given messageType from the child. + * Reserved message names are: "width". + */ + onMessage(messageType: string, callback: (message: string) => void): void; + + /** Unbind child event handlers and timers. */ + remove(): void; + + /** Scroll parent to a given element id. */ + scrollParentTo(hash: string): void; + + /** Scroll parent to a given child element id. */ + scrollParentToChildEl(id: string): void; + + /** Scroll parent to a particular child offset. */ + scrollParentToChildPos(pos: number): void; + + /** Transmit the current iframe height to the parent. */ + sendHeight(): void; + + /** Send a message to the the Parent. */ + sendMessage(messageType: string, message: string): void; + } + + export interface ParentSettings { + /** xdomain to validate messages received */ + xdomain?: string; + /** if passed it will be assigned to the iframe title attribute */ + title?: string; + /** if passed it will be assigned to the iframe name attribute */ + name?: string; + /** if passed it will be assigned to the iframe id attribute */ + id?: string; + /** if passed it will be assigned to the iframe allowfullscreen attribute */ + allowfullscreen?: boolean; + /** + * if passed it will be assigned to the iframe sandbox attribute + * (we do not validate the syntax so be careful!!) + */ + sandbox?: boolean; + /** + * if passed it will be override the default parentUrl query string + * parameter name passed to the iframe src + */ + parenturlparam?: string; + /** + * if passed it will be override the default parentUrl query string + * parameter value passed to the iframe src + */ + parenturlvalue?: string; + /** + * if passed and different than false it will strip the querystring + * params parentUrl and parentTitle passed to the iframe src + */ + optionalparams?: string; + /** if passed it will activate scroll tracking on the parent */ + trackscroll?: boolean; + /** + * if passed it will set the throttle wait in order to fire + * scroll messaging. Defaults to 100 ms. + */ + scrollwait?: number; + } + + /** The Parent half of a response iframe. */ + export class Parent { + /** The container DOM object */ + el: HTMLElement; + + /** The id of the container element */ + id: string; + + /** The contained child iframe */ + iframe: HTMLElement; + + /** Stores the registered messageHandlers for each messageType */ + messageHandlers: Record void>>; + + /** RegularExpression to validate the received messages */ + messageRegex: RegExp; + + /** + * The parent instance settings, updated by the values + * passed in the config object + */ + settings: ParentSettings; + + /** The url that will be set as the iframe's src */ + url: string; + + constructor(id: string, url: string, config: ParentSettings); + + /** + * Bind a callback to a given messageType from the child. + * Reserved message names are: "height", "scrollTo" and "navigateTo". + */ + onMessage(messageType: string, callback: (message: string) => void): void; + + /** Remove this parent from the page and unbind it's event handlers. */ + remove(): void; + + /** Send a message to the the child. */ + sendMessage(messageType: string, message: string): void; + + /** + * Transmit the current viewport and iframe position to the child. + * Sends viewport width, viewport height + * and iframe bounding rect top-left-bottom-right + * all separated by spaces + * + * You shouldn't need to call this directly. + */ + sendViewportAndIFramePosition(): void; + + /** + * Transmit the current iframe width to the child. + * You shouldn't need to call this directly. + */ + sendWidth(): void; + } + export function autoInit(doNotRaiseEvents: boolean): void; +} diff --git a/src/types/react-dev-utils.d.ts b/src/types/react-dev-utils.d.ts new file mode 100644 index 000000000..946e07665 --- /dev/null +++ b/src/types/react-dev-utils.d.ts @@ -0,0 +1,28 @@ +declare module "react-dev-utils/InterpolateHtmlPlugin" { + import { Plugin } from "webpack"; + export default class InterpolateHtmlPlugin extends Plugin { + constructor(env: Record); + } +} + +declare module "react-dev-utils/ModuleScopePlugin" { + import { Plugin } from "webpack"; + export default class ModuleScopePlugin extends Plugin { + constructor(rootPath: string, ignore: ReadonlyArray); + } +} + +declare module "react-dev-utils/WatchMissingNodeModulesPlugin" { + import { Plugin } from "webpack"; + export default class ModuleScopePlugin extends Plugin { + constructor(nodeModulesPath: string); + } +} + +declare module "react-dev-utils/errorOverlayMiddleware"; +declare module "react-dev-utils/ignoredFiles"; +declare module "react-dev-utils/noopServiceWorkerMiddleware"; +declare module "react-dev-utils/WebpackDevServerUtils"; +declare module "react-dev-utils/FileSizeReporter"; +declare module "react-dev-utils/formatWebpackMessages"; +declare module "react-dev-utils/printBuildError"; diff --git a/tsconfig.json b/tsconfig.json index bee79d2d0..3ddce16aa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,8 +13,14 @@ "noImplicitAny": true, "strictNullChecks": true, "noErrorTruncation": true, - "lib": ["es6", "esnext.asynciterable"] + "lib": ["dom", "es6", "esnext.asynciterable"] }, - "include": ["./src/**/.*.js", "./scripts/**/*", "./config/**/*", "*.js"], + "include": [ + "./src/**/.*.js", + "./src/types/**/*.d.ts", + "./scripts/**/*", + "./config/**/*", + "*.js" + ], "exclude": ["node_modules"] } From 4606626ec4b0202f1e5b0aca9207ac9c905ec5fc Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 2 Aug 2018 20:09:55 +0000 Subject: [PATCH 45/45] [next] Settings/Tenant (#1758) * feat: initial support for synced tenants * fix: cleanup * fix: logger now respects logging level * fix: cache now ignores updates issued from itself * feat: print subscriber count * fix: support tenant cache for oidc strategy * fix: replace some constructor initializers with property initializers * fix: audit * [next] Comments and Moderation (#1759) * feat: initial moderation + validation for new comments * fix: added Promiseable type * feat: initial actions impl * feat: more moderation phases * fix: handle settings inheritence * fix: moved settings into new file * fix: defaults and documentation * fix: replace merge with object spread * feat: added integration with akismet * fix: fixed compile * fix: import ordering * fix: merge issue causing build to fail --- package-lock.json | 96 ++++++- package.json | 13 +- src/core/common/types.ts | 2 + src/core/server/app/index.ts | 9 +- .../passport/__snapshots__/jwt.spec.ts.snap | 2 +- .../server/app/middleware/passport/index.ts | 19 +- .../app/middleware/passport/jwt.spec.ts | 2 +- .../server/app/middleware/passport/jwt.ts | 19 +- .../server/app/middleware/passport/local.ts | 10 +- .../server/app/middleware/passport/oidc.ts | 38 +-- .../server/app/middleware/passport/sso.ts | 15 +- src/core/server/app/middleware/tenant.ts | 14 +- src/core/server/app/router.ts | 9 +- src/core/server/graph/common/context.ts | 6 +- src/core/server/graph/management/context.ts | 13 +- .../server/graph/management/middleware.ts | 9 +- src/core/server/graph/tenant/context.ts | 29 ++- .../server/graph/tenant/loaders/assets.ts | 4 +- .../server/graph/tenant/loaders/comments.ts | 20 +- src/core/server/graph/tenant/loaders/users.ts | 2 +- src/core/server/graph/tenant/middleware.ts | 26 +- .../server/graph/tenant/mutators/comment.ts | 19 +- .../server/graph/tenant/mutators/index.ts | 3 + .../server/graph/tenant/mutators/settings.ts | 11 + .../tenant/resolvers/auth_integrations.ts | 2 +- .../graph/tenant/resolvers/auth_settings.ts | 2 +- .../resolvers/facebook_auth_integration.ts | 2 +- .../resolvers/google_auth_integration.ts | 2 +- .../resolvers/local_auth_integration.ts | 2 +- .../server/graph/tenant/resolvers/mutation.ts | 4 + .../tenant/resolvers/oidc_auth_integration.ts | 2 +- .../tenant/resolvers/sso_auth_integration.ts | 2 +- .../server/graph/tenant/schema/schema.graphql | 107 +++++++- src/core/server/index.ts | 8 + src/core/server/logger.ts | 6 +- src/core/server/models/actions.ts | 18 +- src/core/server/models/asset.ts | 9 +- src/core/server/models/comment.ts | 12 +- src/core/server/models/settings.ts | 246 ++++++++++++++++++ src/core/server/models/tenant.ts | 174 +++---------- src/core/server/models/user.ts | 8 +- src/core/server/services/comments/index.ts | 58 ++++- .../services/comments/moderation/index.ts | 75 ++++++ .../moderation/phases/assetClosed.spec.ts | 42 +++ .../comments/moderation/phases/assetClosed.ts | 12 + .../moderation/phases/commentLength.ts | 45 ++++ .../moderation/phases/commentingDisabled.ts | 19 ++ .../comments/moderation/phases/index.ts | 26 ++ .../comments/moderation/phases/karma.ts | 40 +++ .../comments/moderation/phases/links.ts | 49 ++++ .../comments/moderation/phases/premod.ts | 28 ++ .../comments/moderation/phases/spam.ts | 74 ++++++ .../comments/moderation/phases/staff.ts | 21 ++ .../comments/moderation/phases/wordlist.ts | 50 ++++ .../comments/moderation/wordlist.spec.ts | 46 ++++ .../services/comments/moderation/wordlist.ts | 25 ++ src/core/server/services/tenant/cache.ts | 144 ++++++++-- src/core/server/services/tenant/index.ts | 30 +++ src/core/server/services/users/karma.ts | 22 ++ src/core/server/types/express.ts | 2 + src/types/akismet-api.d.ts | 33 +++ src/types/dotize.d.ts | 4 +- tslint.json | 11 +- 63 files changed, 1529 insertions(+), 323 deletions(-) create mode 100644 src/core/server/graph/tenant/mutators/settings.ts create mode 100644 src/core/server/models/settings.ts create mode 100644 src/core/server/services/comments/moderation/index.ts create mode 100644 src/core/server/services/comments/moderation/phases/assetClosed.spec.ts create mode 100644 src/core/server/services/comments/moderation/phases/assetClosed.ts create mode 100644 src/core/server/services/comments/moderation/phases/commentLength.ts create mode 100644 src/core/server/services/comments/moderation/phases/commentingDisabled.ts create mode 100644 src/core/server/services/comments/moderation/phases/index.ts create mode 100755 src/core/server/services/comments/moderation/phases/karma.ts create mode 100755 src/core/server/services/comments/moderation/phases/links.ts create mode 100755 src/core/server/services/comments/moderation/phases/premod.ts create mode 100644 src/core/server/services/comments/moderation/phases/spam.ts create mode 100755 src/core/server/services/comments/moderation/phases/staff.ts create mode 100755 src/core/server/services/comments/moderation/phases/wordlist.ts create mode 100644 src/core/server/services/comments/moderation/wordlist.spec.ts create mode 100644 src/core/server/services/comments/moderation/wordlist.ts create mode 100644 src/core/server/services/tenant/index.ts create mode 100644 src/core/server/services/users/karma.ts create mode 100644 src/types/akismet-api.d.ts diff --git a/package-lock.json b/package-lock.json index 9f175a484..9cafac515 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1836,6 +1836,12 @@ "@types/node": "*" } }, + "@types/linkify-it": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-2.0.3.tgz", + "integrity": "sha512-WRj1rJPkj3fyDME63xUkWvcWzq0XjpiqjJGNCH4Y6Fbiv2fVMDCqeIA6ch/UUG3VYrfGq1VNFLsz6Sat6FjsPw==", + "dev": true + }, "@types/lodash": { "version": "4.14.111", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.111.tgz", @@ -2022,6 +2028,12 @@ "integrity": "sha512-78AdXtlhpCHT0K3EytMpn4JNxaf5tbqbLcbIRoQIHzpTIyjpxLQKRoxU55ujBXAtg3Nl2h/XWvfDa9dsMOd0pQ==", "dev": true }, + "@types/tlds": { + "version": "1.199.0", + "resolved": "https://registry.npmjs.org/@types/tlds/-/tlds-1.199.0.tgz", + "integrity": "sha512-wDAuzclsEH+fzHZouSHKB5qHWx2ABHwDfcF1X8Aslu20iHrMbCwOeTPINp9qfymlwnSsH5cMD3Ps7WbgmOcXUg==", + "dev": true + }, "@types/tough-cookie": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-2.3.3.tgz", @@ -2548,6 +2560,15 @@ "integrity": "sha1-6GuBnGAs+IIa1jdBNpjx3sAhhHo=", "dev": true }, + "akismet-api": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/akismet-api/-/akismet-api-4.2.0.tgz", + "integrity": "sha512-HJcF3QYWmiQb3TkcXLxGvc0L0HyGA7edm3GRCXvFWzGKFfsqgCjoehx6Lzmpb5sCUAJQmxu1HpWjv8n7u0GstQ==", + "requires": { + "bluebird": "^3.1.1", + "superagent": "^3.8.0" + } + }, "align-text": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz", @@ -5907,8 +5928,7 @@ "component-emitter": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz", - "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=", - "dev": true + "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=" }, "compressible": { "version": "2.0.14", @@ -6045,6 +6065,11 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" }, + "cookiejar": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.2.tgz", + "integrity": "sha512-Mw+adcfzPxcPeI+0WlvRrr/3lGVO0bD75SxX6811cxSh1Wbxx7xZBGK1eVtDf6si8rg2lhnUjsVLMFMfbRIuwA==" + }, "cookies": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.7.1.tgz", @@ -9351,6 +9376,11 @@ "integrity": "sha1-1hcBB+nv3E7TDJ3DkBbflCtctYs=", "dev": true }, + "formidable": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.1.tgz", + "integrity": "sha512-Fs9VRguL0gqGHkXS5GQiMCr1VhZBxz0JnJs4JmMp/2jL18Fmbzvv7vOFRU+U8TBkHEE/CX1qDXzJplVULgsLeg==" + }, "forwarded": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", @@ -11867,8 +11897,7 @@ "isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", - "dev": true + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" }, "isemail": { "version": "3.1.2", @@ -14239,6 +14268,14 @@ "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.3.tgz", "integrity": "sha512-zrycnIMsLw/3ZxTbW7HCez56rcFGecWTx5OZNplzcXUUmJLmoYArC6qdJzmAN5BWiNXGcpjhF9RQ1HSv5zebEw==" }, + "linkify-it": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.0.3.tgz", + "integrity": "sha1-2UpGSPmxwXnWT6lykSaL22zpQ08=", + "requires": { + "uc.micro": "^1.0.1" + } + }, "load-cfg": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/load-cfg/-/load-cfg-0.5.6.tgz", @@ -18615,8 +18652,7 @@ "process-nextick-args": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", - "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==", - "dev": true + "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==" }, "promise": { "version": "7.3.1", @@ -19441,7 +19477,6 @@ "version": "2.3.6", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", - "dev": true, "requires": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -21283,7 +21318,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, "requires": { "safe-buffer": "~5.1.0" } @@ -21376,6 +21410,33 @@ "ws": "^5.2.0" } }, + "superagent": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.3.tgz", + "integrity": "sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA==", + "requires": { + "component-emitter": "^1.2.0", + "cookiejar": "^2.1.0", + "debug": "^3.1.0", + "extend": "^3.0.0", + "form-data": "^2.3.1", + "formidable": "^1.2.0", + "methods": "^1.1.1", + "mime": "^1.4.1", + "qs": "^6.5.1", + "readable-stream": "^2.3.5" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + } + } + }, "supports-color": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", @@ -21656,6 +21717,11 @@ "integrity": "sha512-rUwGDruKq1gX+FFHbTl5qjI7teVO7eOe+C8IcQ7QT+1BK3eEUXJqbZcBOeaRP4FwSC/C1A5jDoIVta0nIQ9yew==", "dev": true }, + "tlds": { + "version": "1.203.1", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.203.1.tgz", + "integrity": "sha512-7MUlYyGJ6rSitEZ3r1Q1QNV8uSIzapS8SmmhSusBuIc7uIxPPwsKllEP0GRp1NS6Ik6F+fRZvnjDWm3ecv2hDw==" + }, "tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -22351,6 +22417,11 @@ "integrity": "sha512-LtzwHlVHwFGTptfNSgezHp7WUlwiqb0gA9AALRbKaERfxwJoiX0A73QbTToxteIAuIaFshhgIZfqK8s7clqgnA==", "dev": true }, + "uc.micro": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.5.tgz", + "integrity": "sha512-JoLI4g5zv5qNyT09f4YAvEZIIV1oOjqnewYg5D38dkQljIzpPT296dbIGvKro3digYI1bkb7W6EP1y4uDlmzLg==" + }, "uglify-js": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.4.2.tgz", @@ -22810,9 +22881,9 @@ } }, "url-parse": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.4.1.tgz", - "integrity": "sha512-x95Td74QcvICAA0+qERaVkRpTGKyBHHYdwL2LXZm5t/gBtCB9KQSO/0zQgSTYEV1p0WcvSg79TLNPSvd5IDJMQ==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.4.3.tgz", + "integrity": "sha512-rh+KuAW36YKo0vClhQzLLveoj8FwPJNu65xLb7Mrt+eZht0IPT0IXgSv8gcMegZ6NvjJUALf6Mf25POlMwD1Fw==", "dev": true, "requires": { "querystringify": "^2.0.0", @@ -22849,8 +22920,7 @@ "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", - "dev": true + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, "util.promisify": { "version": "1.0.0", diff --git a/package.json b/package.json index f2f981469..749fefffe 100644 --- a/package.json +++ b/package.json @@ -31,34 +31,37 @@ "author": "", "license": "Apache-2.0", "dependencies": { + "akismet-api": "^4.2.0", "apollo-server-express": "^1.3.6", "bcryptjs": "^2.4.3", "bunyan": "^1.8.12", "convict": "^4.3.1", "dataloader": "^1.4.0", - "dotenv-expand": "^4.2.0", "dotenv": "^6.0.0", + "dotenv-expand": "^4.2.0", "dotize": "^0.2.0", - "express-static-gzip": "^0.3.2", "express": "^4.16.3", + "express-static-gzip": "^0.3.2", "fs-extra": "^6.0.1", + "graphql": "^0.13.2", "graphql-config": "^2.0.1", "graphql-redis-subscriptions": "^1.5.0", "graphql-tools": "^3.0.5", - "graphql": "^0.13.2", "ioredis": "^3.2.2", "joi": "^13.4.0", "jsonwebtoken": "^8.3.0", "jwks-rsa": "^1.3.0", + "linkify-it": "^2.0.3", "lodash": "^4.17.10", "luxon": "^1.3.1", "mongodb": "^3.1.1", + "passport": "^0.4.0", "passport-local": "^1.0.0", "passport-oauth2": "^1.4.0", "passport-strategy": "^1.0.0", - "passport": "^0.4.0", "performance-now": "^2.1.0", "subscriptions-transport-ws": "^0.9.12", + "tlds": "^1.203.1", "uuid": "^3.3.2" }, "devDependencies": { @@ -90,6 +93,7 @@ "@types/joi": "^13.0.8", "@types/jsdom": "^11.0.6", "@types/jsonwebtoken": "^7.2.7", + "@types/linkify-it": "^2.0.3", "@types/lodash": "^4.14.111", "@types/luxon": "^0.5.3", "@types/mongodb": "^3.1.1", @@ -107,6 +111,7 @@ "@types/relay-runtime": "github:coralproject/patched#types/relay-runtime", "@types/sane": "^2.0.0", "@types/sinon": "^5.0.1", + "@types/tlds": "^1.199.0", "@types/uglifyjs-webpack-plugin": "^1.1.0", "@types/uuid": "^3.4.3", "@types/webpack": "^4.4.7", diff --git a/src/core/common/types.ts b/src/core/common/types.ts index 2d322e1d3..deb1c57dd 100644 --- a/src/core/common/types.ts +++ b/src/core/common/types.ts @@ -11,3 +11,5 @@ export type Sub = Pick>; * Make all properties in T writeable */ export type Writeable = { -readonly [P in keyof T]: T[P] }; + +export type Promiseable = Promise | T; diff --git a/src/core/server/app/index.ts b/src/core/server/app/index.ts index d1310eb41..3be153164 100644 --- a/src/core/server/app/index.ts +++ b/src/core/server/app/index.ts @@ -9,6 +9,7 @@ import { createPassport } from "talk-server/app/middleware/passport"; import { JWTSigningConfig } from "talk-server/app/middleware/passport/jwt"; import { handleSubscriptions } from "talk-server/graph/common/subscriptions/middleware"; import { Schemas } from "talk-server/graph/schemas"; +import TenantCache from "talk-server/services/tenant/cache"; import { accessLogger, errorLogger } from "./middleware/logging"; import serveStatic from "./middleware/serveStatic"; @@ -21,6 +22,7 @@ export interface AppOptions { redis: Redis; schemas: Schemas; signingConfig: JWTSigningConfig; + tenantCache: TenantCache; } /** @@ -34,10 +36,7 @@ export async function createApp(options: AppOptions): Promise { parent.use(accessLogger); // Create some services for the router. - const passport = createPassport({ - db: options.mongo, - signingConfig: options.signingConfig, - }); + const passport = createPassport(options); // Mount the router. parent.use( @@ -76,7 +75,7 @@ export const listenAndServe = ( * handle websocket traffic by upgrading their http connections to websocket. * * @param schemas schemas for every schema this application handles - * @param server the http.Server to attach the websocket upgraders to + * @param server the http.Server to attach the websocket upgrader to */ export async function attachSubscriptionHandlers( schemas: Schemas, diff --git a/src/core/server/app/middleware/passport/__snapshots__/jwt.spec.ts.snap b/src/core/server/app/middleware/passport/__snapshots__/jwt.spec.ts.snap index 1e61492d7..ada6ed29d 100644 --- a/src/core/server/app/middleware/passport/__snapshots__/jwt.spec.ts.snap +++ b/src/core/server/app/middleware/passport/__snapshots__/jwt.spec.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`createJWTSigningConfig parses a RSA certiciate 1`] = ` +exports[`createJWTSigningConfig parses a RSA certificate 1`] = ` "-----BEGIN RSA PRIVATE KEY----- MIIEpQIBAAKCAQEAyxR2DVlvkQRquggUQTpHN+PxDs2iOiItGgn6u4+faUCdgGEV EnmG69//3lAZHnEQN9rkZS3/20zc41mTJnO7dslJbB316vWUSIwYcVY/VC9DTbk+ diff --git a/src/core/server/app/middleware/passport/index.ts b/src/core/server/app/middleware/passport/index.ts index d9d6768f3..a6bd0032b 100644 --- a/src/core/server/app/middleware/passport/index.ts +++ b/src/core/server/app/middleware/passport/index.ts @@ -12,6 +12,7 @@ import { createLocalStrategy } from "talk-server/app/middleware/passport/local"; import { createOIDCStrategy } from "talk-server/app/middleware/passport/oidc"; import { createSSOStrategy } from "talk-server/app/middleware/passport/sso"; import { User } from "talk-server/models/user"; +import TenantCache from "talk-server/services/tenant/cache"; import { Request } from "talk-server/types/express"; export type VerifyCallback = ( @@ -21,28 +22,28 @@ export type VerifyCallback = ( ) => void; export interface PassportOptions { - db: Db; + mongo: Db; signingConfig: JWTSigningConfig; + tenantCache: TenantCache; } -export function createPassport({ - db, - signingConfig, -}: PassportOptions): passport.Authenticator { +export function createPassport( + options: PassportOptions +): passport.Authenticator { // Create the authenticator. const auth = new Authenticator(); // Use the OIDC Strategy. - auth.use(createOIDCStrategy({ db })); + auth.use(createOIDCStrategy(options)); // Use the LocalStrategy. - auth.use(createLocalStrategy({ db })); + auth.use(createLocalStrategy(options)); // Use the SSOStrategy. - auth.use(createSSOStrategy({ db })); + auth.use(createSSOStrategy(options)); // Use the JWTStrategy. - auth.use(createJWTStrategy({ db, signingConfig })); + auth.use(createJWTStrategy(options)); return auth; } diff --git a/src/core/server/app/middleware/passport/jwt.spec.ts b/src/core/server/app/middleware/passport/jwt.spec.ts index bede65426..3c25db7a4 100644 --- a/src/core/server/app/middleware/passport/jwt.spec.ts +++ b/src/core/server/app/middleware/passport/jwt.spec.ts @@ -67,7 +67,7 @@ describe("extractJWTFromRequest", () => { }); describe("createJWTSigningConfig", () => { - it("parses a RSA certiciate", () => { + it("parses a RSA certificate", () => { const input = `-----BEGIN RSA PRIVATE KEY-----\\nMIIEpQIBAAKCAQEAyxR2DVlvkQRquggUQTpHN+PxDs2iOiItGgn6u4+faUCdgGEV\\nEnmG69//3lAZHnEQN9rkZS3/20zc41mTJnO7dslJbB316vWUSIwYcVY/VC9DTbk+\\nMHWZd94p5hOB8PoY2vEGA53KiyWLqQC5FWE3u7cz7eYTr9/eRPDTc15IzohLXd5U\\nC9EbO5ebho2CvWrBfrLozM5Kidp8r3Jp+A0o3kfJ/kRDDn/BmG6pM0TohWZFYMs2\\nnQaGg+of9tcafgAs7hZAgBrrcc/jke6+MKxpC8algik79nMk7s7prxF1Z9EbAeQV\\n1ssL2VgsjvGAHIV+Arckl6QJbVDvQXNAM0PqbQIDAQABAoIBAQCoG6D5vf5P8nMS\\n2ltB/6cyyfsjgO/45Y+mTXqERwj0DOwUeMkDyRv6KCxb8LxKade+FPIaG7D/7amw\\nfdcE7qrRUyD3YfnPbUk5oNcfAwFbg+BX969WWBMZmgvfDGj1fWKT4w9ScQ1YkFUD\\nKrkLzLVhK+/N0Dad0VjiguTXTMZCSDFOY9fO8HRF6EA3aewEPeEY62J6rSjGXvWB\\nGdW+FNvf/uRr36xGHNqiOP837pdVUppjgDyVsORnMfFtYMyWyxS2XD5r8gRwcRg7\\n0nz6bLM53DjKweO+Yl+pIVPFAyXL0pwzQDlnjShsCzyzjA9lJftkQwbcMWopeegJ\\nkPLmiq4VAoGBAOqDmySNx8vmWWMOaXKFuH6Gqu/Nd7gBHxZ73wvsEmvV52xwa0oi\\n55h+v6P1YEaNZQWXDFsvILoOUHr2kwZY+Du/MC7tgqpj+Fu3h7UHslulJRE3A+sN\\noLbHjZuwm3wwsatpHdyEYOGg0HIGWXi+9pDT/1gy8g3L2Gf0X6rfkBBXAoGBAN2v\\nlbii0+HvZ2y0D0P6NfUJ6cQDrSyuTe7UW6OVYjBjrVAk8+bhnQ4eKd9edCnUDqu6\\n9C8ZSrqR6VBeItbt8y+5ZCRcrigxd2VdH8rL9g6idD9RPnSbHx7Al8DxSUv25xMK\\n8Z/ZOAvuCmwDfdleycNDoTawKqLtWBzUEntLs5DbAoGAPlTKiJWylAxel8h92HWY\\nSvDqQCChgGOz6prz9sxBPS42e4kJy0OpwMt3jlGqzDXKswipvRayoSEq3PPqshY1\\nrFOtr9trDnTRzzbhuAkaq+ciCghQX0pY/BvgFJCFUyXyIzgmOrVotq+yl4v+fexr\\nxqTCSqQH2AjlNQQr5VPUi7MCgYEAsNbbMXE6YlXug+lS8CANoM3qm4FvSGA3LNhb\\nza9hp0YsP+1qXvgEp/lp35RiR+ewWE+HcHbVhOTWYFTnp9ojDyPtfZAtIUTsgIB7\\n1vNC8kOnRccSckQ32/k4VSJlHOL1S9yECMZnjiSyTZ2va5HQkyJE3PJE4LlCe6S0\\npYQq1tcCgYEAoJDeSeAPqi5NIu+MWNUWzw4vo5raKyHrJi+cTvKyM/2zJFHvBc5f\\nRaxkcIAOmIDoVdFgy6APY/0DnDnpqT1kMagUaxZjG9PLFIDds5DRaL99m+S7l8mt\\nySX/MbmhQHYWpVf2nL6pmfPuP4Ih6tbKIUUGA3wZXYYZ5r+pZFG1IrA=\\n-----END RSA PRIVATE KEY-----`; const config = { get: sinon.stub(), diff --git a/src/core/server/app/middleware/passport/jwt.ts b/src/core/server/app/middleware/passport/jwt.ts index 569c005b5..da4d0be11 100644 --- a/src/core/server/app/middleware/passport/jwt.ts +++ b/src/core/server/app/middleware/passport/jwt.ts @@ -67,7 +67,7 @@ export function createAsymmetricSigningConfig( secret: string ): JWTSigningConfig { return { - // Secrets have their newlines encoded with newline litterals. + // Secrets have their newlines encoded with newline literals. secret: Buffer.from(secret.replace(/\\n/g, "\n")), algorithm, }; @@ -138,21 +138,20 @@ export interface JWTToken { export interface JWTStrategyOptions { signingConfig: JWTSigningConfig; - db: Db; + mongo: Db; } export class JWTStrategy extends Strategy { + public name = "jwt"; + private signingConfig: JWTSigningConfig; - private db: Db; + private mongo: Db; - public name: string; - - constructor({ signingConfig, db }: JWTStrategyOptions) { + constructor({ signingConfig, mongo }: JWTStrategyOptions) { super(); - this.name = "jwt"; this.signingConfig = signingConfig; - this.db = db; + this.mongo = mongo; } public authenticate(req: Request) { @@ -160,7 +159,7 @@ export class JWTStrategy extends Strategy { const token = extractJWTFromRequest(req); if (!token) { // There was no token on the request, so there was no user, so let's mark - // that the strategy was succesfull. + // that the strategy was successful. return this.success(null, null); } @@ -187,7 +186,7 @@ export class JWTStrategy extends Strategy { try { // Find the user. - const user = await retrieveUser(this.db, tenant.id, sub); + const user = await retrieveUser(this.mongo, tenant.id, sub); // Return them! The user may be null, but that's ok here. this.success(user, null); diff --git a/src/core/server/app/middleware/passport/local.ts b/src/core/server/app/middleware/passport/local.ts index b57c4402e..588bcfa03 100644 --- a/src/core/server/app/middleware/passport/local.ts +++ b/src/core/server/app/middleware/passport/local.ts @@ -8,7 +8,7 @@ import { } from "talk-server/models/user"; import { Request } from "talk-server/types/express"; -const verifyFactory = (db: Db) => async ( +const verifyFactory = (mongo: Db) => async ( req: Request, email: string, password: string, @@ -21,7 +21,7 @@ const verifyFactory = (db: Db) => async ( const tenant = req.tenant!; // Get the user from the database. - const user = await retrieveUserWithProfile(db, tenant.id, { + const user = await retrieveUserWithProfile(mongo, tenant.id, { id: email, type: "local", }); @@ -44,10 +44,10 @@ const verifyFactory = (db: Db) => async ( }; export interface LocalStrategyOptions { - db: Db; + mongo: Db; } -export function createLocalStrategy({ db }: LocalStrategyOptions) { +export function createLocalStrategy({ mongo }: LocalStrategyOptions) { return new LocalStrategy( { usernameField: "email", @@ -55,6 +55,6 @@ export function createLocalStrategy({ db }: LocalStrategyOptions) { session: false, passReqToCallback: true, }, - verifyFactory(db) + verifyFactory(mongo) ); } diff --git a/src/core/server/app/middleware/passport/oidc.ts b/src/core/server/app/middleware/passport/oidc.ts index 2bbe16a30..a835bd042 100644 --- a/src/core/server/app/middleware/passport/oidc.ts +++ b/src/core/server/app/middleware/passport/oidc.ts @@ -8,8 +8,10 @@ import { Strategy } from "passport-strategy"; import { validate } from "talk-server/app/request/body"; import { reconstructURL } from "talk-server/app/url"; import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; -import { OIDCAuthIntegration, Tenant } from "talk-server/models/tenant"; +import { OIDCAuthIntegration } from "talk-server/models/settings"; +import { Tenant } from "talk-server/models/tenant"; import { OIDCProfile, retrieveUserWithProfile } from "talk-server/models/user"; +import TenantCache from "talk-server/services/tenant/cache"; import { upsert } from "talk-server/services/users"; import { Request } from "talk-server/types/express"; @@ -39,10 +41,6 @@ export interface StrategyItem { jwksClient?: JwksClient; } -export interface OIDCStrategyOptions { - db: Db; -} - export function isOIDCToken(token: OIDCIDToken | object): token is OIDCIDToken { if ( (token as OIDCIDToken).iss && @@ -176,20 +174,28 @@ export async function findOrCreateOIDCUser( */ const OIDC_SCOPE = "openid email profile"; -// FIXME: attach strategy to cache updates of the tenants +export interface OIDCStrategyOptions { + mongo: Db; + tenantCache: TenantCache; +} export default class OIDCStrategy extends Strategy { - public name: string; + public name = "oidc"; - private db: Db; - private cache: Map; + private mongo: Db; + private cache = new Map(); - constructor({ db }: OIDCStrategyOptions) { + constructor({ mongo, tenantCache }: OIDCStrategyOptions) { super(); - this.name = "oidc"; - this.cache = new Map(); - this.db = db; + this.mongo = mongo; + + // Subscribe to updates with Tenants. + tenantCache.subscribe(tenant => { + // Delete the tenant cache item when the tenant changes. The refreshed + // Tenant will come in with the request. + this.cache.delete(tenant.id); + }); } private lookupJWKSClient( @@ -277,7 +283,7 @@ export default class OIDCStrategy extends Strategy { try { const user = await findOrCreateOIDCUser( - this.db, + this.mongo, tenant, decoded as OIDCIDToken ); @@ -370,6 +376,6 @@ export default class OIDCStrategy extends Strategy { } } -export function createOIDCStrategy({ db }: OIDCStrategyOptions) { - return new OIDCStrategy({ db }); +export function createOIDCStrategy(options: OIDCStrategyOptions) { + return new OIDCStrategy(options); } diff --git a/src/core/server/app/middleware/passport/sso.ts b/src/core/server/app/middleware/passport/sso.ts index 4e6903139..066f2d139 100644 --- a/src/core/server/app/middleware/passport/sso.ts +++ b/src/core/server/app/middleware/passport/sso.ts @@ -17,7 +17,7 @@ import { upsert } from "talk-server/services/users"; import { Request } from "talk-server/types/express"; export interface SSOStrategyOptions { - db: Db; + mongo: Db; } export interface SSOUserProfile { @@ -114,15 +114,14 @@ export function isSSOToken(token: SSOToken | object): token is SSOToken { } export default class SSOStrategy extends Strategy { - public name: string; + public name = "sso"; - private db: Db; + private mongo: Db; - constructor({ db }: SSOStrategyOptions) { + constructor({ mongo }: SSOStrategyOptions) { super(); - this.name = "sso"; - this.db = db; + this.mongo = mongo; } /** @@ -162,7 +161,7 @@ export default class SSOStrategy extends Strategy { if (isOIDCToken(token)) { // The token provided for SSO contains an issuer claim. We're assuming // that this request is associated with an OpenID Connect provider. - return findOrCreateOIDCUser(this.db, tenant, token); + return findOrCreateOIDCUser(this.mongo, tenant, token); } // Check to see if this token is a SSO Token or not, if it isn't error out. @@ -174,7 +173,7 @@ export default class SSOStrategy extends Strategy { // The token provided does not confirm to the OpenID Connect provider // spec, but id does conform to a SSOToken so we should expect the token to // contain the user profile. - return findOrCreateSSOUser(this.db, tenant, token); + return findOrCreateSSOUser(this.mongo, tenant, token); } public authenticate(req: Request) { diff --git a/src/core/server/app/middleware/tenant.ts b/src/core/server/app/middleware/tenant.ts index b299b902a..00ff11c07 100644 --- a/src/core/server/app/middleware/tenant.ts +++ b/src/core/server/app/middleware/tenant.ts @@ -1,11 +1,10 @@ import { NextFunction, Response } from "express"; -import { Db } from "mongodb"; -import { retrieveTenantByDomain } from "talk-server/models/tenant"; +import TenantCache from "talk-server/services/tenant/cache"; import { Request } from "talk-server/types/express"; export interface MiddlewareOptions { - db: Db; + cache: TenantCache; } export default (options: MiddlewareOptions) => async ( @@ -14,13 +13,18 @@ export default (options: MiddlewareOptions) => async ( next: NextFunction ) => { try { - // TODO: replace with shared synced cache instead of direct db access. - const tenant = await retrieveTenantByDomain(options.db, req.hostname); + const { cache } = options; + + // Attach the tenant to the request. + const tenant = await cache.retrieveByDomain(req.hostname); if (!tenant) { // TODO: send a http.StatusNotFound? return next(new Error("tenant not found")); } + // Attach the tenant cache to the request. + req.tenantCache = cache; + // Attach the tenant to the request. req.tenant = tenant; diff --git a/src/core/server/app/router.ts b/src/core/server/app/router.ts index 085b9e4f9..059a1c39a 100644 --- a/src/core/server/app/router.ts +++ b/src/core/server/app/router.ts @@ -33,7 +33,7 @@ async function createTenantRouter(app: AppOptions, options: RouterOptions) { const router = express.Router(); // Tenant identification middleware. - router.use(tenantMiddleware({ db: app.mongo })); + router.use(tenantMiddleware({ cache: app.tenantCache })); // Setup Passport middleware. router.use(options.passport.initialize()); @@ -48,7 +48,12 @@ async function createTenantRouter(app: AppOptions, options: RouterOptions) { // Any users may submit their GraphQL requests with authentication, this // middleware will unpack their user into the request. options.passport.authenticate("jwt", { session: false }), - await tenantGraphMiddleware(app.schemas.tenant, app.config, app.mongo) + await tenantGraphMiddleware({ + schema: app.schemas.tenant, + config: app.config, + mongo: app.mongo, + redis: app.redis, + }) ); return router; diff --git a/src/core/server/graph/common/context.ts b/src/core/server/graph/common/context.ts index d939d7b64..7e191e3f8 100644 --- a/src/core/server/graph/common/context.ts +++ b/src/core/server/graph/common/context.ts @@ -1,13 +1,17 @@ import { User } from "talk-server/models/user"; +import { Request } from "talk-server/types/express"; export interface CommonContextOptions { user?: User; + req?: Request; } export default class CommonContext { public user?: User; + public req?: Request; - constructor({ user }: CommonContextOptions) { + constructor({ user, req }: CommonContextOptions) { this.user = user; + this.req = req; } } diff --git a/src/core/server/graph/management/context.ts b/src/core/server/graph/management/context.ts index c77f1a207..cdb25dbe3 100644 --- a/src/core/server/graph/management/context.ts +++ b/src/core/server/graph/management/context.ts @@ -1,16 +1,19 @@ import { Db } from "mongodb"; + import CommonContext from "talk-server/graph/common/context"; +import { Request } from "talk-server/types/express"; export interface ManagementContextOptions { - db: Db; + mongo: Db; + req?: Request; } export default class ManagementContext extends CommonContext { - public db: Db; + public mongo: Db; - constructor({ db }: ManagementContextOptions) { - super({}); + constructor({ req, mongo }: ManagementContextOptions) { + super({ req }); - this.db = db; + this.mongo = mongo; } } diff --git a/src/core/server/graph/management/middleware.ts b/src/core/server/graph/management/middleware.ts index 8a62eed63..6dd15e5da 100644 --- a/src/core/server/graph/management/middleware.ts +++ b/src/core/server/graph/management/middleware.ts @@ -3,11 +3,12 @@ import { Db } from "mongodb"; import { Config } from "talk-common/config"; import { graphqlMiddleware } from "talk-server/graph/common/middleware"; +import { Request } from "talk-server/types/express"; -import Context from "./context"; +import ManagementContext from "./context"; -export default (schema: GraphQLSchema, config: Config, db: Db) => - graphqlMiddleware(config, async () => ({ +export default (schema: GraphQLSchema, config: Config, mongo: Db) => + graphqlMiddleware(config, async (req: Request) => ({ schema, - context: new Context({ db }), + context: new ManagementContext({ req, mongo }), })); diff --git a/src/core/server/graph/tenant/context.ts b/src/core/server/graph/tenant/context.ts index 0f51eb529..3eaf8e1ca 100644 --- a/src/core/server/graph/tenant/context.ts +++ b/src/core/server/graph/tenant/context.ts @@ -1,30 +1,49 @@ +import { Redis } from "ioredis"; import { Db } from "mongodb"; + import CommonContext from "talk-server/graph/common/context"; import { Tenant } from "talk-server/models/tenant"; import { User } from "talk-server/models/user"; +import TenantCache from "talk-server/services/tenant/cache"; +import { Request } from "talk-server/types/express"; + import loaders from "./loaders"; import mutators from "./mutators"; export interface TenantContextOptions { - db: Db; + mongo: Db; + redis: Redis; tenant: Tenant; + tenantCache: TenantCache; + req?: Request; user?: User; } export default class TenantContext extends CommonContext { public loaders: ReturnType; public mutators: ReturnType; - public db: Db; + public mongo: Db; + public redis: Redis; public user?: User; public tenant: Tenant; + public tenantCache: TenantCache; - constructor({ user, tenant, db }: TenantContextOptions) { - super({ user }); + constructor({ + req, + user, + tenant, + mongo, + redis, + tenantCache, + }: TenantContextOptions) { + super({ user, req }); this.tenant = tenant; + this.tenantCache = tenantCache; this.user = user; + this.mongo = mongo; + this.redis = redis; this.loaders = loaders(this); this.mutators = mutators(this); - this.db = db; } } diff --git a/src/core/server/graph/tenant/loaders/assets.ts b/src/core/server/graph/tenant/loaders/assets.ts index b747a1be2..79acdeda9 100644 --- a/src/core/server/graph/tenant/loaders/assets.ts +++ b/src/core/server/graph/tenant/loaders/assets.ts @@ -10,8 +10,8 @@ import { findOrCreate } from "talk-server/services/assets"; export default (ctx: TenantContext) => ({ findOrCreate: (input: FindOrCreateAssetInput) => - findOrCreate(ctx.db, ctx.tenant, input), + findOrCreate(ctx.mongo, ctx.tenant, input), asset: new DataLoader(ids => - retrieveManyAssets(ctx.db, ctx.tenant.id, ids) + retrieveManyAssets(ctx.mongo, ctx.tenant.id, ids) ), }); diff --git a/src/core/server/graph/tenant/loaders/comments.ts b/src/core/server/graph/tenant/loaders/comments.ts index 719046a83..111a40cd4 100644 --- a/src/core/server/graph/tenant/loaders/comments.ts +++ b/src/core/server/graph/tenant/loaders/comments.ts @@ -14,7 +14,7 @@ import { export default (ctx: Context) => ({ comment: new DataLoader((ids: string[]) => - retrieveManyComments(ctx.db, ctx.tenant.id, ids) + retrieveManyComments(ctx.mongo, ctx.tenant.id, ids) ), forAsset: ( assetID: string, @@ -25,7 +25,7 @@ export default (ctx: Context) => ({ after, }: AssetToCommentsArgs ) => - retrieveCommentAssetConnection(ctx.db, ctx.tenant.id, assetID, { + retrieveCommentAssetConnection(ctx.mongo, ctx.tenant.id, assetID, { first, orderBy, after, @@ -40,9 +40,15 @@ export default (ctx: Context) => ({ after, }: CommentToRepliesArgs ) => - retrieveCommentRepliesConnection(ctx.db, ctx.tenant.id, assetID, parentID, { - first, - orderBy, - after, - }), + retrieveCommentRepliesConnection( + ctx.mongo, + ctx.tenant.id, + assetID, + parentID, + { + first, + orderBy, + after, + } + ), }); diff --git a/src/core/server/graph/tenant/loaders/users.ts b/src/core/server/graph/tenant/loaders/users.ts index 34bbebc10..c684a9d23 100644 --- a/src/core/server/graph/tenant/loaders/users.ts +++ b/src/core/server/graph/tenant/loaders/users.ts @@ -4,6 +4,6 @@ import { retrieveManyUsers, User } from "talk-server/models/user"; export default (ctx: Context) => ({ user: new DataLoader(ids => - retrieveManyUsers(ctx.db, ctx.tenant.id, ids) + retrieveManyUsers(ctx.mongo, ctx.tenant.id, ids) ), }); diff --git a/src/core/server/graph/tenant/middleware.ts b/src/core/server/graph/tenant/middleware.ts index f27e2430e..3be331d25 100644 --- a/src/core/server/graph/tenant/middleware.ts +++ b/src/core/server/graph/tenant/middleware.ts @@ -1,4 +1,5 @@ import { GraphQLSchema } from "graphql"; +import { Redis } from "ioredis"; import { Db } from "mongodb"; import { Config } from "talk-common/config"; @@ -7,15 +8,34 @@ import { Request } from "talk-server/types/express"; import TenantContext from "./context"; -export default async (schema: GraphQLSchema, config: Config, db: Db) => { +export interface TenantGraphQLMiddlewareOptions { + schema: GraphQLSchema; + config: Config; + mongo: Db; + redis: Redis; +} + +export default async ({ + schema, + config, + mongo, + redis, +}: TenantGraphQLMiddlewareOptions) => { return graphqlMiddleware(config, async (req: Request) => { // Load the tenant and user from the request. - const { tenant, user } = req; + const { tenant, user, tenantCache } = req; // Return the graph options. return { schema, - context: new TenantContext({ db, tenant: tenant!, user }), + context: new TenantContext({ + req, + mongo, + redis, + tenant: tenant!, + user, + tenantCache, + }), }; }); }; diff --git a/src/core/server/graph/tenant/mutators/comment.ts b/src/core/server/graph/tenant/mutators/comment.ts index 1ad380a78..1560b0095 100644 --- a/src/core/server/graph/tenant/mutators/comment.ts +++ b/src/core/server/graph/tenant/mutators/comment.ts @@ -5,12 +5,17 @@ import { create } from "talk-server/services/comments"; export default (ctx: TenantContext) => ({ create: (input: GQLCreateCommentInput): Promise => { - // FIXME: remove tenant + user ! - return create(ctx.db, ctx.tenant, { - author_id: ctx.user!.id, - asset_id: input.assetID, - body: input.body, - parent_id: input.parentID, - }); + return create( + ctx.mongo, + ctx.tenant, + ctx.user!, + { + author_id: ctx.user!.id, + asset_id: input.assetID, + body: input.body, + parent_id: input.parentID, + }, + ctx.req + ); }, }); diff --git a/src/core/server/graph/tenant/mutators/index.ts b/src/core/server/graph/tenant/mutators/index.ts index 25433bfbe..8ec9d1d02 100644 --- a/src/core/server/graph/tenant/mutators/index.ts +++ b/src/core/server/graph/tenant/mutators/index.ts @@ -1,6 +1,9 @@ import TenantContext from "talk-server/graph/tenant/context"; + import Comment from "./comment"; +import Settings from "./settings"; export default (ctx: TenantContext) => ({ Comment: Comment(ctx), + Settings: Settings(ctx), }); diff --git a/src/core/server/graph/tenant/mutators/settings.ts b/src/core/server/graph/tenant/mutators/settings.ts new file mode 100644 index 000000000..19a7c04e0 --- /dev/null +++ b/src/core/server/graph/tenant/mutators/settings.ts @@ -0,0 +1,11 @@ +import { isNull, omitBy } from "lodash"; + +import TenantContext from "talk-server/graph/tenant/context"; +import { GQLSettingsInput } from "talk-server/graph/tenant/schema/__generated__/types"; +import { Tenant } from "talk-server/models/tenant"; +import { update } from "talk-server/services/tenant"; + +export default ({ mongo, redis, tenantCache, tenant }: TenantContext) => ({ + update: (input: GQLSettingsInput): Promise => + update(mongo, redis, tenantCache, tenant, omitBy(input, isNull)), +}); diff --git a/src/core/server/graph/tenant/resolvers/auth_integrations.ts b/src/core/server/graph/tenant/resolvers/auth_integrations.ts index 6842b891b..164e15bfe 100644 --- a/src/core/server/graph/tenant/resolvers/auth_integrations.ts +++ b/src/core/server/graph/tenant/resolvers/auth_integrations.ts @@ -1,5 +1,5 @@ import { GQLAuthIntegrationsTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; -import { AuthIntegration, AuthIntegrations } from "talk-server/models/tenant"; +import { AuthIntegration, AuthIntegrations } from "talk-server/models/settings"; const disabled: AuthIntegration = { enabled: false }; diff --git a/src/core/server/graph/tenant/resolvers/auth_settings.ts b/src/core/server/graph/tenant/resolvers/auth_settings.ts index ed8ec8659..6847244a9 100644 --- a/src/core/server/graph/tenant/resolvers/auth_settings.ts +++ b/src/core/server/graph/tenant/resolvers/auth_settings.ts @@ -1,5 +1,5 @@ import { GQLAuthSettingsTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; -import { Auth } from "talk-server/models/tenant"; +import { Auth } from "talk-server/models/settings"; const AuthSettings: GQLAuthSettingsTypeResolver = { integrations: auth => auth.integrations, diff --git a/src/core/server/graph/tenant/resolvers/facebook_auth_integration.ts b/src/core/server/graph/tenant/resolvers/facebook_auth_integration.ts index c0a127d82..ddccd91ef 100644 --- a/src/core/server/graph/tenant/resolvers/facebook_auth_integration.ts +++ b/src/core/server/graph/tenant/resolvers/facebook_auth_integration.ts @@ -1,5 +1,5 @@ import { GQLFacebookAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; -import { FacebookAuthIntegration } from "talk-server/models/tenant"; +import { FacebookAuthIntegration } from "talk-server/models/settings"; const FacebookAuthIntegration: GQLFacebookAuthIntegrationTypeResolver< FacebookAuthIntegration diff --git a/src/core/server/graph/tenant/resolvers/google_auth_integration.ts b/src/core/server/graph/tenant/resolvers/google_auth_integration.ts index 8edbabcf6..6ca00f87a 100644 --- a/src/core/server/graph/tenant/resolvers/google_auth_integration.ts +++ b/src/core/server/graph/tenant/resolvers/google_auth_integration.ts @@ -1,5 +1,5 @@ import { GQLGoogleAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; -import { GoogleAuthIntegration } from "talk-server/models/tenant"; +import { GoogleAuthIntegration } from "talk-server/models/settings"; const GoogleAuthIntegration: GQLGoogleAuthIntegrationTypeResolver< GoogleAuthIntegration diff --git a/src/core/server/graph/tenant/resolvers/local_auth_integration.ts b/src/core/server/graph/tenant/resolvers/local_auth_integration.ts index fe14c7b92..3b99eec6a 100644 --- a/src/core/server/graph/tenant/resolvers/local_auth_integration.ts +++ b/src/core/server/graph/tenant/resolvers/local_auth_integration.ts @@ -1,5 +1,5 @@ import { GQLLocalAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; -import { LocalAuthIntegration } from "talk-server/models/tenant"; +import { LocalAuthIntegration } from "talk-server/models/settings"; const LocalAuthIntegration: GQLLocalAuthIntegrationTypeResolver< LocalAuthIntegration diff --git a/src/core/server/graph/tenant/resolvers/mutation.ts b/src/core/server/graph/tenant/resolvers/mutation.ts index f51f0fc1b..8f15117c9 100644 --- a/src/core/server/graph/tenant/resolvers/mutation.ts +++ b/src/core/server/graph/tenant/resolvers/mutation.ts @@ -5,6 +5,10 @@ const Mutation: GQLMutationTypeResolver = { comment: await ctx.mutators.Comment.create(input), clientMutationId: input.clientMutationId, }), + updateSettings: async (source, { input }, ctx) => ({ + settings: await ctx.mutators.Settings.update(input.settings), + clientMutationId: input.clientMutationId, + }), }; export default Mutation; diff --git a/src/core/server/graph/tenant/resolvers/oidc_auth_integration.ts b/src/core/server/graph/tenant/resolvers/oidc_auth_integration.ts index 966001ab9..8595a39f4 100644 --- a/src/core/server/graph/tenant/resolvers/oidc_auth_integration.ts +++ b/src/core/server/graph/tenant/resolvers/oidc_auth_integration.ts @@ -1,5 +1,5 @@ import { GQLOIDCAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; -import { OIDCAuthIntegration } from "talk-server/models/tenant"; +import { OIDCAuthIntegration } from "talk-server/models/settings"; const OIDCAuthIntegration: GQLOIDCAuthIntegrationTypeResolver< OIDCAuthIntegration diff --git a/src/core/server/graph/tenant/resolvers/sso_auth_integration.ts b/src/core/server/graph/tenant/resolvers/sso_auth_integration.ts index 562cad894..14b8e2b82 100644 --- a/src/core/server/graph/tenant/resolvers/sso_auth_integration.ts +++ b/src/core/server/graph/tenant/resolvers/sso_auth_integration.ts @@ -1,5 +1,5 @@ import { GQLSSOAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; -import { SSOAuthIntegration } from "talk-server/models/tenant"; +import { SSOAuthIntegration } from "talk-server/models/settings"; const SSOAuthIntegration: GQLSSOAuthIntegrationTypeResolver< SSOAuthIntegration diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index d8f082ff7..b045c572f 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -25,6 +25,19 @@ Cursor represents a paginating cursor. """ scalar Cursor +################################################################################ +## Actions +################################################################################ + +enum ACTION_TYPE { + FLAG + DONTAGREE +} + +enum ACTION_ITEM_TYPE { + COMMENTS +} + ################################################################################ ## Settings ################################################################################ @@ -287,7 +300,7 @@ type Settings { """ domains will return a given list of whitelisted domains. """ - domains: [String!] @auth(roles: [ADMIN]) @auth(roles: [ADMIN]) + domains: [String!] @auth(roles: [ADMIN]) """ auth contains all the settings related to authentication and authorization. @@ -301,6 +314,7 @@ type Settings { enum USER_ROLE { COMMENTER + STAFF MODERATOR ADMIN } @@ -390,8 +404,34 @@ type User { ################################################################################ enum COMMENT_STATUS { + """ + The comment is not PREMOD, but was not applied a moderation status by a + moderator. + """ NONE + + """ + The comment has been accepted by a moderator. + """ ACCEPTED + + """ + The comment has been rejected by a moderator. + """ + REJECTED + + """ + The comment was created while the asset's premoderation option was on, and + new comments that haven't been moderated yet are referred to as + "premoderated" or "premod" comments. + """ + PREMOD + + """ + SYSTEM_WITHHELD represents a comment that was withheld by the system because + it was flagged by an internal process for further review. + """ + SYSTEM_WITHHELD } """ @@ -661,6 +701,66 @@ type CreateCommentPayload { clientMutationId: String! } +################## +## updateSettings +################## + +""" +SettingsInput is the partial type of the Settings type for performing mutations. +""" +input SettingsInput { + moderation: MODERATION_MODE + requireEmailConfirmation: Boolean + infoBoxEnable: Boolean + infoBoxContent: String + questionBoxEnable: Boolean + questionBoxContent: String + questionBoxIcon: String + premodLinksEnable: Boolean + autoCloseStream: Boolean + customCssUrl: String + closedTimeout: Int + closedMessage: String + disableCommenting: Boolean + disableCommentingMessage: String + editCommentWindowLength: Int + charCountEnable: Boolean + charCount: Int + organizationName: String + organizationContactEmail: String + # wordlist: WordlistSettings @auth(roles: [ADMIN, MODERATOR]) + domains: [String!] + # auth: AuthSettings! +} + +""" +UpdateSettingsInput provides the input for the updateSettings Mutation. +""" +input UpdateSettingsInput { + settings: SettingsInput! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +""" +UpdateSettingsPayload contains the updated Settings after the updateSettings +mutation. +""" +type UpdateSettingsPayload { + """ + settings is the updated Settings. + """ + settings: Settings + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + ################## ## Mutation ################## @@ -670,6 +770,11 @@ type Mutation { createComment will create a Comment as the current logged in User. """ createComment(input: CreateCommentInput!): CreateCommentPayload @auth + + """ + updateSettings will update the Settings for the given Tenant. + """ + updateSettings(input: UpdateSettingsInput!): UpdateSettingsPayload @auth(roles: [ADMIN]) } ################################################################################ diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 3ef89bde4..47a0c2db4 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -7,6 +7,7 @@ import getManagementSchema from "talk-server/graph/management/schema"; import { Schemas } from "talk-server/graph/schemas"; import getTenantSchema from "talk-server/graph/tenant/schema"; +import TenantCache from "talk-server/services/tenant/cache"; import { attachSubscriptionHandlers, createApp, listenAndServe } from "./app"; import logger from "./logger"; import { createMongoDB } from "./services/mongodb"; @@ -68,6 +69,12 @@ class Server { // Create the signing config. const signingConfig = createJWTSigningConfig(this.config); + // Create the TenantCache. + const tenantCache = new TenantCache(mongo, await createRedisClient(config)); + + // Prime the tenant cache so it'll be ready to serve now. + await tenantCache.primeAll(); + // Create the Talk App, branching off from the parent app. const app: Express = await createApp({ parent, @@ -76,6 +83,7 @@ class Server { config: this.config, schemas: this.schemas, signingConfig, + tenantCache, }); // Start the application and store the resulting http.Server. diff --git a/src/core/server/logger.ts b/src/core/server/logger.ts index 7a9ee886e..5332d9778 100644 --- a/src/core/server/logger.ts +++ b/src/core/server/logger.ts @@ -1,8 +1,12 @@ -import bunyan from "bunyan"; +import bunyan, { LogLevelString } from "bunyan"; + +import config from "talk-common/config"; const logger = bunyan.createLogger({ name: "talk", serializers: bunyan.stdSerializers, + // TODO: (wyattjoh) move this into some managed instance? + level: config.get("logging_level") as LogLevelString, }); export default logger; diff --git a/src/core/server/models/actions.ts b/src/core/server/models/actions.ts index ead2033eb..f304da550 100644 --- a/src/core/server/models/actions.ts +++ b/src/core/server/models/actions.ts @@ -1,3 +1,17 @@ -export interface ActionCounts { - [_: string]: number; +import { + GQLACTION_ITEM_TYPE, + GQLACTION_TYPE, +} from "talk-server/graph/tenant/schema/__generated__/types"; + +export type ActionCounts = Record; + +export interface Action { + readonly id: string; + action_type: GQLACTION_TYPE; + item_type: GQLACTION_ITEM_TYPE; + item_id: string; + group_id?: string; + user_id?: string; + created_at: Date; + metadata?: Record; } diff --git a/src/core/server/models/asset.ts b/src/core/server/models/asset.ts index b09583d9c..40fbedf0a 100644 --- a/src/core/server/models/asset.ts +++ b/src/core/server/models/asset.ts @@ -4,6 +4,7 @@ import { Db } from "mongodb"; import uuid from "uuid"; import { Omit } from "talk-common/types"; +import { ModerationSettings } from "talk-server/models/settings"; import { TenantResource } from "talk-server/models/tenant"; function collection(db: Db) { @@ -25,6 +26,12 @@ export interface Asset extends TenantResource { publication_date?: Date; modified_date?: Date; created_at: Date; + + /** + * settings provides a point where the settings can be overriden for a + * specific Asset. + */ + settings?: Partial; } export interface UpsertAssetInput { @@ -170,7 +177,7 @@ export async function updateAsset( const result = await collection(db).findOneAndUpdate( { id, tenant_id: tenantID }, // Only update fields that have been updated. - { $set: dotize(update) }, + { $set: dotize.convert(update) }, // False to return the updated document instead of the original // document. { returnOriginal: false } diff --git a/src/core/server/models/comment.ts b/src/core/server/models/comment.ts index 875317b8d..430f85225 100644 --- a/src/core/server/models/comment.ts +++ b/src/core/server/models/comment.ts @@ -1,4 +1,3 @@ -import { merge } from "lodash"; import { Db } from "mongodb"; import uuid from "uuid"; @@ -91,17 +90,14 @@ export async function createComment( }; // Merge the defaults and the input together. - const comment: Readonly = merge({}, defaults, input); - - // TODO: Check for existence of the parent ID before we create the comment. - - // TODO: Check for existence of the asset ID before we create the comment. + const comment: Readonly = { + ...defaults, + ...input, + }; // Insert it into the database. await collection(db).insertOne(comment); - // TODO: update reply count of parent if exists. - return comment; } diff --git a/src/core/server/models/settings.ts b/src/core/server/models/settings.ts new file mode 100644 index 000000000..217db50b9 --- /dev/null +++ b/src/core/server/models/settings.ts @@ -0,0 +1,246 @@ +import { + GQLMODERATION_MODE, + GQLUSER_ROLE, +} from "talk-server/graph/tenant/schema/__generated__/types"; + +export interface Wordlist { + banned: string[]; + suspect: string[]; +} + +export interface EmailDomainRuleCondition { + /** + * emailDomain is the domain name component of the email addresses that should + * match for this condition. + */ + emailDomain: string; + /** + * emailVerifiedRequired stipulates that this rule only applies when the user + * account has been marked as having their email address already verified. + */ + emailVerifiedRequired: boolean; +} + +/** + * RoleRule describes the role assignment for when a user logs into Talk, how + * they can have their account automatically upgraded to a specific role when + * the domain for their email matches the one provided. + */ +export interface RoleRule extends Partial { + /** + * role is the specific GQLUSER_ROLE that should be assigned to the newly + * created user depending on their email address. + */ + role: GQLUSER_ROLE; +} + +export interface AuthRules { + /** + * roles allow the configuration of automatic role assignment based on the + * user's email address. + */ + roles?: RoleRule[]; + + /** + * restrictTo when populated, will restrict which users can login using this + * integration. If a user successfully logs in using the OIDCStrategy, but + * does not match the following rules, the user will not be created. + */ + restrictTo?: EmailDomainRuleCondition[]; +} + +export interface AuthIntegration { + enabled: boolean; +} + +export interface DisplayNameAuthIntegration { + displayNameEnable: boolean; +} + +/** + * SSOAuthIntegration is an AuthIntegration that provides a secret to the admins + * of a tenant, where they can sign a SSO payload with it to provide to the + * embed to allow single sign on. + */ +export interface SSOAuthIntegration + extends AuthIntegration, + DisplayNameAuthIntegration { + key: string; +} + +/** + * OIDCAuthIntegration provides a way to store Open ID Connect credentials. This + * will be used in the admin to provide staff logins for users. + */ +export interface OIDCAuthIntegration + extends AuthIntegration, + DisplayNameAuthIntegration { + clientID: string; + clientSecret: string; + issuer: string; + authorizationURL: string; + jwksURI: string; + tokenURL: string; +} + +export interface FacebookAuthIntegration extends AuthIntegration { + clientID: string; + clientSecret: string; +} + +export interface GoogleAuthIntegration extends AuthIntegration { + clientID: string; + clientSecret: string; +} + +export type LocalAuthIntegration = AuthIntegration; + +/** + * AuthIntegrations describes all of the possible auth integration + * configurations. + */ +export interface AuthIntegrations { + /** + * local is the auth integration for the email/password based auth. + */ + local: LocalAuthIntegration; + + /** + * sso is the external auth integration for the single sign on auth. + */ + sso?: SSOAuthIntegration; + + /** + * sso is the external auth integration for the OpenID Connect auth. + */ + oidc?: OIDCAuthIntegration; + + /** + * sso is the external auth integration for the Google auth. + */ + google?: GoogleAuthIntegration; + + /** + * sso is the external auth integration for the Facebook auth. + */ + facebook?: FacebookAuthIntegration; +} + +export interface Auth { + integrations: AuthIntegrations; +} + +/** + * Akismet provides integration with the Akismet Spam detection service. + */ +export interface AkismetIntegration { + /** + * When true, it will enable comments to be checked by Akismet. + */ + enabled: boolean; + + /** + * The key for the Akismet integration. + */ + key?: string; + + /** + * The site (blog) for the Akismet integration. + */ + site?: string; +} + +export interface ExternalIntegrations { + /** + * akismet provides integration with the Akismet Spam detection service. + */ + akismet: AkismetIntegration; +} + +export interface ModerationSettings { + moderation: GQLMODERATION_MODE; + requireEmailConfirmation: boolean; + infoBoxEnable: boolean; + infoBoxContent?: string; + questionBoxEnable: boolean; + questionBoxIcon?: string; + questionBoxContent?: string; + premodLinksEnable: boolean; + autoCloseStream: boolean; + closedTimeout: number; + closedMessage?: string; + disableCommenting: boolean; + disableCommentingMessage?: string; + charCountEnable: boolean; + charCount?: number; +} + +/** + * KarmaThreshold defines the bounds for which a User will become unreliable or + * reliable based on their karma score. If the score is equal or less than the + * unreliable value, they are unreliable. If the score is equal or more than the + * reliable value, they are reliable. If they are neither reliable or unreliable + * then they are neutral. + */ +export interface KarmaThreshold { + reliable: number; + unreliable: number; +} + +export interface KarmaThresholds { + /** + * flag represents karma settings in relation to how well a User's flagging + * ability aligns with the moderation decicions made by moderators. + */ + flag: KarmaThreshold; + + /** + * comment represents the karma setting in relation to how well a User's comments are moderated. + */ + comment: KarmaThreshold; +} + +export interface Karma { + /** + * When true, checks will be completed to ensure that the Karma checks are + * completed. + */ + enabled: boolean; + + /** + * karmaThresholds contains the currently set thresholds for triggering Trust + * beheviour. + */ + thresholds: KarmaThresholds; +} + +export interface Settings extends ModerationSettings { + customCssUrl?: string; + + /** + * editCommentWindowLength is the length of time (in milliseconds) after a + * comment is posted that it can still be edited by the author. + */ + editCommentWindowLength: number; + + /** + * karma is the set of settings related to how user Trust and Karma are + * handled. + */ + karma: Karma; + + /** + * wordlist stores all the banned/suspect words. + */ + wordlist: Wordlist; + + /** + * Set of configured authentication integrations. + */ + auth: Auth; + + /** + * Various integrations with external services. + */ + integrations: ExternalIntegrations; +} diff --git a/src/core/server/models/tenant.ts b/src/core/server/models/tenant.ts index bc19c6553..bd67c99b4 100644 --- a/src/core/server/models/tenant.ts +++ b/src/core/server/models/tenant.ts @@ -1,13 +1,10 @@ import dotize from "dotize"; -import { merge } from "lodash"; import { Db } from "mongodb"; import uuid from "uuid"; -import { Sub } from "talk-common/types"; -import { - GQLMODERATION_MODE, - GQLUSER_ROLE, -} from "talk-server/graph/tenant/schema/__generated__/types"; +import { Omit, Sub } from "talk-common/types"; +import { GQLMODERATION_MODE } from "talk-server/graph/tenant/schema/__generated__/types"; +import { Settings } from "talk-server/models/settings"; function collection(db: Db) { return db.collection>("tenants"); @@ -17,147 +14,21 @@ export interface TenantResource { readonly tenant_id: string; } -export interface Wordlist { - banned: string[]; - suspect: string[]; -} - -// AuthIntegrations. - -export interface EmailDomainRuleCondition { - // emailDomain is the domain name component of the email addresses that should - // match for this condition. - emailDomain: string; - - // emailVerifiedRequired stipulates that this rule only applies when the user - // account has been marked as having their email address already verified. - emailVerifiedRequired: boolean; -} - -// RoleRule describes the role assignment for when a user logs into Talk, how -// they can have their account automatically upgraded to a specific role when -// the domain for their email matches the one provided. -export interface RoleRule extends Partial { - // role is the specific GQLUSER_ROLE that should be assigned to the newly created - // user depending on their email address. - role: GQLUSER_ROLE; -} - -export interface AuthRules { - // roles allow the configuration of automatic role assignment based on the - // user's email address. - roles?: RoleRule[]; - - // restrictTo when populated, will restrict which users can login using this - // integration. If a user successfully logs in using the OIDCStrategy, but - // does not match the following rules, the user will not be created. - restrictTo?: EmailDomainRuleCondition[]; -} - -export interface AuthIntegration { - enabled: boolean; -} - -export interface DisplayNameAuthIntegration { - displayNameEnable: boolean; -} - -// SSOAuthIntegration is an AuthIntegration that provides a secret to the admins -// of a tenant, where they can sign a SSO payload with it to provide to the -// embed to allow single sign on. -export interface SSOAuthIntegration - extends AuthIntegration, - DisplayNameAuthIntegration { - key: string; -} - -// OIDCAuthIntegration provides a way to store Open ID Connect credentials. This -// will be used in the admin to provide staff logins for users. -export interface OIDCAuthIntegration - extends AuthIntegration, - DisplayNameAuthIntegration { - clientID: string; - clientSecret: string; - issuer: string; - authorizationURL: string; - jwksURI: string; - tokenURL: string; -} - -export interface FacebookAuthIntegration extends AuthIntegration { - clientID: string; - clientSecret: string; -} - -export interface GoogleAuthIntegration extends AuthIntegration { - clientID: string; - clientSecret: string; -} - -export type LocalAuthIntegration = AuthIntegration; - -// AuthIntegrations describes all of the possible auth integration configurations. -export interface AuthIntegrations { - // local is the auth integration for the local auth. - local: LocalAuthIntegration; - - // sso is the external auth integration for the single sign on auth. - sso?: SSOAuthIntegration; - - // sso is the external auth integration for the OpenID Connect auth. - oidc?: OIDCAuthIntegration; - - // sso is the external auth integration for the Google auth. - google?: GoogleAuthIntegration; - - // sso is the external auth integration for the Facebook auth. - facebook?: FacebookAuthIntegration; -} - -export interface Auth { - integrations: AuthIntegrations; -} - -// Tenant definition. - -export interface Tenant { +/** + * Tenant describes a given Tenant on Talk that has Assets, Comments, and Users. + */ +export interface Tenant extends Settings { readonly id: string; // Domain is set when the tenant is created, and is used to retrieve the // specific tenant that the API request pertains to. domain: string; - moderation: GQLMODERATION_MODE; - requireEmailConfirmation: boolean; - infoBoxEnable: boolean; - infoBoxContent?: string; - questionBoxEnable: boolean; - questionBoxIcon?: string; - questionBoxContent?: string; - premodLinksEnable: boolean; - autoCloseStream: boolean; - closedTimeout: number; - closedMessage?: string; - customCssUrl?: string; - disableCommenting: boolean; - disableCommentingMessage?: string; - - // editCommentWindowLength is the length of time (in milliseconds) after a - // comment is posted that it can still be edited by the author. - editCommentWindowLength: number; - charCountEnable: boolean; - charCount?: number; - organizationName: string; - organizationContactEmail: string; - - // wordlist stores all the banned/suspect words. - wordlist: Wordlist; - - // domains is the set of whitelisted domains. + // domains is the list of domains that are allowed to have the iframe load on. domains: string[]; - // Set of configured authentication integrations. - auth: Auth; + organizationName: string; + organizationContactEmail: string; } /** @@ -207,10 +78,27 @@ export async function createTenant(db: Db, input: CreateTenantInput) { }, }, }, + karma: { + enabled: true, + thresholds: { + // By default, flaggers are reliable after one correct flag, and + // unreliable if there is an incorrect flag. + flag: { reliable: 1, unreliable: -1 }, + comment: { reliable: 1, unreliable: -1 }, + }, + }, + integrations: { + akismet: { + enabled: false, + }, + }, }; // Create the new Tenant by merging it together with the defaults. - const tenant: Readonly = merge({}, input, defaults); + const tenant: Readonly = { + ...defaults, + ...input, + }; // Insert the Tenant into the database. await collection(db).insert(tenant); @@ -258,16 +146,18 @@ export async function retrieveAllTenants(db: Db) { .toArray(); } +export type UpdateTenantInput = Omit, "id" | "domain">; + export async function updateTenant( db: Db, id: string, - update: Partial + update: UpdateTenantInput ) { // Get the tenant from the database. const result = await collection(db).findOneAndUpdate( { id }, // Only update fields that have been updated. - { $set: dotize(update) }, + { $set: dotize.convert(update) }, // False to return the updated document instead of the original // document. { returnOriginal: false } diff --git a/src/core/server/models/user.ts b/src/core/server/models/user.ts index 5adc4bc53..9b1d04343 100644 --- a/src/core/server/models/user.ts +++ b/src/core/server/models/user.ts @@ -1,5 +1,4 @@ import bcrypt from "bcryptjs"; -import { merge } from "lodash"; import { Db } from "mongodb"; import uuid from "uuid"; @@ -131,11 +130,14 @@ export async function upsertUser( } // Merge the defaults and the input together. - const user: Readonly = merge({}, defaults, input, { + const user: Readonly = { + ...defaults, + ...input, + // Specified last in the merge call, it will override any existing password // entry if it is defined. password: hashedPassword, - }); + }; // Create a query that will utilize a findOneAndUpdate to facilitate an upsert // operation to ensure no user has the same profile and/or email address. If diff --git a/src/core/server/services/comments/index.ts b/src/core/server/services/comments/index.ts index dfba035d4..6bacbd415 100644 --- a/src/core/server/services/comments/index.ts +++ b/src/core/server/services/comments/index.ts @@ -1,22 +1,68 @@ import { Db } from "mongodb"; import { Omit } from "talk-common/types"; -import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__/types"; -import { createComment, CreateCommentInput } from "talk-server/models/comment"; +import { retrieveAsset } from "talk-server/models/asset"; +import { + createComment, + CreateCommentInput, + retrieveComment, +} from "talk-server/models/comment"; import { Tenant } from "talk-server/models/tenant"; +import { User } from "talk-server/models/user"; +import { processForModeration } from "talk-server/services/comments/moderation"; +import { Request } from "talk-server/types/express"; export type CreateComment = Omit< CreateCommentInput, "status" | "action_counts" >; -export async function create(db: Db, tenant: Tenant, input: CreateComment) { - // TODO: run the comment through the moderation phases. - const comment = await createComment(db, tenant.id, { - status: GQLCOMMENT_STATUS.ACCEPTED, +export async function create( + mongo: Db, + tenant: Tenant, + author: User, + input: CreateComment, + req?: Request +) { + const asset = await retrieveAsset(mongo, tenant.id, input.asset_id); + if (!asset) { + // TODO: (wyattjoh) return better error. + throw new Error("asset referenced does not exist"); + } + + // TODO: (wyattjoh) Check that the asset was visable. + + if (input.parent_id) { + // Check to see that the reference parent ID exists. + const parent = await retrieveComment(mongo, tenant.id, input.parent_id); + if (!parent) { + // TODO: (wyattjoh) return better error. + throw new Error("parent comment referenced does not exist"); + } + + // TODO: (wyattjoh) Check that the parent comment was visible. + } + + // Run the comment through the moderation phases. + const { status } = await processForModeration({ + asset, + tenant, + comment: input, + author, + req, + }); + + // TODO: (wyattjoh) use the actions somehow. + + const comment = await createComment(mongo, tenant.id, { + status, action_counts: {}, ...input, }); + if (input.parent_id) { + // TODO: update reply count of parent. + } + return comment; } diff --git a/src/core/server/services/comments/moderation/index.ts b/src/core/server/services/comments/moderation/index.ts new file mode 100644 index 000000000..cfd7f2c80 --- /dev/null +++ b/src/core/server/services/comments/moderation/index.ts @@ -0,0 +1,75 @@ +import { Omit, Promiseable } from "talk-common/types"; +import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__/types"; +import { Action } from "talk-server/models/actions"; +import { Asset } from "talk-server/models/asset"; +import { Tenant } from "talk-server/models/tenant"; +import { CreateComment } from "talk-server/services/comments"; + +import { User } from "talk-server/models/user"; +import { Request } from "talk-server/types/express"; +import { moderationPhases } from "./phases"; + +// TODO: (wyattjoh) move into actions module. +export type CreateAction = Omit< + Action, + "id" | "item_type" | "item_id" | "created_at" +>; + +export interface PhaseResult { + actions: CreateAction[]; + status: GQLCOMMENT_STATUS; +} + +export interface ModerationPhaseContext { + asset: Asset; + tenant: Tenant; + comment: CreateComment; + author: User; + req?: Request; +} + +export type ModerationPhase = ( + context: ModerationPhaseContext +) => Promiseable; + +export type IntermediatePhaseResult = Partial | void; + +export type IntermediateModerationPhase = ( + context: ModerationPhaseContext +) => Promiseable; + +/** + * compose will create a moderation pipeline for which is executable with the + * passed actions. + */ +const compose = ( + phases: IntermediateModerationPhase[] +): ModerationPhase => async context => { + const actions: CreateAction[] = []; + + // Loop over all the moderation phases and see if we've resolved the status. + for (const phase of phases) { + const result = await phase(context); + if (result) { + if (result.actions) { + actions.push(...result.actions); + } + + // If this result contained a status, then we've finished resolving + // phases! + const { status } = result; + if (status) { + return { status, actions }; + } + } + } + + // If we didn't determine a different comment from a previous itteration, set + // it to 'NONE'. + return { status: GQLCOMMENT_STATUS.NONE, actions }; +}; + +/** + * process the comment and return moderation details. + */ +export const processForModeration: ModerationPhase = compose(moderationPhases); diff --git a/src/core/server/services/comments/moderation/phases/assetClosed.spec.ts b/src/core/server/services/comments/moderation/phases/assetClosed.spec.ts new file mode 100644 index 000000000..31d5a1f2f --- /dev/null +++ b/src/core/server/services/comments/moderation/phases/assetClosed.spec.ts @@ -0,0 +1,42 @@ +import { Asset } from "talk-server/models/asset"; +import { Comment } from "talk-server/models/comment"; +import { Tenant } from "talk-server/models/tenant"; +import { User } from "talk-server/models/user"; +import { assetClosed } from "talk-server/services/comments/moderation/phases/assetClosed"; + +describe("assetClosed", () => { + it("throws an error when the asset is closed", () => { + const asset = { closedAt: new Date() }; + + expect(() => + assetClosed({ + asset: asset as Asset, + tenant: (null as any) as Tenant, + comment: (null as any) as Comment, + author: (null as any) as User, + }) + ).toThrow(); + }); + + it("does not throw an error when the asset is not closed", () => { + const now = new Date(); + + expect( + assetClosed({ + asset: { closedAt: new Date(now.getTime() + 60000) } as Asset, + tenant: (null as any) as Tenant, + comment: (null as any) as Comment, + author: (null as any) as User, + }) + ).toBeUndefined(); + + expect( + assetClosed({ + asset: {} as Asset, + tenant: (null as any) as Tenant, + comment: (null as any) as Comment, + author: (null as any) as User, + }) + ).toBeUndefined(); + }); +}); diff --git a/src/core/server/services/comments/moderation/phases/assetClosed.ts b/src/core/server/services/comments/moderation/phases/assetClosed.ts new file mode 100644 index 000000000..0755949ac --- /dev/null +++ b/src/core/server/services/comments/moderation/phases/assetClosed.ts @@ -0,0 +1,12 @@ +import { IntermediateModerationPhase } from "talk-server/services/comments/moderation"; + +// This phase checks to see if the asset being processed is closed or not. +export const assetClosed: IntermediateModerationPhase = ({ asset }) => { + // Check to see if the asset has closed commenting... + if (asset.closedAt && asset.closedAt.valueOf() <= Date.now()) { + // TODO: (wyattjoh) return better error. + throw new Error("asset is currently closed for commenting"); + } + + return; +}; diff --git a/src/core/server/services/comments/moderation/phases/commentLength.ts b/src/core/server/services/comments/moderation/phases/commentLength.ts new file mode 100644 index 000000000..d714950b2 --- /dev/null +++ b/src/core/server/services/comments/moderation/phases/commentLength.ts @@ -0,0 +1,45 @@ +import { + GQLACTION_TYPE, + GQLCOMMENT_STATUS, +} from "talk-server/graph/tenant/schema/__generated__/types"; +import { ModerationSettings } from "talk-server/models/settings"; +import { IntermediateModerationPhase } from "talk-server/services/comments/moderation"; + +const testCharCount = (settings: Partial, length: number) => + settings.charCountEnable && settings.charCount && length > settings.charCount; + +export const commentLength: IntermediateModerationPhase = async ({ + asset, + tenant, + comment, +}) => { + const length = comment.body.length; + + // Check to see if the body is too short, if it is, then complain about it! + if (length < 2) { + // TODO: (wyattjoh) return better error. + throw new Error("comment body too short"); + } + + // Reject if the comment is too long + if ( + testCharCount(tenant, length) || + (asset.settings && testCharCount(asset.settings, length)) + ) { + // Add the flag related to Trust to the comment. + return { + status: GQLCOMMENT_STATUS.REJECTED, + actions: [ + { + action_type: GQLACTION_TYPE.FLAG, + group_id: "BODY_COUNT", + metadata: { + count: length, + }, + }, + ], + }; + } + + return; +}; diff --git a/src/core/server/services/comments/moderation/phases/commentingDisabled.ts b/src/core/server/services/comments/moderation/phases/commentingDisabled.ts new file mode 100644 index 000000000..742d97974 --- /dev/null +++ b/src/core/server/services/comments/moderation/phases/commentingDisabled.ts @@ -0,0 +1,19 @@ +import { ModerationSettings } from "talk-server/models/settings"; +import { IntermediateModerationPhase } from "talk-server/services/comments/moderation"; + +const testDisabledCommenting = (settings: Partial) => + settings.disableCommenting; + +export const commentingDisabled: IntermediateModerationPhase = ({ + asset, + tenant, +}) => { + // Check to see if the asset has closed commenting. + if ( + testDisabledCommenting(tenant) || + (asset.settings && testDisabledCommenting(asset.settings)) + ) { + // TODO: (wyattjoh) return better error. + throw new Error("commenting has been disabled tenant wide"); + } +}; diff --git a/src/core/server/services/comments/moderation/phases/index.ts b/src/core/server/services/comments/moderation/phases/index.ts new file mode 100644 index 000000000..6cb656903 --- /dev/null +++ b/src/core/server/services/comments/moderation/phases/index.ts @@ -0,0 +1,26 @@ +import { IntermediateModerationPhase } from "talk-server/services/comments/moderation"; + +import { premod } from "talk-server/services/comments/moderation/phases/premod"; +import { assetClosed } from "./assetClosed"; +import { commentingDisabled } from "./commentingDisabled"; +import { commentLength } from "./commentLength"; +import { karma } from "./karma"; +import { links } from "./links"; +import { spam } from "./spam"; +import { staff } from "./staff"; +import { wordlist } from "./wordlist"; + +/** + * The moderation phases to apply for each comment being processed. + */ +export const moderationPhases: IntermediateModerationPhase[] = [ + commentLength, + assetClosed, + commentingDisabled, + wordlist, + staff, + links, + karma, + spam, + premod, +]; diff --git a/src/core/server/services/comments/moderation/phases/karma.ts b/src/core/server/services/comments/moderation/phases/karma.ts new file mode 100755 index 000000000..3a4d8fa84 --- /dev/null +++ b/src/core/server/services/comments/moderation/phases/karma.ts @@ -0,0 +1,40 @@ +import { + GQLACTION_TYPE, + GQLCOMMENT_STATUS, +} from "talk-server/graph/tenant/schema/__generated__/types"; +import { IntermediateModerationPhase } from "talk-server/services/comments/moderation"; +import { + getCommentTrustScore, + isReliableCommenter, +} from "talk-server/services/users/karma"; + +// This phase checks to see if the user making the comment is allowed to do so +// considering their reliability (Trust) status. +export const karma: IntermediateModerationPhase = ({ tenant, author }) => { + // If the user is not a reliable commenter (passed the unreliability + // threshold by having too many rejected comments) then we can change the + // status of the comment to `SYSTEM_WITHHELD`, therefore pushing the user's + // comments away from the public eye until a moderator can manage them. This + // of course can only be applied if the comment's current status is `NONE`, + // we don't want to interfere if the comment was rejected. + if ( + tenant.karma.enabled && + isReliableCommenter(tenant.karma.thresholds, author) === false + ) { + // Add the flag related to Trust to the comment. + return { + status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD, + actions: [ + { + action_type: GQLACTION_TYPE.FLAG, + group_id: "TRUST", + metadata: { + trust: getCommentTrustScore(author), + }, + }, + ], + }; + } + + return; +}; diff --git a/src/core/server/services/comments/moderation/phases/links.ts b/src/core/server/services/comments/moderation/phases/links.ts new file mode 100755 index 000000000..3db715d5f --- /dev/null +++ b/src/core/server/services/comments/moderation/phases/links.ts @@ -0,0 +1,49 @@ +import linkify from "linkify-it"; +import tlds from "tlds"; + +import { + GQLACTION_TYPE, + GQLCOMMENT_STATUS, +} from "talk-server/graph/tenant/schema/__generated__/types"; +import { ModerationSettings } from "talk-server/models/settings"; +import { IntermediateModerationPhase } from "talk-server/services/comments/moderation"; + +/** + * The preloaded linkify instance with common tlds. + */ +const testForLinks = linkify().tlds(tlds); + +const testPremodLinksEnable = ( + settings: Partial, + body: string +) => settings.premodLinksEnable && testForLinks.test(body); + +// This phase checks the comment if it has any links in it if the check is +// enabled. +export const links: IntermediateModerationPhase = ({ + asset, + tenant, + comment, + author, +}) => { + if ( + testPremodLinksEnable(tenant, comment.body) || + (asset.settings && testPremodLinksEnable(asset.settings, comment.body)) + ) { + // Add the flag related to Trust to the comment. + return { + status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD, + actions: [ + { + action_type: GQLACTION_TYPE.FLAG, + group_id: "LINKS", + metadata: { + links: comment.body, + }, + }, + ], + }; + } + + return; +}; diff --git a/src/core/server/services/comments/moderation/phases/premod.ts b/src/core/server/services/comments/moderation/phases/premod.ts new file mode 100755 index 000000000..c12fb582d --- /dev/null +++ b/src/core/server/services/comments/moderation/phases/premod.ts @@ -0,0 +1,28 @@ +import { + GQLCOMMENT_STATUS, + GQLMODERATION_MODE, +} from "talk-server/graph/tenant/schema/__generated__/types"; +import { ModerationSettings } from "talk-server/models/settings"; +import { IntermediateModerationPhase } from "talk-server/services/comments/moderation"; + +const testModerationMode = (settings: Partial) => + settings.moderation === GQLMODERATION_MODE.PRE; + +// This phase checks to see if the settings have premod enabled, if they do, +// the comment is premod, otherwise, it's just none. +export const premod: IntermediateModerationPhase = ({ asset, tenant }) => { + // If the settings say that we're in premod mode, then the comment is in + // premod status. + + // TODO: (wyattjoh) pull from the asset settings. + if ( + testModerationMode(tenant) || + (asset.settings && testModerationMode(asset.settings)) + ) { + return { + status: GQLCOMMENT_STATUS.PREMOD, + }; + } + + return; +}; diff --git a/src/core/server/services/comments/moderation/phases/spam.ts b/src/core/server/services/comments/moderation/phases/spam.ts new file mode 100644 index 000000000..f44a4e85b --- /dev/null +++ b/src/core/server/services/comments/moderation/phases/spam.ts @@ -0,0 +1,74 @@ +import { Client } from "akismet-api"; + +import { + GQLACTION_TYPE, + GQLCOMMENT_STATUS, +} from "talk-server/graph/tenant/schema/__generated__/types"; +import { IntermediateModerationPhase } from "talk-server/services/comments/moderation"; + +export const spam: IntermediateModerationPhase = async ({ + asset, + tenant, + comment, + author, + req, +}) => { + const integration = tenant.integrations.akismet; + + // We can only check for spam if this comment originated from a graphql + // request via an HTTP call. + if (!req || !integration.enabled) { + return; + } + + if (!integration.key || !integration.site) { + return; + } + + // Create the Akismet client. + const client = new Client({ + key: integration.key, + blog: integration.site, + }); + + // Grab the properties we need. + const userIP = req.ip; + if (!userIP) { + return; + } + + const userAgent = req.get("User-Agent"); + if (!userAgent || userAgent.length === 0) { + return; + } + + const referrer = req.get("Referrer"); + if (!referrer || referrer.length === 0) { + return; + } + + // Check the comment for spam. + const isSpam = await client.checkSpam({ + user_ip: userIP, // REQUIRED + referrer, // REQUIRED + user_agent: userAgent, // REQUIRED + comment_content: comment.body, + permalink: asset.url, + comment_author: author.displayName || author.username || "", + comment_type: "comment", + is_test: false, + }); + if (isSpam) { + return { + status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD, + actions: [ + { + action_type: GQLACTION_TYPE.FLAG, + group_id: "SPAM_COMMENT", + }, + ], + }; + } + + return; +}; diff --git a/src/core/server/services/comments/moderation/phases/staff.ts b/src/core/server/services/comments/moderation/phases/staff.ts new file mode 100755 index 000000000..607e49c82 --- /dev/null +++ b/src/core/server/services/comments/moderation/phases/staff.ts @@ -0,0 +1,21 @@ +import { + GQLCOMMENT_STATUS, + GQLUSER_ROLE, +} from "talk-server/graph/tenant/schema/__generated__/types"; +import { IntermediateModerationPhase } from "talk-server/services/comments/moderation"; + +// If a given user is a staff member, always approve their comment. +export const staff: IntermediateModerationPhase = ({ + asset, + tenant, + comment, + author, +}) => { + if (author.role !== GQLUSER_ROLE.COMMENTER) { + return { + status: GQLCOMMENT_STATUS.ACCEPTED, + }; + } + + return; +}; diff --git a/src/core/server/services/comments/moderation/phases/wordlist.ts b/src/core/server/services/comments/moderation/phases/wordlist.ts new file mode 100755 index 000000000..0c62f3261 --- /dev/null +++ b/src/core/server/services/comments/moderation/phases/wordlist.ts @@ -0,0 +1,50 @@ +import { + GQLACTION_TYPE, + GQLCOMMENT_STATUS, +} from "talk-server/graph/tenant/schema/__generated__/types"; +import { IntermediateModerationPhase } from "talk-server/services/comments/moderation"; +import { containsMatchingPhrase } from "talk-server/services/comments/moderation/wordlist"; + +// This phase checks the comment against the wordlist. +export const wordlist: IntermediateModerationPhase = ({ + asset, + tenant, + comment, + author, +}) => { + // Decide the status based on whether or not the current asset/settings + // has pre-mod enabled or not. If the comment was rejected based on the + // wordlist, then reject it, otherwise if the moderation setting is + // premod, set it to `premod`. + if (containsMatchingPhrase(tenant.wordlist.banned, comment.body)) { + // Add the flag related to Trust to the comment. + return { + status: GQLCOMMENT_STATUS.REJECTED, + actions: [ + { + action_type: GQLACTION_TYPE.FLAG, + group_id: "BANNED_WORD", + }, + ], + }; + } + + // If the comment has a suspect word or a link, we need to add a + // flag to it to indicate that it needs to be looked at. + // Otherwise just return the new comment. + + // If the wordlist has matched the suspect word filter and we haven't disabled + // auto-flagging suspect words, then we should flag the comment! + if (containsMatchingPhrase(tenant.wordlist.suspect, comment.body)) { + return { + actions: [ + { + action_type: GQLACTION_TYPE.FLAG, + group_id: "SUSPECT_WORD", + }, + ], + }; + } + + return; +}; diff --git a/src/core/server/services/comments/moderation/wordlist.spec.ts b/src/core/server/services/comments/moderation/wordlist.spec.ts new file mode 100644 index 000000000..d23705ecf --- /dev/null +++ b/src/core/server/services/comments/moderation/wordlist.spec.ts @@ -0,0 +1,46 @@ +import { containsMatchingPhrase } from "talk-server/services/comments/moderation/wordlist"; + +const phrases = [ + "cookies", + "how to do bad things", + "how to do really bad things", + "s h i t", + "$hit", + "p**ch", + "p*ch", +]; + +describe("containsMatchingPhrase", () => { + it("does match on a word in the list", () => { + [ + "how to do really bad things", + "what is cookies", + "cookies", + "COOKIES.", + "how to do bad things", + "How To do bad things!", + "This stuff is $hit!", + "That's a p**ch!", + ].forEach(word => { + expect(containsMatchingPhrase(phrases, word)).toEqual(true); + }); + }); + + it("does not match on a word not in the list", () => { + [ + "how to", + "cookie", + "how to be a great person?", + "how to not do really bad things?", + "i have $100 dollars.", + "I have bad $ hit lling", + "That's a p***ch!", + ].forEach(word => { + expect(containsMatchingPhrase(phrases, word)).toEqual(false); + }); + }); + + it("allows an empty list", () => { + expect(containsMatchingPhrase([], "test")).toEqual(false); + }); +}); diff --git a/src/core/server/services/comments/moderation/wordlist.ts b/src/core/server/services/comments/moderation/wordlist.ts new file mode 100644 index 000000000..30cc124ac --- /dev/null +++ b/src/core/server/services/comments/moderation/wordlist.ts @@ -0,0 +1,25 @@ +/** + * Escape string for special regular expression characters. + */ +export function escapeRegExp(str: string) { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string +} + +/** + * Generate a regular expression that catches the `phrases`. + */ +export function generateRegExp(phrases: string[]) { + const inner = phrases + .map(phrase => + phrase + .split(/\s+/) + .map(word => escapeRegExp(word)) + .join('[\\s"?!.]+') + ) + .join("|"); + + return new RegExp(`(^|[^\\w])(${inner})(?=[^\\w]|$)`, "iu"); +} + +export const containsMatchingPhrase = (phrases: string[], testString: string) => + phrases.length > 0 ? generateRegExp(phrases).test(testString) : false; diff --git a/src/core/server/services/tenant/cache.ts b/src/core/server/services/tenant/cache.ts index bd3599461..58b1a4a1a 100644 --- a/src/core/server/services/tenant/cache.ts +++ b/src/core/server/services/tenant/cache.ts @@ -1,35 +1,70 @@ import DataLoader from "dataloader"; import { Redis } from "ioredis"; import { Db } from "mongodb"; +import uuid from "uuid"; +import { EventEmitter } from "events"; +import logger from "talk-server/logger"; import { retrieveAllTenants, retrieveManyTenants, + retrieveManyTenantsByDomain, Tenant, } from "talk-server/models/tenant"; -const CacheUpdateChannel = "tenant"; +const TENANT_UPDATE_CHANNEL = "tenant"; -// Cache provides an interface for retrieving tenant stored in local memory -// rather than grabbing it from the database every single call. -export default class Cache { - // private tenants: Map>>; - private tenants: DataLoader | null>; - private db: Db; +const EMITTER_EVENT_NAME = "update"; - constructor(db: Db, subscriber: Redis) { +export type SubscribeCallback = (tenant: Tenant) => void; + +interface TenantUpdateMessage { + tenant: Tenant; + clientApplicationID: string; +} + +// TenantCache provides an interface for retrieving tenant stored in local +// memory rather than grabbing it from the database every single call. +export default class TenantCache { + /** + * tenantsByID reference the tenants that have been cached/retrieved by ID. + */ + private tenantsByID = new DataLoader | null>(ids => { + logger.debug({ ids: ids.length }, "now loading tenants"); + return retrieveManyTenants(this.mongo, ids); + }); + + /** + * tenantsByDomain reference the tenants that have been cached/retrieved by + * Domain. + */ + private tenantsByDomain = new DataLoader | null>( + domains => { + logger.debug({ domains: domains.length }, "now loading tenants"); + return retrieveManyTenantsByDomain(this.mongo, domains); + } + ); + + /** + * Create a new client application ID. This prevents duplicated messages + * generated by this application from being handled as external messages + * as we should have already processed it. + */ + private clientApplicationID = uuid.v4(); + + private mongo: Db; + private emitter = new EventEmitter(); + + constructor(mongo: Db, subscriber: Redis) { // Save the Db reference. - this.db = db; - - // Prepare the list of all tenant's maintained by this instance. - this.tenants = new DataLoader(ids => retrieveManyTenants(db, ids)); - - // Subscribe to tenant notifications. - subscriber.subscribe(CacheUpdateChannel); + this.mongo = mongo; // Attach to messages on this connection so we can receive updates when // the tenant are changed. subscriber.on("message", this.onMessage); + + // Subscribe to tenant notifications. + subscriber.subscribe(TENANT_UPDATE_CHANNEL); } /** @@ -37,13 +72,19 @@ export default class Cache { */ public async primeAll() { // Grab all the tenants for this node. - const tenants = await retrieveAllTenants(this.db); + const tenants = await retrieveAllTenants(this.mongo); // Clear out all the items in the cache. - this.tenants.clearAll(); + this.tenantsByID.clearAll(); + this.tenantsByDomain.clearAll(); // Prime the cache with each of these tenants. - tenants.forEach(tenant => this.tenants.prime(tenant.id, tenant)); + tenants.forEach(tenant => { + this.tenantsByID.prime(tenant.id, tenant); + this.tenantsByDomain.prime(tenant.domain, tenant); + }); + + logger.debug({ tenants: tenants.length }, "primed tenants"); } /** @@ -54,26 +95,61 @@ export default class Cache { message: string ): Promise => { // Only do things when the message is for tenant. - if (channel !== CacheUpdateChannel) { + if (channel !== TENANT_UPDATE_CHANNEL) { return; } try { // Updated tenant come from the messages. - const tenant: Tenant = JSON.parse(message); + const { tenant, clientApplicationID }: TenantUpdateMessage = JSON.parse( + message + ); + + // Check to see if this was the update issued by this instance. + if (clientApplicationID === this.clientApplicationID) { + // It was, so just return here, we already updated/handled it. + return; + } + + logger.debug({ tenant_id: tenant.id }, "received updated tenant"); // Update the tenant cache. - this.tenants.clear(tenant.id).prime(tenant.id, tenant); + this.tenantsByID.clear(tenant.id).prime(tenant.id, tenant); + this.tenantsByDomain.clear(tenant.domain).prime(tenant.domain, tenant); + + // Publish the event for the connected listeners. + this.emitter.emit(EMITTER_EVENT_NAME, tenant); } catch (err) { - // FIXME: handle the error + logger.error( + { err }, + "an error occurred while trying to parse/prime the tenant/tenant cache" + ); } }; + public async retrieveByID(id: string): Promise | null> { + return this.tenantsByID.load(id); + } + + public async retrieveByDomain( + domain: string + ): Promise | null> { + return this.tenantsByDomain.load(domain); + } + /** - * retrieve returns a promise that will resolve to the tenant for Talk. + * This allows you to subscribe to new Tenant updates. This will also return + * a function that when called, unsubscribes you from updates. + * + * @param callback the function to be called when there is an updated Tenant. */ - public async retrieve(id: string): Promise | null> { - return this.tenants.load(id); + public subscribe(callback: SubscribeCallback) { + this.emitter.on(EMITTER_EVENT_NAME, callback); + + // Return the unsubscribe function. + return () => { + this.emitter.removeListener(EMITTER_EVENT_NAME, callback); + }; } /** @@ -85,9 +161,23 @@ export default class Cache { */ public async update(conn: Redis, tenant: Tenant): Promise { // Update the tenant in the local cache. - this.tenants.clear(tenant.id).prime(tenant.id, tenant); + this.tenantsByID.clear(tenant.id).prime(tenant.id, tenant); + this.tenantsByDomain.clear(tenant.domain).prime(tenant.domain, tenant); // Notify the other nodes about the tenant change. - await conn.publish(CacheUpdateChannel, JSON.stringify(tenant)); + const message: TenantUpdateMessage = { + tenant, + clientApplicationID: this.clientApplicationID, + }; + + const subscribers = await conn.publish( + TENANT_UPDATE_CHANNEL, + JSON.stringify(message) + ); + + logger.debug({ tenant_id: tenant.id, subscribers }, "updated tenant"); + + // Publish the event for the connected listeners. + this.emitter.emit(EMITTER_EVENT_NAME, tenant); } } diff --git a/src/core/server/services/tenant/index.ts b/src/core/server/services/tenant/index.ts new file mode 100644 index 000000000..4107e7051 --- /dev/null +++ b/src/core/server/services/tenant/index.ts @@ -0,0 +1,30 @@ +import { Redis } from "ioredis"; +import { Db } from "mongodb"; + +import { + Tenant, + updateTenant, + UpdateTenantInput, +} from "talk-server/models/tenant"; + +import TenantCache from "./cache"; + +export type UpdateTenant = UpdateTenantInput; + +export async function update( + db: Db, + conn: Redis, + cache: TenantCache, + tenant: Tenant, + input: UpdateTenant +): Promise { + const updatedTenant = await updateTenant(db, tenant.id, input); + if (!updatedTenant) { + return null; + } + + // Update the tenant cache. + await cache.update(conn, updatedTenant); + + return updatedTenant; +} diff --git a/src/core/server/services/users/karma.ts b/src/core/server/services/users/karma.ts new file mode 100644 index 000000000..4a17538b0 --- /dev/null +++ b/src/core/server/services/users/karma.ts @@ -0,0 +1,22 @@ +import { get } from "lodash"; + +import { KarmaThresholds } from "talk-server/models/settings"; +import { User } from "talk-server/models/user"; + +export const getCommentTrustScore = (user: User): number => + get(user, "metadata.trust.comment.karma", 0); + +export const isReliableCommenter = ( + thresholds: KarmaThresholds, + user: User +): boolean | null => { + const score = getCommentTrustScore(user); + + if (score >= thresholds.comment.reliable) { + return true; + } else if (score <= thresholds.comment.unreliable) { + return false; + } + + return null; +}; diff --git a/src/core/server/types/express.ts b/src/core/server/types/express.ts index d18c0fdfb..595bc904c 100644 --- a/src/core/server/types/express.ts +++ b/src/core/server/types/express.ts @@ -2,8 +2,10 @@ import { Request } from "express"; import { Tenant } from "talk-server/models/tenant"; import { User } from "talk-server/models/user"; +import TenantCache from "talk-server/services/tenant/cache"; export interface Request extends Request { user?: User; tenant?: Tenant; + tenantCache: TenantCache; } diff --git a/src/types/akismet-api.d.ts b/src/types/akismet-api.d.ts new file mode 100644 index 000000000..a6d6a4475 --- /dev/null +++ b/src/types/akismet-api.d.ts @@ -0,0 +1,33 @@ +declare module "akismet-api" { + export interface ClientOptions { + key: string; + blog: string; + } + + export interface CheckSpamOptions { + user_ip: string; + user_agent: string; + referrer: string; + permalink?: string; + comment_type?: string; + comment_author?: string; + comment_content?: string; + comment_author_url?: string; + comment_author_email?: string; + comment_date_gmt?: string; + comment_post_modified_gmt?: string; + user_role?: string; + is_test?: boolean; + } + + export class Client { + constructor(options: ClientOptions) + + /** + * checkSpam will check the given comment payload for spam. + * + * @param options used to provide the input for checking spam + */ + checkSpam(options: CheckSpamOptions): Promise + } +} \ No newline at end of file diff --git a/src/types/dotize.d.ts b/src/types/dotize.d.ts index f8b3bca5f..0b6e1c5f1 100644 --- a/src/types/dotize.d.ts +++ b/src/types/dotize.d.ts @@ -1,5 +1,3 @@ declare module "dotize" { - export = dotize; - - function dotize(obj: any): { [_: string]: any }; + export function convert(obj: Object): Record } diff --git a/tslint.json b/tslint.json index 68001f8c6..b87117294 100644 --- a/tslint.json +++ b/tslint.json @@ -7,10 +7,7 @@ "rules": { "prettier": true, "object-literal-sort-keys": false, - "interface-name": [ - true, - "never-prefix" - ], + "interface-name": [true, "never-prefix"], "no-switch-case-fall-through": true, "member-ordering": false, "variable-name": [ @@ -35,8 +32,6 @@ ] }, "linterOptions": { - "exclude": [ - "**/node_modules/**/*" - ] + "exclude": ["**/node_modules/**/*", "./config/coverage/**"] } -} \ No newline at end of file +}