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:
parent
9e67f27602
commit
e3be97b528
12 changed files with 575 additions and 40 deletions
|
@ -1,22 +1,38 @@
|
||||||
|
import RequestError from '../errors/RequestError/index.js';
|
||||||
import { expirationTime } from '../queries/verification-records.js';
|
import { expirationTime } from '../queries/verification-records.js';
|
||||||
import {
|
import {
|
||||||
buildVerificationRecord,
|
buildVerificationRecord,
|
||||||
verificationRecordDataGuard,
|
verificationRecordDataGuard,
|
||||||
|
type VerificationRecordMap,
|
||||||
} from '../routes/experience/classes/verifications/index.js';
|
} from '../routes/experience/classes/verifications/index.js';
|
||||||
import { type VerificationRecord } from '../routes/experience/classes/verifications/verification-record.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 Libraries from '../tenants/Libraries.js';
|
||||||
import type Queries from '../tenants/Queries.js';
|
import type Queries from '../tenants/Queries.js';
|
||||||
import assertThat from '../utils/assert-that.js';
|
import assertThat from '../utils/assert-that.js';
|
||||||
|
|
||||||
export const buildUserVerificationRecordById = async (
|
/**
|
||||||
userId: string,
|
* Builds a verification record by its id.
|
||||||
id: string,
|
* The `userId` is optional and is only used for user sensitive permission verifications.
|
||||||
queries: Queries,
|
*/
|
||||||
libraries: Libraries
|
const getVerificationRecordById = async ({
|
||||||
) => {
|
id,
|
||||||
const record = await queries.verificationRecords.findUserActiveVerificationRecordById(userId, 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');
|
assertThat(record, 'verification_record.not_found');
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
assertThat(record.userId === userId, 'verification_record.not_found');
|
||||||
|
}
|
||||||
|
|
||||||
const result = verificationRecordDataGuard.safeParse({
|
const result = verificationRecordDataGuard.safeParse({
|
||||||
...record.data,
|
...record.data,
|
||||||
id: record.id,
|
id: record.id,
|
||||||
|
@ -27,17 +43,86 @@ export const buildUserVerificationRecordById = async (
|
||||||
return buildVerificationRecord(libraries, queries, result.data);
|
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,
|
verificationRecord: VerificationRecord,
|
||||||
queries: Queries
|
queries: Queries,
|
||||||
|
// For new identifier verifications, the user id should be empty
|
||||||
|
userId?: string
|
||||||
) => {
|
) => {
|
||||||
const { id, ...rest } = verificationRecord.toJson();
|
const { id, ...rest } = verificationRecord.toJson();
|
||||||
|
|
||||||
return queries.verificationRecords.upsertRecord({
|
return queries.verificationRecords.insert({
|
||||||
id,
|
id,
|
||||||
userId,
|
userId,
|
||||||
data: rest,
|
data: rest,
|
||||||
expiresAt: new Date(Date.now() + expirationTime).valueOf(),
|
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',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
|
@ -18,24 +18,16 @@ export class VerificationRecordQueries {
|
||||||
returning: true,
|
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 update = buildUpdateWhereWithPool(this.pool)(VerificationRecords, true);
|
||||||
|
|
||||||
public readonly find = buildFindEntityByIdWithPool(this.pool)(VerificationRecords);
|
public readonly find = buildFindEntityByIdWithPool(this.pool)(VerificationRecords);
|
||||||
|
|
||||||
constructor(public readonly pool: CommonQueryMethods) {}
|
constructor(public readonly pool: CommonQueryMethods) {}
|
||||||
|
|
||||||
public findUserActiveVerificationRecordById = async (userId: string, id: string) => {
|
public findActiveVerificationRecordById = async (id: string) => {
|
||||||
return this.pool.maybeOne<VerificationRecord>(sql`
|
return this.pool.maybeOne<VerificationRecord>(sql`
|
||||||
select * from ${table}
|
select * from ${table}
|
||||||
where ${fields.userId} = ${userId}
|
where ${fields.id} = ${id}
|
||||||
and ${fields.id} = ${id}
|
|
||||||
and ${fields.expiresAt} > now()
|
and ${fields.expiresAt} > now()
|
||||||
`);
|
`);
|
||||||
};
|
};
|
||||||
|
|
|
@ -50,7 +50,7 @@
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "UpdatePassword",
|
"operationId": "UpdatePassword",
|
||||||
"summary": "Update password",
|
"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": {
|
"requestBody": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
|
@ -60,7 +60,7 @@
|
||||||
"description": "The new password for the user."
|
"description": "The new password for the user."
|
||||||
},
|
},
|
||||||
"verificationRecordId": {
|
"verificationRecordId": {
|
||||||
"description": "The verification record ID."
|
"description": "The verification record ID for checking sensitive permissions."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -71,8 +71,45 @@
|
||||||
"204": {
|
"204": {
|
||||||
"description": "The password was updated successfully."
|
"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": {
|
"400": {
|
||||||
"description": "The verification record is invalid."
|
"description": "The new verification record is invalid."
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Permission denied, the verification record is invalid."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 { conditional } from '@silverhand/essentials';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
@ -6,7 +7,10 @@ import koaGuard from '#src/middleware/koa-guard.js';
|
||||||
|
|
||||||
import { EnvSet } from '../../env-set/index.js';
|
import { EnvSet } from '../../env-set/index.js';
|
||||||
import { encryptUserPassword } from '../../libraries/user.utils.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 assertThat from '../../utils/assert-that.js';
|
||||||
import type { UserRouter, RouterInitArgs } from '../types.js';
|
import type { UserRouter, RouterInitArgs } from '../types.js';
|
||||||
|
|
||||||
|
@ -70,7 +74,7 @@ export default function profileRoutes<T extends UserRouter>(
|
||||||
'/profile/password',
|
'/profile/password',
|
||||||
koaGuard({
|
koaGuard({
|
||||||
body: z.object({ password: z.string().min(1), verificationRecordId: z.string() }),
|
body: z.object({ password: z.string().min(1), verificationRecordId: z.string() }),
|
||||||
status: [204, 400],
|
status: [204, 400, 403],
|
||||||
}),
|
}),
|
||||||
async (ctx, next) => {
|
async (ctx, next) => {
|
||||||
const { id: userId } = ctx.auth;
|
const { id: userId } = ctx.auth;
|
||||||
|
@ -78,13 +82,12 @@ export default function profileRoutes<T extends UserRouter>(
|
||||||
|
|
||||||
// TODO(LOG-9947): apply password policy
|
// TODO(LOG-9947): apply password policy
|
||||||
|
|
||||||
const verificationRecord = await buildUserVerificationRecordById(
|
await verifyUserSensitivePermission({
|
||||||
userId,
|
userId,
|
||||||
verificationRecordId,
|
id: verificationRecordId,
|
||||||
queries,
|
queries,
|
||||||
libraries
|
libraries,
|
||||||
);
|
});
|
||||||
assertThat(verificationRecord.isVerified, 'verification_record.not_found');
|
|
||||||
|
|
||||||
const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword(password);
|
const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword(password);
|
||||||
const updatedUser = await updateUserById(userId, {
|
const updatedUser = await updateUserById(userId, {
|
||||||
|
@ -99,4 +102,49 @@ export default function profileRoutes<T extends UserRouter>(
|
||||||
return next();
|
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();
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 { z } from 'zod';
|
||||||
|
|
||||||
import koaGuard from '#src/middleware/koa-guard.js';
|
import koaGuard from '#src/middleware/koa-guard.js';
|
||||||
|
|
||||||
import { EnvSet } from '../../env-set/index.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 { 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 { PasswordVerification } from '../experience/classes/verifications/password-verification.js';
|
||||||
import type { UserRouter, RouterInitArgs } from '../types.js';
|
import type { UserRouter, RouterInitArgs } from '../types.js';
|
||||||
|
|
||||||
export default function verificationRoutes<T extends UserRouter>(
|
export default function verificationRoutes<T extends UserRouter>(
|
||||||
...[router, { provider, queries, libraries, sentinel }]: RouterInitArgs<T>
|
...[router, { queries, libraries, sentinel }]: RouterInitArgs<T>
|
||||||
) {
|
) {
|
||||||
if (!EnvSet.values.isDevFeaturesEnabled) {
|
if (!EnvSet.values.isDevFeaturesEnabled) {
|
||||||
return;
|
return;
|
||||||
|
@ -46,7 +58,7 @@ export default function verificationRoutes<T extends UserRouter>(
|
||||||
passwordVerification.verify(password)
|
passwordVerification.verify(password)
|
||||||
);
|
);
|
||||||
|
|
||||||
await saveVerificationRecord(userId, passwordVerification, queries);
|
await insertVerificationRecord(passwordVerification, queries, userId);
|
||||||
|
|
||||||
ctx.body = { verificationRecordId: passwordVerification.id };
|
ctx.body = { verificationRecordId: passwordVerification.id };
|
||||||
ctx.status = 201;
|
ctx.status = 201;
|
||||||
|
@ -54,4 +66,88 @@ export default function verificationRoutes<T extends UserRouter>(
|
||||||
return next();
|
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();
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,16 @@ export const updatePassword = async (
|
||||||
password: string
|
password: string
|
||||||
) => api.post('api/profile/password', { json: { password, verificationRecordId } });
|
) => 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>) =>
|
export const updateUser = async (api: KyInstance, body: Record<string, string>) =>
|
||||||
api.patch('api/profile', { json: body }).json<{
|
api.patch('api/profile', { json: body }).json<{
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
|
import { type SignInIdentifier } from '@logto/schemas';
|
||||||
import { type KyInstance } from 'ky';
|
import { type KyInstance } from 'ky';
|
||||||
|
|
||||||
|
import { readConnectorMessage } from '#src/helpers/index.js';
|
||||||
|
|
||||||
export const createVerificationRecordByPassword = async (api: KyInstance, password: string) => {
|
export const createVerificationRecordByPassword = async (api: KyInstance, password: string) => {
|
||||||
const { verificationRecordId } = await api
|
const { verificationRecordId } = await api
|
||||||
.post('api/verifications/password', {
|
.post('api/verifications/password', {
|
||||||
|
@ -11,3 +14,57 @@ export const createVerificationRecordByPassword = async (api: KyInstance, passwo
|
||||||
|
|
||||||
return verificationRecordId;
|
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;
|
||||||
|
};
|
||||||
|
|
|
@ -9,12 +9,18 @@ import api, { baseApi, authedAdminApi } from '../api/api.js';
|
||||||
|
|
||||||
import { initClient } from './client.js';
|
import { initClient } from './client.js';
|
||||||
|
|
||||||
export const createDefaultTenantUserWithPassword = async () => {
|
export const createDefaultTenantUserWithPassword = async ({
|
||||||
|
primaryEmail,
|
||||||
|
primaryPhone,
|
||||||
|
}: {
|
||||||
|
primaryEmail?: string;
|
||||||
|
primaryPhone?: string;
|
||||||
|
} = {}) => {
|
||||||
const username = generateUsername();
|
const username = generateUsername();
|
||||||
const password = generatePassword();
|
const password = generatePassword();
|
||||||
const user = await authedAdminApi
|
const user = await authedAdminApi
|
||||||
.post('users', {
|
.post('users', {
|
||||||
json: { username, password },
|
json: { username, password, primaryEmail, primaryPhone },
|
||||||
})
|
})
|
||||||
.json<User>();
|
.json<User>();
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -121,8 +121,8 @@ describe('profile', () => {
|
||||||
const newPassword = generatePassword();
|
const newPassword = generatePassword();
|
||||||
|
|
||||||
await expectRejects(updatePassword(api, 'invalid-varification-record-id', newPassword), {
|
await expectRejects(updatePassword(api, 'invalid-varification-record-id', newPassword), {
|
||||||
code: 'verification_record.not_found',
|
code: 'verification_record.permission_denied',
|
||||||
status: 400,
|
status: 401,
|
||||||
});
|
});
|
||||||
|
|
||||||
await deleteDefaultTenantUser(user.id);
|
await deleteDefaultTenantUser(user.id);
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
const verification_record = {
|
const verification_record = {
|
||||||
not_found: 'Verification record not found.',
|
not_found: 'Verification record not found.',
|
||||||
|
permission_denied: 'Permission denied, please re-authenticate.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Object.freeze(verification_record);
|
export default Object.freeze(verification_record);
|
||||||
|
|
Loading…
Reference in a new issue