diff --git a/packages/core/src/routes/session/password.test.ts b/packages/core/src/routes/session/password.test.ts index 9ce4f92c4..4ebcdddc2 100644 --- a/packages/core/src/routes/session/password.test.ts +++ b/packages/core/src/routes/session/password.test.ts @@ -1,10 +1,9 @@ import type { User } from '@logto/schemas'; -import { UserRole, SignInIdentifier, SignUpIdentifier } from '@logto/schemas'; +import { UserRole, SignUpIdentifier } from '@logto/schemas'; import { adminConsoleApplicationId } from '@logto/schemas/lib/seeds'; import { Provider } from 'oidc-provider'; -import { mockSignInExperience, mockSignInMethod, mockUser } from '@/__mocks__'; -import RequestError from '@/errors/RequestError'; +import { mockSignInExperience, mockUser } from '@/__mocks__'; import { createRequester } from '@/utils/test-utils'; import passwordRoutes, { registerRoute, signInRoute } from './password'; @@ -13,13 +12,7 @@ const insertUser = jest.fn(async (..._args: unknown[]) => ({ id: 'id' })); const findUserById = jest.fn(async (): Promise => mockUser); const updateUserById = jest.fn(async (..._args: unknown[]) => ({ id: 'id' })); const hasActiveUsers = jest.fn(async () => true); -const findDefaultSignInExperience = jest.fn(async () => ({ - ...mockSignInExperience, - signUp: { - ...mockSignInExperience.signUp, - identifier: SignUpIdentifier.Username, - }, -})); +const findDefaultSignInExperience = jest.fn(async () => mockSignInExperience); jest.mock('@/queries/user', () => ({ findUserById: async () => findUserById(), @@ -36,7 +29,7 @@ jest.mock('@/queries/user', () => ({ async findUserByUsername(username: string) { const roleNames = username === 'admin' ? [UserRole.Admin] : []; - return { id: 'user1', username, roleNames }; + return { id: 'id', username, roleNames }; }, })); @@ -45,17 +38,7 @@ jest.mock('@/queries/sign-in-experience', () => ({ })); jest.mock('@/lib/user', () => ({ - async verifyUserPassword(user: User, password: string) { - const { username } = user; - - if (username !== 'username' && username !== 'admin') { - throw new RequestError('session.invalid_credentials'); - } - - if (password !== 'password') { - throw new RequestError('session.invalid_credentials'); - } - + async verifyUserPassword(user: User) { return user; }, generateUserId: () => 'user1', @@ -116,60 +99,52 @@ describe('session -> password routes', () => { ], }); - describe('POST /session/sign-in/password/username', () => { - it('assign result and redirect', async () => { - interactionDetails.mockResolvedValueOnce({ params: {} }); - const response = await sessionRequest.post(`${signInRoute}/username`).send({ - username: 'username', - password: 'password', - }); - expect(response.statusCode).toEqual(200); - expect(response.body).toHaveProperty('redirectTo'); - expect(interactionResult).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ login: { accountId: 'user1' } }), - expect.anything() - ); + it('POST /session/sign-in/password/username', async () => { + interactionDetails.mockResolvedValueOnce({ params: {} }); + const response = await sessionRequest.post(`${signInRoute}/username`).send({ + username: 'username', + password: 'password', }); + expect(response.statusCode).toEqual(200); + expect(response.body).toHaveProperty('redirectTo'); + expect(interactionResult).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ login: { accountId: 'id' } }), + expect.anything() + ); + }); - it('throw if user not found', async () => { - interactionDetails.mockResolvedValueOnce({ params: {} }); - const response = await sessionRequest.post(`${signInRoute}/username`).send({ - username: 'notexistuser', - password: 'password', - }); - expect(response.statusCode).toEqual(400); + it('POST /session/sign-in/password/email', async () => { + interactionDetails.mockResolvedValueOnce({ params: {} }); + const response = await sessionRequest.post(`${signInRoute}/email`).send({ + email: 'email', + password: 'password', }); + expect(response.statusCode).toEqual(200); + expect(response.body).toHaveProperty('redirectTo'); + expect(interactionResult).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ login: { accountId: 'id' } }), + expect.anything() + ); + }); - it('throw if user found but wrong password', async () => { - interactionDetails.mockResolvedValueOnce({ params: {} }); - const response = await sessionRequest.post(`${signInRoute}/username`).send({ - username: 'username', - password: '_password', - }); - expect(response.statusCode).toEqual(400); - }); - - it('throw if sign in method is not enabled', async () => { - findDefaultSignInExperience.mockResolvedValueOnce({ - ...mockSignInExperience, - signIn: { - methods: [ - { - ...mockSignInMethod, - identifier: SignInIdentifier.Sms, - password: false, - }, - ], - }, - }); - const response = await sessionRequest.post(`${signInRoute}/username`).send({ - username: 'username', - password: 'password', - }); - expect(response.statusCode).toEqual(422); + it('POST /session/sign-in/password/sms', async () => { + interactionDetails.mockResolvedValueOnce({ params: {} }); + const response = await sessionRequest.post(`${signInRoute}/sms`).send({ + phone: 'phone', + password: 'password', }); + expect(response.statusCode).toEqual(200); + expect(response.body).toHaveProperty('redirectTo'); + expect(interactionResult).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ login: { accountId: 'id' } }), + expect.anything() + ); }); describe('POST /session/register/password/username', () => { diff --git a/packages/core/src/routes/session/password.ts b/packages/core/src/routes/session/password.ts index 2bbd32859..ebc6a3751 100644 --- a/packages/core/src/routes/session/password.ts +++ b/packages/core/src/routes/session/password.ts @@ -6,14 +6,20 @@ import { object, string } from 'zod'; import RequestError from '@/errors/RequestError'; import { assignInteractionResults } from '@/lib/session'; -import { verifyUserPassword, encryptUserPassword, generateUserId, insertUser } from '@/lib/user'; +import { encryptUserPassword, generateUserId, insertUser } from '@/lib/user'; import koaGuard from '@/middleware/koa-guard'; import { findDefaultSignInExperience } from '@/queries/sign-in-experience'; -import { findUserByUsername, hasActiveUsers, hasUser, updateUserById } from '@/queries/user'; +import { + findUserByEmail, + findUserByPhone, + findUserByUsername, + hasActiveUsers, + hasUser, +} from '@/queries/user'; import assertThat from '@/utils/assert-that'; import type { AnonymousRouter } from '../types'; -import { getRoutePrefix } from './utils'; +import { getRoutePrefix, signInWithPassword } from './utils'; export const registerRoute = getRoutePrefix('register', 'password'); export const signInRoute = getRoutePrefix('sign-in', 'password'); @@ -28,28 +34,61 @@ export default function passwordRoutes(router: T, pro }), }), async (ctx, next) => { - const signInExperience = await findDefaultSignInExperience(); - assertThat( - signInExperience.signIn.methods.some( - ({ identifier, password }) => identifier === SignInIdentifier.Username && password - ), - new RequestError({ - code: 'user.sign_in_method_not_enabled', - status: 422, - }) - ); - - await provider.interactionDetails(ctx.req, ctx.res); const { username, password } = ctx.guard.body; const type = 'SignInUsernamePassword'; - ctx.log(type, { username }); + await signInWithPassword(ctx, provider, { + identifier: SignInIdentifier.Username, + password, + logType: type, + logPayload: { username }, + findUser: async () => findUserByUsername(username), + }); - const user = await findUserByUsername(username); - const { id } = await verifyUserPassword(user, password); + return next(); + } + ); - ctx.log(type, { userId: id }); - await updateUserById(id, { lastSignInAt: Date.now() }); - await assignInteractionResults(ctx, provider, { login: { accountId: id } }, true); + router.post( + `${signInRoute}/email`, + koaGuard({ + body: object({ + email: string().min(1), + password: string().min(1), + }), + }), + async (ctx, next) => { + const { email, password } = ctx.guard.body; + const type = 'SignInEmailPassword'; + await signInWithPassword(ctx, provider, { + identifier: SignInIdentifier.Email, + password, + logType: type, + logPayload: { email }, + findUser: async () => findUserByEmail(email), + }); + + return next(); + } + ); + + router.post( + `${signInRoute}/sms`, + koaGuard({ + body: object({ + phone: string().min(1), + password: string().min(1), + }), + }), + async (ctx, next) => { + const { phone, password } = ctx.guard.body; + const type = 'SignInSmsPassword'; + await signInWithPassword(ctx, provider, { + identifier: SignInIdentifier.Sms, + password, + logType: type, + logPayload: { phone }, + findUser: async () => findUserByPhone(phone), + }); return next(); } diff --git a/packages/core/src/routes/session/utils.test.ts b/packages/core/src/routes/session/utils.test.ts new file mode 100644 index 000000000..3bceb3391 --- /dev/null +++ b/packages/core/src/routes/session/utils.test.ts @@ -0,0 +1,184 @@ +import type { User } from '@logto/schemas'; +import { UserRole, SignInIdentifier, SignUpIdentifier } from '@logto/schemas'; +import { createMockContext } from '@shopify/jest-koa-mocks'; +import type { Nullable } from '@silverhand/essentials'; +import { Provider } from 'oidc-provider'; + +import { mockSignInExperience, mockSignInMethod, mockUser } from '@/__mocks__'; +import RequestError from '@/errors/RequestError'; + +import { signInWithPassword } from './utils'; + +const insertUser = jest.fn(async (..._args: unknown[]) => ({ id: 'id' })); +const findUserById = jest.fn(async (): Promise => mockUser); +const updateUserById = jest.fn(async (..._args: unknown[]) => ({ id: 'id' })); +const hasActiveUsers = jest.fn(async () => true); +const findDefaultSignInExperience = jest.fn(async () => ({ + ...mockSignInExperience, + signUp: { + ...mockSignInExperience.signUp, + identifier: SignUpIdentifier.Username, + }, +})); + +jest.mock('@/queries/user', () => ({ + findUserById: async () => findUserById(), + findUserByIdentity: async () => ({ id: 'id', identities: {} }), + findUserByPhone: async () => ({ id: 'id' }), + findUserByEmail: async () => ({ id: 'id' }), + updateUserById: async (...args: unknown[]) => updateUserById(...args), + hasUser: async (username: string) => username === 'username1', + hasUserWithIdentity: async (connectorId: string, userId: string) => + connectorId === 'connectorId' && userId === 'id', + hasUserWithPhone: async (phone: string) => phone === '13000000000', + hasUserWithEmail: async (email: string) => email === 'a@a.com', + hasActiveUsers: async () => hasActiveUsers(), + async findUserByUsername(username: string) { + const roleNames = username === 'admin' ? [UserRole.Admin] : []; + + return { id: 'user1', username, roleNames }; + }, +})); + +jest.mock('@/queries/sign-in-experience', () => ({ + findDefaultSignInExperience: async () => findDefaultSignInExperience(), +})); + +jest.mock('@/lib/user', () => ({ + async verifyUserPassword(user: Nullable, password: string) { + if (!user) { + throw new RequestError('session.invalid_credentials'); + } + + if (password !== 'password') { + throw new RequestError('session.invalid_credentials'); + } + + return user; + }, + generateUserId: () => 'user1', + encryptUserPassword: (password: string) => ({ + passwordEncrypted: password + '_user1', + passwordEncryptionMethod: 'Argon2i', + }), + updateLastSignInAt: async (...args: unknown[]) => updateUserById(...args), + insertUser: async (...args: unknown[]) => insertUser(...args), +})); + +const grantSave = jest.fn(async () => 'finalGrantId'); +const grantAddOIDCScope = jest.fn(); +const grantAddResourceScope = jest.fn(); +const interactionResult = jest.fn(async () => 'redirectTo'); +const interactionDetails: jest.MockedFunction<() => Promise> = jest.fn(async () => ({})); + +class Grant { + static async find(id: string) { + return id === 'exists' ? new Grant() : undefined; + } + + save: typeof grantSave; + addOIDCScope: typeof grantAddOIDCScope; + addResourceScope: typeof grantAddResourceScope; + + constructor() { + this.save = grantSave; + this.addOIDCScope = grantAddOIDCScope; + this.addResourceScope = grantAddResourceScope; + } +} + +const createContext = () => ({ + ...createMockContext(), + addLogContext: jest.fn(), + log: jest.fn(), +}); + +const createProvider = () => new Provider(''); + +jest.mock('oidc-provider', () => ({ + Provider: jest.fn(() => ({ + Grant, + interactionDetails, + interactionResult, + })), +})); + +afterEach(() => { + grantSave.mockClear(); + interactionResult.mockClear(); +}); + +describe('signInWithPassword()', () => { + it('assign result', async () => { + interactionDetails.mockResolvedValueOnce({ params: {} }); + await signInWithPassword(createContext(), createProvider(), { + identifier: SignInIdentifier.Username, + password: 'password', + findUser: jest.fn(async () => mockUser), + logType: 'SignInUsernamePassword', + logPayload: { username: 'username' }, + }); + expect(interactionResult).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ login: { accountId: mockUser.id } }), + expect.anything() + ); + }); + + it('throw if user not found', async () => { + interactionDetails.mockResolvedValueOnce({ params: {} }); + await expect( + signInWithPassword(createContext(), createProvider(), { + identifier: SignInIdentifier.Username, + password: 'password', + findUser: jest.fn(async () => null), + logType: 'SignInUsernamePassword', + logPayload: { username: 'username' }, + }) + ).rejects.toThrowError(new RequestError('session.invalid_credentials')); + }); + + it('throw if user found but wrong password', async () => { + interactionDetails.mockResolvedValueOnce({ params: {} }); + await expect( + signInWithPassword(createContext(), createProvider(), { + identifier: SignInIdentifier.Username, + password: '_password', + findUser: jest.fn(async () => mockUser), + logType: 'SignInUsernamePassword', + logPayload: { username: 'username' }, + }) + ).rejects.toThrowError(new RequestError('session.invalid_credentials')); + }); + + it('throw if sign in method is not enabled', async () => { + findDefaultSignInExperience.mockResolvedValueOnce({ + ...mockSignInExperience, + signIn: { + methods: [ + { + ...mockSignInMethod, + identifier: SignInIdentifier.Sms, + password: false, + }, + ], + }, + }); + interactionDetails.mockResolvedValueOnce({ params: {} }); + await expect( + signInWithPassword(createContext(), createProvider(), { + identifier: SignInIdentifier.Username, + password: 'password', + findUser: jest.fn(async () => mockUser), + logType: 'SignInUsernamePassword', + logPayload: { username: 'username' }, + }) + ).rejects.toThrowError( + new RequestError({ + code: 'user.sign_in_method_not_enabled', + status: 422, + }) + ); + }); +}); diff --git a/packages/core/src/routes/session/utils.ts b/packages/core/src/routes/session/utils.ts index 7e5ac1024..ceb76d6b6 100644 --- a/packages/core/src/routes/session/utils.ts +++ b/packages/core/src/routes/session/utils.ts @@ -1,6 +1,6 @@ -import type { LogType, PasscodeType } from '@logto/schemas'; +import type { LogPayload, LogType, PasscodeType, SignInIdentifier, User } from '@logto/schemas'; import { logTypeGuard } from '@logto/schemas'; -import type { Truthy } from '@silverhand/essentials'; +import type { Nullable, Truthy } from '@silverhand/essentials'; import dayjs from 'dayjs'; import type { Context } from 'koa'; import type { Provider } from 'oidc-provider'; @@ -8,6 +8,11 @@ import type { ZodType } from 'zod'; import { z } from 'zod'; import RequestError from '@/errors/RequestError'; +import { assignInteractionResults } from '@/lib/session'; +import { verifyUserPassword } from '@/lib/user'; +import type { LogContext } from '@/middleware/koa-log'; +import { findDefaultSignInExperience } from '@/queries/sign-in-experience'; +import { updateUserById } from '@/queries/user'; import assertThat from '@/utils/assert-that'; import { verificationTimeout } from './consts'; @@ -94,3 +99,38 @@ export const clearVerificationResult = async (ctx: Context, provider: Provider) await provider.interactionResult(ctx.req, ctx.res, rest); } }; + +type SignInWithPasswordParameter = { + identifier: SignInIdentifier; + password: string; + logType: LogType; + logPayload: LogPayload; + findUser: () => Promise>; +}; + +export const signInWithPassword = async ( + ctx: Context & LogContext, + provider: Provider, + { identifier, findUser, password, logType, logPayload }: SignInWithPasswordParameter +) => { + const signInExperience = await findDefaultSignInExperience(); + assertThat( + signInExperience.signIn.methods.some( + (method) => method.password && method.identifier === identifier + ), + new RequestError({ + code: 'user.sign_in_method_not_enabled', + status: 422, + }) + ); + + await provider.interactionDetails(ctx.req, ctx.res); + ctx.log(logType, logPayload); + + const user = await findUser(); + const { id } = await verifyUserPassword(user, password); + + ctx.log(logType, { userId: id }); + await updateUserById(id, { lastSignInAt: Date.now() }); + await assignInteractionResults(ctx, provider, { login: { accountId: id } }, true); +}; diff --git a/packages/integration-tests/src/helpers.ts b/packages/integration-tests/src/helpers.ts index 43a93c0cd..bbe2c9c73 100644 --- a/packages/integration-tests/src/helpers.ts +++ b/packages/integration-tests/src/helpers.ts @@ -72,8 +72,12 @@ export const setUpConnector = async (connectorId: string, config: Record { - await updateSignInExperience({ signUp: { identifier, password: true, verify: true } }); +export const setSignUpIdentifier = async ( + identifier: SignUpIdentifier, + password = true, + verify = true +) => { + await updateSignInExperience({ signUp: { identifier, password, verify } }); }; type PasscodeRecord = { diff --git a/packages/integration-tests/tests/api/session.test.ts b/packages/integration-tests/tests/api/session.test.ts index 2477a347e..06270c1b8 100644 --- a/packages/integration-tests/tests/api/session.test.ts +++ b/packages/integration-tests/tests/api/session.test.ts @@ -47,7 +47,7 @@ describe('username and password flow', () => { describe('email passwordless flow', () => { beforeAll(async () => { await setUpConnector(mockEmailConnectorId, mockEmailConnectorConfig); - await setSignUpIdentifier(SignUpIdentifier.Email); + await setSignUpIdentifier(SignUpIdentifier.Email, false); }); // Since we can not create a email register user throw admin. Have to run the register then sign-in concurrently. @@ -121,7 +121,7 @@ describe('email passwordless flow', () => { describe('sms passwordless flow', () => { beforeAll(async () => { await setUpConnector(mockSmsConnectorId, mockSmsConnectorConfig); - await setSignUpIdentifier(SignUpIdentifier.Sms); + await setSignUpIdentifier(SignUpIdentifier.Sms, false); }); // Since we can not create a sms register user throw admin. Have to run the register then sign-in concurrently. diff --git a/packages/schemas/src/types/log.ts b/packages/schemas/src/types/log.ts index 2d60fee15..a349be204 100644 --- a/packages/schemas/src/types/log.ts +++ b/packages/schemas/src/types/log.ts @@ -79,6 +79,20 @@ const signInUsernamePasswordLogPayloadGuard = arbitraryLogPayloadGuard.and( }) ); +const signInEmailPasswordLogPayloadGuard = arbitraryLogPayloadGuard.and( + z.object({ + userId: z.string().optional(), + email: z.string().optional(), + }) +); + +const signInSmsPasswordLogPayloadGuard = arbitraryLogPayloadGuard.and( + z.object({ + userId: z.string().optional(), + sms: z.string().optional(), + }) +); + const signInEmailSendPasscodeLogPayloadGuard = arbitraryLogPayloadGuard.and( z.object({ email: z.string().optional(), @@ -197,6 +211,8 @@ const logPayloadsGuard = z.object({ RegisterSocialBind: registerSocialBindLogPayloadGuard, RegisterSocial: registerSocialLogPayloadGuard, SignInUsernamePassword: signInUsernamePasswordLogPayloadGuard, + SignInEmailPassword: signInEmailPasswordLogPayloadGuard, + SignInSmsPassword: signInSmsPasswordLogPayloadGuard, SignInEmailSendPasscode: signInEmailSendPasscodeLogPayloadGuard, SignInEmail: signInEmailLogPayloadGuard, SignInSmsSendPasscode: signInSmsSendPasscodeLogPayloadGuard,