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

feat(core,schemas): implement the username password registration flow (#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
This commit is contained in:
simeng-li 2024-07-16 16:18:30 +08:00 committed by GitHub
parent ae4a12757a
commit 0a9da5245b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 589 additions and 11 deletions

View file

@ -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);

View file

@ -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: {

View file

@ -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',
});
});
});

View file

@ -1,3 +1,4 @@
import { type UserInfo } from '@logto/core-kit';
import {
SignInIdentifier,
VerificationType,
@ -37,6 +38,7 @@ export const getNewUserProfileFromVerificationRecord = async (
verificationRecord: VerificationRecord
): Promise<InteractionProfile> => {
switch (verificationRecord.type) {
case VerificationType.NewPasswordIdentity:
case VerificationType.VerificationCode: {
return verificationRecord.toUserProfile();
}
@ -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,
};
}

View file

@ -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';
@ -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 });
}
}
}

View file

@ -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';
@ -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,
@ -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,

View file

@ -1,3 +1,6 @@
import crypto from 'node:crypto';
import { PasswordPolicyChecker } from '@logto/core-kit';
import {
InteractionEvent,
type SignInExperience,
@ -41,6 +44,7 @@ const getEmailIdentifierFromVerificationRecord = (verificationRecord: Verificati
*/
export class SignInExperienceValidator {
private signInExperienceDataCache?: SignInExperience;
#passwordPolicyChecker?: PasswordPolicyChecker;
constructor(
private readonly libraries: Libraries,
@ -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
*
@ -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 },

View file

@ -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 })

View file

@ -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,
@ -41,7 +46,8 @@ export type VerificationRecordData =
| SocialVerificationRecordData
| EnterpriseSsoVerificationRecordData
| TotpVerificationRecordData
| BackupCodeVerificationRecordData;
| BackupCodeVerificationRecordData
| NewPasswordIdentityVerificationRecordData;
/**
* Union type for all verification record types
@ -57,7 +63,8 @@ export type VerificationRecord =
| SocialVerification
| EnterpriseSsoVerification
| TotpVerification
| BackupCodeVerification;
| BackupCodeVerification
| NewPasswordIdentityVerification;
export const verificationRecordDataGuard = z.discriminatedUnion('type', [
passwordVerificationRecordDataGuard,
@ -66,6 +73,7 @@ export const verificationRecordDataGuard = z.discriminatedUnion('type', [
enterPriseSsoVerificationRecordDataGuard,
totpVerificationRecordDataGuard,
backupCodeVerificationRecordDataGuard,
newPasswordIdentityVerificationRecordDataGuard,
]);
/**
@ -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);
}
}
};

View file

@ -0,0 +1,142 @@
import { type ToZodObject } from '@logto/connector-kit';
import { type PasswordPolicyChecker } from '@logto/core-kit';
import {
type InteractionIdentifier,
interactionIdentifierGuard,
UsersPasswordEncryptionMethod,
VerificationType,
} from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { z } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
import { encryptUserPassword } from '#src/libraries/user.utils.js';
import type Libraries from '#src/tenants/Libraries.js';
import type Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js';
import { interactionIdentifierToUserProfile, profileToUserInfo } from '../utils.js';
import { ProfileValidator } from '../validators/profile-validator.js';
import { type VerificationRecord } from './verification-record.js';
export type NewPasswordIdentityVerificationRecordData = {
id: string;
type: VerificationType.NewPasswordIdentity;
/**
* For now we only support username identifier for new password identity registration.
* For email and phone new identity registration, a `CodeVerification` record is required.
*/
identifier: InteractionIdentifier;
passwordEncrypted?: string;
passwordEncryptionMethod?: UsersPasswordEncryptionMethod.Argon2i;
};
export const newPasswordIdentityVerificationRecordDataGuard = z.object({
id: z.string(),
type: z.literal(VerificationType.NewPasswordIdentity),
identifier: interactionIdentifierGuard,
passwordEncrypted: z.string().optional(),
passwordEncryptionMethod: z.literal(UsersPasswordEncryptionMethod.Argon2i).optional(),
}) satisfies ToZodObject<NewPasswordIdentityVerificationRecordData>;
/**
* NewPasswordIdentityVerification class is used for creating a new user using password + identifier.
*
* @remarks This verification record can only be used for new user registration.
* By default this verification record allows all types of identifiers, username, email, and phone.
* But in our current product design, only username + password registration is supported. The identifier type
* will be guarded at the API level.
*/
export class NewPasswordIdentityVerification
implements VerificationRecord<VerificationType.NewPasswordIdentity>
{
/** Factory method to create a new `NewPasswordIdentityVerification` record using an identifier */
static create(libraries: Libraries, queries: Queries, identifier: InteractionIdentifier) {
return new NewPasswordIdentityVerification(libraries, queries, {
id: generateStandardId(),
type: VerificationType.NewPasswordIdentity,
identifier,
});
}
readonly type = VerificationType.NewPasswordIdentity;
readonly id: string;
readonly identifier: InteractionIdentifier;
private passwordEncrypted?: string;
private passwordEncryptionMethod?: UsersPasswordEncryptionMethod.Argon2i;
private readonly profileValidator: ProfileValidator;
constructor(
private readonly libraries: Libraries,
private readonly queries: Queries,
data: NewPasswordIdentityVerificationRecordData
) {
const { id, identifier, passwordEncrypted, passwordEncryptionMethod } = data;
this.id = id;
this.identifier = identifier;
this.passwordEncrypted = passwordEncrypted;
this.passwordEncryptionMethod = passwordEncryptionMethod;
this.profileValidator = new ProfileValidator(libraries, queries);
}
get isVerified() {
return Boolean(this.passwordEncrypted) && Boolean(this.passwordEncryptionMethod);
}
/**
* Verify the new password identity
*
* - Check if the identifier is unique across users
* - Validate the password against the password policy
*/
async verify(password: string, passwordPolicyChecker: PasswordPolicyChecker) {
const { identifier } = this;
const identifierProfile = interactionIdentifierToUserProfile(identifier);
await this.profileValidator.guardProfileUniquenessAcrossUsers(identifierProfile);
await this.profileValidator.validatePassword(
password,
passwordPolicyChecker,
profileToUserInfo(identifierProfile)
);
const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword(password);
this.passwordEncrypted = passwordEncrypted;
this.passwordEncryptionMethod = passwordEncryptionMethod;
}
toUserProfile() {
assertThat(
this.passwordEncrypted && this.passwordEncryptionMethod,
new RequestError({ code: 'session.verification_failed', status: 400 })
);
const { identifier, passwordEncrypted, passwordEncryptionMethod } = this;
const identifierProfile = interactionIdentifierToUserProfile(identifier);
return {
...identifierProfile,
passwordEncrypted,
passwordEncryptionMethod,
};
}
toJson(): NewPasswordIdentityVerificationRecordData {
const { id, type, identifier, passwordEncrypted, passwordEncryptionMethod } = this;
return {
id,
type,
identifier,
passwordEncrypted,
passwordEncryptionMethod,
};
}
}

