mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
feat(core): add and change phone (#6682)
This commit is contained in:
parent
64d7b6a4f3
commit
2fb85a229b
5 changed files with 213 additions and 6 deletions
|
@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 { VerificationType, userProfileResponseGuard, userProfileGuard } from '@logto/schemas';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
@ -155,7 +155,7 @@ export default function profileRoutes<T extends UserRouter>(
|
||||||
verificationRecordId: z.string(),
|
verificationRecordId: z.string(),
|
||||||
newIdentifierVerificationRecordId: z.string(),
|
newIdentifierVerificationRecordId: z.string(),
|
||||||
}),
|
}),
|
||||||
status: [204, 400, 403],
|
status: [204, 400, 401],
|
||||||
}),
|
}),
|
||||||
async (ctx, next) => {
|
async (ctx, next) => {
|
||||||
const { id: userId, scopes } = ctx.auth;
|
const { id: userId, scopes } = ctx.auth;
|
||||||
|
@ -191,4 +191,49 @@ export default function profileRoutes<T extends UserRouter>(
|
||||||
return next();
|
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();
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -125,7 +125,10 @@ export default function verificationRoutes<T extends UserRouter>(
|
||||||
const { identifier, code, verificationId } = ctx.guard.body;
|
const { identifier, code, verificationId } = ctx.guard.body;
|
||||||
|
|
||||||
const codeVerification = await buildVerificationRecordByIdAndType({
|
const codeVerification = await buildVerificationRecordByIdAndType({
|
||||||
type: VerificationType.EmailVerificationCode,
|
type:
|
||||||
|
identifier.type === SignInIdentifier.Email
|
||||||
|
? VerificationType.EmailVerificationCode
|
||||||
|
: VerificationType.PhoneVerificationCode,
|
||||||
id: verificationId,
|
id: verificationId,
|
||||||
queries,
|
queries,
|
||||||
libraries,
|
libraries,
|
||||||
|
|
|
@ -17,6 +17,16 @@ export const updatePrimaryEmail = async (
|
||||||
json: { email, verificationRecordId, newIdentifierVerificationRecordId },
|
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<string, unknown>) =>
|
export const updateUser = async (api: KyInstance, body: Record<string, unknown>) =>
|
||||||
api.patch('api/profile', { json: body }).json<Partial<UserProfileResponse>>();
|
api.patch('api/profile', { json: body }).json<Partial<UserProfileResponse>>();
|
||||||
|
|
||||||
|
|
|
@ -2,12 +2,12 @@ import { UserScope } from '@logto/core-kit';
|
||||||
import { SignInIdentifier } from '@logto/schemas';
|
import { SignInIdentifier } from '@logto/schemas';
|
||||||
|
|
||||||
import { authedAdminApi } from '#src/api/api.js';
|
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 {
|
import {
|
||||||
createAndVerifyVerificationCode,
|
createAndVerifyVerificationCode,
|
||||||
createVerificationRecordByPassword,
|
createVerificationRecordByPassword,
|
||||||
} from '#src/api/verification-record.js';
|
} 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 { expectRejects } from '#src/helpers/index.js';
|
||||||
import {
|
import {
|
||||||
createDefaultTenantUserWithPassword,
|
createDefaultTenantUserWithPassword,
|
||||||
|
@ -15,7 +15,7 @@ import {
|
||||||
signInAndGetUserApi,
|
signInAndGetUserApi,
|
||||||
} from '#src/helpers/profile.js';
|
} from '#src/helpers/profile.js';
|
||||||
import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.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;
|
const { describe, it } = devFeatureTest;
|
||||||
|
|
||||||
|
@ -23,6 +23,7 @@ describe('profile (email and phone)', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await enableAllPasswordSignInMethods();
|
await enableAllPasswordSignInMethods();
|
||||||
await setEmailConnector(authedAdminApi);
|
await setEmailConnector(authedAdminApi);
|
||||||
|
await setSmsConnector(authedAdminApi);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /profile/primary-email', () => {
|
describe('POST /profile/primary-email', () => {
|
||||||
|
@ -135,4 +136,115 @@ describe('profile (email and phone)', () => {
|
||||||
await deleteDefaultTenantUser(user.id);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue