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:
parent
09683ac384
commit
43470c41f1
10 changed files with 182 additions and 36 deletions
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
|
@ -37,6 +37,7 @@
|
||||||
"silverhand",
|
"silverhand",
|
||||||
"slonik",
|
"slonik",
|
||||||
"stylelint",
|
"stylelint",
|
||||||
|
"timestamptz",
|
||||||
"topbar",
|
"topbar",
|
||||||
"withtyped"
|
"withtyped"
|
||||||
]
|
]
|
||||||
|
|
50
packages/core/src/libraries/verification-status.ts
Normal file
50
packages/core/src/libraries/verification-status.ts
Normal 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 };
|
||||||
|
};
|
41
packages/core/src/queries/verification-status.ts
Normal file
41
packages/core/src/queries/verification-status.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
|
@ -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 });
|
||||||
|
|
|
@ -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.
|
|
||||||
|
|
|
@ -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) {}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
|
@ -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>;
|
|
||||||
|
|
16
packages/schemas/tables/verification_statuses.sql
Normal file
16
packages/schemas/tables/verification_statuses.sql
Normal 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);
|
Loading…
Reference in a new issue