View file

@ -45,8 +45,8 @@ export class PasswordVerification
}
readonly type = VerificationType.Password;
public readonly identifier: InteractionIdentifier;
public readonly id: string;
readonly identifier: InteractionIdentifier;
readonly id: string;
private verified: boolean;
/**

View file

@ -26,6 +26,7 @@ import koaExperienceInteraction, {
} from './middleware/koa-experience-interaction.js';
import backupCodeVerificationRoutes from './verification-routes/backup-code-verification.js';
import enterpriseSsoVerificationRoutes from './verification-routes/enterprise-sso-verification.js';
import newPasswordIdentityVerificationRoutes from './verification-routes/new-password-identity-verification.js';
import passwordVerificationRoutes from './verification-routes/password-verification.js';
import socialVerificationRoutes from './verification-routes/social-verification.js';
import totpVerificationRoutes from './verification-routes/totp-verification.js';
@ -145,4 +146,5 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
enterpriseSsoVerificationRoutes(router, tenant);
totpVerificationRoutes(router, tenant);
backupCodeVerificationRoutes(router, tenant);
newPasswordIdentityVerificationRoutes(router, tenant);
}

View file

@ -0,0 +1,52 @@
import { newPasswordIdentityVerificationPayloadGuard } from '@logto/schemas';
import type Router from 'koa-router';
import { z } from 'zod';
import { type WithLogContext } from '#src/middleware/koa-audit-log.js';
import koaGuard from '#src/middleware/koa-guard.js';
import type TenantContext from '#src/tenants/TenantContext.js';
import { NewPasswordIdentityVerification } from '../classes/verifications/new-password-identity-verification.js';
import { experienceRoutes } from '../const.js';
import { type WithExperienceInteractionContext } from '../middleware/koa-experience-interaction.js';
export default function newPasswordIdentityVerificationRoutes<T extends WithLogContext>(
router: Router<unknown, WithExperienceInteractionContext<T>>,
{ libraries, queries }: TenantContext
) {
router.post(
`${experienceRoutes.verification}/new-password-identity`,
koaGuard({
body: newPasswordIdentityVerificationPayloadGuard,
status: [200, 400, 422],
response: z.object({
verificationId: z.string(),
}),
}),
async (ctx, next) => {
const { identifier, password } = ctx.guard.body;
const { experienceInteraction } = ctx;
const newPasswordIdentityVerification = NewPasswordIdentityVerification.create(
libraries,
queries,
identifier
);
const policyChecker =
await experienceInteraction.signInExperienceValidator.getPasswordPolicyChecker();
await newPasswordIdentityVerification.verify(password, policyChecker);
ctx.experienceInteraction.setVerificationRecord(newPasswordIdentityVerification);
await ctx.experienceInteraction.save();
ctx.body = { verificationId: newPasswordIdentityVerification.id };
ctx.status = 200;
return next();
}
);
}

View file

@ -2,6 +2,7 @@ import {
type CreateExperienceApiPayload,
type IdentificationApiPayload,
type InteractionEvent,
type NewPasswordIdentityVerificationPayload,
type PasswordVerificationPayload,
type VerificationCodeIdentifier,
} from '@logto/schemas';
@ -184,4 +185,15 @@ export class ExperienceClient extends MockClient {
})
.json<{ verificationId: string }>();
}
public async createNewPasswordIdentityVerification(
payload: NewPasswordIdentityVerificationPayload
) {
return api
.post(`${experienceRoutes.verification}/new-password-identity`, {
headers: { cookie: this.interactionCookie },
json: payload,
})
.json<{ verificationId: string }>();
}
}

View file

@ -227,3 +227,24 @@ export const signInWithEnterpriseSso = async (
return userId;
};
export const registerNewUserUsernamePassword = async (username: string, password: string) => {
const client = await initExperienceClient();
await client.initInteraction({ interactionEvent: InteractionEvent.Register });
const { verificationId } = await client.createNewPasswordIdentityVerification({
identifier: {
type: SignInIdentifier.Username,
value: username,
},
password,
});
await client.identifyUser({ verificationId });
const { redirectTo } = await client.submitInteraction();
const userId = await processSession(client, redirectTo);
await logoutClient(client);
return userId;
};

View file

@ -0,0 +1,40 @@
import { SignInIdentifier } from '@logto/schemas';
import { deleteUser } from '#src/api/admin-user.js';
import { updateSignInExperience } from '#src/api/sign-in-experience.js';
import {
registerNewUserUsernamePassword,
signInWithPassword,
} from '#src/helpers/experience/index.js';
import { generateNewUserProfile } from '#src/helpers/user.js';
import { devFeatureTest } from '#src/utils.js';
devFeatureTest.describe('register new user with username and password', () => {
const { username, password } = generateNewUserProfile({ username: true, password: true });
beforeAll(async () => {
// Disable password policy here to make sure the test is not affected by the password policy.
await updateSignInExperience({
signUp: {
identifiers: [SignInIdentifier.Username],
password: true,
verify: false,
},
passwordPolicy: {},
});
});
it('should register new user with username and password and able to sign-in using the same credentials', async () => {
const userId = await registerNewUserUsernamePassword(username, password);
await signInWithPassword({
identifier: {
type: SignInIdentifier.Username,
value: username,
},
password,
});
await deleteUser(userId);
});
});

View file

@ -0,0 +1,127 @@
import { SignInIdentifier } from '@logto/schemas';
import { updateSignInExperience } from '#src/api/sign-in-experience.js';
import { initExperienceClient } from '#src/helpers/client.js';
import { expectRejects } from '#src/helpers/index.js';
import { generateNewUser } from '#src/helpers/user.js';
import { devFeatureTest, randomString } from '#src/utils.js';
const invalidIdentifiers = Object.freeze([
{
type: SignInIdentifier.Email,
value: 'email',
},
{
type: SignInIdentifier.Phone,
value: 'phone',
},
]);
devFeatureTest.describe('password verifications', () => {
const username = 'test_' + randomString();
beforeAll(async () => {
await updateSignInExperience({
passwordPolicy: {
length: { min: 8, max: 32 },
characterTypes: { min: 3 },
rejects: {
pwned: true,
repetitionAndSequence: true,
userInfo: true,
words: [username],
},
},
});
});
afterAll(async () => {
await updateSignInExperience({
// Need to reset password policy to default value otherwise it will affect other tests.
passwordPolicy: {},
});
});
it.each(invalidIdentifiers)(
'should fail to verify with password using %p',
async (identifier) => {
const client = await initExperienceClient();
await expectRejects(
client.createNewPasswordIdentityVerification({
// @ts-expect-error
identifier,
password: 'password',
}),
{
code: 'guard.invalid_input',
status: 400,
}
);
}
);
it('should throw error if username is registered', async () => {
const { userProfile } = await generateNewUser({ username: true, password: true });
const { username } = userProfile;
const client = await initExperienceClient();
await expectRejects(
client.createNewPasswordIdentityVerification({
identifier: {
type: SignInIdentifier.Username,
value: username,
},
password: 'password',
}),
{
code: 'user.username_already_in_use',
status: 422,
}
);
});
describe('password policy check', () => {
const invalidPasswords: Array<[string, string | RegExp]> = [
['123', 'minimum length'],
['12345678', 'at least 3 types'],
['123456aA', 'simple password'],
['defghiZ@', 'sequential characters'],
['TTTTTT@z', 'repeated characters'],
[username, 'userInfo'],
];
it.each(invalidPasswords)('should reject invalid password %p', async (password) => {
const client = await initExperienceClient();
await expectRejects(
client.createNewPasswordIdentityVerification({
identifier: {
type: SignInIdentifier.Username,
value: username,
},
password,
}),
{
code: `password.rejected`,
status: 422,
}
);
});
});
it('should create new password identity verification successfully', async () => {
const client = await initExperienceClient();
const { verificationId } = await client.createNewPasswordIdentityVerification({
identifier: {
type: SignInIdentifier.Username,
value: username,
},
password: '?sy8Q3z3_G',
});
expect(verificationId).toBeTruthy();
});
});

View file

@ -31,8 +31,8 @@ export enum InteractionEvent {
// ====== Experience API payload guards and type definitions start ======
/** Identifiers that can be used to uniquely identify a user. */
export type InteractionIdentifier = {
type: SignInIdentifier;
export type InteractionIdentifier<T extends SignInIdentifier = SignInIdentifier> = {
type: T;
value: string;
};
@ -61,6 +61,7 @@ export enum VerificationType {
TOTP = 'Totp',
WebAuthn = 'WebAuthn',
BackupCode = 'BackupCode',
NewPasswordIdentity = 'NewPasswordIdentity',
}
// REMARK: API payload guard
@ -115,6 +116,26 @@ export const backupCodeVerificationVerifyPayloadGuard = z.object({
code: z.string().min(1),
}) satisfies ToZodObject<BackupCodeVerificationVerifyPayload>;
/**
* Payload type for `POST /api/experience/verification/new-password-identity`.
* @remarks Currently we only support username identifier for new password identity registration.
* For email and phone new identity registration, a `CodeVerification` record is required.
*/
export type NewPasswordIdentityVerificationPayload = {
identifier: {
type: SignInIdentifier.Username;
value: string;
};
password: string;
};
export const newPasswordIdentityVerificationPayloadGuard = z.object({
identifier: z.object({
type: z.literal(SignInIdentifier.Username),
value: z.string(),
}),
password: z.string().min(1),
}) satisfies ToZodObject<NewPasswordIdentityVerificationPayload>;
/** Payload type for `POST /api/experience/identification`. */
export type IdentificationApiPayload = {
/** The ID of the verification record that is used to identify the user. */