0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

fix(core,schemas): add new verification status table (#3312)

This commit is contained in:
Charles Zhao 2023-03-08 15:21:59 +08:00 committed by GitHub
parent 09683ac384
commit 43470c41f1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 182 additions and 36 deletions

View file

@ -37,6 +37,7 @@
"silverhand", "silverhand",
"slonik", "slonik",
"stylelint", "stylelint",
"timestamptz",
"topbar", "topbar",
"withtyped" "withtyped"
] ]

View file

@ -0,0 +1,50 @@
import { generateStandardId } from '@logto/core-kit';
import RequestError from '#src/errors/RequestError/index.js';
import { verificationTimeout } from '#src/routes/consts.js';
import type Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js';
export type VerificationStatusLibrary = ReturnType<typeof createVerificationStatusLibrary>;
export const createVerificationStatusLibrary = (queries: Queries) => {
const {
findVerificationStatusByUserIdAndSessionId,
insertVerificationStatus,
deleteVerificationStatusesByUserIdAndSessionId,
} = queries.verificationStatuses;
const createVerificationStatus = async (userId: string, sessionId: string) => {
// Remove existing verification statuses for current user in current session.
await deleteVerificationStatusesByUserIdAndSessionId(userId, sessionId);
// When creating new verification record, we use session ID to identify the client device.
// The session ID is a cookie value, which is unique for each client.
// This prevents the user from proceeding after being verified on another device.
return insertVerificationStatus({
id: generateStandardId(),
sessionId,
userId,
});
};
const checkVerificationStatus = async (userId: string, sessionId: string): Promise<void> => {
const verificationStatus = await findVerificationStatusByUserIdAndSessionId(userId, sessionId);
assertThat(verificationStatus, 'session.verification_session_not_found');
const { sessionId: storedSessionId, createdAt } = verificationStatus;
// The user verification status is considered valid if:
// 1. The user is verified within 10 minutes.
// 2. The user is verified with the same client session (cookie).
const isValid =
Date.now() - createdAt < verificationTimeout &&
Boolean(sessionId) &&
storedSessionId === sessionId;
assertThat(isValid, new RequestError({ code: 'session.verification_failed', status: 422 }));
};
return { createVerificationStatus, checkVerificationStatus };
};

View file

@ -0,0 +1,41 @@
import type { CreateVerificationStatus, VerificationStatus } from '@logto/schemas';
import { VerificationStatuses } from '@logto/schemas';
import { convertToIdentifiers } from '@logto/shared';
import type { CommonQueryMethods } from 'slonik';
import { sql } from 'slonik';
import { buildInsertIntoWithPool } from '#src/database/insert-into.js';
const { table, fields } = convertToIdentifiers(VerificationStatuses);
export const createVerificationStatusQueries = (pool: CommonQueryMethods) => {
const findVerificationStatusByUserIdAndSessionId = async (userId: string, sessionId: string) =>
pool.maybeOne<VerificationStatus>(sql`
select ${sql.join(Object.values(fields), sql`, `)}
from ${table}
where ${fields.sessionId}=${sessionId} and ${fields.userId}=${userId}
`);
const insertVerificationStatus = buildInsertIntoWithPool(pool)<
CreateVerificationStatus,
VerificationStatus
>(VerificationStatuses, {
returning: true,
});
const deleteVerificationStatusesByUserIdAndSessionId = async (
userId: string,
sessionId: string
) => {
await pool.query(sql`
delete from ${table}
where ${fields.sessionId}=${sessionId} and ${fields.userId}=${userId}
`);
};
return {
findVerificationStatusByUserIdAndSessionId,
insertVerificationStatus,
deleteVerificationStatusesByUserIdAndSessionId,
};
};

View file

