Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 96 additions & 11 deletions packages/core/src/libraries/verification.ts
Original file line number Diff line number Diff line change
@@ -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 buildVerificationRecordById = async ({
id,
queries,
libraries,
userId,
}: {
id: string;
queries: Queries;
libraries: Libraries;
userId?: string;
}) => {
const record = await queries.verificationRecords.findActiveVerificationRecordById(id);

Check warning on line 29 in packages/core/src/libraries/verification.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/libraries/verification.ts#L19-L29

Added lines #L19 - L29 were not covered by tests
assertThat(record, 'verification_record.not_found');

if (userId) {
assertThat(record.userId === userId, 'verification_record.not_found');
}

Check warning on line 35 in packages/core/src/libraries/verification.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/libraries/verification.ts#L32-L35

Added lines #L32 - L35 were not covered by tests
const result = verificationRecordDataGuard.safeParse({
...record.data,
id: record.id,
Expand All @@ -27,17 +43,86 @@
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 buildVerificationRecordById({ 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: 403 });
}

throw error;
}
};

Check warning on line 72 in packages/core/src/libraries/verification.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/libraries/verification.ts#L51-L72

Added lines #L51 - L72 were not covered by tests

/**
* 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 buildVerificationRecordById({ id, queries, libraries }));

const instance = records.get(type);

assertThat(instance?.type === type, 'verification_record.not_found');

return instance;
};

Check warning on line 98 in packages/core/src/libraries/verification.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/libraries/verification.ts#L80-L98

Added lines #L80 - L98 were not covered by tests

export const insertVerificationRecord = async (
verificationRecord: VerificationRecord,
queries: Queries
queries: Queries,
// For new identifier verifications, the user id should be empty
userId?: string

Check warning on line 104 in packages/core/src/libraries/verification.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/libraries/verification.ts#L102-L104

Added lines #L102 - L104 were not covered by tests
) => {
const { id, ...rest } = verificationRecord.toJson();

return queries.verificationRecords.upsertRecord({
return queries.verificationRecords.insert({

Check warning on line 108 in packages/core/src/libraries/verification.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/libraries/verification.ts#L108

Added line #L108 was not covered by tests
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',
});
};

Check warning on line 128 in packages/core/src/libraries/verification.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/libraries/verification.ts#L118-L128

Added lines #L118 - L128 were not covered by tests
12 changes: 2 additions & 10 deletions packages/core/src/queries/verification-records.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
const { table, fields } = convertToIdentifiers(VerificationRecords);

// Default expiration time for verification records is 10 minutes
// TODO: Remove this after we implement "Account Center" configuration

Check warning on line 13 in packages/core/src/queries/verification-records.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/queries/verification-records.ts#L13

[no-warning-comments] Unexpected 'todo' comment: 'TODO: Remove this after we implement...'.
export const expirationTime = 1000 * 60 * 10;

export class VerificationRecordQueries {
Expand All @@ -18,24 +18,16 @@
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}

Check warning on line 30 in packages/core/src/queries/verification-records.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/queries/verification-records.ts#L30

Added line #L30 was not covered by tests
and ${fields.expiresAt} > now()
`);
};
Expand Down
43 changes: 40 additions & 3 deletions packages/core/src/routes/profile/index.openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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."
}
}
}
Expand All @@ -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."
}
}
}
Expand Down
64 changes: 56 additions & 8 deletions packages/core/src/routes/profile/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
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';

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';

Expand Down Expand Up @@ -70,21 +74,20 @@
'/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;
const { password, verificationRecordId } = ctx.guard.body;

// TODO(LOG-9947): apply password policy

Check warning on line 83 in packages/core/src/routes/profile/index.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/routes/profile/index.ts#L83

[no-warning-comments] Unexpected 'todo' comment: 'TODO(LOG-9947): apply password policy'.

const verificationRecord = await buildUserVerificationRecordById(
await verifyUserSensitivePermission({

Check warning on line 85 in packages/core/src/routes/profile/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/profile/index.ts#L85

Added line #L85 was not covered by tests
userId,
verificationRecordId,
id: verificationRecordId,

Check warning on line 87 in packages/core/src/routes/profile/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/profile/index.ts#L87

Added line #L87 was not covered by tests
queries,
libraries
);
assertThat(verificationRecord.isVerified, 'verification_record.not_found');
libraries,
});

Check warning on line 90 in packages/core/src/routes/profile/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/profile/index.ts#L89-L90

Added lines #L89 - L90 were not covered by tests

const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword(password);
const updatedUser = await updateUserById(userId, {
Expand All @@ -99,4 +102,49 @@
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();
}

Check warning on line 148 in packages/core/src/routes/profile/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/profile/index.ts#L117-L148

Added lines #L117 - L148 were not covered by tests
);
}
65 changes: 65 additions & 0 deletions packages/core/src/routes/verification/index.openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
}
}
}
}
Loading
Loading