diff --git a/packages/core/src/__mocks__/user.ts b/packages/core/src/__mocks__/user.ts index f9883193e..022740b90 100644 --- a/packages/core/src/__mocks__/user.ts +++ b/packages/core/src/__mocks__/user.ts @@ -8,7 +8,8 @@ export const mockUser: User = { username: 'foo', primaryEmail: 'foo@logto.io', primaryPhone: '111111', - passwordEncrypted: 'password', + passwordEncrypted: + '$argon2i$v=19$m=4096,t=256,p=1$SYD0xSoVR8l+CN63Nz8fGw$ln5T09X9u4yd0DwLBKnlNV/eUHxwSWo32scw40ov4kI', passwordEncryptionMethod: UsersPasswordEncryptionMethod.Argon2i, name: null, avatar: null, diff --git a/packages/core/src/libraries/user.test.ts b/packages/core/src/libraries/user.test.ts index 8efb0b5af..1ab423b04 100644 --- a/packages/core/src/libraries/user.test.ts +++ b/packages/core/src/libraries/user.test.ts @@ -2,11 +2,12 @@ import { MfaFactor, UsersPasswordEncryptionMethod } from '@logto/schemas'; import { mockResource, mockAdminUserRole, mockScope } from '#src/__mocks__/index.js'; import { mockUser } from '#src/__mocks__/user.js'; +import RequestError from '#src/errors/RequestError/index.js'; import { MockQueries } from '#src/test-utils/tenant.js'; const { jest } = import.meta; -const { encryptUserPassword, createUserLibrary } = await import('./user.js'); +const { encryptUserPassword, createUserLibrary, verifyUserPassword } = await import('./user.js'); const hasUserWithId = jest.fn(); const updateUserById = jest.fn(); @@ -68,6 +69,90 @@ describe('encryptUserPassword()', () => { }); }); +describe('verifyUserPassword()', () => { + describe('Argon2', () => { + it('resolves when password is correct', async () => { + await expect( + verifyUserPassword(mockUser, 'HOH2hTmW0xtYAJUfRSQjJdW5') + ).resolves.not.toThrowError(); + }); + + it('rejects when password is incorrect', async () => { + await expect(verifyUserPassword(mockUser, 'wrong')).rejects.toThrowError( + new RequestError({ code: 'session.invalid_credentials', status: 422 }) + ); + }); + }); + + describe('MD5', () => { + const user = { + ...mockUser, + passwordEncrypted: '5f4dcc3b5aa765d61d8327deb882cf99', + passwordEncryptionMethod: UsersPasswordEncryptionMethod.MD5, + }; + it('resolves when password is correct', async () => { + await expect(verifyUserPassword(user, 'password')).resolves.not.toThrowError(); + }); + + it('rejects when password is incorrect', async () => { + await expect(verifyUserPassword(user, 'wrong')).rejects.toThrowError( + new RequestError({ code: 'session.invalid_credentials', status: 422 }) + ); + }); + }); + + describe('SHA1', () => { + const user = { + ...mockUser, + passwordEncrypted: '5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8', + passwordEncryptionMethod: UsersPasswordEncryptionMethod.SHA1, + }; + it('resolves when password is correct', async () => { + await expect(verifyUserPassword(user, 'password')).resolves.not.toThrowError(); + }); + + it('rejects when password is incorrect', async () => { + await expect(verifyUserPassword(user, 'wrong')).rejects.toThrowError( + new RequestError({ code: 'session.invalid_credentials', status: 422 }) + ); + }); + }); + + describe('SHA256', () => { + const user = { + ...mockUser, + passwordEncrypted: '5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8', + passwordEncryptionMethod: UsersPasswordEncryptionMethod.SHA256, + }; + it('resolves when password is correct', async () => { + await expect(verifyUserPassword(user, 'password')).resolves.not.toThrowError(); + }); + + it('rejects when password is incorrect', async () => { + await expect(verifyUserPassword(user, 'wrong')).rejects.toThrowError( + new RequestError({ code: 'session.invalid_credentials', status: 422 }) + ); + }); + }); + + describe('Bcrypt', () => { + const user = { + ...mockUser, + passwordEncrypted: '$2a$12$WQMqTfbtcZFBC1C1u8wpie6lXOSciUr5kk/8yEydoIMKltb9UKJ.6', + passwordEncryptionMethod: UsersPasswordEncryptionMethod.Bcrypt, + }; + it('resolves when password is correct', async () => { + await expect(verifyUserPassword(user, 'password')).resolves.not.toThrowError(); + }); + + it('rejects when password is incorrect', async () => { + await expect(verifyUserPassword(user, 'wrong')).rejects.toThrowError( + new RequestError({ code: 'session.invalid_credentials', status: 422 }) + ); + }); + }); +}); + describe('findUserScopesForResourceId()', () => { const { findUserScopesForResourceIndicator } = createUserLibrary(queries); diff --git a/packages/core/src/libraries/user.ts b/packages/core/src/libraries/user.ts index bf29868fc..30012023f 100644 --- a/packages/core/src/libraries/user.ts +++ b/packages/core/src/libraries/user.ts @@ -4,7 +4,7 @@ import { generateStandardShortId, generateStandardId } from '@logto/shared'; import type { OmitAutoSetFields } from '@logto/shared'; import type { Nullable } from '@silverhand/essentials'; import { deduplicate } from '@silverhand/essentials'; -import { argon2Verify } from 'hash-wasm'; +import { argon2Verify, bcryptVerify, md5, sha1, sha256 } from 'hash-wasm'; import pRetry from 'p-retry'; import { buildInsertIntoWithPool } from '#src/database/insert-into.js'; @@ -40,9 +40,47 @@ export const verifyUserPassword = async (user: Nullable, password: string) new RequestError({ code: 'session.invalid_credentials', status: 422 }) ); - const result = await argon2Verify({ password, hash: passwordEncrypted }); + switch (passwordEncryptionMethod) { + case UsersPasswordEncryptionMethod.Argon2i: { + const result = await argon2Verify({ password, hash: passwordEncrypted }); + assertThat(result, new RequestError({ code: 'session.invalid_credentials', status: 422 })); + break; + } + case UsersPasswordEncryptionMethod.MD5: { + const expectedEncrypted = await md5(password); + assertThat( + expectedEncrypted === passwordEncrypted, + new RequestError({ code: 'session.invalid_credentials', status: 422 }) + ); + break; + } + case UsersPasswordEncryptionMethod.SHA1: { + const expectedEncrypted = await sha1(password); + assertThat( + expectedEncrypted === passwordEncrypted, + new RequestError({ code: 'session.invalid_credentials', status: 422 }) + ); + break; + } + case UsersPasswordEncryptionMethod.SHA256: { + const expectedEncrypted = await sha256(password); + assertThat( + expectedEncrypted === passwordEncrypted, + new RequestError({ code: 'session.invalid_credentials', status: 422 }) + ); + break; + } + case UsersPasswordEncryptionMethod.Bcrypt: { + const result = await bcryptVerify({ password, hash: passwordEncrypted }); + assertThat(result, new RequestError({ code: 'session.invalid_credentials', status: 422 })); + break; + } + default: { + throw new RequestError({ code: 'session.invalid_credentials', status: 422 }); + } + } - assertThat(result, new RequestError({ code: 'session.invalid_credentials', status: 422 })); + // TODO(@sijie) migrate to use argon2 return user; }; diff --git a/packages/core/src/utils/password.ts b/packages/core/src/utils/password.ts index 27a809cc5..e5eb46201 100644 --- a/packages/core/src/utils/password.ts +++ b/packages/core/src/utils/password.ts @@ -11,7 +11,6 @@ export const encryptPassword = async ( method: UsersPasswordEncryptionMethod ): Promise => { assertThat( - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition method === UsersPasswordEncryptionMethod.Argon2i, new RequestError({ code: 'password.unsupported_encryption_method', method }) ); diff --git a/packages/schemas/alterations/next-1709521416-user-password-encrypt-method.ts b/packages/schemas/alterations/next-1709521416-user-password-encrypt-method.ts new file mode 100644 index 000000000..38c75b868 --- /dev/null +++ b/packages/schemas/alterations/next-1709521416-user-password-encrypt-method.ts @@ -0,0 +1,36 @@ +import { sql } from 'slonik'; + +import type { AlterationScript } from '../lib/types/alteration.js'; + +const alteration: AlterationScript = { + up: async (pool) => { + await pool.query(sql` + alter type users_password_encryption_method add value 'SHA1'; + alter type users_password_encryption_method add value 'SHA256'; + alter type users_password_encryption_method add value 'MD5'; + alter type users_password_encryption_method add value 'Bcrypt'; + `); + }, + down: async (pool) => { + const { rows } = await pool.query(sql` + select id from users + where password_encryption_method <> ${'Argon2i'} + `); + if (rows.length > 0) { + throw new Error('There are users with password encryption methods other than Argon2i.'); + } + + await pool.query(sql` + create type users_password_encryption_method_revised as enum ('Argon2i'); + + alter table users + alter column password_encryption_method type users_password_encryption_method_revised + using password_encryption_method::text::users_password_encryption_method_revised; + + drop type users_password_encryption_method; + alter type users_password_encryption_method_revised rename to users_password_encryption_method; + `); + }, +}; + +export default alteration; diff --git a/packages/schemas/tables/users.sql b/packages/schemas/tables/users.sql index e873e6b71..be43e5ac4 100644 --- a/packages/schemas/tables/users.sql +++ b/packages/schemas/tables/users.sql @@ -1,6 +1,6 @@ /* init_order = 1 */ -create type users_password_encryption_method as enum ('Argon2i'); +create type users_password_encryption_method as enum ('Argon2i', 'SHA1', 'SHA256', 'MD5', 'Bcrypt'); create table users ( tenant_id varchar(21) not null