@ -1,10 +1,5 @@
import { emailRegEx, passwordRegEx, usernameRegEx } from '@logto/core-kit'; import { emailRegEx, passwordRegEx, usernameRegEx } from '@logto/core-kit';
import type { PasswordVerificationData } from '@logto/schemas'; import { userInfoSelectFields, arbitraryObjectGuard } from '@logto/schemas';
import {
userInfoSelectFields,
passwordVerificationGuard,
arbitraryObjectGuard,
} from '@logto/schemas';
import { pick } from '@silverhand/essentials'; import { pick } from '@silverhand/essentials';
import { literal, object, string } from 'zod'; import { literal, object, string } from 'zod';
@ -26,6 +21,7 @@ export default function userRoutes<T extends AuthedMeRouter>(
}, },
libraries: { libraries: {
users: { checkIdentifierCollision }, users: { checkIdentifierCollision },
verificationStatuses: { createVerificationStatus, checkVerificationStatus },
}, },
} = tenant; } = tenant;
@ -114,19 +110,14 @@ export default function userRoutes<T extends AuthedMeRouter>(
const cookieMap = convertCookieToMap(ctx.request.headers.cookie); const cookieMap = convertCookieToMap(ctx.request.headers.cookie);
const sessionId = cookieMap.get('_session'); const sessionId = cookieMap.get('_session');
assertThat(Boolean(sessionId), new RequestError({ code: 'session.not_found', status: 401 })); assertThat(sessionId, new RequestError({ code: 'session.not_found', status: 401 }));
const user = await findUserById(userId); const user = await findUserById(userId);
assertThat(!user.isSuspended, new RequestError({ code: 'user.suspended', status: 401 })); assertThat(!user.isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
await verifyUserPassword(user, password); await verifyUserPassword(user, password);
const customData: PasswordVerificationData = { await createVerificationStatus(userId, sessionId);
passwordVerifiedAt: Date.now(),
passwordVerifiedWithSessionId: sessionId,
};
await updateUserById(userId, { customData });
ctx.status = 204; ctx.status = 204;
@ -141,23 +132,16 @@ export default function userRoutes<T extends AuthedMeRouter>(
const { id: userId } = ctx.auth; const { id: userId } = ctx.auth;
const { password } = ctx.guard.body; const { password } = ctx.guard.body;
const { customData, isSuspended } = await findUserById(userId); const { isSuspended } = await findUserById(userId);
assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 })); assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
const cookieMap = convertCookieToMap(ctx.request.headers.cookie); const cookieMap = convertCookieToMap(ctx.request.headers.cookie);
const sessionId = cookieMap.get('_session'); const sessionId = cookieMap.get('_session');
const parsed = passwordVerificationGuard.safeParse(customData);
// The password verification status is considered valid if: assertThat(sessionId, new RequestError({ code: 'session.not_found', status: 401 }));
// 1. The password is verified within 10 minutes.
// 2. The password is verified with the same session.
const isValid =
parsed.success &&
Date.now() - parsed.data.passwordVerifiedAt < 1000 * 60 * 10 &&
Boolean(sessionId) &&
parsed.data.passwordVerifiedWithSessionId === sessionId;
assertThat(isValid, new RequestError({ code: 'session.verification_failed', status: 401 })); await checkVerificationStatus(userId, sessionId);
const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword(password); const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword(password);
await updateUserById(userId, { passwordEncrypted, passwordEncryptionMethod }); await updateUserById(userId, { passwordEncrypted, passwordEncryptionMethod });

View file

@ -9,5 +9,4 @@ export const routes = Object.freeze({
signUp, signUp,
} as const); } as const);
export const verificationTimeout = 10 * 60; // 10 mins. export const verificationTimeout = 10 * 60 * 1000; // 10 mins.
export const continueSignInTimeout = 10 * 60; // 10 mins.

View file

@ -7,6 +7,7 @@ import { createResourceLibrary } from '#src/libraries/resource.js';
import { createSignInExperienceLibrary } from '#src/libraries/sign-in-experience/index.js'; import { createSignInExperienceLibrary } from '#src/libraries/sign-in-experience/index.js';
import { createSocialLibrary } from '#src/libraries/social.js'; import { createSocialLibrary } from '#src/libraries/social.js';
import { createUserLibrary } from '#src/libraries/user.js'; import { createUserLibrary } from '#src/libraries/user.js';
import { createVerificationStatusLibrary } from '#src/libraries/verification-status.js';
import type { ModelRouters } from '#src/model-routers/index.js'; import type { ModelRouters } from '#src/model-routers/index.js';
import type Queries from './Queries.js'; import type Queries from './Queries.js';
@ -21,6 +22,7 @@ export default class Libraries {
socials = createSocialLibrary(this.queries, this.connectors); socials = createSocialLibrary(this.queries, this.connectors);
passcodes = createPasscodeLibrary(this.queries, this.connectors); passcodes = createPasscodeLibrary(this.queries, this.connectors);
applications = createApplicationLibrary(this.queries); applications = createApplicationLibrary(this.queries);
verificationStatuses = createVerificationStatusLibrary(this.queries);
constructor(private readonly queries: Queries, private readonly modelRouters: ModelRouters) {} constructor(private readonly queries: Queries, private readonly modelRouters: ModelRouters) {}
} }

