From 6efe36ceaa2562debf99d542bcd3dd760728534b Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 10 Jul 2018 15:54:52 -0600 Subject: [PATCH] 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; +}