-
-
Notifications
You must be signed in to change notification settings - Fork 479
Commit
refactor the user identification flow
- Loading branch information
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,5 @@ | ||
import { InteractionEvent, type VerificationType } from '@logto/schemas'; | ||
import { type ToZodObject } from '@logto/connector-kit'; | ||
import { InteractionEvent, VerificationType } from '@logto/schemas'; | ||
import { z } from 'zod'; | ||
|
||
import RequestError from '#src/errors/RequestError/index.js'; | ||
|
@@ -8,18 +9,27 @@ import assertThat from '#src/utils/assert-that.js'; | |
|
||
import type { Interaction } from '../types.js'; | ||
|
||
import { validateSieVerificationMethod } from './utils.js'; | ||
import { | ||
buildVerificationRecord, | ||
verificationRecordDataGuard, | ||
type VerificationRecord, | ||
type VerificationRecordData, | ||
} from './verifications/index.js'; | ||
|
||
type InteractionStorage = { | ||
interactionEvent?: InteractionEvent; | ||
userId?: string; | ||
profile?: Record<string, unknown>; | ||
verificationRecords?: VerificationRecordData[]; | ||
}; | ||
|
||
const interactionStorageGuard = z.object({ | ||
event: z.nativeEnum(InteractionEvent).optional(), | ||
accountId: z.string().optional(), | ||
interactionEvent: z.nativeEnum(InteractionEvent).optional(), | ||
userId: z.string().optional(), | ||
profile: z.object({}).optional(), | ||
verificationRecords: verificationRecordDataGuard.array().optional(), | ||
}); | ||
}) satisfies ToZodObject<InteractionStorage>; | ||
|
||
/** | ||
* Interaction is a short-lived session session that is initiated when a user starts an interaction flow with the Logto platform. | ||
|
@@ -41,8 +51,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 | ||
Check warning on line 57 in packages/core/src/routes/experience/classes/experience-interaction.ts GitHub Actions / ESLint Report Analysispackages/core/src/routes/experience/classes/experience-interaction.ts#L57
|
||
|
||
|
@@ -60,10 +70,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, interactionEvent } = result.data; | ||
|
||
this.interactionEvent = event; | ||
this.accountId = accountId; // TODO: @simeng-li replace with userId | ||
this.interactionEvent = interactionEvent; | ||
this.userId = userId; // TODO: @simeng-li replace with userId | ||
Check warning on line 76 in packages/core/src/routes/experience/classes/experience-interaction.ts Codecov / codecov/patchpackages/core/src/routes/experience/classes/experience-interaction.ts#L75-L76
Check warning on line 76 in packages/core/src/routes/experience/classes/experience-interaction.ts GitHub Actions / ESLint Report Analysispackages/core/src/routes/experience/classes/experience-interaction.ts#L76
|
||
this.profile = profile; | ||
|
||
this.verificationRecords = new Map(); | ||
|
@@ -75,40 +85,63 @@ export default class ExperienceInteraction { | |
} | ||
|
||
/** Set the interaction event for the current interaction */ | ||
public setInteractionEvent(event: InteractionEvent) { | ||
public setInteractionEvent(interactionEvent: InteractionEvent) { | ||
// TODO: conflict event check (e.g. reset password session can't be used for sign in) | ||
Check warning on line 89 in packages/core/src/routes/experience/classes/experience-interaction.ts GitHub Actions / ESLint Report Analysispackages/core/src/routes/experience/classes/experience-interaction.ts#L89
|
||
this.interactionEvent = event; | ||
this.interactionEvent = interactionEvent; | ||
} | ||
|
||
/** Set the verified `accountId` of the current interaction from the verification record */ | ||
public identifyUser(verificationId: string) { | ||
/** | ||
* Identify the user using the verification record. | ||
* | ||
* - Check if the verification record exists. | ||
* - Check if the verification record is valid for the current interaction event. | ||
* - Create a new user using the verification record if the current interaction event is `Register`. | ||
* - Identify the user using the verification record if the current interaction event is `SignIn` or `ForgotPassword`. | ||
* - Set the user id to the current interaction. | ||
* | ||
* @throws RequestError with 404 if the verification record is not found | ||
* @throws RequestError with 404 if the interaction event is not set | ||
* @throws RequestError with 400 if the verification record is not valid for the current interaction event | ||
* @throws RequestError with 401 if the user is suspended | ||
* @throws RequestError with 409 if the current session has already identified a different user | ||
**/ | ||
public async identifyUser(verificationId: string) { | ||
const verificationRecord = this.getVerificationRecordById(verificationId); | ||
|
||
assertThat( | ||
verificationRecord, | ||
verificationRecord && this.interactionEvent, | ||
new RequestError({ code: 'session.verification_session_not_found', status: 404 }) | ||
); | ||
|
||
// 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, | ||
}) | ||
); | ||
// Existing user identification flow | ||
validateSieVerificationMethod(this.interactionEvent, verificationRecord); | ||
|
||
// 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 }) | ||
); | ||
// User creation flow | ||
if (this.interactionEvent === InteractionEvent.Register) { | ||
this.createNewUser(verificationRecord); | ||
return; | ||
} | ||
|
||
this.accountId = verificationRecord.verifiedUserId; | ||
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; | ||
} | ||
} | ||
} | ||
|
||
/** | ||
|
@@ -145,28 +178,34 @@ 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 | ||
Check warning on line 180 in packages/core/src/routes/experience/classes/experience-interaction.ts GitHub Actions / ESLint Report Analysispackages/core/src/routes/experience/classes/experience-interaction.ts#L180
|
||
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 }; | ||
} | ||
|
||
private get verificationRecordsArray() { | ||
return [...this.verificationRecords.values()]; | ||
} | ||
|
||
/** Convert the current interaction to JSON, so that it can be stored as the OIDC provider interaction result */ | ||
public toJson() { | ||
public toJson(): InteractionStorage { | ||
const { interactionEvent, userId, profile } = this; | ||
|
||
return { | ||
event: this.interactionEvent, | ||
accountId: this.accountId, | ||
profile: this.profile, | ||
interactionEvent, | ||
userId, | ||
profile, | ||
verificationRecords: this.verificationRecordsArray.map((record) => record.toJson()), | ||
}; | ||
} | ||
|
||
private get verificationRecordsArray() { | ||
return [...this.verificationRecords.values()]; | ||
} | ||
|
||
private createNewUser(verificationRecord: VerificationRecord) { | ||
// TODO: create new user for the Register event | ||
Check warning on line 209 in packages/core/src/routes/experience/classes/experience-interaction.ts GitHub Actions / ESLint Report Analysispackages/core/src/routes/experience/classes/experience-interaction.ts#L209
|
||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,62 @@ | ||
import { | ||
InteractionEvent, | ||
InteractionIdentifierType, | ||
VerificationType, | ||
type InteractionIdentifier, | ||
} from '@logto/schemas'; | ||
|
||
import RequestError from '#src/errors/RequestError/index.js'; | ||
import type Queries from '#src/tenants/Queries.js'; | ||
import assertThat from '#src/utils/assert-that.js'; | ||
|
||
import { type VerificationRecord } from './verifications/index.js'; | ||
|
||
export const findUserByIdentifier = async ( | ||
userQuery: Queries['users'], | ||
{ type, value }: InteractionIdentifier | ||
) => { | ||
switch (type) { | ||
case InteractionIdentifierType.Username: { | ||
return userQuery.findUserByUsername(value); | ||
} | ||
case InteractionIdentifierType.Email: { | ||
return userQuery.findUserByEmail(value); | ||
} | ||
case InteractionIdentifierType.Phone: { | ||
return userQuery.findUserByPhone(value); | ||
} | ||
} | ||
}; | ||
|
||
/** | ||
* Check if the verification record is valid for the current interaction event. | ||
* | ||
* This function will compare the verification record for the current interaction event with Logto's SIE settings | ||
* | ||
* @throws RequestError with 400 if the verification record is not valid for the current interaction event | ||
*/ | ||
export const validateSieVerificationMethod = ( | ||
interactionEvent: InteractionEvent, | ||
verificationRecord: VerificationRecord | ||
) => { | ||
switch (interactionEvent) { | ||
case InteractionEvent.SignIn: { | ||
// TODO: sign-in methods validation | ||
Check warning on line 44 in packages/core/src/routes/experience/classes/utils.ts GitHub Actions / ESLint Report Analysispackages/core/src/routes/experience/classes/utils.ts#L44
|
||
break; | ||
} | ||
case InteractionEvent.Register: { | ||
// TODO: sign-up methods validation | ||
Check warning on line 48 in packages/core/src/routes/experience/classes/utils.ts GitHub Actions / ESLint Report Analysispackages/core/src/routes/experience/classes/utils.ts#L48
|
||
break; | ||
} | ||
case InteractionEvent.ForgotPassword: { | ||
// Forgot password only supports verification code type verification record | ||
// The verification record's interaction event must be ForgotPassword | ||
assertThat( | ||
verificationRecord.type === VerificationType.VerificationCode && | ||
verificationRecord.interactionEvent === InteractionEvent.ForgotPassword, | ||
new RequestError({ code: 'session.verification_session_not_found', status: 400 }) | ||
); | ||
break; | ||
} | ||
} | ||
}; | ||