From 2fb85a229b04c6844d3ace0d0ec2668bc46c817d Mon Sep 17 00:00:00 2001 From: wangsijie Date: Thu, 17 Oct 2024 15:06:22 +0800 Subject: [PATCH] feat(core): add and change phone (#6682) --- .../src/routes/profile/index.openapi.json | 37 ++++++ packages/core/src/routes/profile/index.ts | 49 +++++++- .../core/src/routes/verification/index.ts | 5 +- packages/integration-tests/src/api/profile.ts | 10 ++ .../tests/api/profile/email-and-phone.test.ts | 118 +++++++++++++++++- 5 files changed, 213 insertions(+), 6 deletions(-) diff --git a/packages/core/src/routes/profile/index.openapi.json b/packages/core/src/routes/profile/index.openapi.json index c8c3c2438..8609a2fa4 100644 --- a/packages/core/src/routes/profile/index.openapi.json +++ b/packages/core/src/routes/profile/index.openapi.json @@ -181,6 +181,43 @@ } } } + }, + "/api/profile/primary-phone": { + "post": { + "operationId": "UpdatePrimaryPhone", + "summary": "Update primary phone", + "description": "Update primary phone for the user, a verification record is required for checking sensitive permissions, and a new identifier verification record is required for the new phone ownership verification.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "phone": { + "description": "The new phone for the user." + }, + "verificationRecordId": { + "description": "The verification record ID for checking sensitive permissions." + }, + "newIdentifierVerificationRecordId": { + "description": "The identifier verification record ID for the new phone ownership verification." + } + } + } + } + } + }, + "responses": { + "204": { + "description": "The primary phone was updated successfully." + }, + "400": { + "description": "The new verification record is invalid." + }, + "403": { + "description": "Permission denied, the verification record is invalid." + } + } + } } } } diff --git a/packages/core/src/routes/profile/index.ts b/packages/core/src/routes/profile/index.ts index 6bd36c00d..7d6585876 100644 --- a/packages/core/src/routes/profile/index.ts +++ b/packages/core/src/routes/profile/index.ts @@ -1,4 +1,4 @@ -import { emailRegEx, usernameRegEx, UserScope } from '@logto/core-kit'; +import { emailRegEx, phoneRegEx, usernameRegEx, UserScope } from '@logto/core-kit'; import { VerificationType, userProfileResponseGuard, userProfileGuard } from '@logto/schemas'; import { z } from 'zod'; @@ -155,7 +155,7 @@ export default function profileRoutes( verificationRecordId: z.string(), newIdentifierVerificationRecordId: z.string(), }), - status: [204, 400, 403], + status: [204, 400, 401], }), async (ctx, next) => { const { id: userId, scopes } = ctx.auth; @@ -191,4 +191,49 @@ export default function profileRoutes( return next(); } ); + + router.post( + '/profile/primary-phone', + koaGuard({ + body: z.object({ + phone: z.string().regex(phoneRegEx), + verificationRecordId: z.string(), + newIdentifierVerificationRecordId: z.string(), + }), + status: [204, 400, 401], + }), + async (ctx, next) => { + const { id: userId, scopes } = ctx.auth; + const { phone, verificationRecordId, newIdentifierVerificationRecordId } = ctx.guard.body; + + assertThat(scopes.has(UserScope.Phone), 'auth.unauthorized'); + + await verifyUserSensitivePermission({ + userId, + id: verificationRecordId, + queries, + libraries, + }); + + // Check new identifier + const newVerificationRecord = await buildVerificationRecordByIdAndType({ + type: VerificationType.PhoneVerificationCode, + id: newIdentifierVerificationRecordId, + queries, + libraries, + }); + assertThat(newVerificationRecord.isVerified, 'verification_record.not_found'); + assertThat(newVerificationRecord.identifier.value === phone, 'verification_record.not_found'); + + await checkIdentifierCollision({ primaryPhone: phone }, userId); + + const updatedUser = await updateUserById(userId, { primaryPhone: phone }); + + ctx.appendDataHookContext('User.Data.Updated', { user: updatedUser }); + + ctx.status = 204; + + return next(); + } + ); } diff --git a/packages/core/src/routes/verification/index.ts b/packages/core/src/routes/verification/index.ts index 363de1949..b2f9f2570 100644 --- a/packages/core/src/routes/verification/index.ts +++ b/packages/core/src/routes/verification/index.ts @@ -125,7 +125,10 @@ export default function verificationRoutes( const { identifier, code, verificationId } = ctx.guard.body; const codeVerification = await buildVerificationRecordByIdAndType({ - type: VerificationType.EmailVerificationCode, + type: + identifier.type === SignInIdentifier.Email + ? VerificationType.EmailVerificationCode + : VerificationType.PhoneVerificationCode, id: verificationId, queries, libraries, diff --git a/packages/integration-tests/src/api/profile.ts b/packages/integration-tests/src/api/profile.ts index fb83c2bcd..0ee1df3e0 100644 --- a/packages/integration-tests/src/api/profile.ts +++ b/packages/integration-tests/src/api/profile.ts @@ -17,6 +17,16 @@ export const updatePrimaryEmail = async ( json: { email, verificationRecordId, newIdentifierVerificationRecordId }, }); +export const updatePrimaryPhone = async ( + api: KyInstance, + phone: string, + verificationRecordId: string, + newIdentifierVerificationRecordId: string +) => + api.post('api/profile/primary-phone', { + json: { phone, verificationRecordId, newIdentifierVerificationRecordId }, + }); + export const updateUser = async (api: KyInstance, body: Record) => api.patch('api/profile', { json: body }).json>(); diff --git a/packages/integration-tests/src/tests/api/profile/email-and-phone.test.ts b/packages/integration-tests/src/tests/api/profile/email-and-phone.test.ts index 213e87454..301ad8df9 100644 --- a/packages/integration-tests/src/tests/api/profile/email-and-phone.test.ts +++ b/packages/integration-tests/src/tests/api/profile/email-and-phone.test.ts @@ -2,12 +2,12 @@ import { UserScope } from '@logto/core-kit'; import { SignInIdentifier } from '@logto/schemas'; import { authedAdminApi } from '#src/api/api.js'; -import { getUserInfo, updatePrimaryEmail } from '#src/api/profile.js'; +import { getUserInfo, updatePrimaryEmail, updatePrimaryPhone } from '#src/api/profile.js'; import { createAndVerifyVerificationCode, createVerificationRecordByPassword, } from '#src/api/verification-record.js'; -import { setEmailConnector } from '#src/helpers/connector.js'; +import { setEmailConnector, setSmsConnector } from '#src/helpers/connector.js'; import { expectRejects } from '#src/helpers/index.js'; import { createDefaultTenantUserWithPassword, @@ -15,7 +15,7 @@ import { signInAndGetUserApi, } from '#src/helpers/profile.js'; import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js'; -import { devFeatureTest, generateEmail } from '#src/utils.js'; +import { devFeatureTest, generateEmail, generatePhone } from '#src/utils.js'; const { describe, it } = devFeatureTest; @@ -23,6 +23,7 @@ describe('profile (email and phone)', () => { beforeAll(async () => { await enableAllPasswordSignInMethods(); await setEmailConnector(authedAdminApi); + await setSmsConnector(authedAdminApi); }); describe('POST /profile/primary-email', () => { @@ -135,4 +136,115 @@ describe('profile (email and phone)', () => { await deleteDefaultTenantUser(user.id); }); }); + + describe('POST /profile/primary-phone', () => { + it('should fail if scope is missing', async () => { + const { user, username, password } = await createDefaultTenantUserWithPassword(); + const api = await signInAndGetUserApi(username, password); + const newPhone = generatePhone(); + + await expectRejects( + updatePrimaryPhone( + api, + newPhone, + 'invalid-verification-record-id', + 'new-verification-record-id' + ), + { + code: 'auth.unauthorized', + status: 400, + } + ); + + await deleteDefaultTenantUser(user.id); + }); + + it('should fail if verification record is invalid', async () => { + const { user, username, password } = await createDefaultTenantUserWithPassword(); + const api = await signInAndGetUserApi(username, password, { + scopes: [UserScope.Profile, UserScope.Phone], + }); + const newPhone = generatePhone(); + + await expectRejects( + updatePrimaryPhone( + api, + newPhone, + 'invalid-verification-record-id', + 'new-verification-record-id' + ), + { + code: 'verification_record.permission_denied', + status: 401, + } + ); + + await deleteDefaultTenantUser(user.id); + }); + + it('should fail if new identifier verification record is invalid', async () => { + const { user, username, password } = await createDefaultTenantUserWithPassword(); + const api = await signInAndGetUserApi(username, password, { + scopes: [UserScope.Profile, UserScope.Phone], + }); + const newPhone = generatePhone(); + const verificationRecordId = await createVerificationRecordByPassword(api, password); + + await expectRejects( + updatePrimaryPhone(api, newPhone, verificationRecordId, 'new-verification-record-id'), + { + code: 'verification_record.not_found', + status: 400, + } + ); + + await deleteDefaultTenantUser(user.id); + }); + + it('should be able to update primary phone by verifying password', async () => { + const { user, username, password } = await createDefaultTenantUserWithPassword(); + const api = await signInAndGetUserApi(username, password, { + scopes: [UserScope.Profile, UserScope.Phone], + }); + const newPhone = generatePhone(); + const verificationRecordId = await createVerificationRecordByPassword(api, password); + const newVerificationRecordId = await createAndVerifyVerificationCode(api, { + type: SignInIdentifier.Phone, + value: newPhone, + }); + + await updatePrimaryPhone(api, newPhone, verificationRecordId, newVerificationRecordId); + + const userInfo = await getUserInfo(api); + expect(userInfo).toHaveProperty('primaryPhone', newPhone); + + await deleteDefaultTenantUser(user.id); + }); + + it('should be able to update primary phone by verifying existing phone', async () => { + const primaryPhone = generatePhone(); + const { user, username, password } = await createDefaultTenantUserWithPassword({ + primaryPhone, + }); + const api = await signInAndGetUserApi(username, password, { + scopes: [UserScope.Profile, UserScope.Phone], + }); + const newPhone = generatePhone(); + const verificationRecordId = await createAndVerifyVerificationCode(api, { + type: SignInIdentifier.Phone, + value: primaryPhone, + }); + const newVerificationRecordId = await createAndVerifyVerificationCode(api, { + type: SignInIdentifier.Phone, + value: newPhone, + }); + + await updatePrimaryPhone(api, newPhone, verificationRecordId, newVerificationRecordId); + + const userInfo = await getUserInfo(api); + expect(userInfo).toHaveProperty('primaryPhone', newPhone); + + await deleteDefaultTenantUser(user.id); + }); + }); });