feat: auth sessions (#12483)
Adds full session functionality into Payload's existing local authentication strategy. It's enabled by default, because this is a more secure pattern that we should enforce. However, we have provided an opt-out pattern for those that want to stick to stateless JWT authentication by passing `collectionConfig.auth.useSessions: false`. Todo: - [x] @jessrynkar to update the Next.js server functions for refresh and logout to support these new features - [x] @jessrynkar resolve build errors --------- Co-authored-by: Elliot DeNolf <denolfe@gmail.com> Co-authored-by: Jessica Chowdhury <jessica@trbl.design> Co-authored-by: Jarrod Flesch <30633324+JarrodMFlesch@users.noreply.github.com> Co-authored-by: Sasha <64744993+r1tsuu@users.noreply.github.com>
This commit is contained in:
@@ -180,19 +180,22 @@ As Payload sets HTTP-only cookies, logging out cannot be done by just removing a
|
|||||||
**Example REST API logout**:
|
**Example REST API logout**:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
const res = await fetch('http://localhost:3000/api/[collection-slug]/logout', {
|
const res = await fetch(
|
||||||
method: 'POST',
|
'http://localhost:3000/api/[collection-slug]/logout?allSessions=false',
|
||||||
headers: {
|
{
|
||||||
'Content-Type': 'application/json',
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Example GraphQL Mutation**:
|
**Example GraphQL Mutation**:
|
||||||
|
|
||||||
```
|
```
|
||||||
mutation {
|
mutation {
|
||||||
logout[collection-singular-label]
|
logoutUser(allSessions: false)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -203,6 +206,10 @@ mutation {
|
|||||||
docs](../local-api/server-functions#reusable-payload-server-functions).
|
docs](../local-api/server-functions#reusable-payload-server-functions).
|
||||||
</Banner>
|
</Banner>
|
||||||
|
|
||||||
|
#### Logging out with sessions enabled
|
||||||
|
|
||||||
|
By default, logging out will only end the session pertaining to the JWT that was used to log out with. However, you can pass `allSessions: true` to the logout operation in order to end all sessions for the user logging out.
|
||||||
|
|
||||||
## Refresh
|
## Refresh
|
||||||
|
|
||||||
Allows for "refreshing" JWTs. If your user has a token that is about to expire, but the user is still active and using the app, you might want to use the `refresh` operation to receive a new token by executing this operation via the authenticated user.
|
Allows for "refreshing" JWTs. If your user has a token that is about to expire, but the user is still active and using the app, you might want to use the `refresh` operation to receive a new token by executing this operation via the authenticated user.
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ The following options are available:
|
|||||||
| **`strategies`** | Advanced - an array of custom authentication strategies to extend this collection's authentication with. [More details](./custom-strategies). |
|
| **`strategies`** | Advanced - an array of custom authentication strategies to extend this collection's authentication with. [More details](./custom-strategies). |
|
||||||
| **`tokenExpiration`** | How long (in seconds) to keep the user logged in. JWTs and HTTP-only cookies will both expire at the same time. |
|
| **`tokenExpiration`** | How long (in seconds) to keep the user logged in. JWTs and HTTP-only cookies will both expire at the same time. |
|
||||||
| **`useAPIKey`** | Payload Authentication provides for API keys to be set on each user within an Authentication-enabled Collection. [More details](./api-keys). |
|
| **`useAPIKey`** | Payload Authentication provides for API keys to be set on each user within an Authentication-enabled Collection. [More details](./api-keys). |
|
||||||
|
| **`useSessions`** | True by default. Set to `false` to use stateless JWTs for authentication instead of sessions. |
|
||||||
| **`verify`** | Set to `true` or pass an object with verification options to require users to verify by email before they are allowed to log into your app. [More details](./email#email-verification). |
|
| **`verify`** | Set to `true` or pass an object with verification options to require users to verify by email before they are allowed to log into your app. [More details](./email#email-verification). |
|
||||||
|
|
||||||
### Login With Username
|
### Login With Username
|
||||||
|
|||||||
@@ -393,7 +393,7 @@ export default function LoginForm() {
|
|||||||
|
|
||||||
### Logout
|
### Logout
|
||||||
|
|
||||||
Logs out the current user by clearing the authentication cookie.
|
Logs out the current user by clearing the authentication cookie and current sessions.
|
||||||
|
|
||||||
#### Importing the `logout` function
|
#### Importing the `logout` function
|
||||||
|
|
||||||
@@ -401,7 +401,7 @@ Logs out the current user by clearing the authentication cookie.
|
|||||||
import { logout } from '@payloadcms/next/auth'
|
import { logout } from '@payloadcms/next/auth'
|
||||||
```
|
```
|
||||||
|
|
||||||
Similar to the login function, you now need to pass your Payload config to this function and this cannot be done in a client component. Use a helper server function as shown below.
|
Similar to the login function, you now need to pass your Payload config to this function and this cannot be done in a client component. Use a helper server function as shown below. To ensure all sessions are cleared, set `allSessions: true` in the options, if you wish to logout but keep current sessions active, you can set this to `false` or leave it `undefined`.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
'use server'
|
'use server'
|
||||||
@@ -411,7 +411,7 @@ import config from '@payload-config'
|
|||||||
|
|
||||||
export async function logoutAction() {
|
export async function logoutAction() {
|
||||||
try {
|
try {
|
||||||
return await logout({ config })
|
return await logout({ allSessions: true, config })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Logout failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
`Logout failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||||
@@ -434,7 +434,7 @@ export default function LogoutButton() {
|
|||||||
|
|
||||||
### Refresh
|
### Refresh
|
||||||
|
|
||||||
Refreshes the authentication token for the logged-in user.
|
Refreshes the authentication token and current session for the logged-in user.
|
||||||
|
|
||||||
#### Importing the `refresh` function
|
#### Importing the `refresh` function
|
||||||
|
|
||||||
@@ -453,7 +453,6 @@ import config from '@payload-config'
|
|||||||
export async function refreshAction() {
|
export async function refreshAction() {
|
||||||
try {
|
try {
|
||||||
return await refresh({
|
return await refresh({
|
||||||
collection: 'users', // pass your collection slug
|
|
||||||
config,
|
config,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -74,9 +74,7 @@ import * as Sentry from '@sentry/nextjs'
|
|||||||
|
|
||||||
const config = buildConfig({
|
const config = buildConfig({
|
||||||
collections: [Pages, Media],
|
collections: [Pages, Media],
|
||||||
plugins: [
|
plugins: [sentryPlugin({ Sentry })],
|
||||||
sentryPlugin({ Sentry })
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export default config
|
export default config
|
||||||
@@ -98,9 +96,7 @@ export default buildConfig({
|
|||||||
pool: { connectionString: process.env.DATABASE_URL },
|
pool: { connectionString: process.env.DATABASE_URL },
|
||||||
pg, // Inject the patched pg driver for Sentry instrumentation
|
pg, // Inject the patched pg driver for Sentry instrumentation
|
||||||
}),
|
}),
|
||||||
plugins: [
|
plugins: [sentryPlugin({ Sentry })],
|
||||||
sentryPlugin({ Sentry })
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { Context } from '../types.js'
|
|||||||
export function logout(collection: Collection): any {
|
export function logout(collection: Collection): any {
|
||||||
async function resolver(_, args, context: Context) {
|
async function resolver(_, args, context: Context) {
|
||||||
const options = {
|
const options = {
|
||||||
|
allSessions: args.allSessions,
|
||||||
collection,
|
collection,
|
||||||
req: isolateObjectProperty(context.req, 'transactionID'),
|
req: isolateObjectProperty(context.req, 'transactionID'),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -487,6 +487,9 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ
|
|||||||
|
|
||||||
graphqlResult.Mutation.fields[`logout${singularName}`] = {
|
graphqlResult.Mutation.fields[`logout${singularName}`] = {
|
||||||
type: GraphQLString,
|
type: GraphQLString,
|
||||||
|
args: {
|
||||||
|
allSessions: { type: GraphQLBoolean },
|
||||||
|
},
|
||||||
resolve: logout(collection),
|
resolve: logout(collection),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,46 @@
|
|||||||
'use server'
|
'use server'
|
||||||
|
|
||||||
|
import type { SanitizedConfig } from 'payload'
|
||||||
|
|
||||||
import { cookies as getCookies, headers as nextHeaders } from 'next/headers.js'
|
import { cookies as getCookies, headers as nextHeaders } from 'next/headers.js'
|
||||||
import { getPayload } from 'payload'
|
import { createLocalReq, getPayload, logoutOperation } from 'payload'
|
||||||
|
|
||||||
import { getExistingAuthToken } from '../utilities/getExistingAuthToken.js'
|
import { getExistingAuthToken } from '../utilities/getExistingAuthToken.js'
|
||||||
|
|
||||||
export async function logout({ config }: { config: any }) {
|
export async function logout({
|
||||||
|
allSessions = false,
|
||||||
|
config,
|
||||||
|
}: {
|
||||||
|
allSessions?: boolean
|
||||||
|
config: Promise<SanitizedConfig> | SanitizedConfig
|
||||||
|
}) {
|
||||||
const payload = await getPayload({ config })
|
const payload = await getPayload({ config })
|
||||||
const headers = await nextHeaders()
|
const headers = await nextHeaders()
|
||||||
const result = await payload.auth({ headers })
|
const authResult = await payload.auth({ headers })
|
||||||
|
|
||||||
if (!result.user) {
|
if (!authResult.user) {
|
||||||
return { message: 'User already logged out', success: true }
|
return { message: 'User already logged out', success: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingCookie = await getExistingAuthToken(payload.config.cookiePrefix)
|
const { user } = authResult
|
||||||
|
const req = await createLocalReq({ user }, payload)
|
||||||
|
const collection = payload.collections[user.collection]
|
||||||
|
|
||||||
|
const logoutResult = await logoutOperation({
|
||||||
|
allSessions,
|
||||||
|
collection,
|
||||||
|
req,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!logoutResult) {
|
||||||
|
return { message: 'Logout failed', success: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingCookie = await getExistingAuthToken(payload.config.cookiePrefix)
|
||||||
if (existingCookie) {
|
if (existingCookie) {
|
||||||
const cookies = await getCookies()
|
const cookies = await getCookies()
|
||||||
cookies.delete(existingCookie.name)
|
cookies.delete(existingCookie.name)
|
||||||
return { message: 'User logged out successfully', success: true }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { message: 'User logged out successfully', success: true }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,33 +3,45 @@
|
|||||||
import type { CollectionSlug } from 'payload'
|
import type { CollectionSlug } from 'payload'
|
||||||
|
|
||||||
import { headers as nextHeaders } from 'next/headers.js'
|
import { headers as nextHeaders } from 'next/headers.js'
|
||||||
import { getPayload } from 'payload'
|
import { createLocalReq, getPayload, refreshOperation } from 'payload'
|
||||||
|
|
||||||
import { getExistingAuthToken } from '../utilities/getExistingAuthToken.js'
|
import { getExistingAuthToken } from '../utilities/getExistingAuthToken.js'
|
||||||
import { setPayloadAuthCookie } from '../utilities/setPayloadAuthCookie.js'
|
import { setPayloadAuthCookie } from '../utilities/setPayloadAuthCookie.js'
|
||||||
|
|
||||||
export async function refresh({ collection, config }: { collection: CollectionSlug; config: any }) {
|
export async function refresh({ config }: { config: any }) {
|
||||||
const payload = await getPayload({ config })
|
const payload = await getPayload({ config })
|
||||||
const authConfig = payload.collections[collection]?.config.auth
|
const headers = await nextHeaders()
|
||||||
|
const result = await payload.auth({ headers })
|
||||||
|
|
||||||
if (!authConfig) {
|
if (!result.user) {
|
||||||
|
throw new Error('Cannot refresh token: user not authenticated')
|
||||||
|
}
|
||||||
|
|
||||||
|
const collection: CollectionSlug | undefined = result.user.collection
|
||||||
|
const collectionConfig = payload.collections[collection]
|
||||||
|
|
||||||
|
if (!collectionConfig?.config.auth) {
|
||||||
throw new Error(`No auth config found for collection: ${collection}`)
|
throw new Error(`No auth config found for collection: ${collection}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { user } = await payload.auth({ headers: await nextHeaders() })
|
const req = await createLocalReq({ user: result.user }, payload)
|
||||||
|
|
||||||
if (!user) {
|
const refreshResult = await refreshOperation({
|
||||||
throw new Error('User not authenticated')
|
collection: collectionConfig,
|
||||||
|
req,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!refreshResult) {
|
||||||
|
return { message: 'Token refresh failed', success: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingCookie = await getExistingAuthToken(payload.config.cookiePrefix)
|
const existingCookie = await getExistingAuthToken(payload.config.cookiePrefix)
|
||||||
|
|
||||||
if (!existingCookie) {
|
if (!existingCookie) {
|
||||||
return { message: 'No valid token found', success: false }
|
return { message: 'No valid token found to refresh', success: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
await setPayloadAuthCookie({
|
await setPayloadAuthCookie({
|
||||||
authConfig,
|
authConfig: collectionConfig.config.auth,
|
||||||
cookiePrefix: payload.config.cookiePrefix,
|
cookiePrefix: payload.config.cookiePrefix,
|
||||||
token: existingCookie.value,
|
token: existingCookie.value,
|
||||||
})
|
})
|
||||||
|
|||||||
32
packages/payload/src/auth/baseFields/sessions.ts
Normal file
32
packages/payload/src/auth/baseFields/sessions.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { ArrayField } from '../../fields/config/types.js'
|
||||||
|
|
||||||
|
export const sessionsFieldConfig: ArrayField = {
|
||||||
|
name: 'sessions',
|
||||||
|
type: 'array',
|
||||||
|
access: {
|
||||||
|
read: ({ doc, req: { user } }) => {
|
||||||
|
return user?.id === doc?.id
|
||||||
|
},
|
||||||
|
update: () => false,
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'createdAt',
|
||||||
|
type: 'date',
|
||||||
|
defaultValue: () => new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'expiresAt',
|
||||||
|
type: 'date',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -9,8 +9,10 @@ import { logoutOperation } from '../operations/logout.js'
|
|||||||
|
|
||||||
export const logoutHandler: PayloadHandler = async (req) => {
|
export const logoutHandler: PayloadHandler = async (req) => {
|
||||||
const collection = getRequestCollection(req)
|
const collection = getRequestCollection(req)
|
||||||
const { t } = req
|
const { searchParams, t } = req
|
||||||
|
|
||||||
const result = await logoutOperation({
|
const result = await logoutOperation({
|
||||||
|
allSessions: searchParams.get('allSessions') === 'true',
|
||||||
collection,
|
collection,
|
||||||
req,
|
req,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { accountLockFields } from './baseFields/accountLock.js'
|
|||||||
import { apiKeyFields } from './baseFields/apiKey.js'
|
import { apiKeyFields } from './baseFields/apiKey.js'
|
||||||
import { baseAuthFields } from './baseFields/auth.js'
|
import { baseAuthFields } from './baseFields/auth.js'
|
||||||
import { emailFieldConfig } from './baseFields/email.js'
|
import { emailFieldConfig } from './baseFields/email.js'
|
||||||
|
import { sessionsFieldConfig } from './baseFields/sessions.js'
|
||||||
import { usernameFieldConfig } from './baseFields/username.js'
|
import { usernameFieldConfig } from './baseFields/username.js'
|
||||||
import { verificationFields } from './baseFields/verification.js'
|
import { verificationFields } from './baseFields/verification.js'
|
||||||
|
|
||||||
@@ -52,6 +53,10 @@ export const getBaseAuthFields = (authConfig: IncomingAuthType): Field[] => {
|
|||||||
if (authConfig?.maxLoginAttempts && authConfig.maxLoginAttempts > 0) {
|
if (authConfig?.maxLoginAttempts && authConfig.maxLoginAttempts > 0) {
|
||||||
authFields.push(...accountLockFields)
|
authFields.push(...accountLockFields)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (authConfig.useSessions) {
|
||||||
|
authFields.push(sessionsFieldConfig)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return authFields
|
return authFields
|
||||||
|
|||||||
@@ -114,9 +114,10 @@ const traverseFields = ({
|
|||||||
export const getFieldsToSign = (args: {
|
export const getFieldsToSign = (args: {
|
||||||
collectionConfig: CollectionConfig
|
collectionConfig: CollectionConfig
|
||||||
email: string
|
email: string
|
||||||
|
sid?: string
|
||||||
user: PayloadRequest['user']
|
user: PayloadRequest['user']
|
||||||
}): Record<string, unknown> => {
|
}): Record<string, unknown> => {
|
||||||
const { collectionConfig, email, user } = args
|
const { collectionConfig, email, sid, user } = args
|
||||||
|
|
||||||
const result: Record<string, unknown> = {
|
const result: Record<string, unknown> = {
|
||||||
id: user?.id,
|
id: user?.id,
|
||||||
@@ -124,6 +125,10 @@ export const getFieldsToSign = (args: {
|
|||||||
email,
|
email,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sid) {
|
||||||
|
result.sid = sid
|
||||||
|
}
|
||||||
|
|
||||||
traverseFields({
|
traverseFields({
|
||||||
data: user!,
|
data: user!,
|
||||||
fields: collectionConfig.fields,
|
fields: collectionConfig.fields,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { v4 as uuid } from 'uuid'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AuthOperationsFromCollectionSlug,
|
AuthOperationsFromCollectionSlug,
|
||||||
Collection,
|
Collection,
|
||||||
@@ -23,6 +25,7 @@ import { getFieldsToSign } from '../getFieldsToSign.js'
|
|||||||
import { getLoginOptions } from '../getLoginOptions.js'
|
import { getLoginOptions } from '../getLoginOptions.js'
|
||||||
import { isUserLocked } from '../isUserLocked.js'
|
import { isUserLocked } from '../isUserLocked.js'
|
||||||
import { jwtSign } from '../jwt.js'
|
import { jwtSign } from '../jwt.js'
|
||||||
|
import { removeExpiredSessions } from '../removeExpiredSessions.js'
|
||||||
import { authenticateLocalStrategy } from '../strategies/local/authenticate.js'
|
import { authenticateLocalStrategy } from '../strategies/local/authenticate.js'
|
||||||
import { incrementLoginAttempts } from '../strategies/local/incrementLoginAttempts.js'
|
import { incrementLoginAttempts } from '../strategies/local/incrementLoginAttempts.js'
|
||||||
import { resetLoginAttempts } from '../strategies/local/resetLoginAttempts.js'
|
import { resetLoginAttempts } from '../strategies/local/resetLoginAttempts.js'
|
||||||
@@ -114,7 +117,6 @@ export const loginOperation = async <TSlug extends CollectionSlug>(
|
|||||||
// Login
|
// Login
|
||||||
// /////////////////////////////////////
|
// /////////////////////////////////////
|
||||||
|
|
||||||
let user
|
|
||||||
const { email: unsanitizedEmail, password } = data
|
const { email: unsanitizedEmail, password } = data
|
||||||
const loginWithUsername = collectionConfig.auth.loginWithUsername
|
const loginWithUsername = collectionConfig.auth.loginWithUsername
|
||||||
|
|
||||||
@@ -204,7 +206,7 @@ export const loginOperation = async <TSlug extends CollectionSlug>(
|
|||||||
whereConstraint = usernameConstraint
|
whereConstraint = usernameConstraint
|
||||||
}
|
}
|
||||||
|
|
||||||
user = await payload.db.findOne<any>({
|
let user = await payload.db.findOne<any>({
|
||||||
collection: collectionConfig.slug,
|
collection: collectionConfig.slug,
|
||||||
req,
|
req,
|
||||||
where: whereConstraint,
|
where: whereConstraint,
|
||||||
@@ -239,6 +241,41 @@ export const loginOperation = async <TSlug extends CollectionSlug>(
|
|||||||
throw new AuthenticationError(req.t)
|
throw new AuthenticationError(req.t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fieldsToSignArgs: Parameters<typeof getFieldsToSign>[0] = {
|
||||||
|
collectionConfig,
|
||||||
|
email: sanitizedEmail!,
|
||||||
|
user,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collectionConfig.auth.useSessions) {
|
||||||
|
// Add session to user
|
||||||
|
const newSessionID = uuid()
|
||||||
|
const now = new Date()
|
||||||
|
const tokenExpInMs = collectionConfig.auth.tokenExpiration * 1000
|
||||||
|
const expiresAt = new Date(now.getTime() + tokenExpInMs)
|
||||||
|
|
||||||
|
const session = { id: newSessionID, createdAt: now, expiresAt }
|
||||||
|
|
||||||
|
if (!user.sessions?.length) {
|
||||||
|
user.sessions = [session]
|
||||||
|
} else {
|
||||||
|
user.sessions = removeExpiredSessions(user.sessions)
|
||||||
|
user.sessions.push(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
await payload.db.updateOne({
|
||||||
|
id: user.id,
|
||||||
|
collection: collectionConfig.slug,
|
||||||
|
data: user,
|
||||||
|
req,
|
||||||
|
returning: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
fieldsToSignArgs.sid = newSessionID
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldsToSign = getFieldsToSign(fieldsToSignArgs)
|
||||||
|
|
||||||
if (maxLoginAttemptsEnabled) {
|
if (maxLoginAttemptsEnabled) {
|
||||||
await resetLoginAttempts({
|
await resetLoginAttempts({
|
||||||
collection: collectionConfig,
|
collection: collectionConfig,
|
||||||
@@ -248,12 +285,6 @@ export const loginOperation = async <TSlug extends CollectionSlug>(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const fieldsToSign = getFieldsToSign({
|
|
||||||
collectionConfig,
|
|
||||||
email: sanitizedEmail!,
|
|
||||||
user,
|
|
||||||
})
|
|
||||||
|
|
||||||
// /////////////////////////////////////
|
// /////////////////////////////////////
|
||||||
// beforeLogin - Collection
|
// beforeLogin - Collection
|
||||||
// /////////////////////////////////////
|
// /////////////////////////////////////
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { PayloadRequest } from '../../types/index.js'
|
|||||||
import { APIError } from '../../errors/index.js'
|
import { APIError } from '../../errors/index.js'
|
||||||
|
|
||||||
export type Arguments = {
|
export type Arguments = {
|
||||||
|
allSessions?: boolean
|
||||||
collection: Collection
|
collection: Collection
|
||||||
req: PayloadRequest
|
req: PayloadRequest
|
||||||
}
|
}
|
||||||
@@ -13,6 +14,7 @@ export type Arguments = {
|
|||||||
export const logoutOperation = async (incomingArgs: Arguments): Promise<boolean> => {
|
export const logoutOperation = async (incomingArgs: Arguments): Promise<boolean> => {
|
||||||
let args = incomingArgs
|
let args = incomingArgs
|
||||||
const {
|
const {
|
||||||
|
allSessions,
|
||||||
collection: { config: collectionConfig },
|
collection: { config: collectionConfig },
|
||||||
req: { user },
|
req: { user },
|
||||||
req,
|
req,
|
||||||
@@ -36,5 +38,41 @@ export const logoutOperation = async (incomingArgs: Arguments): Promise<boolean>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (collectionConfig.auth.disableLocalStrategy !== true && collectionConfig.auth.useSessions) {
|
||||||
|
const userWithSessions = await req.payload.db.findOne<{
|
||||||
|
id: number | string
|
||||||
|
sessions: { id: string }[]
|
||||||
|
}>({
|
||||||
|
collection: collectionConfig.slug,
|
||||||
|
req,
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
equals: user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!userWithSessions) {
|
||||||
|
throw new APIError('No User', httpStatus.BAD_REQUEST)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allSessions) {
|
||||||
|
userWithSessions.sessions = []
|
||||||
|
} else {
|
||||||
|
const sessionsAfterLogout = (userWithSessions?.sessions || []).filter(
|
||||||
|
(s) => s.id !== req?.user?._sid,
|
||||||
|
)
|
||||||
|
|
||||||
|
userWithSessions.sessions = sessionsAfterLogout
|
||||||
|
}
|
||||||
|
|
||||||
|
await req.payload.db.updateOne({
|
||||||
|
id: user.id,
|
||||||
|
collection: collectionConfig.slug,
|
||||||
|
data: userWithSessions,
|
||||||
|
returning: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import url from 'url'
|
import url from 'url'
|
||||||
|
import { v4 as uuid } from 'uuid'
|
||||||
|
|
||||||
import type { Collection } from '../../collections/config/types.js'
|
import type { Collection } from '../../collections/config/types.js'
|
||||||
import type { Document, PayloadRequest } from '../../types/index.js'
|
import type { Document, PayloadRequest } from '../../types/index.js'
|
||||||
@@ -10,6 +11,7 @@ import { initTransaction } from '../../utilities/initTransaction.js'
|
|||||||
import { killTransaction } from '../../utilities/killTransaction.js'
|
import { killTransaction } from '../../utilities/killTransaction.js'
|
||||||
import { getFieldsToSign } from '../getFieldsToSign.js'
|
import { getFieldsToSign } from '../getFieldsToSign.js'
|
||||||
import { jwtSign } from '../jwt.js'
|
import { jwtSign } from '../jwt.js'
|
||||||
|
import { removeExpiredSessions } from '../removeExpiredSessions.js'
|
||||||
|
|
||||||
export type Result = {
|
export type Result = {
|
||||||
exp: number
|
exp: number
|
||||||
@@ -79,6 +81,31 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise<Result>
|
|||||||
req: args.req,
|
req: args.req,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const sid = args.req.user._sid
|
||||||
|
|
||||||
|
if (collectionConfig.auth.useSessions && !collectionConfig.auth.disableLocalStrategy) {
|
||||||
|
if (!Array.isArray(user.sessions) || !sid) {
|
||||||
|
throw new Forbidden(args.req.t)
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingSession = user.sessions.find(({ id }) => id === sid)
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const tokenExpInMs = collectionConfig.auth.tokenExpiration * 1000
|
||||||
|
existingSession.expiresAt = new Date(now.getTime() + tokenExpInMs)
|
||||||
|
|
||||||
|
await req.payload.db.updateOne({
|
||||||
|
id: user.id,
|
||||||
|
collection: collectionConfig.slug,
|
||||||
|
data: {
|
||||||
|
...user,
|
||||||
|
sessions: removeExpiredSessions(user.sessions),
|
||||||
|
},
|
||||||
|
req,
|
||||||
|
returning: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
user.collection = args.req.user.collection
|
user.collection = args.req.user.collection
|
||||||
user._strategy = args.req.user._strategy
|
user._strategy = args.req.user._strategy
|
||||||
@@ -103,6 +130,7 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise<Result>
|
|||||||
const fieldsToSign = getFieldsToSign({
|
const fieldsToSign = getFieldsToSign({
|
||||||
collectionConfig,
|
collectionConfig,
|
||||||
email: user?.email as string,
|
email: user?.email as string,
|
||||||
|
sid,
|
||||||
user: args?.req?.user,
|
user: args?.req?.user,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
10
packages/payload/src/auth/removeExpiredSessions.ts
Normal file
10
packages/payload/src/auth/removeExpiredSessions.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { UserSession } from './types.js'
|
||||||
|
|
||||||
|
export const removeExpiredSessions = (sessions: UserSession[]) => {
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
return sessions.filter(({ expiresAt }) => {
|
||||||
|
const expiry = expiresAt instanceof Date ? expiresAt : new Date(expiresAt)
|
||||||
|
return expiry > now
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { extractJWT } from '../extractJWT.js'
|
|||||||
type JWTToken = {
|
type JWTToken = {
|
||||||
collection: string
|
collection: string
|
||||||
id: string
|
id: string
|
||||||
|
sid?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
async function autoLogin({
|
async function autoLogin({
|
||||||
@@ -100,6 +101,18 @@ export const JWTAuthentication: AuthStrategyFunction = async ({
|
|||||||
})) as AuthStrategyResult['user']
|
})) as AuthStrategyResult['user']
|
||||||
|
|
||||||
if (user && (!collection!.config.auth.verify || user._verified)) {
|
if (user && (!collection!.config.auth.verify || user._verified)) {
|
||||||
|
if (collection!.config.auth.useSessions) {
|
||||||
|
const existingSession = (user.sessions || []).find(({ id }) => id === decodedPayload.sid)
|
||||||
|
|
||||||
|
if (!existingSession || !decodedPayload.sid) {
|
||||||
|
return {
|
||||||
|
user: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
user._sid = decodedPayload.sid
|
||||||
|
}
|
||||||
|
|
||||||
user.collection = collection!.config.slug
|
user.collection = collection!.config.slug
|
||||||
user._strategy = strategyName
|
user._strategy = strategyName
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ type BaseUser = {
|
|||||||
collection: string
|
collection: string
|
||||||
email?: string
|
email?: string
|
||||||
id: number | string
|
id: number | string
|
||||||
|
sessions?: Array<UserSession>
|
||||||
username?: string
|
username?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +134,7 @@ export type ClientUser = {
|
|||||||
[key: string]: any
|
[key: string]: any
|
||||||
} & BaseUser
|
} & BaseUser
|
||||||
|
|
||||||
|
export type UserSession = { createdAt: Date | string; expiresAt: Date | string; id: string }
|
||||||
type GenerateVerifyEmailHTML<TUser = any> = (args: {
|
type GenerateVerifyEmailHTML<TUser = any> = (args: {
|
||||||
req: PayloadRequest
|
req: PayloadRequest
|
||||||
token: string
|
token: string
|
||||||
@@ -277,6 +279,13 @@ export interface IncomingAuthType {
|
|||||||
* @link https://payloadcms.com/docs/authentication/api-keys
|
* @link https://payloadcms.com/docs/authentication/api-keys
|
||||||
*/
|
*/
|
||||||
useAPIKey?: boolean
|
useAPIKey?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use sessions for authentication. Enabled by default.
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
useSessions?: boolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set to true or pass an object with verification options to require users to verify by email before they are allowed to log into your app.
|
* Set to true or pass an object with verification options to require users to verify by email before they are allowed to log into your app.
|
||||||
* @link https://payloadcms.com/docs/authentication/email#email-verification
|
* @link https://payloadcms.com/docs/authentication/email#email-verification
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ export const authDefaults: IncomingAuthType = {
|
|||||||
loginWithUsername: false,
|
loginWithUsername: false,
|
||||||
maxLoginAttempts: 5,
|
maxLoginAttempts: 5,
|
||||||
tokenExpiration: 7200,
|
tokenExpiration: 7200,
|
||||||
|
useSessions: true,
|
||||||
verify: false,
|
verify: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +143,7 @@ export const addDefaultsToAuthConfig = (auth: IncomingAuthType): IncomingAuthTyp
|
|||||||
auth.loginWithUsername = auth.loginWithUsername ?? false
|
auth.loginWithUsername = auth.loginWithUsername ?? false
|
||||||
auth.maxLoginAttempts = auth.maxLoginAttempts ?? 5
|
auth.maxLoginAttempts = auth.maxLoginAttempts ?? 5
|
||||||
auth.tokenExpiration = auth.tokenExpiration ?? 7200
|
auth.tokenExpiration = auth.tokenExpiration ?? 7200
|
||||||
|
auth.useSessions = auth.useSessions ?? true
|
||||||
auth.verify = auth.verify ?? false
|
auth.verify = auth.verify ?? false
|
||||||
auth.strategies = auth.strategies ?? []
|
auth.strategies = auth.strategies ?? []
|
||||||
|
|
||||||
|
|||||||
@@ -465,7 +465,7 @@ export const promise = async ({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else {
|
} else if (field.hidden !== true || showHiddenFields === true) {
|
||||||
siblingDoc[field.name] = []
|
siblingDoc[field.name] = []
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
@@ -570,7 +570,7 @@ export const promise = async ({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else {
|
} else if (field.hidden !== true || showHiddenFields === true) {
|
||||||
siblingDoc[field.name] = []
|
siblingDoc[field.name] = []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,18 @@ import { getLogger } from './utilities/logger.js'
|
|||||||
import { serverInit as serverInitTelemetry } from './utilities/telemetry/events/serverInit.js'
|
import { serverInit as serverInitTelemetry } from './utilities/telemetry/events/serverInit.js'
|
||||||
import { traverseFields } from './utilities/traverseFields.js'
|
import { traverseFields } from './utilities/traverseFields.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export of all base fields that could potentially be
|
||||||
|
* useful as users wish to extend built-in fields with custom logic
|
||||||
|
*/
|
||||||
|
export { accountLockFields as baseAccountLockFields } from './auth/baseFields/accountLock.js'
|
||||||
|
export { apiKeyFields as baseAPIKeyFields } from './auth/baseFields/apiKey.js'
|
||||||
|
export { baseAuthFields } from './auth/baseFields/auth.js'
|
||||||
|
export { emailFieldConfig as baseEmailField } from './auth/baseFields/email.js'
|
||||||
|
export { sessionsFieldConfig as baseSessionsField } from './auth/baseFields/sessions.js'
|
||||||
|
export { usernameFieldConfig as baseUsernameField } from './auth/baseFields/username.js'
|
||||||
|
|
||||||
|
export { verificationFields as baseVerificationFields } from './auth/baseFields/verification.js'
|
||||||
export { executeAccess } from './auth/executeAccess.js'
|
export { executeAccess } from './auth/executeAccess.js'
|
||||||
export { executeAuthStrategies } from './auth/executeAuthStrategies.js'
|
export { executeAuthStrategies } from './auth/executeAuthStrategies.js'
|
||||||
export { extractAccessFromPermission } from './auth/extractAccessFromPermission.js'
|
export { extractAccessFromPermission } from './auth/extractAccessFromPermission.js'
|
||||||
|
|||||||
@@ -58,27 +58,27 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
|
"@playwright/test": "1.50.0",
|
||||||
"@tailwindcss/typography": "^0.5.13",
|
"@tailwindcss/typography": "^0.5.13",
|
||||||
|
"@testing-library/react": "16.3.0",
|
||||||
"@types/escape-html": "^1.0.2",
|
"@types/escape-html": "^1.0.2",
|
||||||
"@types/node": "22.5.4",
|
"@types/node": "22.5.4",
|
||||||
"@types/react": "19.1.0",
|
"@types/react": "19.1.0",
|
||||||
"@types/react-dom": "19.1.2",
|
"@types/react-dom": "19.1.2",
|
||||||
|
"@vitejs/plugin-react": "4.5.2",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"copyfiles": "^2.4.1",
|
"copyfiles": "^2.4.1",
|
||||||
"eslint": "^9.16.0",
|
"eslint": "^9.16.0",
|
||||||
"eslint-config-next": "15.3.0",
|
"eslint-config-next": "15.3.0",
|
||||||
|
"jsdom": "26.1.0",
|
||||||
|
"playwright": "1.50.0",
|
||||||
|
"playwright-core": "1.50.0",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"tailwindcss": "^3.4.3",
|
"tailwindcss": "^3.4.3",
|
||||||
"@playwright/test": "1.50.0",
|
"typescript": "5.7.3",
|
||||||
"jsdom": "26.1.0",
|
|
||||||
"@testing-library/react": "16.3.0",
|
|
||||||
"@vitejs/plugin-react": "4.5.2",
|
|
||||||
"playwright": "1.50.0",
|
|
||||||
"playwright-core": "1.50.0",
|
|
||||||
"vite-tsconfig-paths": "5.1.4",
|
"vite-tsconfig-paths": "5.1.4",
|
||||||
"vitest": "3.2.3",
|
"vitest": "3.2.3"
|
||||||
"typescript": "5.7.3"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.20.2 || >=20.9.0",
|
"node": "^18.20.2 || >=20.9.0",
|
||||||
|
|||||||
@@ -25,29 +25,47 @@ import { default as default_1a7510af427896d367a49dbf838d2de6 } from '@/component
|
|||||||
import { default as default_8a7ab0eb7ab5c511aba12e68480bfe5e } from '@/components/BeforeLogin'
|
import { default as default_8a7ab0eb7ab5c511aba12e68480bfe5e } from '@/components/BeforeLogin'
|
||||||
|
|
||||||
export const importMap = {
|
export const importMap = {
|
||||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
'@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell':
|
||||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||||
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
'@payloadcms/richtext-lexical/rsc#RscEntryLexicalField':
|
||||||
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||||
"@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient": FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
'@payloadcms/richtext-lexical/rsc#LexicalDiffComponent':
|
||||||
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||||
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
'@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient':
|
||||||
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
'@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient':
|
||||||
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
'@payloadcms/richtext-lexical/client#HeadingFeatureClient':
|
||||||
"@payloadcms/plugin-seo/client#OverviewComponent": OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
"@payloadcms/plugin-seo/client#MetaTitleComponent": MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
'@payloadcms/richtext-lexical/client#ParagraphFeatureClient':
|
||||||
"@payloadcms/plugin-seo/client#MetaImageComponent": MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
"@payloadcms/plugin-seo/client#MetaDescriptionComponent": MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
'@payloadcms/richtext-lexical/client#UnderlineFeatureClient':
|
||||||
"@payloadcms/plugin-seo/client#PreviewComponent": PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
"@/fields/slug/SlugComponent#SlugComponent": SlugComponent_92cc057d0a2abb4f6cf0307edf59f986,
|
'@payloadcms/richtext-lexical/client#BoldFeatureClient':
|
||||||
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
"@payloadcms/richtext-lexical/client#BlocksFeatureClient": BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
'@payloadcms/richtext-lexical/client#ItalicFeatureClient':
|
||||||
"@payloadcms/plugin-search/client#LinkToDoc": LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634,
|
ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
"@payloadcms/plugin-search/client#ReindexButton": ReindexButton_aead06e4cbf6b2620c5c51c9ab283634,
|
'@payloadcms/richtext-lexical/client#LinkFeatureClient':
|
||||||
"@/Header/RowLabel#RowLabel": RowLabel_ec255a65fa6fa8d1faeb09cf35284224,
|
LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
"@/Footer/RowLabel#RowLabel": RowLabel_1f6ff6ff633e3695d348f4f3c58f1466,
|
'@payloadcms/plugin-seo/client#OverviewComponent':
|
||||||
"@/components/BeforeDashboard#default": default_1a7510af427896d367a49dbf838d2de6,
|
OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||||
"@/components/BeforeLogin#default": default_8a7ab0eb7ab5c511aba12e68480bfe5e
|
'@payloadcms/plugin-seo/client#MetaTitleComponent':
|
||||||
|
MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||||
|
'@payloadcms/plugin-seo/client#MetaImageComponent':
|
||||||
|
MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||||
|
'@payloadcms/plugin-seo/client#MetaDescriptionComponent':
|
||||||
|
MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||||
|
'@payloadcms/plugin-seo/client#PreviewComponent':
|
||||||
|
PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||||
|
'@/fields/slug/SlugComponent#SlugComponent': SlugComponent_92cc057d0a2abb4f6cf0307edf59f986,
|
||||||
|
'@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient':
|
||||||
|
HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
'@payloadcms/richtext-lexical/client#BlocksFeatureClient':
|
||||||
|
BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
'@payloadcms/plugin-search/client#LinkToDoc': LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634,
|
||||||
|
'@payloadcms/plugin-search/client#ReindexButton': ReindexButton_aead06e4cbf6b2620c5c51c9ab283634,
|
||||||
|
'@/Header/RowLabel#RowLabel': RowLabel_ec255a65fa6fa8d1faeb09cf35284224,
|
||||||
|
'@/Footer/RowLabel#RowLabel': RowLabel_1f6ff6ff633e3695d348f4f3c58f1466,
|
||||||
|
'@/components/BeforeDashboard#default': default_1a7510af427896d367a49dbf838d2de6,
|
||||||
|
'@/components/BeforeLogin#default': default_8a7ab0eb7ab5c511aba12e68480bfe5e,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ export interface Config {
|
|||||||
'auth-collection': AuthCollection;
|
'auth-collection': AuthCollection;
|
||||||
'payload-locked-documents': PayloadLockedDocument;
|
'payload-locked-documents': PayloadLockedDocument;
|
||||||
'payload-preferences': PayloadPreference;
|
'payload-preferences': PayloadPreference;
|
||||||
|
'payload-sessions': PayloadSession;
|
||||||
'payload-migrations': PayloadMigration;
|
'payload-migrations': PayloadMigration;
|
||||||
};
|
};
|
||||||
collectionsJoins: {};
|
collectionsJoins: {};
|
||||||
@@ -125,6 +126,7 @@ export interface Config {
|
|||||||
'auth-collection': AuthCollectionSelect<false> | AuthCollectionSelect<true>;
|
'auth-collection': AuthCollectionSelect<false> | AuthCollectionSelect<true>;
|
||||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||||
|
'payload-sessions': PayloadSessionsSelect<false> | PayloadSessionsSelect<true>;
|
||||||
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
||||||
};
|
};
|
||||||
db: {
|
db: {
|
||||||
@@ -891,6 +893,26 @@ export interface PayloadPreference {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "payload-sessions".
|
||||||
|
*/
|
||||||
|
export interface PayloadSession {
|
||||||
|
id: string;
|
||||||
|
session: string;
|
||||||
|
expiration: string;
|
||||||
|
user:
|
||||||
|
| {
|
||||||
|
relationTo: 'users';
|
||||||
|
value: string | User;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
relationTo: 'public-users';
|
||||||
|
value: string | PublicUser;
|
||||||
|
};
|
||||||
|
updatedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "payload-migrations".
|
* via the `definition` "payload-migrations".
|
||||||
@@ -1295,6 +1317,17 @@ export interface PayloadPreferencesSelect<T extends boolean = true> {
|
|||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "payload-sessions_select".
|
||||||
|
*/
|
||||||
|
export interface PayloadSessionsSelect<T extends boolean = true> {
|
||||||
|
session?: T;
|
||||||
|
expiration?: T;
|
||||||
|
user?: T;
|
||||||
|
updatedAt?: T;
|
||||||
|
createdAt?: T;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "payload-migrations_select".
|
* via the `definition` "payload-migrations_select".
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable jest/no-conditional-in-test */
|
||||||
import type {
|
import type {
|
||||||
BasePayload,
|
BasePayload,
|
||||||
EmailFieldValidation,
|
EmailFieldValidation,
|
||||||
@@ -737,7 +738,7 @@ describe('Auth', () => {
|
|||||||
|
|
||||||
it('should retain fields when auth.disableLocalStrategy.enableFields is true', () => {
|
it('should retain fields when auth.disableLocalStrategy.enableFields is true', () => {
|
||||||
const authFields = payload.collections[partialDisableLocalStrategiesSlug].config.fields
|
const authFields = payload.collections[partialDisableLocalStrategiesSlug].config.fields
|
||||||
// eslint-disable-next-line jest/no-conditional-in-test
|
|
||||||
.filter((field) => 'name' in field && field.name)
|
.filter((field) => 'name' in field && field.name)
|
||||||
.map((field) => (field as FieldAffectingData).name)
|
.map((field) => (field as FieldAffectingData).name)
|
||||||
|
|
||||||
@@ -751,6 +752,7 @@ describe('Auth', () => {
|
|||||||
'hash',
|
'hash',
|
||||||
'loginAttempts',
|
'loginAttempts',
|
||||||
'lockUntil',
|
'lockUntil',
|
||||||
|
'sessions',
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1051,4 +1053,215 @@ describe('Auth', () => {
|
|||||||
expect(emailValidation('user,name@example.com', mockContext)).toBe('validation:emailAddress')
|
expect(emailValidation('user,name@example.com', mockContext)).toBe('validation:emailAddress')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Sessions', () => {
|
||||||
|
it('should set a session on a user', async () => {
|
||||||
|
const authenticated = await payload.login({
|
||||||
|
collection: slug,
|
||||||
|
data: {
|
||||||
|
email: devUser.email,
|
||||||
|
password: devUser.password,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(authenticated.token).toBeTruthy()
|
||||||
|
|
||||||
|
const user = await payload.db.find<User>({
|
||||||
|
collection: slug,
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
equals: authenticated.user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(Array.isArray(user.docs[0]?.sessions)).toBeTruthy()
|
||||||
|
|
||||||
|
const decoded = jwtDecode<{ sid: string }>(String(authenticated.token))
|
||||||
|
|
||||||
|
expect(decoded.sid).toBeDefined()
|
||||||
|
|
||||||
|
const matchedSession = user.docs[0]?.sessions?.find(({ id }) => id === decoded.sid)
|
||||||
|
|
||||||
|
expect(matchedSession).toBeDefined()
|
||||||
|
expect(matchedSession?.createdAt).toBeDefined()
|
||||||
|
expect(matchedSession?.expiresAt).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should log out a user and delete only the session being logged out', async () => {
|
||||||
|
const authenticated = await payload.login({
|
||||||
|
collection: slug,
|
||||||
|
data: {
|
||||||
|
email: devUser.email,
|
||||||
|
password: devUser.password,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const authenticated2 = await payload.login({
|
||||||
|
collection: slug,
|
||||||
|
data: {
|
||||||
|
email: devUser.email,
|
||||||
|
password: devUser.password,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await restClient.POST(`/${slug}/logout`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `JWT ${authenticated.token}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const user = await payload.db.find<User>({
|
||||||
|
collection: slug,
|
||||||
|
where: {
|
||||||
|
email: {
|
||||||
|
equals: devUser.email,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const decoded = jwtDecode<{ sid: string }>(String(authenticated.token))
|
||||||
|
expect(decoded.sid).toBeDefined()
|
||||||
|
|
||||||
|
const remainingSessions = user.docs[0]?.sessions ?? []
|
||||||
|
|
||||||
|
const loggedOutSession = remainingSessions.find(({ id }) => id === decoded.sid)
|
||||||
|
expect(loggedOutSession).toBeUndefined()
|
||||||
|
|
||||||
|
const decoded2 = jwtDecode<{ sid: string }>(String(authenticated2.token))
|
||||||
|
expect(decoded2.sid).toBeDefined()
|
||||||
|
|
||||||
|
const existingSession = remainingSessions.find(({ id }) => id === decoded2.sid)
|
||||||
|
expect(existingSession?.id).toStrictEqual(decoded2.sid)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should refresh an existing session', async () => {
|
||||||
|
const authenticated = await payload.login({
|
||||||
|
collection: slug,
|
||||||
|
data: {
|
||||||
|
email: devUser.email,
|
||||||
|
password: devUser.password,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const decoded = jwtDecode<{ sid: string }>(String(authenticated.token))
|
||||||
|
|
||||||
|
const user = await payload.db.find<User>({
|
||||||
|
collection: slug,
|
||||||
|
where: {
|
||||||
|
email: {
|
||||||
|
equals: devUser.email,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const matchedSession = user.docs[0]?.sessions?.find(({ id }) => id === decoded.sid)
|
||||||
|
|
||||||
|
const refreshed = await restClient
|
||||||
|
.POST(`/${slug}/refresh-token`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `JWT ${authenticated.token}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((res) => res.json())
|
||||||
|
|
||||||
|
const refreshedUser = await payload.db.find<User>({
|
||||||
|
collection: slug,
|
||||||
|
where: {
|
||||||
|
email: {
|
||||||
|
equals: devUser.email,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const decodedRefreshed = jwtDecode<{ sid: string }>(String(refreshed.refreshedToken))
|
||||||
|
|
||||||
|
const matchedRefreshedSession = refreshedUser.docs[0]?.sessions?.find(
|
||||||
|
({ id }) => id === decodedRefreshed.sid,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(decodedRefreshed.sid).toStrictEqual(decoded.sid)
|
||||||
|
|
||||||
|
expect(new Date(matchedSession?.expiresAt as unknown as string).getTime()).toBeLessThan(
|
||||||
|
new Date(matchedRefreshedSession?.expiresAt as unknown as string).getTime(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not authenticate a user who has a JWT but its session has been terminated', async () => {
|
||||||
|
const authenticated = await payload.login({
|
||||||
|
collection: slug,
|
||||||
|
data: {
|
||||||
|
email: devUser.email,
|
||||||
|
password: devUser.password,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await restClient.POST(`/${slug}/logout?allSessions=true`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `JWT ${authenticated.token}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const user = await payload.db.find<User>({
|
||||||
|
collection: slug,
|
||||||
|
where: {
|
||||||
|
email: {
|
||||||
|
equals: devUser.email,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const remainingSessions = user.docs[0]?.sessions
|
||||||
|
expect(remainingSessions).toHaveLength(0)
|
||||||
|
|
||||||
|
const meQuery = await restClient
|
||||||
|
.GET(`/${slug}/me`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `JWT ${authenticated.token}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((res) => res.json())
|
||||||
|
|
||||||
|
expect(meQuery.user).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clean up expired sessions when logging in', async () => {
|
||||||
|
const userWithExpiredSession = await payload.create({
|
||||||
|
collection: slug,
|
||||||
|
data: {
|
||||||
|
email: `${devUser.email}.au`,
|
||||||
|
password: devUser.password,
|
||||||
|
roles: ['admin'],
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: uuid(),
|
||||||
|
createdAt: new Date().toDateString(),
|
||||||
|
expiresAt: new Date(new Date().getTime() - 5000).toDateString(), // Set an expired session
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(userWithExpiredSession.sessions).toHaveLength(1)
|
||||||
|
|
||||||
|
await payload.login({
|
||||||
|
collection: slug,
|
||||||
|
data: {
|
||||||
|
email: devUser.email,
|
||||||
|
password: devUser.password,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const user2 = await payload.db.find<User>({
|
||||||
|
collection: slug,
|
||||||
|
where: {
|
||||||
|
email: {
|
||||||
|
equals: devUser.email,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(user2.docs[0]?.sessions).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -248,6 +248,11 @@ export interface User {
|
|||||||
hash?: string | null;
|
hash?: string | null;
|
||||||
loginAttempts?: number | null;
|
loginAttempts?: number | null;
|
||||||
lockUntil?: string | null;
|
lockUntil?: string | null;
|
||||||
|
sessions: {
|
||||||
|
id: string;
|
||||||
|
createdAt?: string | null;
|
||||||
|
expiresAt: string;
|
||||||
|
}[];
|
||||||
password?: string | null;
|
password?: string | null;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@@ -265,6 +270,11 @@ export interface PartialDisableLocalStrategy {
|
|||||||
hash?: string | null;
|
hash?: string | null;
|
||||||
loginAttempts?: number | null;
|
loginAttempts?: number | null;
|
||||||
lockUntil?: string | null;
|
lockUntil?: string | null;
|
||||||
|
sessions: {
|
||||||
|
id: string;
|
||||||
|
createdAt?: string | null;
|
||||||
|
expiresAt: string;
|
||||||
|
}[];
|
||||||
password?: string | null;
|
password?: string | null;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@@ -306,6 +316,11 @@ export interface PublicUser {
|
|||||||
_verificationToken?: string | null;
|
_verificationToken?: string | null;
|
||||||
loginAttempts?: number | null;
|
loginAttempts?: number | null;
|
||||||
lockUntil?: string | null;
|
lockUntil?: string | null;
|
||||||
|
sessions: {
|
||||||
|
id: string;
|
||||||
|
createdAt?: string | null;
|
||||||
|
expiresAt: string;
|
||||||
|
}[];
|
||||||
password?: string | null;
|
password?: string | null;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@@ -471,6 +486,13 @@ export interface UsersSelect<T extends boolean = true> {
|
|||||||
hash?: T;
|
hash?: T;
|
||||||
loginAttempts?: T;
|
loginAttempts?: T;
|
||||||
lockUntil?: T;
|
lockUntil?: T;
|
||||||
|
sessions?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
id?: T;
|
||||||
|
createdAt?: T;
|
||||||
|
expiresAt?: T;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
@@ -486,6 +508,13 @@ export interface PartialDisableLocalStrategiesSelect<T extends boolean = true> {
|
|||||||
hash?: T;
|
hash?: T;
|
||||||
loginAttempts?: T;
|
loginAttempts?: T;
|
||||||
lockUntil?: T;
|
lockUntil?: T;
|
||||||
|
sessions?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
id?: T;
|
||||||
|
createdAt?: T;
|
||||||
|
expiresAt?: T;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
@@ -523,6 +552,13 @@ export interface PublicUsersSelect<T extends boolean = true> {
|
|||||||
_verificationToken?: T;
|
_verificationToken?: T;
|
||||||
loginAttempts?: T;
|
loginAttempts?: T;
|
||||||
lockUntil?: T;
|
lockUntil?: T;
|
||||||
|
sessions?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
id?: T;
|
||||||
|
createdAt?: T;
|
||||||
|
expiresAt?: T;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
|||||||
@@ -96,5 +96,8 @@
|
|||||||
"ts-essentials": "10.0.3",
|
"ts-essentials": "10.0.3",
|
||||||
"typescript": "5.7.3",
|
"typescript": "5.7.3",
|
||||||
"uuid": "10.0.0"
|
"uuid": "10.0.0"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"neverBuiltDependencies": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ export async function loginFunction({ email, password }: LoginArgs) {
|
|||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
})
|
})
|
||||||
return result
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Login failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
throw new Error(`Login failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import config from '../config.js'
|
|||||||
export async function refreshFunction() {
|
export async function refreshFunction() {
|
||||||
try {
|
try {
|
||||||
return await refresh({
|
return await refresh({
|
||||||
collection: 'users', // update this to your collection slug
|
|
||||||
config,
|
config,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user