View file

@ -15,6 +15,7 @@ import { createScopeQueries } from '#src/queries/scope.js';
import { createSignInExperienceQueries } from '#src/queries/sign-in-experience.js'; import { createSignInExperienceQueries } from '#src/queries/sign-in-experience.js';
import { createUserQueries } from '#src/queries/user.js'; import { createUserQueries } from '#src/queries/user.js';
import { createUsersRolesQueries } from '#src/queries/users-roles.js'; import { createUsersRolesQueries } from '#src/queries/users-roles.js';
import { createVerificationStatusQueries } from '#src/queries/verification-status.js';
export default class Queries { export default class Queries {
applications = createApplicationQueries(this.pool); applications = createApplicationQueries(this.pool);
@ -32,6 +33,7 @@ export default class Queries {
users = createUserQueries(this.pool); users = createUserQueries(this.pool);
usersRoles = createUsersRolesQueries(this.pool); usersRoles = createUsersRolesQueries(this.pool);
applicationsRoles = createApplicationsRolesQueries(this.pool); applicationsRoles = createApplicationsRolesQueries(this.pool);
verificationStatuses = createVerificationStatusQueries(this.pool);
constructor(public readonly pool: CommonQueryMethods) {} constructor(public readonly pool: CommonQueryMethods) {}
} }

View file

@ -0,0 +1,61 @@
import type { CommonQueryMethods } from 'slonik';
import { sql } from 'slonik';
import type { AlterationScript } from '../lib/types/alteration.js';
const getId = (value: string) => sql.identifier([value]);
const getDatabaseName = async (pool: CommonQueryMethods) => {
const { currentDatabase } = await pool.one<{ currentDatabase: string }>(sql`
select current_database();
`);
return currentDatabase.replaceAll('-', '_');
};
const alteration: AlterationScript = {
up: async (pool) => {
const database = await getDatabaseName(pool);
const baseRoleId = getId(`logto_tenant_${database}`);
await pool.query(sql`
create table verification_statuses (
tenant_id varchar(21) not null
references tenants (id) on update cascade on delete cascade,
id varchar(21) not null,
user_id varchar(21) not null
references users (id) on update cascade on delete cascade,
session_id varchar(128) not null,
created_at timestamptz not null default(now()),
primary key (id)
);
create index verification_statuses__id
on verification_statuses (tenant_id, id);
create index verification_statuses__user_id__session_id
on verification_statuses (tenant_id, user_id, session_id);
create trigger set_tenant_id before insert on verification_statuses
for each row execute procedure set_tenant_id();
alter table verification_statuses enable row level security;
create policy verification_statuses_tenant_id on verification_statuses to ${baseRoleId}
using (tenant_id = (select id from tenants where db_user = current_user));
grant select, insert, update, delete on verification_statuses to ${baseRoleId};
`);
},
down: async (pool) => {
await pool.query(sql`
drop policy verification_statuses_tenant_id on verification_statuses;
alter table verification_statuses disable row level security;
drop table verification_statuses;
`);
},
};
export default alteration;

View file

@ -1,6 +1,3 @@
import type { z } from 'zod';
import { number, object, string } from 'zod';
import type { CreateUser } from '../db-entries/index.js'; import type { CreateUser } from '../db-entries/index.js';
export const userInfoSelectFields = Object.freeze([ export const userInfoSelectFields = Object.freeze([
@ -34,10 +31,3 @@ export enum UserRole {
export enum PredefinedScope { export enum PredefinedScope {
All = 'all', All = 'all',
} }
export const passwordVerificationGuard = object({
passwordVerifiedAt: number(),
passwordVerifiedWithSessionId: string().optional(),
});
export type PasswordVerificationData = z.infer<typeof passwordVerificationGuard>;

View file

@ -0,0 +1,16 @@
create table verification_statuses (
tenant_id varchar(21) not null
references tenants (id) on update cascade on delete cascade,
id varchar(21) not null,
user_id varchar(21) not null
references users (id) on update cascade on delete cascade,
session_id varchar(128) not null,
created_at timestamptz not null default(now()),
primary key (id)
);
create index verification_statuses__id
on verification_statuses (tenant_id, id);
create index verification_statuses__user_id__session_id
on verification_statuses (tenant_id, user_id, session_id);