fix: use atomic operation for incrementing login attempts (#13204)
--- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1210561338171141
This commit is contained in:
@@ -498,13 +498,21 @@ describe('Auth', () => {
|
||||
describe('Account Locking', () => {
|
||||
const userEmail = 'lock@me.com'
|
||||
|
||||
const tryLogin = async () => {
|
||||
await restClient.POST(`/${slug}/login`, {
|
||||
body: JSON.stringify({
|
||||
email: userEmail,
|
||||
password: 'bad',
|
||||
}),
|
||||
const tryLogin = async (success?: boolean) => {
|
||||
const res = await restClient.POST(`/${slug}/login`, {
|
||||
body: JSON.stringify(
|
||||
success
|
||||
? {
|
||||
email: userEmail,
|
||||
password,
|
||||
}
|
||||
: {
|
||||
email: userEmail,
|
||||
password: 'bad',
|
||||
},
|
||||
),
|
||||
})
|
||||
return await res.json()
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -530,10 +538,32 @@ describe('Auth', () => {
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await payload.db.updateOne({
|
||||
collection: slug,
|
||||
data: {
|
||||
lockUntil: null,
|
||||
loginAttempts: 0,
|
||||
},
|
||||
where: {
|
||||
email: {
|
||||
equals: userEmail,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const lockedMessage = 'This user is locked due to having too many failed login attempts.'
|
||||
const incorrectMessage = 'The email or password provided is incorrect.'
|
||||
|
||||
it('should lock the user after too many attempts', async () => {
|
||||
await tryLogin()
|
||||
await tryLogin()
|
||||
await tryLogin() // Let it call multiple times, therefore the unlock condition has no bug.
|
||||
const user1 = await tryLogin()
|
||||
const user2 = await tryLogin()
|
||||
const user3 = await tryLogin() // Let it call multiple times, therefore the unlock condition has no bug.
|
||||
|
||||
expect(user1.errors[0].message).toBe(incorrectMessage)
|
||||
expect(user2.errors[0].message).toBe(incorrectMessage)
|
||||
expect(user3.errors[0].message).toBe(lockedMessage)
|
||||
|
||||
const userResult = await payload.find({
|
||||
collection: slug,
|
||||
@@ -546,10 +576,98 @@ describe('Auth', () => {
|
||||
},
|
||||
})
|
||||
|
||||
const { lockUntil, loginAttempts } = userResult.docs[0]
|
||||
const { lockUntil, loginAttempts } = userResult.docs[0]!
|
||||
|
||||
expect(loginAttempts).toBe(2)
|
||||
expect(lockUntil).toBeDefined()
|
||||
|
||||
const successfulLogin = await tryLogin(true)
|
||||
expect(successfulLogin.errors?.[0].message).toBe(
|
||||
'This user is locked due to having too many failed login attempts.',
|
||||
)
|
||||
})
|
||||
|
||||
it('should lock the user after too many parallel attempts', async () => {
|
||||
const tryLoginAttempts = 100
|
||||
const users = await Promise.allSettled(
|
||||
Array.from({ length: tryLoginAttempts }, () => tryLogin()),
|
||||
)
|
||||
|
||||
expect(users).toHaveLength(tryLoginAttempts)
|
||||
|
||||
// Expect min. 8 locked message max. 2 incorrect messages.
|
||||
const lockedMessages = users.filter(
|
||||
(result) =>
|
||||
result.status === 'fulfilled' && result.value?.errors?.[0]?.message === lockedMessage,
|
||||
)
|
||||
const incorrectMessages = users.filter(
|
||||
(result) =>
|
||||
result.status === 'fulfilled' &&
|
||||
result.value?.errors?.[0]?.message === incorrectMessage,
|
||||
)
|
||||
|
||||
const userResult = await payload.find({
|
||||
collection: slug,
|
||||
limit: 1,
|
||||
showHiddenFields: true,
|
||||
where: {
|
||||
email: {
|
||||
equals: userEmail,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const { lockUntil, loginAttempts } = userResult.docs[0]!
|
||||
|
||||
// loginAttempts does not have to be exactly the same amount of login attempts. If this ran sequentially, login attempts would stop
|
||||
// incrementing after maxLoginAttempts is reached. Since this is run in parallel, it can increment more than maxLoginAttempts, but it is not
|
||||
// expected to and can be less depending on the timing.
|
||||
expect(loginAttempts).toBeGreaterThan(3)
|
||||
expect(lockUntil).toBeDefined()
|
||||
|
||||
expect(incorrectMessages.length).toBeLessThanOrEqual(2)
|
||||
expect(lockedMessages.length).toBeGreaterThanOrEqual(tryLoginAttempts - 2)
|
||||
|
||||
const successfulLogin = await tryLogin(true)
|
||||
|
||||
expect(successfulLogin.errors?.[0].message).toBe(
|
||||
'This user is locked due to having too many failed login attempts.',
|
||||
)
|
||||
})
|
||||
|
||||
it('ensure that login session expires if max login attempts is reached within narrow time-frame', async () => {
|
||||
const tryLoginAttempts = 5
|
||||
|
||||
// If there are 100 parallel login attempts, 99 incorrect and 1 correct one, we do not want the correct one to be able to consistently be able
|
||||
// to login successfully.
|
||||
const user = await tryLogin(true)
|
||||
const firstMeResponse = await restClient.GET(`/${slug}/me`, {
|
||||
headers: {
|
||||
Authorization: `JWT ${user.token}`,
|
||||
},
|
||||
})
|
||||
|
||||
expect(firstMeResponse.status).toBe(200)
|
||||
|
||||
const firstMeData = await firstMeResponse.json()
|
||||
|
||||
expect(firstMeData.token).toBeDefined()
|
||||
expect(firstMeData.user.email).toBeDefined()
|
||||
|
||||
await Promise.allSettled(Array.from({ length: tryLoginAttempts }, () => tryLogin()))
|
||||
|
||||
const secondMeResponse = await restClient.GET(`/${slug}/me`, {
|
||||
headers: {
|
||||
Authorization: `JWT ${user.token}`,
|
||||
},
|
||||
})
|
||||
|
||||
expect(secondMeResponse.status).toBe(200)
|
||||
|
||||
const secondMeData = await secondMeResponse.json()
|
||||
|
||||
expect(secondMeData.user).toBeNull()
|
||||
expect(secondMeData.token).not.toBeDefined()
|
||||
})
|
||||
|
||||
it('should unlock account once lockUntil period is over', async () => {
|
||||
|
||||
Reference in New Issue
Block a user