diff --git a/packages/core/src/routes/admin-user.ts b/packages/core/src/routes/admin-user.ts index 3d72b96c9..cf9708a6d 100644 --- a/packages/core/src/routes/admin-user.ts +++ b/packages/core/src/routes/admin-user.ts @@ -23,7 +23,7 @@ import { } from '@/queries/user'; import assertThat from '@/utils/assert-that'; -import { checkExistingSignUpIdentifiers } from './session/utils'; +import { checkSignUpIdentifierCollision } from './session/utils'; import type { AuthedRouter } from './types'; export default function adminUserRoutes(router: T) { @@ -187,7 +187,7 @@ export default function adminUserRoutes(router: T) { } = ctx.guard; await findUserById(userId); - await checkExistingSignUpIdentifiers(body, userId); + await checkSignUpIdentifierCollision(body, userId); // Temp solution to validate the existence of input roleNames if (body.roleNames?.length) { diff --git a/packages/core/src/routes/session/index.ts b/packages/core/src/routes/session/index.ts index bdae57efa..4b7f8f4fb 100644 --- a/packages/core/src/routes/session/index.ts +++ b/packages/core/src/routes/session/index.ts @@ -18,6 +18,7 @@ import forgotPasswordRoutes from './forgot-password'; import koaGuardSessionAction from './middleware/koa-guard-session-action'; import passwordRoutes from './password'; import passwordlessRoutes from './passwordless'; +import profileRoutes from './profile'; import socialRoutes from './social'; import { getRoutePrefix } from './utils'; @@ -51,8 +52,7 @@ export default function sessionRoutes(router: T, prov const { accountId } = session; - // Temp solution before migrating to RBAC. Block non-admin user from consent to admin console - + // Temp solution before migrating to RBAC. Block non-admin user from consenting to admin console if (String(client_id) === adminConsoleApplicationId) { const { roleNames } = await findUserById(accountId); @@ -105,6 +105,6 @@ export default function sessionRoutes(router: T, prov passwordlessRoutes(router, provider); socialRoutes(router, provider); continueRoutes(router, provider); - forgotPasswordRoutes(router, provider); + profileRoutes(router, provider); } diff --git a/packages/core/src/routes/session/profile.test.ts b/packages/core/src/routes/session/profile.test.ts new file mode 100644 index 000000000..01296baeb --- /dev/null +++ b/packages/core/src/routes/session/profile.test.ts @@ -0,0 +1,93 @@ +import type { CreateUser, User } from '@logto/schemas'; +import { SignUpIdentifier } from '@logto/schemas'; +import { getUnixTime } from 'date-fns'; +import { Provider } from 'oidc-provider'; + +import { mockUser, mockUserResponse } from '@/__mocks__'; +import { createRequester } from '@/utils/test-utils'; + +import profileRoutes, { profileRoute } from './profile'; + +const mockFindUserById = jest.fn(async (): Promise => mockUser); +const mockHasUser = jest.fn(async () => false); +const mockHasUserWithEmail = jest.fn(async () => false); +const mockHasUserWithPhone = jest.fn(async () => false); +const mockUpdateUserById = jest.fn( + async (_, data: Partial): Promise => ({ + ...mockUser, + ...data, + }) +); + +jest.mock('oidc-provider', () => ({ + Provider: jest.fn(() => ({ + Session: { + get: jest.fn(async () => ({ accountId: 'id', loginTs: getUnixTime(new Date()) - 60 })), + }, + })), +})); + +jest.mock('@/queries/user', () => ({ + ...jest.requireActual('@/queries/user'), + findUserById: async () => mockFindUserById(), + hasUser: async () => mockHasUser(), + hasUserWithEmail: async () => mockHasUserWithEmail(), + hasUserWithPhone: async () => mockHasUserWithPhone(), + updateUserById: async (id: string, data: Partial) => mockUpdateUserById(id, data), +})); + +const mockFindDefaultSignInExperience = jest.fn(async () => ({ + signUp: { + identifier: SignUpIdentifier.None, + password: false, + verify: false, + }, +})); + +jest.mock('@/queries/sign-in-experience', () => ({ + findDefaultSignInExperience: jest.fn(async () => mockFindDefaultSignInExperience()), +})); + +describe('session -> profileRoutes', () => { + const sessionRequest = createRequester({ + anonymousRoutes: profileRoutes, + provider: new Provider(''), + middlewares: [ + async (ctx, next) => { + ctx.addLogContext = jest.fn(); + ctx.log = jest.fn(); + + return next(); + }, + ], + }); + + test('GET /session/profile should return current user data', async () => { + const response = await sessionRequest.get(profileRoute); + expect(response.statusCode).toEqual(200); + expect(response.body).toEqual(mockUserResponse); + }); + + describe('PATCH /session/profile/username', () => { + it('should update username with the new value', async () => { + const newUsername = 'charles'; + + const response = await sessionRequest + .patch(`${profileRoute}/username`) + .send({ username: newUsername }); + + expect(response.statusCode).toEqual(200); + expect(response.body).toEqual({ ...mockUserResponse, username: newUsername }); + }); + + it('should throw when username is already in use', async () => { + mockHasUser.mockImplementationOnce(async () => true); + + const response = await sessionRequest + .patch(`${profileRoute}/username`) + .send({ username: 'test' }); + + expect(response.statusCode).toEqual(422); + }); + }); +}); diff --git a/packages/core/src/routes/session/profile.ts b/packages/core/src/routes/session/profile.ts new file mode 100644 index 000000000..3467a2c77 --- /dev/null +++ b/packages/core/src/routes/session/profile.ts @@ -0,0 +1,55 @@ +import { usernameRegEx } from '@logto/core-kit'; +import { userInfoSelectFields } from '@logto/schemas'; +import pick from 'lodash.pick'; +import type { Provider } from 'oidc-provider'; +import { object, string } from 'zod'; + +import RequestError from '@/errors/RequestError'; +import { checkSessionHealth } from '@/lib/session'; +import koaGuard from '@/middleware/koa-guard'; +import { findUserById, updateUserById } from '@/queries/user'; +import assertThat from '@/utils/assert-that'; + +import type { AnonymousRouter } from '../types'; +import { verificationTimeout } from './consts'; +import { checkSignUpIdentifierCollision } from './utils'; + +export const profileRoute = '/session/profile'; + +export default function profileRoutes(router: T, provider: Provider) { + router.get(profileRoute, async (ctx, next) => { + const { accountId } = await provider.Session.get(ctx); + + if (!accountId) { + throw new RequestError('auth.unauthorized'); + } + + const user = await findUserById(accountId); + + ctx.body = pick(user, ...userInfoSelectFields); + + return next(); + }); + + router.patch( + `${profileRoute}/username`, + koaGuard({ + body: object({ username: string().regex(usernameRegEx) }), + }), + async (ctx, next) => { + const userId = await checkSessionHealth(ctx, provider, verificationTimeout); + + assertThat(userId, new RequestError('auth.unauthorized')); + + const { username } = ctx.guard.body; + + await checkSignUpIdentifierCollision({ username }, userId); + + const user = await updateUserById(userId, { username }, 'replace'); + + ctx.body = pick(user, ...userInfoSelectFields); + + return next(); + } + ); +} diff --git a/packages/core/src/routes/session/utils.ts b/packages/core/src/routes/session/utils.ts index 629f60d40..8749171d8 100644 --- a/packages/core/src/routes/session/utils.ts +++ b/packages/core/src/routes/session/utils.ts @@ -196,9 +196,31 @@ export const checkRequiredProfile = async ( throw new RequestError({ code: 'user.require_email_or_sms', status: 422 }); } }; + +export const checkMissingRequiredSignUpIdentifiers = async (identifiers: { + primaryEmail?: Nullable; + primaryPhone?: Nullable; +}) => { + // We do not check username as we decided to prohibit the removal of username from user profile. + const { primaryEmail, primaryPhone } = identifiers; + + const { signUp } = await getSignInExperienceForApplication(); + + if (signUp.identifier === SignUpIdentifier.Email && !primaryEmail) { + throw new RequestError({ code: 'user.require_email', status: 422 }); + } + + if (signUp.identifier === SignUpIdentifier.Sms && !primaryPhone) { + throw new RequestError({ code: 'user.require_sms', status: 422 }); + } + + if (signUp.identifier === SignUpIdentifier.EmailOrSms && !primaryEmail && !primaryPhone) { + throw new RequestError({ code: 'user.require_email_or_sms', status: 422 }); + } +}; /* eslint-enable complexity */ -export const checkExistingSignUpIdentifiers = async ( +export const checkSignUpIdentifierCollision = async ( identifiers: { username?: Nullable; primaryEmail?: Nullable;