mirror of
https://github.com/logto-io/logto.git
synced 2025-03-31 22:51:25 -05:00
refactor(core): add required profile validation (#2561)
This commit is contained in:
parent
aafae8e02b
commit
0e402fdcd3
24 changed files with 418 additions and 62 deletions
|
@ -1,3 +1,4 @@
|
|||
import { Event } from '@logto/schemas';
|
||||
import type { Provider } from 'oidc-provider';
|
||||
|
||||
import koaGuard from '#src/middleware/koa-guard.js';
|
||||
|
@ -8,7 +9,11 @@ import koaSessionSignInExperienceGuard from './middleware/koa-session-sign-in-ex
|
|||
import { sendPasscodePayloadGuard, getSocialAuthorizationUrlPayloadGuard } from './types/guard.js';
|
||||
import { sendPasscodeToIdentifier } from './utils/passcode-validation.js';
|
||||
import { createSocialAuthorizationUrl } from './utils/social-verification.js';
|
||||
import { identifierVerification } from './verifications/index.js';
|
||||
import {
|
||||
identifierVerification,
|
||||
profileVerification,
|
||||
mandatoryUserProfileValidation,
|
||||
} from './verifications/index.js';
|
||||
|
||||
export const identifierPrefix = '/identifier';
|
||||
export const verificationPrefix = '/verification';
|
||||
|
@ -27,6 +32,16 @@ export default function interactionRoutes<T extends AnonymousRouter>(
|
|||
|
||||
const verifiedIdentifiers = await identifierVerification(ctx, provider);
|
||||
|
||||
const profile = await profileVerification(ctx, verifiedIdentifiers);
|
||||
|
||||
const { event } = ctx.interactionPayload;
|
||||
|
||||
if (event !== Event.ForgotPassword) {
|
||||
await mandatoryUserProfileValidation(ctx, verifiedIdentifiers, profile);
|
||||
}
|
||||
|
||||
// TODO: SignIn Register & ResetPassword final step
|
||||
|
||||
ctx.status = 200;
|
||||
|
||||
return next();
|
||||
|
|
|
@ -3,8 +3,8 @@ import koaBody from 'koa-body';
|
|||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
|
||||
import type { InteractionPayload } from '../types/guard.js';
|
||||
import { interactionPayloadGuard } from '../types/guard.js';
|
||||
import type { InteractionPayload } from '../types/index.js';
|
||||
|
||||
export type WithGuardedIdentifierPayloadContext<ContextT> = ContextT & {
|
||||
interactionPayload: InteractionPayload;
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import type { SignInExperience } from '@logto/schemas';
|
||||
import { Event } from '@logto/schemas';
|
||||
import type { MiddlewareType } from 'koa';
|
||||
import type { IRouterParamContext } from 'koa-router';
|
||||
|
@ -12,11 +13,17 @@ import {
|
|||
} from '../utils/sign-in-experience-validation.js';
|
||||
import type { WithGuardedIdentifierPayloadContext } from './koa-interaction-body-guard.js';
|
||||
|
||||
export type WithSignInExperienceContext<ContextT> = ContextT & {
|
||||
signInExperience: SignInExperience;
|
||||
};
|
||||
|
||||
export default function koaSessionSignInExperienceGuard<
|
||||
StateT,
|
||||
ContextT extends WithGuardedIdentifierPayloadContext<IRouterParamContext>,
|
||||
ResponseBodyT
|
||||
>(provider: Provider): MiddlewareType<StateT, ContextT, ResponseBodyT> {
|
||||
>(
|
||||
provider: Provider
|
||||
): MiddlewareType<StateT, WithSignInExperienceContext<ContextT>, ResponseBodyT> {
|
||||
return async (ctx, next) => {
|
||||
const interaction = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
const { event, identifier, profile } = ctx.interactionPayload;
|
||||
|
@ -39,6 +46,8 @@ export default function koaSessionSignInExperienceGuard<
|
|||
profileValidation(profile, signInExperience);
|
||||
}
|
||||
|
||||
ctx.signInExperience = signInExperience;
|
||||
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
|
|
@ -39,6 +39,7 @@ describe('koaSessionSignInExperienceGuard', () => {
|
|||
identifier: { username: 'username', password: 'password' },
|
||||
profile: { email: 'email' },
|
||||
}),
|
||||
signInExperience: mockSignInExperience,
|
||||
};
|
||||
|
||||
await koaSessionSignInExperienceGuard(new Provider(''))(ctx, next);
|
||||
|
|
|
@ -1,11 +1,4 @@
|
|||
import { emailRegEx, phoneRegEx, validateRedirectUrl } from '@logto/core-kit';
|
||||
import type {
|
||||
UsernamePasswordPayload,
|
||||
EmailPasswordPayload,
|
||||
EmailPasscodePayload,
|
||||
PhonePasswordPayload,
|
||||
PhonePasscodePayload,
|
||||
} from '@logto/schemas';
|
||||
import {
|
||||
usernamePasswordPayloadGuard,
|
||||
emailPasscodePayloadGuard,
|
||||
|
@ -13,12 +6,14 @@ import {
|
|||
socialConnectorPayloadGuard,
|
||||
eventGuard,
|
||||
profileGuard,
|
||||
identifierGuard,
|
||||
identifierPayloadGuard,
|
||||
Event,
|
||||
} from '@logto/schemas';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Interaction Route Guard
|
||||
import { socialUserInfoGuard } from '#src/connectors/types.js';
|
||||
|
||||
// Interaction Payload Guard
|
||||
const forgotPasswordInteractionPayloadGuard = z.object({
|
||||
event: z.literal(Event.ForgotPassword),
|
||||
identifier: z.union([emailPasscodePayloadGuard, phonePasscodePayloadGuard]).optional(),
|
||||
|
@ -33,7 +28,7 @@ const registerInteractionPayloadGuard = z.object({
|
|||
|
||||
const signInInteractionPayloadGuard = z.object({
|
||||
event: z.literal(Event.SignIn),
|
||||
identifier: identifierGuard.optional(),
|
||||
identifier: identifierPayloadGuard.optional(),
|
||||
profile: profileGuard.optional(),
|
||||
});
|
||||
|
||||
|
@ -43,16 +38,6 @@ export const interactionPayloadGuard = z.discriminatedUnion('event', [
|
|||
forgotPasswordInteractionPayloadGuard,
|
||||
]);
|
||||
|
||||
export type InteractionPayload = z.infer<typeof interactionPayloadGuard>;
|
||||
export type IdentifierPayload = z.infer<typeof identifierGuard>;
|
||||
|
||||
export type PasswordIdentifierPayload =
|
||||
| UsernamePasswordPayload
|
||||
| EmailPasswordPayload
|
||||
| PhonePasswordPayload;
|
||||
|
||||
export type PasscodeIdentifierPayload = EmailPasscodePayload | PhonePasscodePayload;
|
||||
|
||||
// Passcode Send Route Payload Guard
|
||||
export const sendPasscodePayloadGuard = z.union([
|
||||
z.object({
|
||||
|
@ -64,7 +49,6 @@ export const sendPasscodePayloadGuard = z.union([
|
|||
phone: z.string().regex(phoneRegEx),
|
||||
}),
|
||||
]);
|
||||
export type SendPasscodePayload = z.infer<typeof sendPasscodePayloadGuard>;
|
||||
|
||||
// Social Authorization Uri Route Payload Guard
|
||||
export const getSocialAuthorizationUrlPayloadGuard = z.object({
|
||||
|
@ -72,8 +56,6 @@ export const getSocialAuthorizationUrlPayloadGuard = z.object({
|
|||
state: z.string(),
|
||||
redirectUri: z.string().refine((url) => validateRedirectUrl(url, 'web')),
|
||||
});
|
||||
export type SocialAuthorizationUrlPayload = z.infer<typeof getSocialAuthorizationUrlPayloadGuard>;
|
||||
|
||||
// Register Profile Guard
|
||||
const emailProfileGuard = emailPasscodePayloadGuard.pick({ email: true });
|
||||
const phoneProfileGuard = phonePasscodePayloadGuard.pick({ phone: true });
|
||||
|
@ -85,3 +67,38 @@ export const registerProfileSafeGuard = z.union([
|
|||
phoneProfileGuard,
|
||||
socialProfileGuard,
|
||||
]);
|
||||
|
||||
// Identifier Guard
|
||||
export const accountIdIdentifierGuard = z.object({
|
||||
key: z.literal('accountId'),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
export const verifiedEmailIdentifierGuard = z.object({
|
||||
key: z.literal('emailVerified'),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
export const verifiedPhoneIdentifierGuard = z.object({
|
||||
key: z.literal('phoneVerified'),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
export const socialIdentifierGuard = z.object({
|
||||
key: z.literal('social'),
|
||||
connectorId: z.string(),
|
||||
value: socialUserInfoGuard,
|
||||
});
|
||||
|
||||
export const identifierGuard = z.discriminatedUnion('key', [
|
||||
accountIdIdentifierGuard,
|
||||
verifiedEmailIdentifierGuard,
|
||||
verifiedPhoneIdentifierGuard,
|
||||
socialIdentifierGuard,
|
||||
]);
|
||||
|
||||
export const customInteractionResultGuard = z.object({
|
||||
event: eventGuard.optional(),
|
||||
profile: profileGuard.optional(),
|
||||
identifiers: z.array(identifierGuard).optional(),
|
||||
});
|
||||
|
|
|
@ -1,31 +1,52 @@
|
|||
import type {
|
||||
UsernamePasswordPayload,
|
||||
EmailPasswordPayload,
|
||||
EmailPasscodePayload,
|
||||
PhonePasswordPayload,
|
||||
PhonePasscodePayload,
|
||||
} from '@logto/schemas';
|
||||
import type { Context } from 'koa';
|
||||
import type { IRouterParamContext } from 'koa-router';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import type { SocialUserInfo } from '#src/connectors/types.js';
|
||||
|
||||
import type { WithGuardedIdentifierPayloadContext } from '../middleware/koa-interaction-body-guard.js';
|
||||
import type {
|
||||
interactionPayloadGuard,
|
||||
sendPasscodePayloadGuard,
|
||||
getSocialAuthorizationUrlPayloadGuard,
|
||||
accountIdIdentifierGuard,
|
||||
verifiedEmailIdentifierGuard,
|
||||
verifiedPhoneIdentifierGuard,
|
||||
socialIdentifierGuard,
|
||||
identifierGuard,
|
||||
} from './guard.js';
|
||||
|
||||
export type Identifier =
|
||||
| AccountIdIdentifier
|
||||
| VerifiedEmailIdentifier
|
||||
| VerifiedPhoneIdentifier
|
||||
| SocialIdentifier;
|
||||
// Payload Types
|
||||
export type InteractionPayload = z.infer<typeof interactionPayloadGuard>;
|
||||
|
||||
export type AccountIdIdentifier = { key: 'accountId'; value: string };
|
||||
export type PasswordIdentifierPayload =
|
||||
| UsernamePasswordPayload
|
||||
| EmailPasswordPayload
|
||||
| PhonePasswordPayload;
|
||||
|
||||
export type VerifiedEmailIdentifier = { key: 'emailVerified'; value: string };
|
||||
export type PasscodeIdentifierPayload = EmailPasscodePayload | PhonePasscodePayload;
|
||||
|
||||
export type VerifiedPhoneIdentifier = { key: 'phoneVerified'; value: string };
|
||||
export type SendPasscodePayload = z.infer<typeof sendPasscodePayloadGuard>;
|
||||
|
||||
export type SocialIdentifier = { key: 'social'; connectorId: string; value: UseInfo };
|
||||
export type SocialAuthorizationUrlPayload = z.infer<typeof getSocialAuthorizationUrlPayloadGuard>;
|
||||
|
||||
type UseInfo = {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
name?: string;
|
||||
avatar?: string;
|
||||
id: string;
|
||||
};
|
||||
// Identifier Types
|
||||
export type AccountIdIdentifier = z.infer<typeof accountIdIdentifierGuard>;
|
||||
|
||||
export type VerifiedEmailIdentifier = z.infer<typeof verifiedEmailIdentifierGuard>;
|
||||
|
||||
export type VerifiedPhoneIdentifier = z.infer<typeof verifiedPhoneIdentifierGuard>;
|
||||
|
||||
export type SocialIdentifier = z.infer<typeof socialIdentifierGuard>;
|
||||
|
||||
export type Identifier = z.infer<typeof identifierGuard>;
|
||||
|
||||
export type InteractionContext = WithGuardedIdentifierPayloadContext<IRouterParamContext & Context>;
|
||||
|
||||
|
|
|
@ -1,10 +1,6 @@
|
|||
import type { Profile, SocialConnectorPayload, User } from '@logto/schemas';
|
||||
import type { Profile, SocialConnectorPayload, User, IdentifierPayload } from '@logto/schemas';
|
||||
|
||||
import type {
|
||||
PasscodeIdentifierPayload,
|
||||
IdentifierPayload,
|
||||
PasswordIdentifierPayload,
|
||||
} from '../types/guard.js';
|
||||
import type { PasscodeIdentifierPayload, PasswordIdentifierPayload } from '../types/index.js';
|
||||
|
||||
export const isPasswordIdentifier = (
|
||||
identifier: IdentifierPayload
|
||||
|
|
|
@ -2,7 +2,7 @@ import { PasscodeType, Event } from '@logto/schemas';
|
|||
|
||||
import { createPasscode, sendPasscode } from '#src/lib/passcode.js';
|
||||
|
||||
import type { SendPasscodePayload } from '../types/guard.js';
|
||||
import type { SendPasscodePayload } from '../types/index.js';
|
||||
import { sendPasscodeToIdentifier } from './passcode-validation.js';
|
||||
|
||||
jest.mock('#src/lib/passcode.js', () => ({
|
||||
|
|
|
@ -5,7 +5,7 @@ import { createPasscode, sendPasscode, verifyPasscode } from '#src/lib/passcode.
|
|||
import type { LogContext } from '#src/middleware/koa-log.js';
|
||||
import { getPasswordlessRelatedLogType } from '#src/routes/session/utils.js';
|
||||
|
||||
import type { SendPasscodePayload, PasscodeIdentifierPayload } from '../types/guard.js';
|
||||
import type { SendPasscodePayload, PasscodeIdentifierPayload } from '../types/index.js';
|
||||
|
||||
/**
|
||||
* Refactor Needed:
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
import type { SignInExperience, Profile } from '@logto/schemas';
|
||||
import type { SignInExperience, Profile, IdentifierPayload } from '@logto/schemas';
|
||||
import { SignInMode, SignInIdentifier, Event } from '@logto/schemas';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
import type { IdentifierPayload } from '../types/guard.js';
|
||||
|
||||
const forbiddenEventError = new RequestError({ code: 'auth.forbidden', status: 403 });
|
||||
|
||||
const forbiddenIdentifierError = new RequestError({
|
||||
|
|
|
@ -7,7 +7,7 @@ import { getUserInfoByAuthCode } from '#src/lib/social.js';
|
|||
import type { LogContext } from '#src/middleware/koa-log.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
import type { SocialAuthorizationUrlPayload } from '../types/guard.js';
|
||||
import type { SocialAuthorizationUrlPayload } from '../types/index.js';
|
||||
|
||||
export const createSocialAuthorizationUrl = async (payload: SocialAuthorizationUrlPayload) => {
|
||||
const { connectorId, state, redirectUri } = payload;
|
||||
|
|
|
@ -8,8 +8,13 @@ import { verifyUserPassword } from '#src/lib/user.js';
|
|||
import assertThat from '#src/utils/assert-that.js';
|
||||
import { maskUserInfo } from '#src/utils/format.js';
|
||||
|
||||
import type { PasswordIdentifierPayload, PasscodeIdentifierPayload } from '../types/guard.js';
|
||||
import type { InteractionContext, Identifier, SocialIdentifier } from '../types/index.js';
|
||||
import type {
|
||||
PasswordIdentifierPayload,
|
||||
PasscodeIdentifierPayload,
|
||||
InteractionContext,
|
||||
Identifier,
|
||||
SocialIdentifier,
|
||||
} from '../types/index.js';
|
||||
import findUserByIdentifier from '../utils/find-user-by-identifier.js';
|
||||
import { isPasscodeIdentifier, isPasswordIdentifier, isProfileIdentifier } from '../utils/index.js';
|
||||
import { assignIdentifierVerificationResult } from '../utils/interaction.js';
|
||||
|
@ -53,7 +58,7 @@ const passcodeIdentifierVerification = async (
|
|||
const user = await findUserByIdentifier(identifier);
|
||||
|
||||
if (!user) {
|
||||
// Throw verification result and assign verified identifiers
|
||||
// Throw verification exception and assign verified identifiers to the interaction
|
||||
if (event !== Event.ForgotPassword) {
|
||||
await assignIdentifierVerificationResult(
|
||||
{ event, identifiers: [verifiedPasscodeIdentifier] },
|
||||
|
@ -90,7 +95,7 @@ const socialIdentifierVerification = async (
|
|||
const user = await findUserByIdentifier({ connectorId, userInfo });
|
||||
|
||||
if (!user) {
|
||||
// Throw verification result and assign verified identifiers
|
||||
// Throw verification exception and assign verified identifiers to the interaction
|
||||
await assignIdentifierVerificationResult(
|
||||
{ event, identifiers: [socialIdentifier] },
|
||||
ctx,
|
||||
|
|
|
@ -1 +1,3 @@
|
|||
export { default as identifierVerification } from './identifier-verification.js';
|
||||
export { default as profileVerification } from './profile-verification.js';
|
||||
export { default as mandatoryUserProfileValidation } from './mandatory-user-profile-validation.js';
|
||||
|
|
|
@ -0,0 +1,165 @@
|
|||
import { MissingProfile, SignInIdentifier } from '@logto/schemas';
|
||||
|
||||
import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { findUserById } from '#src/queries/user.js';
|
||||
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
|
||||
|
||||
import type { Identifier } from '../types/index.js';
|
||||
import { isUserPasswordSet } from '../utils/index.js';
|
||||
import mandatoryUserProfileValidation from './mandatory-user-profile-validation.js';
|
||||
|
||||
jest.mock('#src/queries/user.js', () => ({
|
||||
findUserById: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../utils/index.js', () => ({
|
||||
isUserPasswordSet: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('mandatoryUserProfileValidation', () => {
|
||||
const baseCtx = createContextWithRouteParameters();
|
||||
const identifiers: Identifier[] = [{ key: 'accountId', value: 'foo' }];
|
||||
|
||||
it('username and password missing but required', async () => {
|
||||
const ctx = {
|
||||
...baseCtx,
|
||||
signInExperience: mockSignInExperience,
|
||||
};
|
||||
|
||||
await expect(
|
||||
mandatoryUserProfileValidation(ctx, identifiers, { email: 'email' })
|
||||
).rejects.toMatchError(
|
||||
new RequestError(
|
||||
{ code: 'user.missing_profile', status: 422 },
|
||||
{ missingProfile: [MissingProfile.password, MissingProfile.username] }
|
||||
)
|
||||
);
|
||||
|
||||
await expect(
|
||||
mandatoryUserProfileValidation(ctx, identifiers, {
|
||||
username: 'username',
|
||||
password: 'password',
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('user account has username and password', async () => {
|
||||
(findUserById as jest.Mock).mockResolvedValueOnce({
|
||||
username: 'foo',
|
||||
});
|
||||
(isUserPasswordSet as jest.Mock).mockResolvedValueOnce(true);
|
||||
|
||||
const ctx = {
|
||||
...baseCtx,
|
||||
signInExperience: mockSignInExperience,
|
||||
};
|
||||
|
||||
await expect(mandatoryUserProfileValidation(ctx, identifiers, {})).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('email missing but required', async () => {
|
||||
const ctx = {
|
||||
...baseCtx,
|
||||
signInExperience: {
|
||||
...mockSignInExperience,
|
||||
signUp: { identifiers: [SignInIdentifier.Email], password: false, verify: true },
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
mandatoryUserProfileValidation(ctx, identifiers, { username: 'username' })
|
||||
).rejects.toMatchError(
|
||||
new RequestError(
|
||||
{ code: 'user.missing_profile', status: 422 },
|
||||
{ missingProfile: [MissingProfile.email] }
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('user account has email', async () => {
|
||||
(findUserById as jest.Mock).mockResolvedValueOnce({
|
||||
primaryEmail: 'email',
|
||||
});
|
||||
|
||||
const ctx = {
|
||||
...baseCtx,
|
||||
signInExperience: {
|
||||
...mockSignInExperience,
|
||||
signUp: { identifiers: [SignInIdentifier.Email], password: false, verify: true },
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
mandatoryUserProfileValidation(ctx, identifiers, { username: 'username' })
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('phone missing but required', async () => {
|
||||
const ctx = {
|
||||
...baseCtx,
|
||||
signInExperience: {
|
||||
...mockSignInExperience,
|
||||
signUp: { identifiers: [SignInIdentifier.Sms], password: false, verify: true },
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
mandatoryUserProfileValidation(ctx, identifiers, { username: 'username' })
|
||||
).rejects.toMatchError(
|
||||
new RequestError(
|
||||
{ code: 'user.missing_profile', status: 422 },
|
||||
{ missingProfile: [MissingProfile.phone] }
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('user account has phone', async () => {
|
||||
(findUserById as jest.Mock).mockResolvedValueOnce({
|
||||
primaryPhone: 'phone',
|
||||
});
|
||||
|
||||
const ctx = {
|
||||
...baseCtx,
|
||||
signInExperience: {
|
||||
...mockSignInExperience,
|
||||
signUp: { identifiers: [SignInIdentifier.Sms], password: false, verify: true },
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
mandatoryUserProfileValidation(ctx, identifiers, { username: 'username' })
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('email or Phone required', async () => {
|
||||
const ctx = {
|
||||
...baseCtx,
|
||||
signInExperience: {
|
||||
...mockSignInExperience,
|
||||
signUp: {
|
||||
identifiers: [SignInIdentifier.Email, SignInIdentifier.Sms],
|
||||
password: false,
|
||||
verify: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
mandatoryUserProfileValidation(ctx, identifiers, { username: 'username' })
|
||||
).rejects.toMatchError(
|
||||
new RequestError(
|
||||
{ code: 'user.missing_profile', status: 422 },
|
||||
{ missingProfile: [MissingProfile.emailOrPhone] }
|
||||
)
|
||||
);
|
||||
|
||||
await expect(
|
||||
mandatoryUserProfileValidation(ctx, identifiers, { email: 'email' })
|
||||
).resolves.not.toThrow();
|
||||
|
||||
await expect(
|
||||
mandatoryUserProfileValidation(ctx, identifiers, { phone: 'phone' })
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,102 @@
|
|||
import type { Profile, SignInExperience, User } from '@logto/schemas';
|
||||
import { MissingProfile, SignInIdentifier } from '@logto/schemas';
|
||||
import type { Nullable } from '@silverhand/essentials';
|
||||
import type { Context } from 'koa';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { findUserById } from '#src/queries/user.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
import type { WithSignInExperienceContext } from '../middleware/koa-session-sign-in-experience-guard.js';
|
||||
import type { Identifier, AccountIdIdentifier } from '../types/index.js';
|
||||
import { isUserPasswordSet } from '../utils/index.js';
|
||||
|
||||
const findUserByIdentifiers = async (identifiers: Identifier[]) => {
|
||||
const accountIdentifier = identifiers.find(
|
||||
(identifier): identifier is AccountIdIdentifier => identifier.key === 'accountId'
|
||||
);
|
||||
|
||||
if (!accountIdentifier) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return findUserById(accountIdentifier.value);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line complexity
|
||||
const getMissingProfileBySignUpIdentifiers = ({
|
||||
signUp,
|
||||
user,
|
||||
profile,
|
||||
}: {
|
||||
signUp: SignInExperience['signUp'];
|
||||
user: Nullable<User>;
|
||||
profile?: Profile;
|
||||
}) => {
|
||||
const missingProfile = new Set<MissingProfile>();
|
||||
|
||||
if (signUp.password && !(user && isUserPasswordSet(user)) && !profile?.password) {
|
||||
missingProfile.add(MissingProfile.password);
|
||||
}
|
||||
|
||||
const signUpIdentifiersSet = new Set(signUp.identifiers);
|
||||
|
||||
// Username
|
||||
if (
|
||||
signUpIdentifiersSet.has(SignInIdentifier.Username) &&
|
||||
!user?.username &&
|
||||
!profile?.username
|
||||
) {
|
||||
missingProfile.add(MissingProfile.username);
|
||||
|
||||
return missingProfile;
|
||||
}
|
||||
|
||||
// Email or phone
|
||||
if (
|
||||
signUpIdentifiersSet.has(SignInIdentifier.Email) &&
|
||||
signUpIdentifiersSet.has(SignInIdentifier.Sms)
|
||||
) {
|
||||
if (!user?.primaryPhone && !user?.primaryEmail && !profile?.phone && !profile?.email) {
|
||||
missingProfile.add(MissingProfile.emailOrPhone);
|
||||
}
|
||||
|
||||
return missingProfile;
|
||||
}
|
||||
|
||||
// Email only
|
||||
if (signUpIdentifiersSet.has(SignInIdentifier.Email) && !user?.primaryEmail && !profile?.email) {
|
||||
missingProfile.add(MissingProfile.email);
|
||||
|
||||
return missingProfile;
|
||||
}
|
||||
|
||||
// Phone only
|
||||
if (signUpIdentifiersSet.has(SignInIdentifier.Sms) && !user?.primaryPhone && !profile?.phone) {
|
||||
missingProfile.add(MissingProfile.phone);
|
||||
|
||||
return missingProfile;
|
||||
}
|
||||
|
||||
return missingProfile;
|
||||
};
|
||||
|
||||
export default async function mandatoryUserProfileValidation(
|
||||
ctx: WithSignInExperienceContext<Context>,
|
||||
identifiers: Identifier[],
|
||||
profile?: Profile
|
||||
) {
|
||||
const {
|
||||
signInExperience: { signUp },
|
||||
} = ctx;
|
||||
const user = await findUserByIdentifiers(identifiers);
|
||||
const missingProfileSet = getMissingProfileBySignUpIdentifiers({ signUp, user, profile });
|
||||
|
||||
assertThat(
|
||||
missingProfileSet.size === 0,
|
||||
new RequestError(
|
||||
{ code: 'user.missing_profile', status: 422 },
|
||||
{ missingProfile: Array.from(missingProfileSet) }
|
||||
)
|
||||
);
|
||||
}
|
|
@ -177,7 +177,7 @@ const profileExistValidation = async (
|
|||
export default async function profileVerification(
|
||||
ctx: InteractionContext,
|
||||
identifiers: Identifier[]
|
||||
) {
|
||||
): Promise<Profile | undefined> {
|
||||
const { profile, event } = ctx.interactionPayload;
|
||||
|
||||
if (!profile) {
|
||||
|
@ -193,7 +193,7 @@ export default async function profileVerification(
|
|||
await profileExistValidation(profile, user);
|
||||
await profileRegisteredValidation(profile, identifiers);
|
||||
|
||||
return;
|
||||
return profile;
|
||||
}
|
||||
|
||||
if (event === Event.Register) {
|
||||
|
@ -206,7 +206,7 @@ export default async function profileVerification(
|
|||
|
||||
await profileRegisteredValidation(profile, identifiers);
|
||||
|
||||
return;
|
||||
return profile;
|
||||
}
|
||||
|
||||
// ForgotPassword
|
||||
|
@ -216,4 +216,6 @@ export default async function profileVerification(
|
|||
!oldPasswordEncrypted || !(await argon2Verify({ password, hash: oldPasswordEncrypted })),
|
||||
new RequestError({ code: 'user.same_password', status: 422 })
|
||||
);
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
|
|
@ -57,6 +57,7 @@ const errors = {
|
|||
require_email_or_sms: 'You need to add an email address or phone number before signing-in.', // UNTRANSLATED
|
||||
suspended: 'This account is suspended.', // UNTRANSLATED
|
||||
user_not_exist: 'User with {{ identity }} has not been registered yet', // UNTRANSLATED,
|
||||
missing_profile: 'You need to provide additional info before signing-in.', // UNTRANSLATED
|
||||
},
|
||||
password: {
|
||||
unsupported_encryption_method: 'Die Verschlüsselungsmethode {{name}} wird nicht unterstützt.',
|
||||
|
|
|
@ -57,6 +57,7 @@ const errors = {
|
|||
require_email_or_sms: 'You need to add an email address or phone number before signing-in.',
|
||||
suspended: 'This account is suspended.',
|
||||
user_not_exist: 'User with {{ identity }} has not been registered yet',
|
||||
missing_profile: 'You need to provide additional info before signing-in.',
|
||||
},
|
||||
password: {
|
||||
unsupported_encryption_method: 'The encryption method {{name}} is not supported.',
|
||||
|
|
|
@ -58,6 +58,7 @@ const errors = {
|
|||
require_email_or_sms: 'You need to add an email address or phone number before signing-in.', // UNTRANSLATED
|
||||
suspended: 'This account is suspended.', // UNTRANSLATED
|
||||
user_not_exist: 'User with {{ identity }} has not been registered yet', // UNTRANSLATED,
|
||||
missing_profile: 'You need to provide additional info before signing-in.', // UNTRANSLATED
|
||||
},
|
||||
password: {
|
||||
unsupported_encryption_method: "La méthode de cryptage {{name}} n'est pas prise en charge.",
|
||||
|
|
|
@ -56,6 +56,7 @@ const errors = {
|
|||
require_email_or_sms: 'You need to add an email address or phone number before signing-in.', // UNTRANSLATED
|
||||
suspended: 'This account is suspended.', // UNTRANSLATED
|
||||
user_not_exist: 'User with {{ identity }} has not been registered yet', // UNTRANSLATED,
|
||||
missing_profile: 'You need to provide additional info before signing-in.', // UNTRANSLATED
|
||||
},
|
||||
password: {
|
||||
unsupported_encryption_method: '{{name}} 암호화 방법을 지원하지 않아요.',
|
||||
|
|
|
@ -56,6 +56,7 @@ const errors = {
|
|||
require_email_or_sms: 'You need to add an email address or phone number before signing-in.', // UNTRANSLATED
|
||||
suspended: 'This account is suspended.', // UNTRANSLATED
|
||||
user_not_exist: 'User with {{ identity }} has not been registered yet', // UNTRANSLATED,
|
||||
missing_profile: 'You need to provide additional info before signing-in.', // UNTRANSLATED
|
||||
},
|
||||
password: {
|
||||
unsupported_encryption_method: 'O método de enncriptação {{name}} não é suportado.',
|
||||
|
|
|
@ -57,6 +57,7 @@ const errors = {
|
|||
require_email_or_sms: 'You need to add an email address or phone number before signing-in.', // UNTRANSLATED
|
||||
suspended: 'This account is suspended.', // UNTRANSLATED
|
||||
user_not_exist: 'User with {{ identity }} has not been registered yet', // UNTRANSLATED,
|
||||
missing_profile: 'You need to provide additional info before signing-in.', // UNTRANSLATED
|
||||
},
|
||||
password: {
|
||||
unsupported_encryption_method: '{{name}} şifreleme metodu desteklenmiyor.',
|
||||
|
|
|
@ -56,6 +56,7 @@ const errors = {
|
|||
require_email_or_sms: '请绑定邮箱地址或手机号码',
|
||||
suspended: '账号已被禁用',
|
||||
user_not_exist: 'User with {{ identity }} has not been registered yet', // UNTRANSLATED,
|
||||
missing_profile: 'You need to provide additional info before signing-in.', // UNTRANSLATED
|
||||
},
|
||||
password: {
|
||||
unsupported_encryption_method: '不支持的加密方法 {{name}}',
|
||||
|
|
|
@ -52,7 +52,7 @@ export enum Event {
|
|||
|
||||
export const eventGuard = z.nativeEnum(Event);
|
||||
|
||||
export const identifierGuard = z.union([
|
||||
export const identifierPayloadGuard = z.union([
|
||||
usernamePasswordPayloadGuard,
|
||||
emailPasswordPayloadGuard,
|
||||
phonePasswordPayloadGuard,
|
||||
|
@ -61,6 +61,14 @@ export const identifierGuard = z.union([
|
|||
socialConnectorPayloadGuard,
|
||||
]);
|
||||
|
||||
export type IdentifierPayload =
|
||||
| UsernamePasswordPayload
|
||||
| EmailPasswordPayload
|
||||
| PhonePasswordPayload
|
||||
| EmailPasscodePayload
|
||||
| PhonePasscodePayload
|
||||
| SocialConnectorPayload;
|
||||
|
||||
export const profileGuard = z.object({
|
||||
username: z.string().regex(usernameRegEx).optional(),
|
||||
email: z.string().regex(emailRegEx).optional(),
|
||||
|
@ -70,3 +78,11 @@ export const profileGuard = z.object({
|
|||
});
|
||||
|
||||
export type Profile = z.infer<typeof profileGuard>;
|
||||
|
||||
export enum MissingProfile {
|
||||
username = 'username',
|
||||
email = 'email',
|
||||
phone = 'phone',
|
||||
password = 'password',
|
||||
emailOrPhone = 'emailOrPhone',
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue