Skip to content

Commit

Permalink
refactor(core): refactor the user identification logic
Browse files Browse the repository at this point in the history
refactor the user identification logic
  • Loading branch information
simeng-li committed Jul 4, 2024
1 parent 2db2627 commit d2f3990
Show file tree
Hide file tree
Showing 6 changed files with 167 additions and 107 deletions.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { InteractionEvent, type VerificationType } from '@logto/schemas';
import { InteractionEvent, VerificationType } from '@logto/schemas';
import { z } from 'zod';

import RequestError from '#src/errors/RequestError/index.js';
Expand All @@ -16,7 +16,7 @@ import {

const interactionStorageGuard = z.object({
event: z.nativeEnum(InteractionEvent).optional(),
accountId: z.string().optional(),
userId: z.string().optional(),
profile: z.object({}).optional(),
verificationRecords: verificationRecordDataGuard.array().optional(),
});
Expand All @@ -41,8 +41,8 @@ export default class ExperienceInteraction {
private interactionEvent?: InteractionEvent;
/** The user verification record list for the current interaction. */
private readonly verificationRecords: Map<VerificationType, VerificationRecord>;
/** The accountId of the user for the current interaction. Only available once the user is identified. */
private accountId?: string;
/** The userId of the user for the current interaction. Only available once the user is identified. */
private userId?: string;
/** The user provided profile data in the current interaction that needs to be stored to database. */
private readonly profile?: Record<string, unknown>; // TODO: Fix the type

Expand All @@ -60,10 +60,10 @@ export default class ExperienceInteraction {
new RequestError({ code: 'session.interaction_not_found', status: 404 })
);

const { verificationRecords = [], profile, accountId, event } = result.data;
const { verificationRecords = [], profile, userId, event } = result.data;

this.interactionEvent = event;
this.accountId = accountId; // TODO: @simeng-li replace with userId
this.userId = userId; // TODO: @simeng-li replace with userId
this.profile = profile;

this.verificationRecords = new Map();
Expand All @@ -80,28 +80,28 @@ export default class ExperienceInteraction {
this.interactionEvent = event;
}

/** Set the verified `accountId` of the current interaction from the verification record */
public identifyUser(verificationRecord: VerificationRecord) {
// Throws an 404 error if the user is not found by the given verification record
// TODO: refactor using real-time user verification. Static verifiedUserId will be removed.
assertThat(
verificationRecord.verifiedUserId,
new RequestError({
code: 'user.user_not_exist',
status: 404,
})
);

// Throws an 409 error if the current session has already identified a different user
if (this.accountId) {
assertThat(
this.accountId === verificationRecord.verifiedUserId,
new RequestError({ code: 'session.identity_conflict', status: 409 })
);
return;
/** Set the verified userId of the current interaction session from the verification record */
public async identifyUser(verificationRecord: VerificationRecord) {
switch (verificationRecord.type) {
case VerificationType.Password:
case VerificationType.VerificationCode:
case VerificationType.Social: {
const { id, isSuspended } = await verificationRecord.identifyUser();
assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));

// Throws an 409 error if the current session has already identified a different user
if (this.userId) {
assertThat(
this.userId === id,
new RequestError({ code: 'session.identity_conflict', status: 409 })
);
return;
}

this.userId = id;
break;
}
}

this.accountId = verificationRecord.verifiedUserId;
}

/**
Expand Down Expand Up @@ -138,12 +138,12 @@ export default class ExperienceInteraction {
/** Submit the current interaction result to the OIDC provider and clear the interaction data */
public async submit() {
// TODO: refine the error code
assertThat(this.accountId, 'session.verification_session_not_found');
assertThat(this.userId, 'session.verification_session_not_found');

const { provider } = this.tenant;

const redirectTo = await provider.interactionResult(this.ctx.req, this.ctx.res, {
login: { accountId: this.accountId },
login: { accountId: this.userId },
});

this.ctx.body = { redirectTo };
Expand All @@ -155,10 +155,12 @@ export default class ExperienceInteraction {

/** Convert the current interaction to JSON, so that it can be stored as the OIDC provider interaction result */
private toJson() {
const { interactionEvent: event, userId, profile } = this;

return {
event: this.interactionEvent,
accountId: this.accountId,
profile: this.profile,
event,
userId,
profile,
verificationRecords: this.verificationRecordsArray.map((record) => record.toJson()),
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import {
InteractionEvent,
VerificationType,
verificationCodeIdentifierGuard,
type User,
type VerificationCodeIdentifier,
} from '@logto/schemas';
import { type ToZodObject } from '@logto/schemas/lib/utils/zod.js';
import { generateStandardId } from '@logto/shared';
import { z } from 'zod';

import RequestError from '#src/errors/RequestError/index.js';
import { type createPasscodeLibrary } from '#src/libraries/passcode.js';
import type Libraries from '#src/tenants/Libraries.js';
import type Queries from '#src/tenants/Queries.js';
Expand Down Expand Up @@ -39,7 +41,6 @@ export type CodeVerificationRecordData = {
type: VerificationType.VerificationCode;
identifier: VerificationCodeIdentifier;
interactionEvent: InteractionEvent;
userId?: string;
verified: boolean;
};

Expand All @@ -48,7 +49,6 @@ export const codeVerificationRecordDataGuard = z.object({
type: z.literal(VerificationType.VerificationCode),
identifier: verificationCodeIdentifierGuard,
interactionEvent: z.nativeEnum(InteractionEvent),
userId: z.string().optional(),
verified: z.boolean(),
}) satisfies ToZodObject<CodeVerificationRecordData>;

Expand Down Expand Up @@ -102,22 +102,19 @@ export class CodeVerification implements VerificationRecord<VerificationType.Ver
* @remark
* `InteractionEvent.ForgotPassword` triggered verification results can not used as a verification record for other events.
*/
private readonly interactionEvent: InteractionEvent;
/** The userId will be set after the verification if the identifier matches any existing user's record */
private userId?: string;
public readonly interactionEvent: InteractionEvent;
private verified: boolean;

constructor(
private readonly libraries: Libraries,
private readonly queries: Queries,
data: CodeVerificationRecordData
) {
const { id, identifier, userId, verified, interactionEvent } = data;
const { id, identifier, verified, interactionEvent } = data;

this.id = id;
this.identifier = identifier;
this.interactionEvent = interactionEvent;
this.userId = userId;
this.verified = verified;
}

Expand All @@ -126,14 +123,6 @@ export class CodeVerification implements VerificationRecord<VerificationType.Ver
return this.verified;
}

/**
* Returns the userId if it is set
* @deprecated this will be removed in the upcoming PR
*/
get verifiedUserId() {
return this.userId;
}

/**
* Verify the `identifier` with the given code
*
Expand All @@ -157,20 +146,42 @@ export class CodeVerification implements VerificationRecord<VerificationType.Ver
);

this.verified = true;
}

/**
* Identify the user by the current `identifier`.
* Return undefined if the verification record is not verified or no user is found by the identifier.
*/
async identifyUser(): Promise<User> {
assertThat(
this.verified,
new RequestError({ code: 'session.verification_failed', status: 400 })
);

// Try to lookup the user by the identifier
const user = await findUserByIdentifier(this.queries.users, this.identifier);
this.userId = user?.id;

assertThat(
user,
new RequestError(
{ code: 'user.user_not_exist', status: 404 },
{
identifier: this.identifier.value,
}
)
);

return user;
}

toJson(): CodeVerificationRecordData {
const { id, type, identifier, interactionEvent, verified } = this;

return {
id: this.id,
type: this.type,
identifier: this.identifier,
interactionEvent: this.interactionEvent,
userId: this.userId,
verified: this.verified,
id,
type,
identifier,
interactionEvent,
verified,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
VerificationType,
interactionIdentifierGuard,
type InteractionIdentifier,
type User,
} from '@logto/schemas';
import { type ToZodObject } from '@logto/schemas/lib/utils/zod.js';
import { generateStandardId } from '@logto/shared';
Expand All @@ -20,15 +21,14 @@ export type PasswordVerificationRecordData = {
id: string;
type: VerificationType.Password;
identifier: InteractionIdentifier;
/** The userId of the user that has been verified. */
userId?: string;
verified: boolean;
};

export const passwordVerificationRecordDataGuard = z.object({
id: z.string(),
type: z.literal(VerificationType.Password),
identifier: interactionIdentifierGuard,
userId: z.string().optional(),
verified: z.boolean(),
}) satisfies ToZodObject<PasswordVerificationRecordData>;

export class PasswordVerification implements VerificationRecord<VerificationType.Password> {
Expand All @@ -38,13 +38,14 @@ export class PasswordVerification implements VerificationRecord<VerificationType
id: generateStandardId(),
type: VerificationType.Password,
identifier,
verified: false,
});
}

readonly type = VerificationType.Password;
public readonly identifier: InteractionIdentifier;
public readonly id: string;
private userId?: string;
private verified: boolean;

/**
* The constructor method is intended to be used internally by the interaction class
Expand All @@ -57,20 +58,16 @@ export class PasswordVerification implements VerificationRecord<VerificationType
private readonly queries: Queries,
data: PasswordVerificationRecordData
) {
const { id, identifier, userId } = data;
const { id, identifier, verified } = data;

this.id = id;
this.identifier = identifier;
this.userId = userId;
this.verified = verified;
}

/** Returns true if a userId is set */
get isVerified() {
return this.userId !== undefined;
}

get verifiedUserId() {
return this.userId;
return this.verified;
}

/**
Expand All @@ -82,21 +79,38 @@ export class PasswordVerification implements VerificationRecord<VerificationType
*/
async verify(password: string) {
const user = await findUserByIdentifier(this.queries.users, this.identifier);
const { isSuspended, id } = await this.libraries.users.verifyUserPassword(user, password);

// Throws an 422 error if the user is not found or the password is incorrect
const { isSuspended } = await this.libraries.users.verifyUserPassword(user, password);
assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));

this.userId = id;
this.verified = true;

return user;
}

/** Identifies the user using the username */
async identifyUser(): Promise<User> {
assertThat(
this.verified,
new RequestError({ code: 'session.verification_failed', status: 400 })
);

const user = await findUserByIdentifier(this.queries.users, this.identifier);

assertThat(user, new RequestError({ code: 'user.user_not_exist', status: 404 }));

return user;
}

toJson(): PasswordVerificationRecordData {
const { id, type, identifier, userId } = this;
const { id, type, identifier, verified } = this;

return {
id,
type,
identifier,
userId,
verified,
};
}
}
Loading

0 comments on commit d2f3990

Please sign in to comment.