diff --git a/packages/console/src/App.tsx b/packages/console/src/App.tsx index 30830308c53..aac799a98f8 100644 --- a/packages/console/src/App.tsx +++ b/packages/console/src/App.tsx @@ -116,23 +116,23 @@ function Providers() { - - - {/** - * If it's not Cloud (OSS), render the tenant app container directly since only default tenant is available; - * if it's Cloud, render the tenant app container only when a tenant ID is available (in a tenant context). - */} - {!isCloud || currentTenantId ? ( - - + + + + {/** + * If it's not Cloud (OSS), render the tenant app container directly since only default tenant is available; + * if it's Cloud, render the tenant app container only when a tenant ID is available (in a tenant context). + */} + {!isCloud || currentTenantId ? ( + - - - ) : ( - - )} - - + + ) : ( + + )} + + + ); diff --git a/packages/console/src/pages/Profile/containers/VerificationCodeModal/index.tsx b/packages/console/src/pages/Profile/containers/VerificationCodeModal/index.tsx index a5e217af1cf..83000f23994 100644 --- a/packages/console/src/pages/Profile/containers/VerificationCodeModal/index.tsx +++ b/packages/console/src/pages/Profile/containers/VerificationCodeModal/index.tsx @@ -61,7 +61,7 @@ function VerificationCodeModal() { } try { - await api.post(`me/verification-codes/verify`, { json: { verificationCode, email, action } }); + await api.post(`me/verification-codes/verify`, { json: { verificationCode, email } }); if (action === 'changeEmail') { await api.patch('me', { json: { primaryEmail: email } }); diff --git a/packages/core/src/libraries/verification-status.ts b/packages/core/src/libraries/verification-status.ts index bdf1565b713..7a40d1b5fdd 100644 --- a/packages/core/src/libraries/verification-status.ts +++ b/packages/core/src/libraries/verification-status.ts @@ -1,4 +1,5 @@ import { generateStandardId } from '@logto/shared'; +import { type Nullable } from '@silverhand/essentials'; import RequestError from '#src/errors/RequestError/index.js'; import type Queries from '#src/tenants/Queries.js'; @@ -13,17 +14,21 @@ export const createVerificationStatusLibrary = (queries: Queries) => { deleteVerificationStatusesByUserId, } = queries.verificationStatuses; - const createVerificationStatus = async (userId: string) => { + const createVerificationStatus = async (userId: string, identifier: Nullable) => { // Remove existing verification statuses for current user. await deleteVerificationStatusesByUserId(userId); return insertVerificationStatus({ id: generateStandardId(), userId, + verifiedIdentifier: identifier, }); }; - const checkVerificationStatus = async (userId: string): Promise => { + const checkVerificationStatus = async ( + userId: string, + identifier: Nullable + ): Promise => { const verificationStatus = await findVerificationStatusByUserId(userId); assertThat(verificationStatus, 'session.verification_session_not_found'); @@ -31,7 +36,15 @@ export const createVerificationStatusLibrary = (queries: Queries) => { // The user verification status is considered valid if the user is verified within 10 minutes. const isValid = Date.now() - verificationStatus.createdAt < verificationTimeout; assertThat(isValid, new RequestError({ code: 'session.verification_failed', status: 422 })); + + assertThat( + verificationStatus.verifiedIdentifier === identifier, + new RequestError({ code: 'session.verification_failed', status: 422 }) + ); }; - return { createVerificationStatus, checkVerificationStatus }; + return { + createVerificationStatus, + checkVerificationStatus, + }; }; diff --git a/packages/core/src/queries/verification-status.ts b/packages/core/src/queries/verification-status.ts index 2439a9a12e0..8054b2232e6 100644 --- a/packages/core/src/queries/verification-status.ts +++ b/packages/core/src/queries/verification-status.ts @@ -1,5 +1,4 @@ -import type { VerificationStatus } from '@logto/schemas'; -import { VerificationStatuses } from '@logto/schemas'; +import { type VerificationStatus, VerificationStatuses } from '@logto/schemas'; import type { CommonQueryMethods } from '@silverhand/slonik'; import { sql } from '@silverhand/slonik'; diff --git a/packages/core/src/routes-me/user.ts b/packages/core/src/routes-me/user.ts index 42fab5a3e04..30a3cab4215 100644 --- a/packages/core/src/routes-me/user.ts +++ b/packages/core/src/routes-me/user.ts @@ -44,8 +44,8 @@ export default function userRoutes( '/', koaGuard({ body: object({ - username: string().regex(usernameRegEx), - primaryEmail: string().regex(emailRegEx), + username: string().regex(usernameRegEx), // OSS only + primaryEmail: string().regex(emailRegEx), // Cloud only name: string().or(literal('')).nullable(), avatar: string().url().or(literal('')).nullable(), }).partial(), @@ -57,6 +57,12 @@ export default function userRoutes( const user = await findUserById(userId); assertThat(!user.isSuspended, new RequestError({ code: 'user.suspended', status: 401 })); + const { primaryEmail } = body; + if (primaryEmail) { + // Check if user has verified email within 10 minutes. + await checkVerificationStatus(userId, primaryEmail); + } + await checkIdentifierCollision(body, userId); const updatedUser = await updateUserById(userId, body); @@ -113,7 +119,7 @@ export default function userRoutes( await verifyUserPassword(user, password); - await createVerificationStatus(userId); + await createVerificationStatus(userId, null); ctx.status = 204; @@ -128,13 +134,11 @@ export default function userRoutes( const { id: userId } = ctx.auth; const { password } = ctx.guard.body; - const { isSuspended, passwordEncrypted: oldPasswordEncrypted } = await findUserById(userId); + const { isSuspended } = await findUserById(userId); assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 })); - if (oldPasswordEncrypted) { - await checkVerificationStatus(userId); - } + await checkVerificationStatus(userId, null); const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword(password); await updateUserById(userId, { passwordEncrypted, passwordEncryptionMethod }); diff --git a/packages/core/src/routes-me/verification-code.ts b/packages/core/src/routes-me/verification-code.ts index 0b3d136ddcb..1a24d62d16b 100644 --- a/packages/core/src/routes-me/verification-code.ts +++ b/packages/core/src/routes-me/verification-code.ts @@ -1,11 +1,12 @@ import { TemplateType } from '@logto/connector-kit'; import { emailRegEx } from '@logto/core-kit'; -import { literal, object, string, union } from 'zod'; +import { object, string } from 'zod'; -import RequestError from '#src/errors/RequestError/index.js'; import koaGuard from '#src/middleware/koa-guard.js'; import type { RouterInitArgs } from '#src/routes/types.js'; -import assertThat from '#src/utils/assert-that.js'; + +import RequestError from '../errors/RequestError/index.js'; +import assertThat from '../utils/assert-that.js'; import type { AuthedMeRouter } from './types.js'; @@ -44,21 +45,18 @@ export default function verificationCodeRoutes( body: object({ email: string().regex(emailRegEx), verificationCode: string().min(1), - action: union([literal('changeEmail'), literal('changePassword')]), }), }), async (ctx, next) => { const { id: userId } = ctx.auth; - const { verificationCode, action, ...identifier } = ctx.guard.body; - await verifyPasscode(undefined, codeType, verificationCode, identifier); + const { verificationCode, ...identifier } = ctx.guard.body; - if (action === 'changePassword') { - // Store password verification status - const user = await findUserById(userId); - assertThat(!user.isSuspended, new RequestError({ code: 'user.suspended', status: 401 })); + const user = await findUserById(userId); + assertThat(!user.isSuspended, new RequestError({ code: 'user.suspended', status: 401 })); + + await verifyPasscode(undefined, codeType, verificationCode, identifier); - await createVerificationStatus(userId); - } + await createVerificationStatus(userId, identifier.email); ctx.status = 204; diff --git a/packages/schemas/alterations/next-1724316971-add-verified-identifier-to-verification-statuses.ts b/packages/schemas/alterations/next-1724316971-add-verified-identifier-to-verification-statuses.ts new file mode 100644 index 00000000000..8009fd97308 --- /dev/null +++ b/packages/schemas/alterations/next-1724316971-add-verified-identifier-to-verification-statuses.ts @@ -0,0 +1,18 @@ +import { sql } from '@silverhand/slonik'; + +import type { AlterationScript } from '../lib/types/alteration.js'; + +const alteration: AlterationScript = { + up: async (pool) => { + await pool.query(sql` + alter table verification_statuses add column verified_identifier varchar(255); + `); + }, + down: async (pool) => { + await pool.query(sql` + alter table verification_statuses drop column verified_identifier; + `); + }, +}; + +export default alteration; diff --git a/packages/schemas/tables/verification_statuses.sql b/packages/schemas/tables/verification_statuses.sql index 1c9f6030082..4442988eb40 100644 --- a/packages/schemas/tables/verification_statuses.sql +++ b/packages/schemas/tables/verification_statuses.sql @@ -5,6 +5,7 @@ create table verification_statuses ( user_id varchar(21) not null references users (id) on update cascade on delete cascade, created_at timestamptz not null default(now()), + verified_identifier varchar(255), primary key (id) );