From fa72d5deda8662bb249bb6c2469d93267bf9a07f Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 19 Oct 2018 16:05:58 -0600 Subject: [PATCH] feat: support new auth methods for Tenants - New Time scalar type is implemented on the Server - Single Sign-On keys can now be generated - Single Sign-On keys can be regenerated - Single Sign-On keys now store the date they were generated on. - Initial implementation of `AuthenticationTargetFilter`'s --- scripts/generateSchemaTypes.js | 3 +- src/core/server/graph/common/scalars/time.ts | 36 +++++ .../graph/management/resolvers/index.ts | 6 +- .../server/graph/tenant/mutators/settings.ts | 4 +- .../server/graph/tenant/resolvers/index.ts | 3 + .../server/graph/tenant/resolvers/mutation.ts | 4 + .../server/graph/tenant/schema/schema.graphql | 134 +++++++++++++++++- src/core/server/models/tenant.ts | 42 ++++++ src/core/server/services/tenant/index.ts | 22 +++ 9 files changed, 249 insertions(+), 5 deletions(-) create mode 100644 src/core/server/graph/common/scalars/time.ts diff --git a/scripts/generateSchemaTypes.js b/scripts/generateSchemaTypes.js index c52393830..11e373843 100644 --- a/scripts/generateSchemaTypes.js +++ b/scripts/generateSchemaTypes.js @@ -37,7 +37,7 @@ async function main() { 'import { Cursor } from "talk-server/models/connection";', 'import TenantContext from "talk-server/graph/tenant/context";', ], - customScalarType: { Cursor: "Cursor", Time: "string" }, + customScalarType: { Cursor: "Cursor", Time: "Date" }, }, }, { @@ -48,6 +48,7 @@ async function main() { importStatements: [ 'import ManagementContext from "talk-server/graph/management/context";', ], + customScalarType: { Time: "Date" }, }, }, ]; diff --git a/src/core/server/graph/common/scalars/time.ts b/src/core/server/graph/common/scalars/time.ts new file mode 100644 index 000000000..66e5c9fc0 --- /dev/null +++ b/src/core/server/graph/common/scalars/time.ts @@ -0,0 +1,36 @@ +import { GraphQLScalarType } from "graphql"; +import { Kind } from "graphql/language"; +import { DateTime } from "luxon"; + +export default new GraphQLScalarType({ + name: "Time", + description: "Time represented as an ISO8601 string.", + serialize(value) { + if (typeof value === "string") { + return value; + } + + return value.toISOString(); + }, + parseValue(value) { + return new Date(value); + }, + parseLiteral(ast) { + switch (ast.kind) { + case Kind.STRING: + // This handles an empty string. + if (ast.value && ast.value.length === 0) { + return null; + } + + const date = DateTime.fromISO(ast.value, {}); + if (!date.isValid) { + return null; + } + + return date.toJSDate(); + default: + return null; + } + }, +}); diff --git a/src/core/server/graph/management/resolvers/index.ts b/src/core/server/graph/management/resolvers/index.ts index b1bcbcef8..7cb79b325 100644 --- a/src/core/server/graph/management/resolvers/index.ts +++ b/src/core/server/graph/management/resolvers/index.ts @@ -1,5 +1,9 @@ +import Time from "talk-server/graph/common/scalars/time"; + import { GQLResolver } from "talk-server/graph/management/schema/__generated__/types"; -const Resolvers: GQLResolver = {}; +const Resolvers: GQLResolver = { + Time, +}; export default Resolvers; diff --git a/src/core/server/graph/tenant/mutators/settings.ts b/src/core/server/graph/tenant/mutators/settings.ts index 19a7c04e0..d678f5c61 100644 --- a/src/core/server/graph/tenant/mutators/settings.ts +++ b/src/core/server/graph/tenant/mutators/settings.ts @@ -3,9 +3,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"; +import { regenerateSSOKey, 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)), + regenerateSSOKey: (): Promise => + regenerateSSOKey(mongo, redis, tenantCache, tenant), }); diff --git a/src/core/server/graph/tenant/resolvers/index.ts b/src/core/server/graph/tenant/resolvers/index.ts index 999760912..8081b892b 100644 --- a/src/core/server/graph/tenant/resolvers/index.ts +++ b/src/core/server/graph/tenant/resolvers/index.ts @@ -1,4 +1,6 @@ import Cursor from "talk-server/graph/common/scalars/cursor"; +import Time from "talk-server/graph/common/scalars/time"; + import { GQLResolver } from "talk-server/graph/tenant/schema/__generated__/types"; import Asset from "./asset"; @@ -20,6 +22,7 @@ const Resolvers: GQLResolver = { Profile, Query, User, + Time, }; export default Resolvers; diff --git a/src/core/server/graph/tenant/resolvers/mutation.ts b/src/core/server/graph/tenant/resolvers/mutation.ts index 77f0ca109..1b308ff4f 100644 --- a/src/core/server/graph/tenant/resolvers/mutation.ts +++ b/src/core/server/graph/tenant/resolvers/mutation.ts @@ -47,6 +47,10 @@ const Mutation: GQLMutationTypeResolver = { comment: await ctx.mutators.Comment.deleteFlag(input), clientMutationId: input.clientMutationId, }), + regenerateSSOKey: async (source, { input }, ctx) => ({ + settings: await ctx.mutators.Settings.regenerateSSOKey(), + clientMutationId: input.clientMutationId, + }), }; export default Mutation; diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 5bea357e0..4bd6d0e2f 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -244,12 +244,32 @@ type Wordlist { ## Auth ################################################################################ +########################## +## AuthenticationTargetFilter +########################## + +""" +AuthenticationTargetFilter when non-null, will specify the targets that a +specific authentication integration will be enabled on. +""" +type AuthenticationTargetFilter { + admin: Boolean! + stream: Boolean! +} + ########################## ## LocalAuthIntegration ########################## type LocalAuthIntegration { enabled: Boolean! + + """ + targetFilter will restrict where the authentication integration should be + displayed. If the value of targetFilter is null, then the authentication + integration should be displayed in all targets. + """ + targetFilter: AuthenticationTargetFilter } ########################## @@ -264,11 +284,23 @@ embed to allow single sign on. type SSOAuthIntegration { enabled: Boolean! + """ + targetFilter will restrict where the authentication integration should be + displayed. If the value of targetFilter is null, then the authentication + integration should be displayed in all targets. + """ + targetFilter: AuthenticationTargetFilter + """ key is the secret that is used to sign tokens. """ key: String @auth(roles: [ADMIN]) + """ + keyGeneratedAt is the Time that the key was effective from. + """ + keyGeneratedAt: Time @auth(roles: [ADMIN]) + """ displayNameEnable when enabled, will allow Users to set and view their displayName's. @@ -287,6 +319,16 @@ will be used in the admin to provide staff logins for users. type OIDCAuthIntegration { enabled: Boolean! + """ + targetFilter will restrict where the authentication integration should be + displayed. If the value of targetFilter is null, then the authentication + integration should be displayed in all targets. + """ + targetFilter: AuthenticationTargetFilter + + """ + name is the label assigned to reference the provider of the OIDC integration. + """ name: String clientID: String @auth(roles: [ADMIN]) clientSecret: String @auth(roles: [ADMIN]) @@ -308,6 +350,14 @@ type OIDCAuthIntegration { type GoogleAuthIntegration { enabled: Boolean! + + """ + targetFilter will restrict where the authentication integration should be + displayed. If the value of targetFilter is null, then the authentication + integration should be displayed in all targets. + """ + targetFilter: AuthenticationTargetFilter + clientID: String @auth(roles: [ADMIN]) clientSecret: String @auth(roles: [ADMIN]) } @@ -318,6 +368,14 @@ type GoogleAuthIntegration { type FacebookAuthIntegration { enabled: Boolean! + + """ + targetFilter will restrict where the authentication integration should be + displayed. If the value of targetFilter is null, then the authentication + integration should be displayed in all targets. + """ + targetFilter: AuthenticationTargetFilter + clientID: String @auth(roles: [ADMIN]) clientSecret: String @auth(roles: [ADMIN]) } @@ -1258,17 +1316,31 @@ input SettingsWordlistInput { suspect: [String!] } +input SettingsAuthenticationTargetFilterInput { + admin: Boolean! + stream: Boolean! +} + input SettingsLocalAuthIntegrationInput { enabled: Boolean + + """ + targetFilter will restrict where the authentication integration should be + displayed. If the value of targetFilter is null, then the authentication + integration should be displayed in all targets. + """ + targetFilter: SettingsAuthenticationTargetFilterInput } input SettingsSSOAuthIntegrationInput { enabled: Boolean """ - key is the secret that is used to sign tokens. + targetFilter will restrict where the authentication integration should be + displayed. If the value of targetFilter is null, then the authentication + integration should be displayed in all targets. """ - key: String + targetFilter: SettingsAuthenticationTargetFilterInput """ displayNameEnable when enabled, will allow Users to set and view their @@ -1280,11 +1352,23 @@ input SettingsSSOAuthIntegrationInput { input SettingsOIDCAuthIntegrationInput { enabled: Boolean + """ + targetFilter will restrict where the authentication integration should be + displayed. If the value of targetFilter is null, then the authentication + integration should be displayed in all targets. + """ + targetFilter: SettingsAuthenticationTargetFilterInput + + """ + name is the label assigned to reference the provider of the OIDC integration. + """ name: String clientID: String clientSecret: String authorizationURL: String tokenURL: String + jwksURI: String + issuer: String """ displayNameEnable when enabled, will allow Users to set and view their @@ -1295,12 +1379,28 @@ input SettingsOIDCAuthIntegrationInput { input SettingsGoogleAuthIntegrationInput { enabled: Boolean + + """ + targetFilter will restrict where the authentication integration should be + displayed. If the value of targetFilter is null, then the authentication + integration should be displayed in all targets. + """ + targetFilter: SettingsAuthenticationTargetFilterInput + clientID: String clientSecret: String } input SettingsFacebookAuthIntegrationInput { enabled: Boolean + + """ + targetFilter will restrict where the authentication integration should be + displayed. If the value of targetFilter is null, then the authentication + integration should be displayed in all targets. + """ + targetFilter: SettingsAuthenticationTargetFilterInput + clientID: String clientSecret: String } @@ -1777,6 +1877,29 @@ type DeleteCommentFlagPayload { clientMutationId: String! } +################## +## regenerateSSOKey +################## + +input RegenerateSSOKeyInput { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type RegenerateSSOKeyPayload { + """ + settings is the Settings that the SSO key was regenerated on. + """ + settings: Settings + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + ################## ## Mutation ################## @@ -1799,6 +1922,13 @@ type Mutation { updateSettings(input: UpdateSettingsInput!): UpdateSettingsPayload @auth(roles: [ADMIN, MODERATOR]) + """ + regenerateSSOKey will regenerate the SSO key used to sign secrets. This will + invalidate any existing user sessions. + """ + regenerateSSOKey(input: RegenerateSSOKeyInput!): RegenerateSSOKeyPayload + @auth(roles: [ADMIN]) + """ createCommentReaction will create a Reaction authored by the current logged in User on a Comment. diff --git a/src/core/server/models/tenant.ts b/src/core/server/models/tenant.ts index 3030e3cdd..166dcc9c2 100644 --- a/src/core/server/models/tenant.ts +++ b/src/core/server/models/tenant.ts @@ -1,3 +1,4 @@ +import crypto from "crypto"; import { Db } from "mongodb"; import uuid from "uuid"; @@ -203,3 +204,44 @@ export async function updateTenant( return result.value || null; } + +/** + * regenerateTenantSSOKey will regenerate the SSO key used for Single Sing-On + * for the specified Tenant. All existing user sessions signed with the old + * secret will be invalidated. + */ +export async function regenerateTenantSSOKey(db: Db, id: string) { + // Generate a new key. We generate a key of minimum length 32 up to 37 bytes, + // as 16 was the minimum length recommended. + // + // Reference: https://security.stackexchange.com/a/96176 + const key = crypto + .randomBytes(32 + Math.floor(Math.random() * 5)) + .toString("hex"); + + // Construct the update. + const update: DeepPartial = { + auth: { + integrations: { + sso: { + key, + keyGeneratedAt: new Date(), + }, + }, + }, + }; + + // Update the Tenant with this new key. + const result = await collection(db).findOneAndUpdate( + { id }, + // Serialize the deep update into the Tenant. + { + $set: dotize(update), + }, + // False to return the updated document instead of the original + // document. + { returnOriginal: false } + ); + + return result.value || null; +} diff --git a/src/core/server/services/tenant/index.ts b/src/core/server/services/tenant/index.ts index 54ab1d5a0..8bcfca5eb 100644 --- a/src/core/server/services/tenant/index.ts +++ b/src/core/server/services/tenant/index.ts @@ -5,6 +5,7 @@ import { GQLSettingsInput } from "talk-server/graph/tenant/schema/__generated__/ import { createTenant, CreateTenantInput, + regenerateTenantSSOKey, Tenant, updateTenant, } from "talk-server/models/tenant"; @@ -76,3 +77,24 @@ export async function canInstall(cache: TenantCache) { export async function isInstalled(cache: TenantCache) { return (await cache.count()) > 0; } + +/** + * regenerateSSOKey will regenerate the Single Sign-On key for the specified + * Tenant and notify all other Tenant's connected that the Tenant was updated. + */ +export async function regenerateSSOKey( + mongo: Db, + redis: Redis, + cache: TenantCache, + tenant: Tenant +) { + const updatedTenant = await regenerateTenantSSOKey(mongo, tenant.id); + if (!updatedTenant) { + return null; + } + + // Update the tenant cache. + await cache.update(redis, updatedTenant); + + return updatedTenant; +}