Skip to content

Commit

Permalink
feat: disableLocalStrategy with auth fields still enabled (#9579)
Browse files Browse the repository at this point in the history
Adds configuration options to `auth.disableLocalStrategy` to allow
customization of how payload treats an auth enabled collection.

Two new properties have been added to `disableLocalStrategy`:

- `enableFields` Include auth fields on the collection even though the
local strategy is disabled. Useful when you do not want the database or
types to vary depending on the auth configuration used.
- `optionalPassword`: makes the password field not required
  • Loading branch information
DanRibbens authored Dec 3, 2024
1 parent 40f5c72 commit 6104fe5
Show file tree
Hide file tree
Showing 16 changed files with 256 additions and 41 deletions.
12 changes: 10 additions & 2 deletions packages/graphql/src/schema/initCollections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,20 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ

const mutationInputFields = [...fields]

if (collectionConfig.auth && !collectionConfig.auth.disableLocalStrategy) {
if (
collectionConfig.auth &&
(!collectionConfig.auth.disableLocalStrategy ||
(typeof collectionConfig.auth.disableLocalStrategy === 'object' &&
collectionConfig.auth.disableLocalStrategy.optionalPassword))
) {
mutationInputFields.push({
name: 'password',
type: 'text',
label: 'Password',
required: true,
required: !(
typeof collectionConfig.auth.disableLocalStrategy === 'object' &&
collectionConfig.auth.disableLocalStrategy.optionalPassword
),
})
}

Expand Down
6 changes: 5 additions & 1 deletion packages/payload/src/auth/getAuthFields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ export const getBaseAuthFields = (authConfig: IncomingAuthType): Field[] => {
authFields.push(...apiKeyFields)
}

if (!authConfig.disableLocalStrategy) {
if (
!authConfig.disableLocalStrategy ||
(typeof authConfig.disableLocalStrategy === 'object' &&
authConfig.disableLocalStrategy.enableFields)
) {
const emailField = { ...emailFieldConfig }
let usernameField: TextField | undefined

Expand Down
9 changes: 6 additions & 3 deletions packages/payload/src/auth/operations/forgotPassword.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { PayloadRequest, Where } from '../../types/index.js'

import { buildAfterOperation } from '../../collections/operations/utils.js'
import { APIError } from '../../errors/index.js'
import { Forbidden } from '../../index.js'
import { commitTransaction } from '../../utilities/commitTransaction.js'
import { initTransaction } from '../../utilities/initTransaction.js'
import { killTransaction } from '../../utilities/killTransaction.js'
Expand Down Expand Up @@ -43,15 +44,18 @@ export const forgotPasswordOperation = async <TSlug extends CollectionSlug>(
? data.username.toLowerCase().trim()
: null

let args = incomingArgs

if (incomingArgs.collection.config.auth.disableLocalStrategy) {
throw new Forbidden(incomingArgs.req.t)
}
if (!sanitizedEmail && !sanitizedUsername) {
throw new APIError(
`Missing ${loginWithUsername ? 'username' : 'email'}.`,
httpStatus.BAD_REQUEST,
)
}

let args = incomingArgs

try {
const shouldCommit = await initTransaction(args.req)

Expand All @@ -74,7 +78,6 @@ export const forgotPasswordOperation = async <TSlug extends CollectionSlug>(

const {
collection: { config: collectionConfig },
data,
disableEmail,
expiration,
req: {
Expand Down
5 changes: 5 additions & 0 deletions packages/payload/src/auth/operations/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { User } from '../types.js'
import { buildAfterOperation } from '../../collections/operations/utils.js'
import { AuthenticationError, LockedAuth, ValidationError } from '../../errors/index.js'
import { afterRead } from '../../fields/hooks/afterRead/index.js'
import { Forbidden } from '../../index.js'
import { killTransaction } from '../../utilities/killTransaction.js'
import sanitizeInternalFields from '../../utilities/sanitizeInternalFields.js'
import { getFieldsToSign } from '../getFieldsToSign.js'
Expand Down Expand Up @@ -40,6 +41,10 @@ export const loginOperation = async <TSlug extends CollectionSlug>(
): Promise<{ user: DataFromCollectionSlug<TSlug> } & Result> => {
let args = incomingArgs

if (args.collection.config.auth.disableLocalStrategy) {
throw new Forbidden(args.req.t)
}

try {
// /////////////////////////////////////
// beforeOperation - Collection
Expand Down
4 changes: 4 additions & 0 deletions packages/payload/src/auth/operations/registerFirstUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ export const registerFirstUserOperation = async <TSlug extends CollectionSlug>(
req: { payload },
} = args

if (config.auth.disableLocalStrategy) {
throw new Forbidden(req.t)
}

try {
const shouldCommit = await initTransaction(req)

Expand Down
20 changes: 12 additions & 8 deletions packages/payload/src/auth/operations/resetPassword.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import httpStatus from 'http-status'
import type { Collection } from '../../collections/config/types.js'
import type { PayloadRequest } from '../../types/index.js'

import { APIError } from '../../errors/index.js'
import { APIError, Forbidden } from '../../errors/index.js'
import { commitTransaction } from '../../utilities/commitTransaction.js'
import { initTransaction } from '../../utilities/initTransaction.js'
import { killTransaction } from '../../utilities/killTransaction.js'
Expand All @@ -29,13 +29,6 @@ export type Arguments = {
}

export const resetPasswordOperation = async (args: Arguments): Promise<Result> => {
if (
!Object.prototype.hasOwnProperty.call(args.data, 'token') ||
!Object.prototype.hasOwnProperty.call(args.data, 'password')
) {
throw new APIError('Missing required data.', httpStatus.BAD_REQUEST)
}

const {
collection: { config: collectionConfig },
data,
Expand All @@ -48,6 +41,17 @@ export const resetPasswordOperation = async (args: Arguments): Promise<Result> =
req,
} = args

if (
!Object.prototype.hasOwnProperty.call(data, 'token') ||
!Object.prototype.hasOwnProperty.call(data, 'password')
) {
throw new APIError('Missing required data.', httpStatus.BAD_REQUEST)
}

if (collectionConfig.auth.disableLocalStrategy) {
throw new Forbidden(req.t)
}

try {
const shouldCommit = await initTransaction(req)

Expand Down
4 changes: 4 additions & 0 deletions packages/payload/src/auth/operations/unlock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { CollectionSlug } from '../../index.js'
import type { PayloadRequest, Where } from '../../types/index.js'

import { APIError } from '../../errors/index.js'
import { Forbidden } from '../../index.js'
import { commitTransaction } from '../../utilities/commitTransaction.js'
import { initTransaction } from '../../utilities/initTransaction.js'
import { killTransaction } from '../../utilities/killTransaction.js'
Expand Down Expand Up @@ -44,6 +45,9 @@ export const unlockOperation = async <TSlug extends CollectionSlug>(
args.data.username.toLowerCase().trim()) ||
null

if (collectionConfig.auth.disableLocalStrategy) {
throw new Forbidden(req.t)
}
if (!sanitizedEmail && !sanitizedUsername) {
throw new APIError(
`Missing ${collectionConfig.auth.loginWithUsername ? 'username' : 'email'}.`,
Expand Down
6 changes: 5 additions & 1 deletion packages/payload/src/auth/operations/verifyEmail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import httpStatus from 'http-status'
import type { Collection } from '../../collections/config/types.js'
import type { PayloadRequest } from '../../types/index.js'

import { APIError } from '../../errors/index.js'
import { APIError, Forbidden } from '../../errors/index.js'
import { commitTransaction } from '../../utilities/commitTransaction.js'
import { initTransaction } from '../../utilities/initTransaction.js'
import { killTransaction } from '../../utilities/killTransaction.js'
Expand All @@ -16,6 +16,10 @@ export type Args = {

export const verifyEmailOperation = async (args: Args): Promise<boolean> => {
const { collection, req, token } = args

if (collection.config.auth.disableLocalStrategy) {
throw new Forbidden(req.t)
}
if (!Object.prototype.hasOwnProperty.call(args, 'token')) {
throw new APIError('Missing required data.', httpStatus.BAD_REQUEST)
}
Expand Down
11 changes: 10 additions & 1 deletion packages/payload/src/auth/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,16 @@ export interface IncomingAuthType {
/**
* Advanced - disable Payload's built-in local auth strategy. Only use this property if you have replaced Payload's auth mechanisms with your own.
*/
disableLocalStrategy?: true
disableLocalStrategy?:
| {
/**
* Include auth fields on the collection even though the local strategy is disabled.
* Useful when you do not want the database or types to vary depending on the auth configuration.
*/
enableFields?: true
optionalPassword?: true
}
| true
/**
* Customize the way that the forgotPassword operation functions.
* @link https://payloadcms.com/docs/authentication/email#forgot-password
Expand Down
8 changes: 7 additions & 1 deletion packages/payload/src/utilities/configToJSONSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -617,7 +617,13 @@ export function entityToJSONSchema(
})
}

if ('auth' in entity && entity.auth && !entity.auth?.disableLocalStrategy) {
if (
'auth' in entity &&
entity.auth &&
(!entity.auth?.disableLocalStrategy ||
(typeof entity.auth?.disableLocalStrategy === 'object' &&
entity.auth.disableLocalStrategy.enableFields))
) {
entity.flattenedFields.push({
name: 'password',
type: 'text',
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/views/Edit/Auth/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { SanitizedCollectionConfig } from 'payload'
export type Props = {
className?: string
collectionSlug: SanitizedCollectionConfig['slug']
disableLocalStrategy?: boolean
disableLocalStrategy?: SanitizedCollectionConfig['auth']['disableLocalStrategy']
email: string
loginWithUsername: SanitizedCollectionConfig['auth']['loginWithUsername']
operation: 'create' | 'update'
Expand Down
27 changes: 26 additions & 1 deletion test/auth/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ import { v4 as uuid } from 'uuid'

import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js'
import { apiKeysSlug, namedSaveToJWTValue, saveToJWTKey, slug } from './shared.js'
import {
apiKeysSlug,
namedSaveToJWTValue,
partialDisableLocaleStrategiesSlug,
saveToJWTKey,
slug,
} from './shared.js'

export default buildConfigWithDefaults({
admin: {
Expand Down Expand Up @@ -174,6 +180,25 @@ export default buildConfigWithDefaults({
},
],
},
{
slug: partialDisableLocaleStrategiesSlug,
auth: {
disableLocalStrategy: {
// optionalPassword: true,
enableFields: true,
},
},
fields: [
// with `enableFields: true`, the following DB columns will be created:
// email
// reset_password_token
// reset_password_expiration
// salt
// hash
// login_attempts
// lock_until
],
},
{
slug: apiKeysSlug,
access: {
Expand Down
1 change: 0 additions & 1 deletion test/auth/e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import type { SanitizedConfig } from 'payload'
import { expect, test } from '@playwright/test'
import { devUser } from 'credentials.js'
import path from 'path'
import { wait } from 'payload/shared'
import { fileURLToPath } from 'url'
import { v4 as uuid } from 'uuid'

Expand Down
74 changes: 72 additions & 2 deletions test/auth/int.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Payload, User } from 'payload'
import type { FieldAffectingData, Payload, User } from 'payload'

import { jwtDecode } from 'jwt-decode'
import path from 'path'
Expand All @@ -9,7 +9,13 @@ import type { NextRESTClient } from '../helpers/NextRESTClient.js'

import { devUser } from '../credentials.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
import { apiKeysSlug, namedSaveToJWTValue, saveToJWTKey, slug } from './shared.js'
import {
apiKeysSlug,
namedSaveToJWTValue,
partialDisableLocaleStrategiesSlug,
saveToJWTKey,
slug,
} from './shared.js'

let restClient: NextRESTClient
let payload: Payload
Expand Down Expand Up @@ -709,6 +715,70 @@ describe('Auth', () => {
})
})

describe('disableLocalStrategy', () => {
it('should allow create of a user with disableLocalStrategy', async () => {
const email = 'test@example.com'
const user = await payload.create({
collection: partialDisableLocaleStrategiesSlug,
data: {
email,
// password is not required
},
})
expect(user.email).toStrictEqual(email)
})

it('should retain fields when auth.disableLocalStrategy.enableFields is true', () => {
const authFields = payload.collections[partialDisableLocaleStrategiesSlug].config.fields
// eslint-disable-next-line jest/no-conditional-in-test
.filter((field) => 'name' in field && field.name)
.map((field) => (field as FieldAffectingData).name)

expect(authFields).toMatchObject([
'updatedAt',
'createdAt',
'email',
'resetPasswordToken',
'resetPasswordExpiration',
'salt',
'hash',
'loginAttempts',
'lockUntil',
])
})

it('should prevent login of user with disableLocalStrategy.', async () => {
await payload.create({
collection: partialDisableLocaleStrategiesSlug,
data: {
email: devUser.email,
password: devUser.password,
},
})

await expect(async () => {
await payload.login({
collection: partialDisableLocaleStrategiesSlug,
data: {
email: devUser.email,
password: devUser.password,
},
})
}).rejects.toThrow('You are not allowed to perform this action.')
})

it('rest - should prevent login', async () => {
const response = await restClient.POST(`/${partialDisableLocaleStrategiesSlug}/login`, {
body: JSON.stringify({
email,
password,
}),
})

expect(response.status).toBe(403)
})
})

describe('API Key', () => {
it('should authenticate via the correct API key user', async () => {
const usersQuery = await payload.find({
Expand Down
Loading

0 comments on commit 6104fe5

Please sign in to comment.