Skip to content

Commit

Permalink
feat(core,schemas): implement the username password registration flow (
Browse files Browse the repository at this point in the history
…#6249)

* feat(core,schemas): implement the username password registration flow

implement the username password registration flow

* chore(core): update some comments

update some comments

* fix(test): fix integration tests

fix integration tests

* fix(test): fix lint

fix lint
  • Loading branch information
simeng-li committed Jul 16, 2024
1 parent ae4a127 commit 0a9da52
Show file tree
Hide file tree
Showing 18 changed files with 589 additions and 11 deletions.
2 changes: 1 addition & 1 deletion packages/core/src/libraries/user.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const encryptUserPassword = async (
password: string
): Promise<{
passwordEncrypted: string;
passwordEncryptionMethod: UsersPasswordEncryptionMethod;
passwordEncryptionMethod: UsersPasswordEncryptionMethod.Argon2i;
}> => {
const passwordEncryptionMethod = UsersPasswordEncryptionMethod.Argon2i;
const passwordEncrypted = await encryptPassword(password, passwordEncryptionMethod);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,12 @@ export default class ExperienceInteraction {
this.userId = id;
}

/**
* Create a new user using the verification record.
*
* @throws {RequestError} with 422 if the profile data is not unique across users
* @throws {RequestError} with 400 if the verification record is invalid for creating a new user or not verified
*/
private async createNewUser(verificationRecord: VerificationRecord) {
const {
libraries: {
Expand Down
52 changes: 52 additions & 0 deletions packages/core/src/routes/experience/classes/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { type InteractionIdentifier, SignInIdentifier } from '@logto/schemas';

import { type InteractionProfile } from '../types.js';

import { interactionIdentifierToUserProfile, profileToUserInfo } from './utils.js';

const identifierToProfileTestCase = [
{
identifier: {
type: SignInIdentifier.Username,
value: 'username',
},
expected: { username: 'username' },
},
{
identifier: {
type: SignInIdentifier.Email,
value: 'email',
},
expected: { primaryEmail: 'email' },
},
{
identifier: {
type: SignInIdentifier.Phone,
value: 'phone',
},
expected: { primaryPhone: 'phone' },
},
] satisfies Array<{ identifier: InteractionIdentifier; expected: InteractionProfile }>;

describe('experience utils tests', () => {
it.each(identifierToProfileTestCase)(
`interactionIdentifierToUserProfile %p`,
({ identifier, expected }) => {
expect(interactionIdentifierToUserProfile(identifier)).toEqual(expected);
}
);
it('profileToUserInfo', () => {
expect(
profileToUserInfo({
username: 'username',
primaryEmail: 'email',
primaryPhone: 'phone',
})
).toEqual({
name: undefined,
username: 'username',
email: 'email',
phoneNumber: 'phone',
});
});
});
36 changes: 36 additions & 0 deletions packages/core/src/routes/experience/classes/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { type UserInfo } from '@logto/core-kit';
import {
SignInIdentifier,
VerificationType,
Expand Down Expand Up @@ -37,6 +38,7 @@ export const getNewUserProfileFromVerificationRecord = async (
verificationRecord: VerificationRecord
): Promise<InteractionProfile> => {
switch (verificationRecord.type) {
case VerificationType.NewPasswordIdentity:
case VerificationType.VerificationCode: {
return verificationRecord.toUserProfile();
}
Expand Down Expand Up @@ -68,3 +70,37 @@ export const toUserSocialIdentityData = (
},
};
};

export function interactionIdentifierToUserProfile(
identifier: InteractionIdentifier
): { username: string } | { primaryEmail: string } | { primaryPhone: string } {
const { type, value } = identifier;
switch (type) {
case SignInIdentifier.Username: {
return { username: value };
}
case SignInIdentifier.Email: {
return { primaryEmail: value };
}
case SignInIdentifier.Phone: {
return { primaryPhone: value };
}
}
}

/**
* This function is used to convert the interaction profile to the UserInfo format.
* It will be used by the PasswordPolicyChecker to check the password policy against the user profile.
*/
export function profileToUserInfo(
profile: Pick<InteractionProfile, 'name' | 'username' | 'primaryEmail' | 'primaryPhone'>
): UserInfo {
const { name, username, primaryEmail, primaryPhone } = profile;

return {
name: name ?? undefined,
username: username ?? undefined,
email: primaryEmail ?? undefined,
phoneNumber: primaryPhone ?? undefined,
};
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { type PasswordPolicyChecker, type UserInfo } from '@logto/core-kit';

import RequestError from '#src/errors/RequestError/index.js';
import type Libraries from '#src/tenants/Libraries.js';
import type Queries from '#src/tenants/Queries.js';
Expand Down Expand Up @@ -79,7 +81,24 @@ export class ProfileValidator {
})
);
}
}

// TODO: Password validation
/**
* Validate password against the given password policy
* throw a {@link RequestError} -422 if the password is invalid; otherwise, do nothing.
*/
public async validatePassword(
password: string,
passwordPolicyChecker: PasswordPolicyChecker,
userInfo: UserInfo = {}
) {
const issues = await passwordPolicyChecker.check(
password,
passwordPolicyChecker.policy.rejects.userInfo ? userInfo : {}
);

if (issues.length > 0) {
throw new RequestError({ code: 'password.rejected', status: 422 }, { issues });
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { MockTenant } from '#src/test-utils/tenant.js';
import { CodeVerification } from '../verifications/code-verification.js';
import { EnterpriseSsoVerification } from '../verifications/enterprise-sso-verification.js';
import { type VerificationRecord } from '../verifications/index.js';
import { NewPasswordIdentityVerification } from '../verifications/new-password-identity-verification.js';
import { PasswordVerification } from '../verifications/password-verification.js';
import { SocialVerification } from '../verifications/social-verification.js';

Expand All @@ -32,6 +33,15 @@ const ssoConnectors = {

const mockTenant = new MockTenant(undefined, { signInExperiences }, undefined, { ssoConnectors });

const newPasswordIdentityVerificationRecord = NewPasswordIdentityVerification.create(
mockTenant.libraries,
mockTenant.queries,
{
type: SignInIdentifier.Username,
value: 'username',
}
);

const passwordVerificationRecords = Object.fromEntries(
Object.values(SignInIdentifier).map((identifier) => [
identifier,
Expand Down Expand Up @@ -326,7 +336,10 @@ describe('SignInExperienceValidator', () => {
'only username is enabled for sign-up': {
signInExperience: mockSignInExperience,
cases: [
// TODO: username password registration
{
verificationRecord: newPasswordIdentityVerificationRecord,
accepted: true,
},
{
verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Email],
accepted: false,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import crypto from 'node:crypto';

import { PasswordPolicyChecker } from '@logto/core-kit';
import {
InteractionEvent,
type SignInExperience,
Expand Down Expand Up @@ -41,6 +44,7 @@ const getEmailIdentifierFromVerificationRecord = (verificationRecord: Verificati
*/
export class SignInExperienceValidator {
private signInExperienceDataCache?: SignInExperience;
#passwordPolicyChecker?: PasswordPolicyChecker;

constructor(
private readonly libraries: Libraries,
Expand Down Expand Up @@ -114,6 +118,15 @@ export class SignInExperienceValidator {
return this.signInExperienceDataCache;
}

public async getPasswordPolicyChecker() {
if (!this.#passwordPolicyChecker) {
const { passwordPolicy } = await this.getSignInExperienceData();
this.#passwordPolicyChecker = new PasswordPolicyChecker(passwordPolicy, crypto.subtle);
}

return this.#passwordPolicyChecker;
}

/**
* Guard the verification records contains email identifier with SSO enabled
*
Expand Down Expand Up @@ -193,7 +206,18 @@ export class SignInExperienceValidator {
const { signUp, singleSignOnEnabled } = await this.getSignInExperienceData();

switch (verificationRecord.type) {
// TODO: username password registration
// Username and password registration
case VerificationType.NewPasswordIdentity: {
const {
identifier: { type },
} = verificationRecord;

assertThat(
signUp.identifiers.includes(type) && signUp.password,
new RequestError({ code: 'user.sign_up_method_not_enabled', status: 422 })
);
break;
}
case VerificationType.VerificationCode: {
const {
identifier: { type },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ export class CodeVerification
return user;
}

async toUserProfile(): Promise<{ primaryEmail: string } | { primaryPhone: string }> {
toUserProfile(): { primaryEmail: string } | { primaryPhone: string } {
assertThat(
this.verified,
new RequestError({ code: 'session.verification_failed', status: 400 })
Expand Down
15 changes: 13 additions & 2 deletions packages/core/src/routes/experience/classes/verifications/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ import {
enterPriseSsoVerificationRecordDataGuard,
type EnterpriseSsoVerificationRecordData,
} from './enterprise-sso-verification.js';
import {
NewPasswordIdentityVerification,
newPasswordIdentityVerificationRecordDataGuard,
type NewPasswordIdentityVerificationRecordData,
} from './new-password-identity-verification.js';
import {
PasswordVerification,
passwordVerificationRecordDataGuard,
Expand All @@ -41,7 +46,8 @@ export type VerificationRecordData =
| SocialVerificationRecordData
| EnterpriseSsoVerificationRecordData
| TotpVerificationRecordData
| BackupCodeVerificationRecordData;
| BackupCodeVerificationRecordData
| NewPasswordIdentityVerificationRecordData;

/**
* Union type for all verification record types
Expand All @@ -57,7 +63,8 @@ export type VerificationRecord =
| SocialVerification
| EnterpriseSsoVerification
| TotpVerification
| BackupCodeVerification;
| BackupCodeVerification
| NewPasswordIdentityVerification;

export const verificationRecordDataGuard = z.discriminatedUnion('type', [
passwordVerificationRecordDataGuard,
Expand All @@ -66,6 +73,7 @@ export const verificationRecordDataGuard = z.discriminatedUnion('type', [
enterPriseSsoVerificationRecordDataGuard,
totpVerificationRecordDataGuard,
backupCodeVerificationRecordDataGuard,
newPasswordIdentityVerificationRecordDataGuard,
]);

/**
Expand Down Expand Up @@ -95,5 +103,8 @@ export const buildVerificationRecord = (
case VerificationType.BackupCode: {
return new BackupCodeVerification(libraries, queries, data);
}
case VerificationType.NewPasswordIdentity: {
return new NewPasswordIdentityVerification(libraries, queries, data);
}
}
};
Loading

0 comments on commit 0a9da52

Please sign in to comment.