0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

feat(core): add and change primary email (#6643)

This commit is contained in:
wangsijie 2024-10-09 10:21:25 +08:00 committed by GitHub
parent 9e67f27602
commit e3be97b528
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 575 additions and 40 deletions

View file

@ -1,22 +1,38 @@
import RequestError from '../errors/RequestError/index.js';
import { expirationTime } from '../queries/verification-records.js';
import {
buildVerificationRecord,
verificationRecordDataGuard,
type VerificationRecordMap,
} from '../routes/experience/classes/verifications/index.js';
import { type VerificationRecord } from '../routes/experience/classes/verifications/verification-record.js';
import { VerificationRecordsMap } from '../routes/experience/classes/verifications/verification-records-map.js';
import type Libraries from '../tenants/Libraries.js';
import type Queries from '../tenants/Queries.js';
import assertThat from '../utils/assert-that.js';
export const buildUserVerificationRecordById = async (
userId: string,
id: string,
queries: Queries,
libraries: Libraries
) => {
const record = await queries.verificationRecords.findUserActiveVerificationRecordById(userId, id);
/**
* Builds a verification record by its id.
* The `userId` is optional and is only used for user sensitive permission verifications.
*/
const getVerificationRecordById = async ({
id,
queries,
libraries,
userId,
}: {
id: string;
queries: Queries;
libraries: Libraries;
userId?: string;
}) => {
const record = await queries.verificationRecords.findActiveVerificationRecordById(id);
assertThat(record, 'verification_record.not_found');
if (userId) {
assertThat(record.userId === userId, 'verification_record.not_found');
}
const result = verificationRecordDataGuard.safeParse({
...record.data,
id: record.id,
@ -27,17 +43,86 @@ export const buildUserVerificationRecordById = async (
return buildVerificationRecord(libraries, queries, result.data);
};
export const saveVerificationRecord = async (
userId: string,
/**
* Verifies the user sensitive permission by checking if the verification record is valid
* and associated with the user.
*/
export const verifyUserSensitivePermission = async ({
userId,
id,
queries,
libraries,
}: {
userId: string;
id: string;
queries: Queries;
libraries: Libraries;
}): Promise<void> => {
try {
const record = await getVerificationRecordById({ id, queries, libraries, userId });
assertThat(record.isVerified, 'verification_record.not_found');
} catch (error) {
if (error instanceof RequestError) {
throw new RequestError({ code: 'verification_record.permission_denied', status: 401 });
}
throw error;
}
};
/**
* Builds a user verification record by its id and type.
* This is used to build a verification record for new identifier verifications,
* and may not be associated with a user.
*/
export const buildVerificationRecordByIdAndType = async <K extends keyof VerificationRecordMap>({
type,
id,
queries,
libraries,
}: {
type: K;
id: string;
queries: Queries;
libraries: Libraries;
}): Promise<VerificationRecordMap[K]> => {
const records = new VerificationRecordsMap();
records.setValue(await getVerificationRecordById({ id, queries, libraries }));
const instance = records.get(type);
assertThat(instance?.type === type, 'verification_record.not_found');
return instance;
};
export const insertVerificationRecord = async (
verificationRecord: VerificationRecord,
queries: Queries
queries: Queries,
// For new identifier verifications, the user id should be empty
userId?: string
) => {
const { id, ...rest } = verificationRecord.toJson();
return queries.verificationRecords.upsertRecord({
return queries.verificationRecords.insert({
id,
userId,
data: rest,
expiresAt: new Date(Date.now() + expirationTime).valueOf(),
});
};
// The upsert query can not update JSONB fields, so we need to use the update query
export const updateVerificationRecord = async (
verificationRecord: VerificationRecord,
queries: Queries
) => {
const { id, ...rest } = verificationRecord.toJson();
return queries.verificationRecords.update({
where: { id },
set: { data: rest },
jsonbMode: 'replace',
});
};

View file

@ -18,24 +18,16 @@ export class VerificationRecordQueries {
returning: true,
});
public upsertRecord = buildInsertIntoWithPool(this.pool)(VerificationRecords, {
onConflict: {
fields: [fields.id],
setExcludedFields: [fields.expiresAt],
},
});
public readonly update = buildUpdateWhereWithPool(this.pool)(VerificationRecords, true);
public readonly find = buildFindEntityByIdWithPool(this.pool)(VerificationRecords);
constructor(public readonly pool: CommonQueryMethods) {}
public findUserActiveVerificationRecordById = async (userId: string, id: string) => {
public findActiveVerificationRecordById = async (id: string) => {
return this.pool.maybeOne<VerificationRecord>(sql`
select * from ${table}
where ${fields.userId} = ${userId}
and ${fields.id} = ${id}
where ${fields.id} = ${id}
and ${fields.expiresAt} > now()
`);
};

View file

@ -50,7 +50,7 @@
"post": {
"operationId": "UpdatePassword",
"summary": "Update password",
"description": "Update password for the user, a verification record is required.",
"description": "Update password for the user, a verification record is required for checking sensitive permissions.",
"requestBody": {
"content": {
"application/json": {
@ -60,7 +60,7 @@
"description": "The new password for the user."
},
"verificationRecordId": {
"description": "The verification record ID."
"description": "The verification record ID for checking sensitive permissions."
}
}
}
@ -71,8 +71,45 @@
"204": {
"description": "The password was updated successfully."
},
"403": {
"description": "Permission denied, the verification record is invalid."
}
}
}
},
"/api/profile/primary-email": {
"post": {
"operationId": "UpdatePrimaryEmail",
"summary": "Update primary email",
"description": "Update primary email for the user, a verification record is required for checking sensitive permissions, and a new identifier verification record is required for the new email ownership verification.",
"requestBody": {
"content": {
"application/json": {
"schema": {
"properties": {
"email": {
"description": "The new email for the user."
},
"verificationRecordId": {
"description": "The verification record ID for checking sensitive permissions."
},
"newIdentifierVerificationRecordId": {
"description": "The identifier verification record ID for the new email ownership verification."
}
}
}
}
}
},
"responses": {
"204": {
"description": "The primary email was updated successfully."
},
"400": {
"description": "The verification record is invalid."
"description": "The new verification record is invalid."
},
"403": {
"description": "Permission denied, the verification record is invalid."
}
}
}

View file

@ -1,4 +1,5 @@
import { usernameRegEx, UserScope } from '@logto/core-kit';
import { emailRegEx, usernameRegEx, UserScope } from '@logto/core-kit';
import { VerificationType } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { z } from 'zod';
@ -6,7 +7,10 @@ import koaGuard from '#src/middleware/koa-guard.js';
import { EnvSet } from '../../env-set/index.js';
import { encryptUserPassword } from '../../libraries/user.utils.js';
import { buildUserVerificationRecordById } from '../../libraries/verification.js';
import {
buildVerificationRecordByIdAndType,
verifyUserSensitivePermission,
} from '../../libraries/verification.js';
import assertThat from '../../utils/assert-that.js';
import type { UserRouter, RouterInitArgs } from '../types.js';
@ -70,7 +74,7 @@ export default function profileRoutes<T extends UserRouter>(
'/profile/password',
koaGuard({
body: z.object({ password: z.string().min(1), verificationRecordId: z.string() }),
status: [204, 400],
status: [204, 400, 403],
}),
async (ctx, next) => {
const { id: userId } = ctx.auth;
@ -78,13 +82,12 @@ export default function profileRoutes<T extends UserRouter>(
// TODO(LOG-9947): apply password policy
const verificationRecord = await buildUserVerificationRecordById(
await verifyUserSensitivePermission({
userId,
verificationRecordId,
id: verificationRecordId,
queries,
libraries
);
assertThat(verificationRecord.isVerified, 'verification_record.not_found');
libraries,
});
const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword(password);
const updatedUser = await updateUserById(userId, {
@ -99,4 +102,49 @@ export default function profileRoutes<T extends UserRouter>(
return next();
}
);
router.post(
'/profile/primary-email',
koaGuard({
body: z.object({
email: z.string().regex(emailRegEx),
verificationRecordId: z.string(),
newIdentifierVerificationRecordId: z.string(),
}),
status: [204, 400, 403],
}),
async (ctx, next) => {
const { id: userId, scopes } = ctx.auth;
const { email, verificationRecordId, newIdentifierVerificationRecordId } = ctx.guard.body;
assertThat(scopes.has(UserScope.Email), 'auth.unauthorized');
await verifyUserSensitivePermission({
userId,
id: verificationRecordId,
queries,
libraries,
});
// Check new identifier
const newVerificationRecord = await buildVerificationRecordByIdAndType({
type: VerificationType.EmailVerificationCode,
id: newIdentifierVerificationRecordId,
queries,
libraries,
});
assertThat(newVerificationRecord.isVerified, 'verification_record.not_found');
assertThat(newVerificationRecord.identifier.value === email, 'verification_record.not_found');
await checkIdentifierCollision({ primaryEmail: email }, userId);
const updatedUser = await updateUserById(userId, { primaryEmail: email });
ctx.appendDataHookContext('User.Data.Updated', { user: updatedUser });
ctx.status = 204;
return next();
}
);
}

View file

@ -36,6 +36,71 @@
}
}
}
},
"/api/verifications/verification-code": {
"post": {
"operationId": "CreateVerificationByVerificationCode",
"summary": "Create a record by verification code",
"description": "Create a verification record and send the code to the specified identifier. The code verification can be used to verify the given identifier.",
"requestBody": {
"content": {
"application/json": {
"schema": {
"properties": {
"identifier": {
"description": "The identifier (email address or phone number) to send the verification code to."
}
}
}
}
}
},
"responses": {
"201": {
"description": "The verification code has been successfully sent."
},
"501": {
"description": "The connector for sending the verification code is not configured."
}
}
}
},
"/api/verifications/verification-code/verify": {
"post": {
"operationId": "VerifyVerificationByVerificationCode",
"summary": "Verify verification code",
"description": "Verify the provided verification code against the identifier. If successful, the verification record will be marked as verified.",
"requestBody": {
"content": {
"application/json": {
"schema": {
"properties": {
"code": {
"description": "The verification code to be verified."
},
"identifier": {
"description": "The identifier (email address or phone number) to verify the code against. Must match the identifier used to send the verification code."
},
"verificationId": {
"description": "The verification ID of the CodeVerification record."
}
}
}
}
}
},
"responses": {
"200": {
"description": "The verification code has been successfully verified."
},
"400": {
"description": "The verification code is invalid or the maximum number of attempts has been exceeded. Check the error message for details."
},
"501": {
"description": "The connector for sending the verification code is not configured."
}
}
}
}
}
}

View file

@ -1,16 +1,28 @@
import { AdditionalIdentifier, SentinelActivityAction } from '@logto/schemas';
import {
AdditionalIdentifier,
InteractionEvent,
SentinelActivityAction,
SignInIdentifier,
verificationCodeIdentifierGuard,
VerificationType,
} from '@logto/schemas';
import { z } from 'zod';
import koaGuard from '#src/middleware/koa-guard.js';
import { EnvSet } from '../../env-set/index.js';
import { saveVerificationRecord } from '../../libraries/verification.js';
import {
buildVerificationRecordByIdAndType,
insertVerificationRecord,
updateVerificationRecord,
} from '../../libraries/verification.js';
import { withSentinel } from '../experience/classes/libraries/sentinel-guard.js';
import { createNewCodeVerificationRecord } from '../experience/classes/verifications/code-verification.js';
import { PasswordVerification } from '../experience/classes/verifications/password-verification.js';
import type { UserRouter, RouterInitArgs } from '../types.js';
export default function verificationRoutes<T extends UserRouter>(
...[router, { provider, queries, libraries, sentinel }]: RouterInitArgs<T>
...[router, { queries, libraries, sentinel }]: RouterInitArgs<T>
) {
if (!EnvSet.values.isDevFeaturesEnabled) {
return;
@ -46,7 +58,7 @@ export default function verificationRoutes<T extends UserRouter>(
passwordVerification.verify(password)
);
await saveVerificationRecord(userId, passwordVerification, queries);
await insertVerificationRecord(passwordVerification, queries, userId);
ctx.body = { verificationRecordId: passwordVerification.id };
ctx.status = 201;
@ -54,4 +66,88 @@ export default function verificationRoutes<T extends UserRouter>(
return next();
}
);
router.post(
'/verifications/verification-code',
koaGuard({
body: z.object({
identifier: verificationCodeIdentifierGuard,
}),
response: z.object({ verificationRecordId: z.string() }),
status: [201, 501],
}),
async (ctx, next) => {
const { id: userId } = ctx.auth;
const { identifier } = ctx.guard.body;
const user = await queries.users.findUserById(userId);
const codeVerification = createNewCodeVerificationRecord(
libraries,
queries,
identifier,
// TODO(LOG-10148): Add new event
InteractionEvent.SignIn
);
await codeVerification.sendVerificationCode();
await insertVerificationRecord(
codeVerification,
queries,
// If the identifier is the primary email or phone, the verification record is associated with the user.
(identifier.type === SignInIdentifier.Email && identifier.value === user.primaryEmail) ||
(identifier.type === SignInIdentifier.Phone && identifier.value === user.primaryPhone)
? userId
: undefined
);
ctx.body = { verificationRecordId: codeVerification.id };
ctx.status = 201;
return next();
}
);
router.post(
'/verifications/verification-code/verify',
koaGuard({
body: z.object({
identifier: verificationCodeIdentifierGuard,
verificationId: z.string(),
code: z.string(),
}),
response: z.object({ verificationRecordId: z.string() }),
// 501: connector not found
status: [200, 400, 501],
}),
async (ctx, next) => {
const { identifier, code, verificationId } = ctx.guard.body;
const codeVerification = await buildVerificationRecordByIdAndType({
type: VerificationType.EmailVerificationCode,
id: verificationId,
queries,
libraries,
});
await withSentinel(
{
sentinel,
action: SentinelActivityAction.VerificationCode,
identifier,
payload: {
verificationId: codeVerification.id,
},
},
codeVerification.verify(identifier, code)
);
await updateVerificationRecord(codeVerification, queries);
ctx.body = { verificationRecordId: codeVerification.id };
return next();
}
);
}

View file

@ -7,6 +7,16 @@ export const updatePassword = async (
password: string
) => api.post('api/profile/password', { json: { password, verificationRecordId } });
export const updatePrimaryEmail = async (
api: KyInstance,
email: string,
verificationRecordId: string,
newIdentifierVerificationRecordId: string
) =>
api.post('api/profile/primary-email', {
json: { email, verificationRecordId, newIdentifierVerificationRecordId },
});
export const updateUser = async (api: KyInstance, body: Record<string, string>) =>
api.patch('api/profile', { json: body }).json<{
name?: string;

View file

@ -1,5 +1,8 @@
import { type SignInIdentifier } from '@logto/schemas';
import { type KyInstance } from 'ky';
import { readConnectorMessage } from '#src/helpers/index.js';
export const createVerificationRecordByPassword = async (api: KyInstance, password: string) => {
const { verificationRecordId } = await api
.post('api/verifications/password', {
@ -11,3 +14,57 @@ export const createVerificationRecordByPassword = async (api: KyInstance, passwo
return verificationRecordId;
};
const createVerificationCode = async (
api: KyInstance,
identifier: { type: SignInIdentifier; value: string }
) => {
const { verificationRecordId } = await api
.post('api/verifications/verification-code', {
json: {
identifier: {
type: identifier.type,
value: identifier.value,
},
},
})
.json<{ verificationRecordId: string }>();
return verificationRecordId;
};
const verifyVerificationCode = async (
api: KyInstance,
code: string,
identifier: { type: SignInIdentifier; value: string },
verificationId: string
) => {
const { verificationRecordId } = await api
.post('api/verifications/verification-code/verify', {
json: {
code,
identifier,
verificationId,
},
})
.json<{ verificationRecordId: string }>();
return verificationRecordId;
};
export const createAndVerifyVerificationCode = async (
api: KyInstance,
identifier: { type: SignInIdentifier; value: string }
) => {
const verificationRecordId = await createVerificationCode(api, identifier);
const { code, phone, address } = await readConnectorMessage(
identifier.type === 'email' ? 'Email' : 'Sms'
);
expect(code).toBeTruthy();
expect(identifier.type === 'email' ? address : phone).toBe(identifier.value);
await verifyVerificationCode(api, code, identifier, verificationRecordId);
return verificationRecordId;
};

View file

@ -9,12 +9,18 @@ import api, { baseApi, authedAdminApi } from '../api/api.js';
import { initClient } from './client.js';
export const createDefaultTenantUserWithPassword = async () => {
export const createDefaultTenantUserWithPassword = async ({
primaryEmail,
primaryPhone,
}: {
primaryEmail?: string;
primaryPhone?: string;
} = {}) => {
const username = generateUsername();
const password = generatePassword();
const user = await authedAdminApi
.post('users', {
json: { username, password },
json: { username, password, primaryEmail, primaryPhone },
})
.json<User>();

View file

@ -0,0 +1,138 @@
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 {
createAndVerifyVerificationCode,
createVerificationRecordByPassword,
} from '#src/api/verification-record.js';
import { setEmailConnector } from '#src/helpers/connector.js';
import { expectRejects } from '#src/helpers/index.js';
import {
createDefaultTenantUserWithPassword,
deleteDefaultTenantUser,
signInAndGetUserApi,
} from '#src/helpers/profile.js';
import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js';
import { devFeatureTest, generateEmail } from '#src/utils.js';
const { describe, it } = devFeatureTest;
describe('profile (email and phone)', () => {
beforeAll(async () => {
await enableAllPasswordSignInMethods();
await setEmailConnector(authedAdminApi);
});
describe('POST /profile/primary-email', () => {
it('should fail if scope is missing', async () => {
const { user, username, password } = await createDefaultTenantUserWithPassword();
const api = await signInAndGetUserApi(username, password);
const newEmail = generateEmail();
await expectRejects(
updatePrimaryEmail(
api,
newEmail,
'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.Email],
});
const newEmail = generateEmail();
await expectRejects(
updatePrimaryEmail(
api,
newEmail,
'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.Email],
});
const newEmail = generateEmail();
const verificationRecordId = await createVerificationRecordByPassword(api, password);
await expectRejects(
updatePrimaryEmail(api, newEmail, verificationRecordId, 'new-verification-record-id'),
{
code: 'verification_record.not_found',
status: 400,
}
);
await deleteDefaultTenantUser(user.id);
});
it('should be able to update primary email by verifying password', async () => {
const { user, username, password } = await createDefaultTenantUserWithPassword();
const api = await signInAndGetUserApi(username, password, {
scopes: [UserScope.Profile, UserScope.Email],
});
const newEmail = generateEmail();
const verificationRecordId = await createVerificationRecordByPassword(api, password);
const newVerificationRecordId = await createAndVerifyVerificationCode(api, {
type: SignInIdentifier.Email,
value: newEmail,
});
await updatePrimaryEmail(api, newEmail, verificationRecordId, newVerificationRecordId);
const userInfo = await getUserInfo(api);
expect(userInfo).toHaveProperty('email', newEmail);
await deleteDefaultTenantUser(user.id);
});
it('should be able to update primary email by verifying existing email', async () => {
const primaryEmail = generateEmail();
const { user, username, password } = await createDefaultTenantUserWithPassword({
primaryEmail,
});
const api = await signInAndGetUserApi(username, password, {
scopes: [UserScope.Profile, UserScope.Email],
});
const newEmail = generateEmail();
const verificationRecordId = await createAndVerifyVerificationCode(api, {
type: SignInIdentifier.Email,
value: primaryEmail,
});
const newVerificationRecordId = await createAndVerifyVerificationCode(api, {
type: SignInIdentifier.Email,
value: newEmail,
});
await updatePrimaryEmail(api, newEmail, verificationRecordId, newVerificationRecordId);
const userInfo = await getUserInfo(api);
expect(userInfo).toHaveProperty('email', newEmail);
await deleteDefaultTenantUser(user.id);
});
});
});

View file

@ -121,8 +121,8 @@ describe('profile', () => {
const newPassword = generatePassword();
await expectRejects(updatePassword(api, 'invalid-varification-record-id', newPassword), {
code: 'verification_record.not_found',
status: 400,
code: 'verification_record.permission_denied',
status: 401,
});
await deleteDefaultTenantUser(user.id);

View file

@ -1,5 +1,6 @@
const verification_record = {
not_found: 'Verification record not found.',
permission_denied: 'Permission denied, please re-authenticate.',
};
export default Object.freeze(verification_record);