0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-10 22:22:45 -05:00

fix(core,schemas): check email verification status in me api (#6507)

This commit is contained in:
Charles Zhao 2024-08-22 23:41:11 +08:00 committed by GitHub
parent 8edbff2f8a
commit 14d25bab1f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 74 additions and 41 deletions

View file

@ -116,23 +116,23 @@ function Providers() {
<AppThemeProvider>
<Helmet titleTemplate={`%s - ${mainTitle}`} defaultTitle={mainTitle} />
<Toast />
<ErrorBoundary>
<LogtoErrorBoundary>
{/**
* 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 ? (
<AppDataProvider>
<AppConfirmModalProvider>
<AppConfirmModalProvider>
<ErrorBoundary>
<LogtoErrorBoundary>
{/**
* 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 ? (
<AppDataProvider>
<AppRoutes />
</AppConfirmModalProvider>
</AppDataProvider>
) : (
<CloudAppRoutes />
)}
</LogtoErrorBoundary>
</ErrorBoundary>
</AppDataProvider>
) : (
<CloudAppRoutes />
)}
</LogtoErrorBoundary>
</ErrorBoundary>
</AppConfirmModalProvider>
</AppThemeProvider>
</LogtoProvider>
);

View file

@ -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 } });

View file

@ -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<string>) => {
// Remove existing verification statuses for current user.
await deleteVerificationStatusesByUserId(userId);
return insertVerificationStatus({
id: generateStandardId(),
userId,
verifiedIdentifier: identifier,
});
};
const checkVerificationStatus = async (userId: string): Promise<void> => {
const checkVerificationStatus = async (
userId: string,
identifier: Nullable<string>
): Promise<void> => {
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,
};
};

View file

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

View file

@ -44,8 +44,8 @@ export default function userRoutes<T extends AuthedMeRouter>(
'/',
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<T extends AuthedMeRouter>(
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<T extends AuthedMeRouter>(
await verifyUserPassword(user, password);
await createVerificationStatus(userId);
await createVerificationStatus(userId, null);
ctx.status = 204;
@ -128,13 +134,11 @@ export default function userRoutes<T extends AuthedMeRouter>(
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 });

View file

@ -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<T extends AuthedMeRouter>(
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;
const { verificationCode, ...identifier } = ctx.guard.body;
const user = await findUserById(userId);
assertThat(!user.isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
await verifyPasscode(undefined, codeType, verificationCode, identifier);
if (action === 'changePassword') {
// Store password verification status
const user = await findUserById(userId);
assertThat(!user.isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
await createVerificationStatus(userId);
}
await createVerificationStatus(userId, identifier.email);
ctx.status = 204;

View file

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

View file

@ -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)
);