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:
parent
ae4a12757a
commit
0a9da5245b
18 changed files with 589 additions and 11 deletions
|
@ -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);
|
||||
|
|
|
@ -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: {
|
||||
|
|
52
packages/core/src/routes/experience/classes/utils.test.ts
Normal file
52
packages/core/src/routes/experience/classes/utils.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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 })
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
||||
/**
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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 }>();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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. */
|
||||
|
|
Loading…
Reference in a new issue