diff --git a/examples-next/basic/.keystone/schema-types.ts b/examples-next/basic/.keystone/schema-types.ts index 61bd23ce51e..7c8ac969c00 100644 --- a/examples-next/basic/.keystone/schema-types.ts +++ b/examples-next/basic/.keystone/schema-types.ts @@ -117,6 +117,56 @@ export type UserWhereInput = { readonly oneTimeThing_not_ends_with_i?: Scalars['String'] | null; readonly oneTimeThing_in?: ReadonlyArray | null; readonly oneTimeThing_not_in?: ReadonlyArray | null; + readonly passwordResetToken_is_set?: Scalars['Boolean'] | null; + readonly passwordResetIssuedAt?: Scalars['String'] | null; + readonly passwordResetIssuedAt_not?: Scalars['String'] | null; + readonly passwordResetIssuedAt_lt?: Scalars['String'] | null; + readonly passwordResetIssuedAt_lte?: Scalars['String'] | null; + readonly passwordResetIssuedAt_gt?: Scalars['String'] | null; + readonly passwordResetIssuedAt_gte?: Scalars['String'] | null; + readonly passwordResetIssuedAt_in?: ReadonlyArray< + Scalars['String'] | null + > | null; + readonly passwordResetIssuedAt_not_in?: ReadonlyArray< + Scalars['String'] | null + > | null; + readonly passwordResetRedeemedAt?: Scalars['String'] | null; + readonly passwordResetRedeemedAt_not?: Scalars['String'] | null; + readonly passwordResetRedeemedAt_lt?: Scalars['String'] | null; + readonly passwordResetRedeemedAt_lte?: Scalars['String'] | null; + readonly passwordResetRedeemedAt_gt?: Scalars['String'] | null; + readonly passwordResetRedeemedAt_gte?: Scalars['String'] | null; + readonly passwordResetRedeemedAt_in?: ReadonlyArray< + Scalars['String'] | null + > | null; + readonly passwordResetRedeemedAt_not_in?: ReadonlyArray< + Scalars['String'] | null + > | null; + readonly magicAuthToken_is_set?: Scalars['Boolean'] | null; + readonly magicAuthIssuedAt?: Scalars['String'] | null; + readonly magicAuthIssuedAt_not?: Scalars['String'] | null; + readonly magicAuthIssuedAt_lt?: Scalars['String'] | null; + readonly magicAuthIssuedAt_lte?: Scalars['String'] | null; + readonly magicAuthIssuedAt_gt?: Scalars['String'] | null; + readonly magicAuthIssuedAt_gte?: Scalars['String'] | null; + readonly magicAuthIssuedAt_in?: ReadonlyArray< + Scalars['String'] | null + > | null; + readonly magicAuthIssuedAt_not_in?: ReadonlyArray< + Scalars['String'] | null + > | null; + readonly magicAuthRedeemedAt?: Scalars['String'] | null; + readonly magicAuthRedeemedAt_not?: Scalars['String'] | null; + readonly magicAuthRedeemedAt_lt?: Scalars['String'] | null; + readonly magicAuthRedeemedAt_lte?: Scalars['String'] | null; + readonly magicAuthRedeemedAt_gt?: Scalars['String'] | null; + readonly magicAuthRedeemedAt_gte?: Scalars['String'] | null; + readonly magicAuthRedeemedAt_in?: ReadonlyArray< + Scalars['String'] | null + > | null; + readonly magicAuthRedeemedAt_not_in?: ReadonlyArray< + Scalars['String'] | null + > | null; }; export type UserWhereUniqueInput = { @@ -139,7 +189,15 @@ export type SortUsersBy = | 'something_ASC' | 'something_DESC' | 'oneTimeThing_ASC' - | 'oneTimeThing_DESC'; + | 'oneTimeThing_DESC' + | 'passwordResetIssuedAt_ASC' + | 'passwordResetIssuedAt_DESC' + | 'passwordResetRedeemedAt_ASC' + | 'passwordResetRedeemedAt_DESC' + | 'magicAuthIssuedAt_ASC' + | 'magicAuthIssuedAt_DESC' + | 'magicAuthRedeemedAt_ASC' + | 'magicAuthRedeemedAt_DESC'; export type UserUpdateInput = { readonly name?: Scalars['String'] | null; @@ -149,6 +207,12 @@ export type UserUpdateInput = { readonly roles?: Scalars['String'] | null; readonly posts?: PostRelateToManyInput | null; readonly something?: Scalars['String'] | null; + readonly passwordResetToken?: Scalars['String'] | null; + readonly passwordResetIssuedAt?: Scalars['String'] | null; + readonly passwordResetRedeemedAt?: Scalars['String'] | null; + readonly magicAuthToken?: Scalars['String'] | null; + readonly magicAuthIssuedAt?: Scalars['String'] | null; + readonly magicAuthRedeemedAt?: Scalars['String'] | null; }; export type UsersUpdateInput = { @@ -165,6 +229,12 @@ export type UserCreateInput = { readonly posts?: PostRelateToManyInput | null; readonly something?: Scalars['String'] | null; readonly oneTimeThing?: Scalars['String'] | null; + readonly passwordResetToken?: Scalars['String'] | null; + readonly passwordResetIssuedAt?: Scalars['String'] | null; + readonly passwordResetRedeemedAt?: Scalars['String'] | null; + readonly magicAuthToken?: Scalars['String'] | null; + readonly magicAuthIssuedAt?: Scalars['String'] | null; + readonly magicAuthRedeemedAt?: Scalars['String'] | null; }; export type UsersCreateInput = { @@ -289,6 +359,24 @@ export type _ListSchemaFieldsInput = { export type CacheControlScope = 'PUBLIC' | 'PRIVATE'; +export type AuthErrorCode = + | 'PASSWORD_AUTH_FAILURE' + | 'PASSWORD_AUTH_IDENTITY_NOT_FOUND' + | 'PASSWORD_AUTH_SECRET_NOT_SET' + | 'PASSWORD_AUTH_MULTIPLE_IDENTITY_MATCHES' + | 'PASSWORD_AUTH_SECRET_MISMATCH' + | 'AUTH_TOKEN_REQUEST_IDENTITY_NOT_FOUND' + | 'AUTH_TOKEN_REQUEST_MULTIPLE_IDENTITY_MATCHES' + | 'AUTH_TOKEN_REDEMPTION_FAILURE' + | 'AUTH_TOKEN_REDEMPTION_IDENTITY_NOT_FOUND' + | 'AUTH_TOKEN_REDEMPTION_MULTIPLE_IDENTITY_MATCHES' + | 'AUTH_TOKEN_REDEMPTION_TOKEN_NOT_SET' + | 'AUTH_TOKEN_REDEMPTION_TOKEN_MISMATCH' + | 'AUTH_TOKEN_REDEMPTION_TOKEN_EXPIRED' + | 'AUTH_TOKEN_REDEMPTION_TOKEN_REDEEMED' + | 'INTERNAL_ERROR' + | 'CUSTOM_ERROR'; + export type CreateInitialUserInput = { readonly name?: Scalars['String'] | null; readonly email?: Scalars['String'] | null; @@ -314,7 +402,13 @@ export type UserListTypeInfo = { | 'roles' | 'posts' | 'something' - | 'oneTimeThing'; + | 'oneTimeThing' + | 'passwordResetToken' + | 'passwordResetIssuedAt' + | 'passwordResetRedeemedAt' + | 'magicAuthToken' + | 'magicAuthIssuedAt' + | 'magicAuthRedeemedAt'; backing: { readonly id: string | number; readonly name?: string | null; @@ -325,6 +419,12 @@ export type UserListTypeInfo = { readonly posts?: string | null; readonly something?: string | null; readonly oneTimeThing?: string | null; + readonly passwordResetToken?: string | null; + readonly passwordResetIssuedAt?: Date | null; + readonly passwordResetRedeemedAt?: Date | null; + readonly magicAuthToken?: string | null; + readonly magicAuthIssuedAt?: Date | null; + readonly magicAuthRedeemedAt?: Date | null; }; inputs: { where: UserWhereInput; diff --git a/examples-next/basic/keystone.ts b/examples-next/basic/keystone.ts index c540e7240ce..221cfefba42 100644 --- a/examples-next/basic/keystone.ts +++ b/examples-next/basic/keystone.ts @@ -22,6 +22,16 @@ const auth = createAuth({ isAdmin: true, }, }, + passwordResetLink: { + sendToken(args) { + console.log(`Password reset info:`, args); + }, + }, + magicAuthLink: { + sendToken(args) { + console.log(`Magic auth info:`, args); + }, + }, }); // const isAccessAllowed = ({ session }: { session: any }) => !!session?.item?.isAdmin; diff --git a/examples-next/basic/schema.ts b/examples-next/basic/schema.ts index 3c88ee98032..60893cca4a2 100644 --- a/examples-next/basic/schema.ts +++ b/examples-next/basic/schema.ts @@ -14,11 +14,11 @@ export const lists = createSchema({ }, hooks: { resolveInput: ({ resolvedData, originalInput }) => { - console.log({ resolvedData, originalInput }); + console.log('list hooks: resolveInput', { resolvedData, originalInput }); return resolvedData; }, beforeChange({ resolvedData, originalInput }) { - console.log({ resolvedData, originalInput }); + console.log('list hooks: beforeChange', { resolvedData, originalInput }); }, }, access: { diff --git a/packages-next/auth/HOOKS.md b/packages-next/auth/HOOKS.md new file mode 100644 index 00000000000..36c95802e23 --- /dev/null +++ b/packages-next/auth/HOOKS.md @@ -0,0 +1,79 @@ +# Auth Hooks Spec'ing + +See: + +- The current [hooks API ref](https://www.keystonejs.com/api/hooks#authentication-hooks) +- The current [hooks guide](https://www.keystonejs.com/guides/hooks) +- The [PR that added auth hooks](https://github.com/keystonejs/keystone/pull/2039) (I thought there'd be more relevant discussion in here but there isn't really) + +Currently: + +```js +keystone.createAuthStrategy({ + type: PasswordAuthStrategy, + list: 'User', + hooks: { + resolveAuthInput: async (...) => {...}, + validateAuthInput: async (...) => {...}, + beforeAuth: async (...) => {...}, + afterAuth: async (...) => {...}, + + beforeUnauth: async (...) => {...}, + afterUnauth: async (...) => {...}, + }, +}); +``` + +## New Operations + +We now have **more potential auth-related operations**: + +- `authenticate` (existing) +- `unauthenticate` (existing) +- `createInitialItem` +- `sendPasswordResetLink` +- `redeemPasswordResetLink` +- `sendMagicAuthLink` +- `redeemMagicAuthLink` + +(See [existing operations](https://www.keystonejs.com/guides/hooks#operation).) + +## Opinions + +- We don't need hooks for the `createInitialItem` operation, it's once off + - Or.. is this how we collect metrics from the demo projects? +- We should maintain the separation between "resolve" (can modify `resolvedData`) AND "validate" (can add validation errors) for auth hooks +- We should _reuse_ the existing `resolveAuthInput` and `validateAuthInput` functions for the new auth operations (as we do with update/create) + +## Usage + +So usage becomes something like...? + +```js +keystone.createAuthStrategy({ + type: PasswordAuthStrategy, + list: 'User', + hooks: { + resolveAuthInput: async (...) => {...}, + validateAuthInput: async (...) => {...}, + + beforeAuth: async (...) => {...}, + afterAuth: async (...) => {...}, + + beforeUnauth: async (...) => {...}, + afterUnauth: async (...) => {...}, + + beforeSendPasswordResetLink: async (...) => {...}, + afterSendPasswordResetLink: async (...) => {...}, + + beforeRedeemPasswordResetLink: async (...) => {...}, + afterRedeemPasswordResetLink: async (...) => {...}, + + beforeSendMagicAuthLink: async (...) => {...}, + afterSendMagicAuthLink: async (...) => {...}, + + beforeRedeemMagicAuthLink: async (...) => {...}, + afterRedeemMagicAuthLink: async (...) => {...}, + }, +}); +``` diff --git a/packages-next/auth/README.md b/packages-next/auth/README.md new file mode 100644 index 00000000000..d6665ca812a --- /dev/null +++ b/packages-next/auth/README.md @@ -0,0 +1,9 @@ +## Identity Protection + +TODO: Edit + +If we're trying to maintain the privacy of accounts (hopefully, yes) make some effort to prevent timing attacks +Note, we're not attempting to protect the hashing comparisson itself from timing attacks, just _the existance of an item_ +We can't assume the work factor so can't include a pre-generated hash to compare but generating a new hash will create a similar delay +Changes to the work factor, latency loading the item(s) and many other factors will still be detectable by a dedicated attacker +This is far from perfect (but better than nothing) diff --git a/packages-next/auth/TODO.md b/packages-next/auth/TODO.md index f359d1938f2..ae19209ae9b 100644 --- a/packages-next/auth/TODO.md +++ b/packages-next/auth/TODO.md @@ -22,9 +22,21 @@ - [ ] Create UI for the signout page - [ ] Only generate the signout page if the config is enabled - [x] Add a signout button to the Admin UI when the config is enabled -- [ ] Implement forgotten password & magic links @molomby - - [ ] Define the list - - [ ] Add the mutations +- [.] Implement forgotten password & magic links @molomby + - [x] Define the list/fields + - [x] Add the mutations (auth, get reset token, get magic link) + - [x] Don't error on failure; create types/union type; `UserPasswordAuthSuccess { item token } UserPasswordAuthFailure { code message }` + - [x] Refactor the list and field validation into `validateConfig()` + - [x] Build out redemption mutations + - [x] `Auth` to return set of fields (to be added to the list); move fields def from example app + - [x] `withAuth()` to configure the list config directly + - [x] Add suffix to config; use for types, mutations, field names, etc. + - [ ] Add config for `validUserConditions` as an optional set of GraphQL filters; slightly refactor loading of item(s) + - [ ] Fix the `withAuth` destructuring around fields + - [ ] Hooks – See notes in HOOKS.md + - [ ] Review/revise the [existing hooks](https://www.keystonejs.com/api/hooks#authentication-hooks) + - [ ] Implement hooks for the auth, reset pass and magic link + - [ ] Support rate limiting use case - [ ] Generate the UI if it is enabled - [ ] Wire up the UI - [ ] Implement init first user @mitchell @@ -39,6 +51,7 @@ # Backlog +- [ ] Handle session token authorisation header use case - [ ] Review the API that session functions get, try not to provide the keystone instance - [ ] 2FA - [ ] Social Auth diff --git a/packages-next/auth/src/getExtendGraphQLSchema.ts b/packages-next/auth/src/getExtendGraphQLSchema.ts deleted file mode 100644 index 94fdf7bd2e2..00000000000 --- a/packages-next/auth/src/getExtendGraphQLSchema.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { graphQLSchemaExtension } from '@keystone-spike/keystone/schema'; -import { ResolvedAuthGqlNames } from './types'; - -export function getExtendGraphQLSchema({ - listKey, - identityField, - secretField, - protectIdentities, - gqlNames, -}: { - listKey: string; - identityField: string; - secretField: string; - protectIdentities: boolean; - gqlNames: ResolvedAuthGqlNames; -}) { - async function validate(args: Record, list: any) { - // Validate the config - const secretFieldInstance = list.fieldsByPath[secretField]; - if ( - typeof secretFieldInstance.compare !== 'function' || - secretFieldInstance.compare.length < 2 - ) { - throw new Error( - `Field type specified does not support required functionality. ` + - `createAuth for list '${listKey}' is using a secretField of '${secretField}'` + - ` but field type does not provide the required compare() functionality.` - ); - } - if (typeof secretFieldInstance.generateHash !== 'function') { - throw new Error( - `Field type specified does not support required functionality. ` + - `createAuth for list '${listKey}' is using a secretField of '${secretField}'` + - ` but field type does not provide the required generateHash() functionality.` - ); - } - const itemResult = await getItem(args, list); - if (!itemResult.success) return { success: false as const, message: itemResult.message }; - // Verify the secret matches - const match = await matchItem(itemResult.item, args, secretFieldInstance); - if (!match.success) { - return { - success: false as const, - message: protectIdentities ? '[passwordAuth:failure] Authentication failed' : match.message, - }; - } - return { success: true as const, item: itemResult.item }; - } - async function getItem(args: Record, list: any) { - const secretFieldInstance = list.fieldsByPath[secretField]; - // Match by identity - const identity = args[identityField]; - const results = await list.adapter.find({ [identityField]: identity }); - // If we failed to match an identity and we're protecting existing identities then combat timing - // attacks by creating an arbitrary hash (should take about as long has comparing an existing one) - if (results.length !== 1 && protectIdentities) { - // This may still leak if the workfactor for the password field has changed - const hash = await secretFieldInstance.generateHash( - 'simulated-password-to-counter-timing-attack' - ); - await secretFieldInstance.compare('', hash); - return { success: false as const, message: '[passwordAuth:failure] Authentication failed' }; - } - // Identity failures with helpful errors - if (results.length === 0) { - const key = '[passwordAuth:identity:notFound]'; - const message = `${key} The ${identityField} provided didn't identify any ${list.plural}`; - return { success: false as const, message }; - } - if (results.length > 1) { - const key = '[passwordAuth:identity:multipleFound]'; - const message = `${key} The ${identityField} provided identified ${results.length} ${list.plural}`; - return { success: false as const, message }; - } - const item = results[0]; - return { success: true as const, item }; - } - async function matchItem( - item: Record, - args: Record, - secretFieldInstance: { - compare(a: string, b: string): boolean; - generateHash(secret: string): string; - } - ) { - const secret = args[secretField]; - if (item[secretField]) { - const success = await secretFieldInstance.compare(secret, item[secretField]); - return { - success, - message: success - ? 'Authentication successful' - : `[passwordAuth:secret:mismatch] The ${secretField} provided is incorrect`, - }; - } - const hash = await secretFieldInstance.generateHash( - 'simulated-password-to-counter-timing-attack' - ); - await secretFieldInstance.compare(secret, hash); - return { - success: false, - message: - '[passwordAuth:secret:notSet] The item identified has no secret set so can not be authenticated', - }; - } - // note that authenticate${listKey}WithPassword is non-nullable because it throws when auth fails - return graphQLSchemaExtension({ - typeDefs: ` - union AuthenticatedItem = ${listKey} - type Query { - authenticatedItem: AuthenticatedItem - } - type Mutation { - ${gqlNames.authenticateItemWithPassword}(${identityField}: String!, ${secretField}: String!): ${gqlNames.ItemAuthenticationWithPasswordResult}! - } - type ${gqlNames.ItemAuthenticationWithPasswordResult} { - token: String! - item: ${listKey}! - } - `, - resolvers: { - Mutation: { - async [`authenticate${listKey}WithPassword`](root: any, args: any, ctx: any) { - const result = await validate(args, ctx.keystone.lists[listKey]); - if (!result.success) { - throw new Error(result.message); - } - const token = await ctx.startSession({ listKey: 'User', itemId: result.item.id + '' }); - return { - token, - item: result.item, - }; - }, - }, - Query: { - async authenticatedItem(root: any, args: any, ctx: any) { - if (typeof ctx.session?.itemId === 'string' && typeof ctx.session.listKey === 'string') { - const item = ( - await ctx.keystone.lists[ctx.session.listKey].adapter.find({ - id: ctx.session.itemId, - }) - )[0]; - if (!item) return null; - return { - ...item, - // TODO: is this okay? - // probably yes but ¯\_(ツ)_/¯ - __typename: ctx.session.listKey, - }; - } - return null; - }, - }, - AuthenticatedItem: { - __resolveType(rootVal: any) { - return rootVal.__typename; - }, - }, - }, - }); -} diff --git a/packages-next/auth/src/gql/getBaseAuthSchema.ts b/packages-next/auth/src/gql/getBaseAuthSchema.ts new file mode 100644 index 00000000000..0f754875bc0 --- /dev/null +++ b/packages-next/auth/src/gql/getBaseAuthSchema.ts @@ -0,0 +1,119 @@ +import { graphQLSchemaExtension } from '@keystone-spike/keystone/schema'; +import { AuthGqlNames } from '../types'; + +import { attemptAuthentication } from '../lib/attemptAuthentication'; +import { getErrorMessage } from '../lib/getErrorMessage'; + +export function getBaseAuthSchema({ + listKey, + identityField, + secretField, + protectIdentities, + gqlNames, +}: { + listKey: string; + identityField: string; + secretField: string; + protectIdentities: boolean; + gqlNames: AuthGqlNames; +}) { + return graphQLSchemaExtension({ + typeDefs: ` + # Auth + union AuthenticatedItem = ${listKey} + type Query { + authenticatedItem: AuthenticatedItem + } + enum AuthErrorCode { + PASSWORD_AUTH_FAILURE + PASSWORD_AUTH_IDENTITY_NOT_FOUND + PASSWORD_AUTH_SECRET_NOT_SET + PASSWORD_AUTH_MULTIPLE_IDENTITY_MATCHES + PASSWORD_AUTH_SECRET_MISMATCH + AUTH_TOKEN_REQUEST_IDENTITY_NOT_FOUND + AUTH_TOKEN_REQUEST_MULTIPLE_IDENTITY_MATCHES + AUTH_TOKEN_REDEMPTION_FAILURE + AUTH_TOKEN_REDEMPTION_IDENTITY_NOT_FOUND + AUTH_TOKEN_REDEMPTION_MULTIPLE_IDENTITY_MATCHES + AUTH_TOKEN_REDEMPTION_TOKEN_NOT_SET + AUTH_TOKEN_REDEMPTION_TOKEN_MISMATCH + AUTH_TOKEN_REDEMPTION_TOKEN_EXPIRED + AUTH_TOKEN_REDEMPTION_TOKEN_REDEEMED + } + + # Password auth + type Mutation { + ${gqlNames.authenticateItemWithPassword}(${identityField}: String!, ${secretField}: String!): + ${gqlNames.ItemAuthenticationWithPasswordResult}! + } + union ${gqlNames.ItemAuthenticationWithPasswordResult} = ${gqlNames.ItemAuthenticationWithPasswordSuccess} | ${gqlNames.ItemAuthenticationWithPasswordFailure} + type ${gqlNames.ItemAuthenticationWithPasswordSuccess} { + sessionToken: String! + item: ${listKey}! + } + type ${gqlNames.ItemAuthenticationWithPasswordFailure} { + code: AuthErrorCode! + message: String! + } + `, + resolvers: { + Mutation: { + async [gqlNames.authenticateItemWithPassword](root: any, args: any, ctx: any) { + const list = ctx.keystone.lists[listKey]; + const result = await attemptAuthentication( + list, + identityField, + secretField, + protectIdentities, + args + ); + + if (!result.success) { + const message = getErrorMessage( + identityField, + secretField, + list.adminUILabels.singular, + list.adminUILabels.plural, + result.code + ); + return { code: result.code, message }; + } + + const sessionToken = await ctx.startSession({ listKey: 'User', itemId: result.item.id }); + return { token: sessionToken, item: result.item }; + }, + }, + Query: { + async authenticatedItem(root: any, args: any, ctx: any) { + if (typeof ctx.session?.itemId === 'string' && typeof ctx.session.listKey === 'string') { + const item = ( + await ctx.keystone.lists[ctx.session.listKey].adapter.find({ + id: ctx.session.itemId, + }) + )[0]; + if (!item) return null; + return { + ...item, + // TODO: Is there a better way of doing this? + __typename: ctx.session.listKey, + }; + } + return null; + }, + }, + AuthenticatedItem: { + __resolveType(rootVal: any) { + return rootVal.__typename; + }, + }, + // TODO: Is this the preferred approach for this? + [gqlNames.ItemAuthenticationWithPasswordResult]: { + __resolveType(rootVal: any) { + return rootVal.token + ? gqlNames.ItemAuthenticationWithPasswordSuccess + : gqlNames.ItemAuthenticationWithPasswordFailure; + }, + }, + }, + }); +} diff --git a/packages-next/auth/src/initFirstItemSchemaExtension.ts b/packages-next/auth/src/gql/getInitFirstItemSchema.ts similarity index 69% rename from packages-next/auth/src/initFirstItemSchemaExtension.ts rename to packages-next/auth/src/gql/getInitFirstItemSchema.ts index ae1888069c0..04593dcba04 100644 --- a/packages-next/auth/src/initFirstItemSchemaExtension.ts +++ b/packages-next/auth/src/gql/getInitFirstItemSchema.ts @@ -1,8 +1,8 @@ import { GraphQLSchema } from 'graphql'; import { graphQLSchemaExtension } from '@keystone-spike/keystone/schema'; -import { ResolvedAuthGqlNames } from './types'; +import { AuthGqlNames } from '../types'; -export function initFirstItemSchemaExtension({ +export function getInitFirstItemSchema({ listKey, fields, itemData, @@ -11,32 +11,30 @@ export function initFirstItemSchemaExtension({ listKey: string; fields: string[]; itemData: Record | undefined; - gqlNames: ResolvedAuthGqlNames; + gqlNames: AuthGqlNames; }) { return (schema: GraphQLSchema, keystoneClassInstance: any) => { const list = keystoneClassInstance.lists[listKey]; - const createInitialInputName = `CreateInitial${listKey}Input`; - const createInitialMutationName = `createInitial${listKey}`; const newSchema = graphQLSchemaExtension({ typeDefs: ` - input ${createInitialInputName} { - ${Array.prototype - .concat( - ...fields.map(fieldPath => - list.fieldsByPath[fieldPath].gqlCreateInputFields({ - schemaName: 'public', - }) + input ${gqlNames.CreateInitialInput} { + ${Array.prototype + .concat( + ...fields.map(fieldPath => + list.fieldsByPath[fieldPath].gqlCreateInputFields({ + schemaName: 'public', + }) + ) ) - ) - .join('\n')} - } - type Mutation { - ${createInitialMutationName}(data: ${createInitialInputName}!): ${ + .join('\n')} + } + type Mutation { + ${gqlNames.createInitialItem}(data: ${gqlNames.CreateInitialInput}!): ${ gqlNames.ItemAuthenticationWithPasswordResult }! - } - `, + } + `, resolvers: { Mutation: { async [`createInitial${listKey}`](rootVal: any, { data }: any, context: any) { diff --git a/packages-next/auth/src/gql/getMagicAuthLinkSchema.ts b/packages-next/auth/src/gql/getMagicAuthLinkSchema.ts new file mode 100644 index 00000000000..f7077e9dc76 --- /dev/null +++ b/packages-next/auth/src/gql/getMagicAuthLinkSchema.ts @@ -0,0 +1,118 @@ +import { graphQLSchemaExtension } from '@keystone-spike/keystone/schema'; +import { AuthGqlNames, AuthTokenTypeConfig } from '../types'; + +import { updateAuthToken } from '../lib/updateAuthToken'; +import { redeemAuthToken } from '../lib/redeemAuthToken'; +import { getErrorMessage } from '../lib/getErrorMessage'; + +export function getMagicAuthLinkSchema({ + listKey, + identityField, + secretField, + protectIdentities, + gqlNames, + magicAuthLink, +}: { + listKey: string; + identityField: string; + secretField: string; + protectIdentities: boolean; + gqlNames: AuthGqlNames; + magicAuthLink: AuthTokenTypeConfig; +}) { + return graphQLSchemaExtension({ + typeDefs: ` + # Magic links + type Mutation { + ${gqlNames.sendItemMagicAuthLink}(${identityField}: String!): ${gqlNames.SendItemMagicAuthLinkResult}! + } + type ${gqlNames.SendItemMagicAuthLinkResult} { + code: AuthErrorCode! + message: String! + } + type Mutation { + ${gqlNames.redeemItemMagicAuthToken}(${identityField}: String!, token: String!): ${gqlNames.RedeemItemMagicAuthTokenResult} + } + union ${gqlNames.RedeemItemMagicAuthTokenResult} = ${gqlNames.RedeemItemMagicAuthTokenSuccess} | ${gqlNames.RedeemItemMagicAuthTokenFailure} + type ${gqlNames.RedeemItemMagicAuthTokenSuccess} { + token: String! + item: ${listKey}! + } + type ${gqlNames.RedeemItemMagicAuthTokenFailure} { + code: AuthErrorCode! + message: String! + } + `, + resolvers: { + Mutation: { + async [gqlNames.sendItemMagicAuthLink](root: any, args: any, ctx: any) { + const list = ctx.keystone.lists[listKey]; + const identity = args[identityField]; + const result = await updateAuthToken( + 'magicAuth', + list, + identityField, + protectIdentities, + identity, + ctx + ); + + // Note: `success` can be false with no code + if (!result.success && result.code) { + const message = getErrorMessage( + identityField, + secretField, + list.adminUILabels.singular, + list.adminUILabels.plural, + result.code + ); + return { code: result.code, message }; + } + if (result.success) { + await magicAuthLink.sendToken({ + itemId: result.itemId, + identity, + token: result.token, + }); + } + return {}; + }, + async [gqlNames.redeemItemMagicAuthToken](root: any, args: any, ctx: any) { + const list = ctx.keystone.lists[listKey]; + const result = await redeemAuthToken( + 'magicAuth', + list, + identityField, + protectIdentities, + magicAuthLink.tokensValidForMins, + args, + ctx + ); + + if (!result.success) { + const message = getErrorMessage( + identityField, + secretField, + list.adminUILabels.singular, + list.adminUILabels.plural, + result.code + ); + return { code: result.code, message }; + } + + const sessionToken = await ctx.startSession({ listKey: 'User', itemId: result.item.id }); + return { token: sessionToken, item: result.item }; + }, + }, + + // TODO: Is this the preferred approach for this? + [gqlNames.RedeemItemMagicAuthTokenResult]: { + __resolveType(rootVal: any) { + return rootVal.token + ? gqlNames.RedeemItemMagicAuthTokenSuccess + : gqlNames.RedeemItemMagicAuthTokenFailure; + }, + }, + }, + }); +} diff --git a/packages-next/auth/src/gql/getPasswordResetSchema.ts b/packages-next/auth/src/gql/getPasswordResetSchema.ts new file mode 100644 index 00000000000..53f0b925640 --- /dev/null +++ b/packages-next/auth/src/gql/getPasswordResetSchema.ts @@ -0,0 +1,155 @@ +import { graphQLSchemaExtension } from '@keystone-spike/keystone/schema'; +import { AuthGqlNames, AuthTokenTypeConfig } from '../types'; + +import { updateAuthToken } from '../lib/updateAuthToken'; +import { redeemAuthToken } from '../lib/redeemAuthToken'; +import { validateAuthToken } from '../lib/validateAuthToken'; +import { updateItemSecret } from '../lib/updateItemSecret'; +import { getErrorMessage } from '../lib/getErrorMessage'; + +export function getPasswordResetSchema({ + listKey, + identityField, + secretField, + protectIdentities, + gqlNames, + passwordResetLink, +}: { + listKey: string; + identityField: string; + secretField: string; + protectIdentities: boolean; + gqlNames: AuthGqlNames; + passwordResetLink: AuthTokenTypeConfig; +}) { + return graphQLSchemaExtension({ + typeDefs: ` + # Reset password + type Mutation { + ${gqlNames.sendItemPasswordResetLink}(${identityField}: String!): ${gqlNames.SendItemPasswordResetLinkResult}! + } + type ${gqlNames.SendItemPasswordResetLinkResult} { + code: AuthErrorCode + message: String + } + type Query { + ${gqlNames.validateItemPasswordResetToken}(${identityField}: String!, token: String!): ${gqlNames.ValidateItemPasswordResetTokenResult}! + } + type ${gqlNames.ValidateItemPasswordResetTokenResult} { + code: AuthErrorCode + message: String + } + type Mutation { + ${gqlNames.redeemItemPasswordResetToken}(${identityField}: String!, token: String!, ${secretField}: String!): ${gqlNames.RedeemItemPasswordResetTokenResult}! + } + type ${gqlNames.RedeemItemPasswordResetTokenResult} { + code: AuthErrorCode + message: String + } + `, + resolvers: { + Mutation: { + async [gqlNames.sendItemPasswordResetLink](root: any, args: any, ctx: any) { + const list = ctx.keystone.lists[listKey]; + const identity = args[identityField]; + const result = await updateAuthToken( + 'passwordReset', + list, + identityField, + protectIdentities, + identity, + ctx + ); + + // Note: `success` can be false with no code + if (!result.success && result.code) { + const message = getErrorMessage( + identityField, + secretField, + list.adminUILabels.singular, + list.adminUILabels.plural, + result.code + ); + return { code: result.code, message }; + } + if (result.success) { + await passwordResetLink.sendToken({ + itemId: result.itemId, + identity, + token: result.token, + }); + } + return {}; + }, + async [gqlNames.redeemItemPasswordResetToken](root: any, args: any, ctx: any) { + const list = ctx.keystone.lists[listKey]; + const result = await redeemAuthToken( + 'passwordReset', + list, + identityField, + protectIdentities, + passwordResetLink.tokensValidForMins, + args, + ctx + ); + + if (!result.success) { + const message = getErrorMessage( + identityField, + secretField, + list.adminUILabels.singular, + list.adminUILabels.plural, + result.code + ); + return { code: result.code, message }; + } + + const secretPlaintext = args[secretField]; + const updateResult = await updateItemSecret( + list, + result.item.id, + secretPlaintext, + secretField, + ctx + ); + if (updateResult.code) { + const message = getErrorMessage( + identityField, + secretField, + list.adminUILabels.singular, + list.adminUILabels.plural, + updateResult.code + ); + return { code: updateResult.code, message }; + } + return {}; + }, + }, + Query: { + async [gqlNames.validateItemPasswordResetToken](root: any, args: any, ctx: any) { + const list = ctx.keystone.lists[listKey]; + const result = await validateAuthToken( + 'passwordReset', + list, + identityField, + protectIdentities, + passwordResetLink.tokensValidForMins, + args + ); + + if (!result.success && result.code) { + const message = getErrorMessage( + identityField, + secretField, + list.adminUILabels.singular, + list.adminUILabels.plural, + result.code + ); + return { code: result.code, message }; + } + return {}; + }, + }, + }, + }); +} diff --git a/packages-next/auth/src/index.ts b/packages-next/auth/src/index.ts index 70409eab14d..5617a07817f 100644 --- a/packages-next/auth/src/index.ts +++ b/packages-next/auth/src/index.ts @@ -1,14 +1,19 @@ +import url from 'url'; + import { AdminFileToWrite, BaseGeneratedListTypes, KeystoneConfig, SerializedFieldMeta, } from '@keystone-spike/types'; -import url from 'url'; +import { password, timestamp } from '@keystone-spike/fields'; + +import { AuthConfig, Auth, AuthGqlNames } from './types'; -import { getExtendGraphQLSchema } from './getExtendGraphQLSchema'; -import { initFirstItemSchemaExtension } from './initFirstItemSchemaExtension'; -import { AuthConfig, Auth, ResolvedAuthGqlNames } from './types'; +import { getBaseAuthSchema } from './gql/getBaseAuthSchema'; +import { getInitFirstItemSchema } from './gql/getInitFirstItemSchema'; +import { getPasswordResetSchema } from './gql/getPasswordResetSchema'; +import { getMagicAuthLinkSchema } from './gql/getMagicAuthLinkSchema'; import { signinTemplate } from './templates/signin'; import { initTemplate } from './templates/init'; @@ -18,15 +23,45 @@ import { initTemplate } from './templates/init'; * * Generates config for Keystone to implement standard auth features. */ -export function createAuth( - config: AuthConfig -): Auth { - const gqlNames: ResolvedAuthGqlNames = { - authenticateItemWithPassword: `authenticate${config.listKey}WithPassword`, - createInitialItem: `createInitial${config.listKey}`, - sendItemForgottenPassword: `send${config.listKey}ForgottenPassword`, - sendItemMagicAuthenticateLink: `send${config.listKey}MagicAuthenticateLink`, - ItemAuthenticationWithPasswordResult: `${config.listKey}AuthenticationWithPasswordResult`, +export function createAuth({ + listKey, + secretField, + protectIdentities = false, + gqlSuffix = '', + initFirstItem, + identityField, + magicAuthLink, + passwordResetLink, +}: AuthConfig): Auth { + const gqlNames: AuthGqlNames = { + CreateInitialInput: `CreateInitial${listKey}Input${gqlSuffix}`, + createInitialItem: `createInitial${listKey}${gqlSuffix}`, + authenticateItemWithPassword: `authenticate${listKey}WithPassword${gqlSuffix}`, + ItemAuthenticationWithPasswordResult: `${listKey}AuthenticationWithPasswordResult${gqlSuffix}`, + ItemAuthenticationWithPasswordSuccess: `${listKey}AuthenticationWithPasswordSuccess${gqlSuffix}`, + ItemAuthenticationWithPasswordFailure: `${listKey}AuthenticationWithPasswordFailure${gqlSuffix}`, + sendItemPasswordResetLink: `send${listKey}PasswordResetLink${gqlSuffix}`, + SendItemPasswordResetLinkResult: `Send${listKey}PasswordResetLinkResult${gqlSuffix}`, + validateItemPasswordResetToken: `validate${listKey}PasswordResetToken${gqlSuffix}`, + ValidateItemPasswordResetTokenResult: `Validate${listKey}PasswordResetTokenResult${gqlSuffix}`, + redeemItemPasswordResetToken: `redeem${listKey}PasswordResetToken${gqlSuffix}`, + RedeemItemPasswordResetTokenResult: `Redeem${listKey}PasswordResetTokenResult${gqlSuffix}`, + sendItemMagicAuthLink: `send${listKey}MagicAuthLink${gqlSuffix}`, + SendItemMagicAuthLinkResult: `Send${listKey}MagicAuthLinkResult${gqlSuffix}`, + redeemItemMagicAuthToken: `redeem${listKey}MagicAuthToken${gqlSuffix}`, + RedeemItemMagicAuthTokenResult: `Redeem${listKey}MagicAuthTokenResult${gqlSuffix}`, + RedeemItemMagicAuthTokenSuccess: `Redeem${listKey}MagicAuthTokenSuccess${gqlSuffix}`, + RedeemItemMagicAuthTokenFailure: `Redeem${listKey}MagicAuthTokenFailure${gqlSuffix}`, + }; + + // Fields added to the auth list + const additionalListFields = { + [`${secretField}ResetToken`]: password({ access: () => false }), // isRequired: false + [`${secretField}ResetIssuedAt`]: timestamp({ access: () => false, isRequired: false }), + [`${secretField}ResetRedeemedAt`]: timestamp({ access: () => false, isRequired: false }), + [`magicAuthToken`]: password({ access: () => false }), // isRequired: false + [`magicAuthIssuedAt`]: timestamp({ access: () => false, isRequired: false }), + [`magicAuthRedeemedAt`]: timestamp({ access: () => false, isRequired: false }), }; /** @@ -48,7 +83,7 @@ export function createAuth( const pathname = url.parse(req.url!).pathname!; if (isValidSession) { - if (pathname === '/signin' || (config.initFirstItem && pathname === '/init')) { + if (pathname === '/signin' || (initFirstItem && pathname === '/init')) { return { kind: 'redirect', to: '/', @@ -57,8 +92,8 @@ export function createAuth( return; } - if (!session && config.initFirstItem) { - const { count } = await keystone.keystone.lists[config.listKey].adapter.itemsQuery( + if (!session && initFirstItem) { + const { count } = await keystone.keystone.lists[listKey].adapter.itemsQuery( {}, { meta: true, @@ -99,16 +134,17 @@ export function createAuth( src: signinTemplate({ gqlNames }), }, ]; - if (config.initFirstItem) { + if (initFirstItem) { const fields: Record = {}; - for (const fieldPath of config.initFirstItem.fields) { - fields[fieldPath] = keystone.adminMeta.lists[config.listKey].fields[fieldPath]; + for (const fieldPath of initFirstItem.fields) { + fields[fieldPath] = keystone.adminMeta.lists[listKey].fields[fieldPath]; } filesToWrite.push({ mode: 'write', outputPath: 'pages/init.js', - src: initTemplate({ config, fields }), + // wonder what this template expects from config... + src: initTemplate({ listKey, initFirstItem, fields }), }); } return filesToWrite; @@ -126,17 +162,47 @@ export function createAuth( * * Must be added to the extendGraphqlSchema config. Can be composed. */ - let extendGraphqlSchema = getExtendGraphQLSchema({ - ...config, - protectIdentities: config.protectIdentities || false, + let extendGraphqlSchema = getBaseAuthSchema({ + identityField, + listKey, + protectIdentities, + secretField, gqlNames, }); - if (config.initFirstItem) { - let existingExtendGraphqlSchema = extendGraphqlSchema; - let extension = initFirstItemSchemaExtension({ - listKey: config.listKey, - fields: config.initFirstItem.fields, - itemData: config.initFirstItem.itemData, + + // Wrap extendGraphqlSchema to add optional functionality + if (initFirstItem) { + const existingExtendGraphqlSchema = extendGraphqlSchema; + const extension = getInitFirstItemSchema({ + listKey: listKey, + fields: initFirstItem.fields, + itemData: initFirstItem.itemData, + gqlNames, + }); + extendGraphqlSchema = (schema, keystone) => + extension(existingExtendGraphqlSchema(schema, keystone), keystone); + } + if (passwordResetLink) { + const existingExtendGraphqlSchema = extendGraphqlSchema; + const extension = getPasswordResetSchema({ + identityField, + listKey, + protectIdentities, + secretField, + passwordResetLink, + gqlNames, + }); + extendGraphqlSchema = (schema, keystone) => + extension(existingExtendGraphqlSchema(schema, keystone), keystone); + } + if (magicAuthLink) { + const existingExtendGraphqlSchema = extendGraphqlSchema; + const extension = getMagicAuthLinkSchema({ + identityField, + listKey, + protectIdentities, + secretField, + magicAuthLink, gqlNames, }); extendGraphqlSchema = (schema, keystone) => @@ -149,41 +215,85 @@ export function createAuth( * Validates the provided auth config; optional step when integrating auth */ const validateConfig = (keystoneConfig: KeystoneConfig) => { - const specifiedListConfig = keystoneConfig.lists[config.listKey]; - if (keystoneConfig.lists[config.listKey] === undefined) { + const specifiedListConfig = keystoneConfig.lists[listKey]; + if (keystoneConfig.lists[listKey] === undefined) { throw new Error( - `In createAuth, you've specified the list ${JSON.stringify( - config.listKey - )} but you do not have a list named ${JSON.stringify(config.listKey)}` + `A createAuth() invocation specifies the list ${JSON.stringify( + listKey + )} but no list with that key has been defined.` ); } - if (specifiedListConfig.fields[config.identityField] === undefined) { + + // TODO: Check for String-like typing for identityField? How? + const identityFieldConfig = specifiedListConfig.fields[identityField]; + if (identityFieldConfig === undefined) { throw new Error( - `In createAuth, you\'ve specified ${JSON.stringify( - config.identityField - )} as your identityField on ${JSON.stringify(config.listKey)} but ${JSON.stringify( - config.listKey - )} does not have a field named ${JSON.stringify(config.identityField)}` + `A createAuth() invocation for the ${JSON.stringify( + listKey + )} list specifies ${JSON.stringify( + identityField + )} as its identityField but no field with that key exists on the list.` ); } - if (specifiedListConfig.fields[config.secretField] === undefined) { + + // TODO: We could make the secret field optional to disable the standard id/secret auth and password resets (ie. magic links only) + const secretFieldConfig = specifiedListConfig.fields[secretField]; + if (secretFieldConfig === undefined) { throw new Error( - `In createAuth, you've specified ${JSON.stringify( - config.secretField - )} as your secretField on ${JSON.stringify(config.listKey)} but ${JSON.stringify( - config.listKey - )} does not have a field named ${JSON.stringify(config.secretField)}` + `A createAuth() invocation for the ${JSON.stringify( + listKey + )} list specifies ${JSON.stringify( + secretField + )} as its secretField but no field with that key exists on the list.` + ); + } + const secretPrototype = + secretFieldConfig.type && + secretFieldConfig.type.implementation && + secretFieldConfig.type.implementation.prototype; + const secretTypename = secretFieldConfig.type && secretFieldConfig.type.type; + if (typeof secretPrototype.compare !== 'function' || secretPrototype.compare.length < 2) { + throw new Error( + `A createAuth() invocation for the ${JSON.stringify( + listKey + )} list specifies ${JSON.stringify( + secretField + )} as its secretField, which uses the field type ${JSON.stringify( + secretTypename + )}. But the ${JSON.stringify( + secretTypename + )} field type doesn't implement the required compare() functionality.` + + (secretTypename !== 'Password' + ? ` Did you mean to reference a field of type Password instead?` + : '') + ); + } + if (typeof secretPrototype.generateHash !== 'function') { + throw new Error( + `A createAuth() invocation for the ${JSON.stringify( + listKey + )} list specifies ${JSON.stringify( + secretField + )} as its secretField, which uses the field type ${JSON.stringify( + secretTypename + )}. But the ${JSON.stringify( + secretTypename + )} field type doesn't implement the required generateHash() functionality.` + + (secretTypename !== 'Password' + ? ` Did you mean to reference a field of type Password instead?` + : '') ); } - for (const field of config.initFirstItem?.fields || []) { + // TODO: Could also validate initFirstItem.itemData keys? + for (const field of initFirstItem?.fields || []) { if (specifiedListConfig.fields[field] === undefined) { throw new Error( - `In createAuth, you've specified the field ${JSON.stringify( + `A createAuth() invocation for the ${JSON.stringify( + listKey + )} list specifies the field ${JSON.stringify( field - )} in initFirstItem.fields but it does not exist on the list ${JSON.stringify( - config.listKey - )}` + )} in initFirstItem.fields array but no field with that key exist on the list.` ); } } @@ -218,6 +328,20 @@ export function createAuth( return { ...keystoneConfig, admin, + // Add the additional fields to the references lists fields object + // TODO: The additionalListFields we're adding here shouldn't naively replace existing fields with the same key + // Leaving existing fields in place would allow solution devs to customise these field defs (eg. access control, + // work factor for the tokens, etc.) without abandoning the withAuth() interface + lists: { + ...keystoneConfig.lists, + [listKey]: { + ...keystoneConfig.lists[listKey], + fields: { + ...keystoneConfig.lists[listKey].fields, + ...additionalListFields, + }, + }, + }, extendGraphqlSchema: existingExtendGraphQLSchema ? (schema, keystone) => existingExtendGraphQLSchema(extendGraphqlSchema(schema, keystone), keystone) @@ -237,6 +361,7 @@ export function createAuth( publicPages: adminPublicPages, getAdditionalFiles: additionalFiles, }, + fields: additionalListFields, extendGraphqlSchema, validateConfig, withAuth, diff --git a/packages-next/auth/src/lib/attemptAuthentication.ts b/packages-next/auth/src/lib/attemptAuthentication.ts new file mode 100644 index 00000000000..14cf1215398 --- /dev/null +++ b/packages-next/auth/src/lib/attemptAuthentication.ts @@ -0,0 +1,55 @@ +import { AuthErrorCode } from '../types'; + +export async function attemptAuthentication( + list: any, + identityField: string, + secretField: string, + protectIdentities: boolean, + args: Record +): Promise< + | { + success: false; + code: AuthErrorCode; + } + | { + success: true; + item: { id: any; [prop: string]: any }; + } +> { + const identity = args[identityField]; + const canidatePlaintext = args[secretField]; + const secretFieldInstance = list.fieldsByPath[secretField]; + + // TODO: Allow additional filters to be suppled in config? eg. `validUserConditions: { isEnable: true, isVerified: true, ... }` + // TODO: Maybe talk to the list rather than the adapter? (might not validate the filters though) + const items = await list.adapter.find({ [identityField]: identity }); + + // Identity failures with helpful errors + let specificCode: AuthErrorCode | undefined; + if (items.length === 0) { + specificCode = 'PASSWORD_AUTH_IDENTITY_NOT_FOUND'; + } else if (items.length === 1 && !items[0][secretField]) { + specificCode = 'PASSWORD_AUTH_SECRET_NOT_SET'; + } else if (items.length > 1) { + specificCode = 'PASSWORD_AUTH_MULTIPLE_IDENTITY_MATCHES'; + } + if (typeof specificCode !== 'undefined') { + // See "Identity Protection" in the README as to why this is a thing + if (protectIdentities) { + await secretFieldInstance.generateHash('simulated-password-to-counter-timing-attack'); + } + return { success: false, code: protectIdentities ? 'PASSWORD_AUTH_FAILURE' : specificCode }; + } + + const item = items[0]; + const isMatch = await secretFieldInstance.compare(canidatePlaintext, item[secretField]); + if (!isMatch) { + return { + success: false, + code: protectIdentities ? 'PASSWORD_AUTH_FAILURE' : 'PASSWORD_AUTH_SECRET_MISMATCH', + }; + } + + // Authenticated! + return { success: true, item }; +} diff --git a/packages-next/auth/src/lib/generateToken.ts b/packages-next/auth/src/lib/generateToken.ts new file mode 100644 index 00000000000..ea402bfc283 --- /dev/null +++ b/packages-next/auth/src/lib/generateToken.ts @@ -0,0 +1,8 @@ +import { randomBytes } from 'crypto'; + +export function generateToken(length: number): string { + return randomBytes(length) + .toString('base64') + .slice(0, length) + .replace(/[^a-zA-Z0-9]/g, ''); +} diff --git a/packages-next/auth/src/lib/getErrorMessage.ts b/packages-next/auth/src/lib/getErrorMessage.ts new file mode 100644 index 00000000000..ae7417ca1c7 --- /dev/null +++ b/packages-next/auth/src/lib/getErrorMessage.ts @@ -0,0 +1,45 @@ +import { AuthErrorCode } from '../types'; + +export function getErrorMessage( + identityField: string, + secretField: string, + itemSingular: string, + itemPlural: string, + code: AuthErrorCode +): string { + switch (code) { + case 'PASSWORD_AUTH_FAILURE': + return 'Authentication failed.'; + case 'PASSWORD_AUTH_IDENTITY_NOT_FOUND': + return `The ${identityField} value provided didn't identify any ${itemPlural}.`; + case 'PASSWORD_AUTH_SECRET_NOT_SET': + return `The ${itemSingular} identified has no ${secretField} set so can not be authenticated.`; + case 'PASSWORD_AUTH_MULTIPLE_IDENTITY_MATCHES': + return `The ${identityField} value provided identified more than one ${itemSingular}.`; + case 'PASSWORD_AUTH_SECRET_MISMATCH': + return `The ${secretField} provided is incorrect.`; + + case 'AUTH_TOKEN_REQUEST_IDENTITY_NOT_FOUND': + return `The ${identityField} value provided didn't identify any ${itemPlural}.`; + case 'AUTH_TOKEN_REQUEST_MULTIPLE_IDENTITY_MATCHES': + return `The ${identityField} value provided identified more than one ${itemSingular}.`; + case 'AUTH_TOKEN_REDEMPTION_FAILURE': + return 'Auth token redemtion failed.'; + case 'AUTH_TOKEN_REDEMPTION_IDENTITY_NOT_FOUND': + return `The ${identityField} value provided didn't identify any ${itemPlural}.`; + case 'AUTH_TOKEN_REDEMPTION_MULTIPLE_IDENTITY_MATCHES': + return `The ${identityField} value provided identified more than one ${itemSingular}.`; + case 'AUTH_TOKEN_REDEMPTION_TOKEN_NOT_SET': + return `The ${itemSingular} identified has no auth token of this type set.`; + case 'AUTH_TOKEN_REDEMPTION_TOKEN_MISMATCH': + return 'The auth token provided is incorrect.'; + case 'AUTH_TOKEN_REDEMPTION_TOKEN_EXPIRED': + return 'The auth token provided has expired.'; + case 'AUTH_TOKEN_REDEMPTION_TOKEN_REDEEMED': + return 'Auth tokens are single use and the auth token provided has already been redeemed.'; + + case 'INTERNAL_ERROR': + return `An unexpected error condition was encountered while creating or redeeming an auth token.`; + } + return 'No error message defined.'; +} diff --git a/packages-next/auth/src/lib/redeemAuthToken.ts b/packages-next/auth/src/lib/redeemAuthToken.ts new file mode 100644 index 00000000000..da93650827a --- /dev/null +++ b/packages-next/auth/src/lib/redeemAuthToken.ts @@ -0,0 +1,56 @@ +import { AuthErrorCode } from '../types'; +import { validateAuthToken } from './validateAuthToken'; + +export async function redeemAuthToken( + tokenType: 'passwordReset' | 'magicAuth', + list: any, + identityField: string, + protectIdentities: boolean, + tokenValidMins: number | undefined, + args: Record, + ctx: any +): Promise< + | { + success: false; + code: AuthErrorCode; + } + | { + success: true; + item: { id: any; [prop: string]: any }; + } +> { + const fieldKeys = { + token: `${tokenType}Token`, + issuedAt: `${tokenType}IssuedAt`, + redeemedAt: `${tokenType}RedeemedAt`, + }; + + // Palm off the bulk of the work; validating the identity and token + const validationResult = await validateAuthToken( + tokenType, + list, + identityField, + protectIdentities, + tokenValidMins, + args + ); + if (!validationResult.success) { + return validationResult; + } + + // Save the token and related info back to the item + const { errors } = await ctx.keystone.executeGraphQL({ + context: ctx.keystone.createContext({ skipAccessControl: true }), + query: `mutation($id: String, $token: String, $now: String) { + ${list.gqlNames.updateMutationName}(id: $id, data: { ${fieldKeys.redeemedAt}: $now }) { id } + }`, + variables: { id: validationResult.item.id, now: new Date().toISOString() }, + }); + if (Array.isArray(errors) && errors.length > 0) { + console.error(errors[0] && (errors[0].stack || errors[0].message)); + return { success: false, code: 'INTERNAL_ERROR' }; + } + + // Authenticated! + return { success: true, item: validationResult.item }; +} diff --git a/packages-next/auth/src/lib/updateAuthToken.ts b/packages-next/auth/src/lib/updateAuthToken.ts new file mode 100644 index 00000000000..39d29d1a399 --- /dev/null +++ b/packages-next/auth/src/lib/updateAuthToken.ts @@ -0,0 +1,59 @@ +import { AuthErrorCode } from '../types'; +import { generateToken } from './generateToken'; + +// TODO: Auth token mutations may leak user identities due to timing attacks :( +// We don't (currently) make any effort to mitigate the time taken to record the new token or sent the email when successful +export async function updateAuthToken( + tokenType: 'passwordReset' | 'magicAuth', + list: any, + identityField: string, + protectIdentities: boolean, + identity: string, + ctx: any +): Promise< + | { + success: false; + code?: AuthErrorCode; + } + | { + success: true; + itemId: string | number; + token: string; + } +> { + const items = await list.adapter.find({ [identityField]: identity }); + + // Identity failures with helpful errors (unless it would violate our protectIdentities config) + let specificCode: AuthErrorCode | undefined; + if (items.length === 0) { + specificCode = 'AUTH_TOKEN_REQUEST_IDENTITY_NOT_FOUND'; + } else if (items.length > 1) { + specificCode = 'AUTH_TOKEN_REQUEST_MULTIPLE_IDENTITY_MATCHES'; + } + if (typeof specificCode !== 'undefined') { + // There is no generic `AUTH_TOKEN_REQUEST_FAILURE` code; it's existance would alow values in the identity field to be probed + return { success: false, code: protectIdentities ? undefined : specificCode }; + } + + const item = items[0]; + const token = generateToken(20); + + // Save the token and related info back to the item + const { errors } = await ctx.keystone.executeGraphQL({ + context: ctx.keystone.createContext({ skipAccessControl: true }), + query: `mutation($id: String, $token: String, $now: String) { + ${list.gqlNames.updateMutationName}(id: $id, data: { + ${tokenType}Token: $token, + ${tokenType}IssuedAt: $now, + ${tokenType}RedeemedAt: null + }) { id } + }`, + variables: { id: item.id, token, now: new Date().toISOString() }, + }); + if (Array.isArray(errors) && errors.length > 0) { + console.error(errors[0] && (errors[0].stack || errors[0].message)); + return { success: false, code: 'INTERNAL_ERROR' }; + } + + return { success: true, itemId: item.id, token }; +} diff --git a/packages-next/auth/src/lib/updateItemSecret.ts b/packages-next/auth/src/lib/updateItemSecret.ts new file mode 100644 index 00000000000..e20c50db0cf --- /dev/null +++ b/packages-next/auth/src/lib/updateItemSecret.ts @@ -0,0 +1,28 @@ +import { AuthErrorCode } from '../types'; + +export async function updateItemSecret( + list: any, + itemId: string | number, + secretPlaintext: string, + secretField: string, + ctx: any +): Promise<{ + code?: AuthErrorCode; +}> { + // Save the provided secret + const { errors } = await ctx.keystone.executeGraphQL({ + context: ctx.keystone.createContext({ skipAccessControl: true }), + query: `mutation($itemId: String, $secretPlaintext: String) { + ${list.gqlNames.updateMutationName}(id: $itemId, data: { ${secretField}: $secretPlaintext }) { id } + }`, + variables: { itemId, secretPlaintext }, + }); + + // TODO: The underlying Password field will still hard error on validation failures; these should be surfaced better + if (Array.isArray(errors) && errors.length > 0) { + console.error(errors[0] && (errors[0].stack || errors[0].message)); + return { code: 'INTERNAL_ERROR' }; + } + + return {}; +} diff --git a/packages-next/auth/src/lib/validateAuthToken.ts b/packages-next/auth/src/lib/validateAuthToken.ts new file mode 100644 index 00000000000..910608650a9 --- /dev/null +++ b/packages-next/auth/src/lib/validateAuthToken.ts @@ -0,0 +1,94 @@ +import { AuthErrorCode } from '../types'; + +// The tokensValidForMins config is from userland so could be anything; make it sane +function sanitiseValidForMinsConfig(input: any): number { + const parsed = Number.parseFloat(input); + // > 10 seconds, < 24 hrs, default 10 mins + return parsed ? Math.max(1 / 6, Math.min(parsed, 60 * 24)) : 10; +} + +export async function validateAuthToken( + tokenType: 'passwordReset' | 'magicAuth', + list: any, + identityField: string, + protectIdentities: boolean, + tokenValidMins: number | undefined, + args: Record +): Promise< + | { + success: false; + code: AuthErrorCode; + } + | { + success: true; + item: { id: any; [prop: string]: any }; + } +> { + const fieldKeys = { + token: `${tokenType}Token`, + issuedAt: `${tokenType}IssuedAt`, + redeemedAt: `${tokenType}RedeemedAt`, + }; + const tokenFieldInstance = list.fieldsByPath[fieldKeys.token]; + const identity = args[identityField]; + const canidatePlaintext = args.token; + + // TODO: Allow additional filters to be suppled in config? eg. `validUserConditions: { isEnable: true, isVerified: true, ... }` + // TODO: Maybe talk to the list rather than the adapter? (Might not validate the filters though) + const items = await list.adapter.find({ [identityField]: identity }); + + // Check the for identity-related failures first + let specificCode: AuthErrorCode | undefined; + if (items.length === 0) { + specificCode = 'AUTH_TOKEN_REDEMPTION_IDENTITY_NOT_FOUND'; + } else if (items.length === 1 && !items[0][fieldKeys.token]) { + specificCode = 'AUTH_TOKEN_REDEMPTION_TOKEN_NOT_SET'; + } else if (items.length > 1) { + specificCode = 'AUTH_TOKEN_REDEMPTION_MULTIPLE_IDENTITY_MATCHES'; + } + if (typeof specificCode !== 'undefined') { + // See "Identity Protection" in the README as to why this is a thing + if (protectIdentities) { + await tokenFieldInstance.generateHash('simulated-password-to-counter-timing-attack'); + } + return { + success: false, + code: protectIdentities ? 'AUTH_TOKEN_REDEMPTION_FAILURE' : specificCode, + }; + } + + // Check for non-identity failures + const item = items[0]; + const isMatch = await tokenFieldInstance.compare(canidatePlaintext, item[fieldKeys.token]); + if (!isMatch) { + return { + success: false, + code: protectIdentities + ? 'AUTH_TOKEN_REDEMPTION_FAILURE' + : 'AUTH_TOKEN_REDEMPTION_TOKEN_MISMATCH', + }; + } + + // Now that we know the identity and token are valid, we can always return 'helpful' errors and stop worrying about protectIdentities + if (item[fieldKeys.redeemedAt]) { + return { success: false, code: 'AUTH_TOKEN_REDEMPTION_TOKEN_REDEEMED' }; + } + if (!item[fieldKeys.issuedAt] || typeof item[fieldKeys.issuedAt].getTime !== 'function') { + console.error( + new Error( + `Error redeeming authToken: field ${JSON.stringify(list.listKey)}.${JSON.stringify( + fieldKeys.issuedAt + )} isn't a valid Date object.` + ) + ); + return { success: false, code: 'INTERNAL_ERROR' }; + } + const elapsedMins = (Date.now() - item[fieldKeys.issuedAt].getTime()) / (1000 * 60); + const validForMins = sanitiseValidForMinsConfig(tokenValidMins); + if (elapsedMins > validForMins) { + return { success: false, code: 'AUTH_TOKEN_REDEMPTION_TOKEN_EXPIRED' }; + } + + // Authenticated! + return { success: true, item }; +} diff --git a/packages-next/auth/src/pages/SigninPage.tsx b/packages-next/auth/src/pages/SigninPage.tsx index 601e0c01a77..c33d6b30edd 100644 --- a/packages-next/auth/src/pages/SigninPage.tsx +++ b/packages-next/auth/src/pages/SigninPage.tsx @@ -14,6 +14,7 @@ import { useRouter } from '@keystone-spike/admin-ui/router'; export const SigninPage = ({ mutation }: { mutation: DocumentNode }) => { /* TODO: + - [x] Move this into the new keystone auth plugin package - [ ] Initialise with the current session, and bounce if the user is signed in - [x] Call mutation to actually sign in, then redirect - [ ] Show error messages when the user doesn't sign in successfully (inc. full & limited messages) diff --git a/packages-next/auth/src/templates/init.ts b/packages-next/auth/src/templates/init.ts index 79b3d7da740..9296f348091 100644 --- a/packages-next/auth/src/templates/init.ts +++ b/packages-next/auth/src/templates/init.ts @@ -2,31 +2,32 @@ import { BaseGeneratedListTypes, SerializedFieldMeta } from '@keystone-spike/typ import { AuthConfig } from '../types'; type InitTemplateArgs = { - config: AuthConfig; + listKey: string; + initFirstItem: NonNullable['initFirstItem']>; fields: Record; }; -export const initTemplate = ({ config, fields }: InitTemplateArgs) => { - if (!config.initFirstItem) return ''; - +export const initTemplate = ({ listKey, initFirstItem, fields }: InitTemplateArgs) => { // -- TEMPLATE START return `import { InitPage } from '@keystone-spike/auth/pages/InitPage'; import React from 'react'; import { gql } from '@keystone-spike/admin-ui/apollo'; - + const fieldsMeta = ${JSON.stringify(fields)} - - const mutation = gql\`mutation($data: CreateInitial${config.listKey}Input!) { - createInitial${config.listKey}(data: $data) { - item { - id + + const mutation = gql\`mutation($data: CreateInitial${listKey}Input!) { + createInitial${listKey}(data: $data) { + ... on UserAuthenticationWithPasswordSuccess { + item { + id + } } } }\` - + export default function Init() { return } `; diff --git a/packages-next/auth/src/templates/signin.ts b/packages-next/auth/src/templates/signin.ts index 299962d43d9..811a5a6744c 100644 --- a/packages-next/auth/src/templates/signin.ts +++ b/packages-next/auth/src/templates/signin.ts @@ -1,12 +1,12 @@ -import { ResolvedAuthGqlNames } from '../types'; +import { AuthGqlNames } from '../types'; -export const signinTemplate = ({ gqlNames }: { gqlNames: ResolvedAuthGqlNames }) => { +export const signinTemplate = ({ gqlNames }: { gqlNames: AuthGqlNames }) => { // -- TEMPLATE START return ` import React from 'react'; import { gql } from '@keystone-spike/admin-ui/apollo'; import { SigninPage } from '@keystone-spike/auth/pages/SigninPage' - + export default function Signin() { return Promise | void; -export type AuthGqlNames = { - /** Change the name of the authenticate{listKey}WithPassword mutation */ - authenticateItemWithPassword?: string; - /** Change the name of the send{listKey}ForgottenPassword mutation */ - sendItemForgottenPassword?: string; - /** Change the name of the send{listKey}MagicAuthenticateLink mutation */ - sendItemMagicAuthenticateLink?: string; - /** Change the name of the createInitial{listKey} mutation */ - createInitialItem?: string; -}; - -export type ResolvedAuthGqlNames = Required & { - ItemAuthenticationWithPasswordResult: string; +export type AuthTokenTypeConfig = { + /** Called when a user should be sent the magic signin token they requested */ + sendToken: SendTokenFn; + /** How long do tokens stay valid for from time of issue, in minutes **/ + tokensValidForMins?: number; }; export type AuthConfig = { @@ -30,19 +41,13 @@ export type AuthConfig = { identityField: GeneratedListTypes['fields']; /** The path of the field the secret is stored in; must be password-ish */ secretField: GeneratedListTypes['fields']; - + /** Attempts to prevent consumers of the API from being able to determine the value of identity fields */ protectIdentities?: boolean; - - gqlNames?: AuthGqlNames; - - forgottenPassword?: { - /** Called when a user should be sent the forgotten password token they requested */ - sendToken: SendTokenFn; - }; - magicLink?: { - /** Called when a user should be sent the magic signin token they requested */ - sendToken: SendTokenFn; - }; + /** Password reset link functionality */ + passwordResetLink?: AuthTokenTypeConfig; + /** "Magic link" functionality */ + magicAuthLink?: AuthTokenTypeConfig; + /** The initial user/db seeding functionality */ initFirstItem?: { /** Array of fields to collect, e.g ['name', 'email', 'password'] */ fields: GeneratedListTypes['fields'][]; @@ -51,6 +56,8 @@ export type AuthConfig = { /** Extra input to add for the create mutation */ itemData?: Partial; }; + /** A string added GraphQL names created by this package; can be used to support multiple sets of auth functionality */ + gqlSuffix?: string; }; export type Auth = { @@ -61,7 +68,30 @@ export type Auth = { getAdditionalFiles: NonNullable[number]; }; extendGraphqlSchema: NonNullable; - lists?: KeystoneConfig['lists']; + fields: { [prop: string]: any }; validateConfig: (keystoneConfig: KeystoneConfig) => void; withAuth: (config: KeystoneConfig) => KeystoneConfig; }; + +export type AuthErrorCode = + // Password authentication + | 'PASSWORD_AUTH_FAILURE' // Generic + | 'PASSWORD_AUTH_IDENTITY_NOT_FOUND' + | 'PASSWORD_AUTH_SECRET_NOT_SET' + | 'PASSWORD_AUTH_MULTIPLE_IDENTITY_MATCHES' + | 'PASSWORD_AUTH_SECRET_MISMATCH' + // Password resets and magic links + | 'AUTH_TOKEN_REQUEST_IDENTITY_NOT_FOUND' + | 'AUTH_TOKEN_REQUEST_MULTIPLE_IDENTITY_MATCHES' + | 'AUTH_TOKEN_REDEMPTION_FAILURE' // Generic + | 'AUTH_TOKEN_REDEMPTION_IDENTITY_NOT_FOUND' + | 'AUTH_TOKEN_REDEMPTION_MULTIPLE_IDENTITY_MATCHES' + | 'AUTH_TOKEN_REDEMPTION_TOKEN_NOT_SET' + | 'AUTH_TOKEN_REDEMPTION_TOKEN_MISMATCH' + | 'AUTH_TOKEN_REDEMPTION_TOKEN_EXPIRED' + | 'AUTH_TOKEN_REDEMPTION_TOKEN_REDEEMED' + // Bad times + | 'INTERNAL_ERROR' + // Not used by the auth package itself + // Allows custom logic/errors to be generated without replacing the gql output types + | 'CUSTOM_ERROR';