From 0e402fdcd322c2a9bd56f3dfbc41e8881ac2813c Mon Sep 17 00:00:00 2001 From: simeng-li Date: Sat, 3 Dec 2022 22:33:10 +0800 Subject: [PATCH] refactor(core): add required profile validation (#2561) --- packages/core/src/routes/interaction/index.ts | 17 +- .../middleware/koa-interaction-body-guard.ts | 2 +- .../koa-session-sign-in-experience-guard.ts | 11 +- ...oa-session-sign-inexperience-guard.test.ts | 1 + .../src/routes/interaction/types/guard.ts | 63 ++++--- .../src/routes/interaction/types/index.ts | 53 ++++-- .../src/routes/interaction/utils/index.ts | 8 +- .../utils/passcode-validation.test.ts | 2 +- .../interaction/utils/passcode-validation.ts | 2 +- .../utils/sign-in-experience-validation.ts | 4 +- .../interaction/utils/social-verification.ts | 2 +- .../verifications/identifier-verification.ts | 13 +- .../routes/interaction/verifications/index.ts | 2 + .../mandatory-user-profile-validation.test.ts | 165 ++++++++++++++++++ .../mandatory-user-profile-validation.ts | 102 +++++++++++ .../verifications/profile-verification.ts | 8 +- packages/phrases/src/locales/de/errors.ts | 1 + packages/phrases/src/locales/en/errors.ts | 1 + packages/phrases/src/locales/fr/errors.ts | 1 + packages/phrases/src/locales/ko/errors.ts | 1 + packages/phrases/src/locales/pt-pt/errors.ts | 1 + packages/phrases/src/locales/tr-tr/errors.ts | 1 + packages/phrases/src/locales/zh-cn/errors.ts | 1 + packages/schemas/src/types/interactions.ts | 18 +- 24 files changed, 418 insertions(+), 62 deletions(-) create mode 100644 packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.test.ts create mode 100644 packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.ts diff --git a/packages/core/src/routes/interaction/index.ts b/packages/core/src/routes/interaction/index.ts index f4b96845d..12cde4415 100644 --- a/packages/core/src/routes/interaction/index.ts +++ b/packages/core/src/routes/interaction/index.ts @@ -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( 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(); diff --git a/packages/core/src/routes/interaction/middleware/koa-interaction-body-guard.ts b/packages/core/src/routes/interaction/middleware/koa-interaction-body-guard.ts index cbcb603f9..b93ae33f0 100644 --- a/packages/core/src/routes/interaction/middleware/koa-interaction-body-guard.ts +++ b/packages/core/src/routes/interaction/middleware/koa-interaction-body-guard.ts @@ -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 & { interactionPayload: InteractionPayload; diff --git a/packages/core/src/routes/interaction/middleware/koa-session-sign-in-experience-guard.ts b/packages/core/src/routes/interaction/middleware/koa-session-sign-in-experience-guard.ts index 59cbfba86..fbcbd604f 100644 --- a/packages/core/src/routes/interaction/middleware/koa-session-sign-in-experience-guard.ts +++ b/packages/core/src/routes/interaction/middleware/koa-session-sign-in-experience-guard.ts @@ -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 & { + signInExperience: SignInExperience; +}; + export default function koaSessionSignInExperienceGuard< StateT, ContextT extends WithGuardedIdentifierPayloadContext, ResponseBodyT ->(provider: Provider): MiddlewareType { +>( + provider: Provider +): MiddlewareType, 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(); }; } diff --git a/packages/core/src/routes/interaction/middleware/koa-session-sign-inexperience-guard.test.ts b/packages/core/src/routes/interaction/middleware/koa-session-sign-inexperience-guard.test.ts index 9f4350559..119a163a1 100644 --- a/packages/core/src/routes/interaction/middleware/koa-session-sign-inexperience-guard.test.ts +++ b/packages/core/src/routes/interaction/middleware/koa-session-sign-inexperience-guard.test.ts @@ -39,6 +39,7 @@ describe('koaSessionSignInExperienceGuard', () => { identifier: { username: 'username', password: 'password' }, profile: { email: 'email' }, }), + signInExperience: mockSignInExperience, }; await koaSessionSignInExperienceGuard(new Provider(''))(ctx, next); diff --git a/packages/core/src/routes/interaction/types/guard.ts b/packages/core/src/routes/interaction/types/guard.ts index 43d8cf5fc..b2174343c 100644 --- a/packages/core/src/routes/interaction/types/guard.ts +++ b/packages/core/src/routes/interaction/types/guard.ts @@ -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; -export type IdentifierPayload = z.infer; - -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; // 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; - // 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(), +}); diff --git a/packages/core/src/routes/interaction/types/index.ts b/packages/core/src/routes/interaction/types/index.ts index 5087937bb..32de7811a 100644 --- a/packages/core/src/routes/interaction/types/index.ts +++ b/packages/core/src/routes/interaction/types/index.ts @@ -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; -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; -export type SocialIdentifier = { key: 'social'; connectorId: string; value: UseInfo }; +export type SocialAuthorizationUrlPayload = z.infer; -type UseInfo = { - email?: string; - phone?: string; - name?: string; - avatar?: string; - id: string; -}; +// Identifier Types +export type AccountIdIdentifier = z.infer; + +export type VerifiedEmailIdentifier = z.infer; + +export type VerifiedPhoneIdentifier = z.infer; + +export type SocialIdentifier = z.infer; + +export type Identifier = z.infer; export type InteractionContext = WithGuardedIdentifierPayloadContext; diff --git a/packages/core/src/routes/interaction/utils/index.ts b/packages/core/src/routes/interaction/utils/index.ts index a12d4a188..4d529015e 100644 --- a/packages/core/src/routes/interaction/utils/index.ts +++ b/packages/core/src/routes/interaction/utils/index.ts @@ -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 diff --git a/packages/core/src/routes/interaction/utils/passcode-validation.test.ts b/packages/core/src/routes/interaction/utils/passcode-validation.test.ts index 2d653956e..e8c5d27f2 100644 --- a/packages/core/src/routes/interaction/utils/passcode-validation.test.ts +++ b/packages/core/src/routes/interaction/utils/passcode-validation.test.ts @@ -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', () => ({ diff --git a/packages/core/src/routes/interaction/utils/passcode-validation.ts b/packages/core/src/routes/interaction/utils/passcode-validation.ts index ac473555d..66adc62f6 100644 --- a/packages/core/src/routes/interaction/utils/passcode-validation.ts +++ b/packages/core/src/routes/interaction/utils/passcode-validation.ts @@ -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: diff --git a/packages/core/src/routes/interaction/utils/sign-in-experience-validation.ts b/packages/core/src/routes/interaction/utils/sign-in-experience-validation.ts index ffaf933cd..15849d618 100644 --- a/packages/core/src/routes/interaction/utils/sign-in-experience-validation.ts +++ b/packages/core/src/routes/interaction/utils/sign-in-experience-validation.ts @@ -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({ diff --git a/packages/core/src/routes/interaction/utils/social-verification.ts b/packages/core/src/routes/interaction/utils/social-verification.ts index de62ad339..787b69c60 100644 --- a/packages/core/src/routes/interaction/utils/social-verification.ts +++ b/packages/core/src/routes/interaction/utils/social-verification.ts @@ -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; diff --git a/packages/core/src/routes/interaction/verifications/identifier-verification.ts b/packages/core/src/routes/interaction/verifications/identifier-verification.ts index f4f7d1660..c3931f6d7 100644 --- a/packages/core/src/routes/interaction/verifications/identifier-verification.ts +++ b/packages/core/src/routes/interaction/verifications/identifier-verification.ts @@ -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, diff --git a/packages/core/src/routes/interaction/verifications/index.ts b/packages/core/src/routes/interaction/verifications/index.ts index ff1c414cb..a8208a419 100644 --- a/packages/core/src/routes/interaction/verifications/index.ts +++ b/packages/core/src/routes/interaction/verifications/index.ts @@ -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'; diff --git a/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.test.ts b/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.test.ts new file mode 100644 index 000000000..8d80de410 --- /dev/null +++ b/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.test.ts @@ -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(); + }); +}); diff --git a/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.ts b/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.ts new file mode 100644 index 000000000..551d9042f --- /dev/null +++ b/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.ts @@ -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; + profile?: Profile; +}) => { + const missingProfile = new Set(); + + 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, + 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) } + ) + ); +} diff --git a/packages/core/src/routes/interaction/verifications/profile-verification.ts b/packages/core/src/routes/interaction/verifications/profile-verification.ts index 06c078f7d..f90484bfb 100644 --- a/packages/core/src/routes/interaction/verifications/profile-verification.ts +++ b/packages/core/src/routes/interaction/verifications/profile-verification.ts @@ -177,7 +177,7 @@ const profileExistValidation = async ( export default async function profileVerification( ctx: InteractionContext, identifiers: Identifier[] -) { +): Promise { 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; } diff --git a/packages/phrases/src/locales/de/errors.ts b/packages/phrases/src/locales/de/errors.ts index 6dbb259ad..bcd6bd116 100644 --- a/packages/phrases/src/locales/de/errors.ts +++ b/packages/phrases/src/locales/de/errors.ts @@ -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.', diff --git a/packages/phrases/src/locales/en/errors.ts b/packages/phrases/src/locales/en/errors.ts index aec933bab..0dd04b266 100644 --- a/packages/phrases/src/locales/en/errors.ts +++ b/packages/phrases/src/locales/en/errors.ts @@ -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.', diff --git a/packages/phrases/src/locales/fr/errors.ts b/packages/phrases/src/locales/fr/errors.ts index 6e105b7b7..ab6bd6eeb 100644 --- a/packages/phrases/src/locales/fr/errors.ts +++ b/packages/phrases/src/locales/fr/errors.ts @@ -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.", diff --git a/packages/phrases/src/locales/ko/errors.ts b/packages/phrases/src/locales/ko/errors.ts index fb6d8dd14..67b4c21f8 100644 --- a/packages/phrases/src/locales/ko/errors.ts +++ b/packages/phrases/src/locales/ko/errors.ts @@ -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}} 암호화 방법을 지원하지 않아요.', diff --git a/packages/phrases/src/locales/pt-pt/errors.ts b/packages/phrases/src/locales/pt-pt/errors.ts index a4aaf439c..b1ea0d7bd 100644 --- a/packages/phrases/src/locales/pt-pt/errors.ts +++ b/packages/phrases/src/locales/pt-pt/errors.ts @@ -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.', diff --git a/packages/phrases/src/locales/tr-tr/errors.ts b/packages/phrases/src/locales/tr-tr/errors.ts index c162ae879..0912454b3 100644 --- a/packages/phrases/src/locales/tr-tr/errors.ts +++ b/packages/phrases/src/locales/tr-tr/errors.ts @@ -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.', diff --git a/packages/phrases/src/locales/zh-cn/errors.ts b/packages/phrases/src/locales/zh-cn/errors.ts index 3526d2f04..2b8ecf4d3 100644 --- a/packages/phrases/src/locales/zh-cn/errors.ts +++ b/packages/phrases/src/locales/zh-cn/errors.ts @@ -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}}', diff --git a/packages/schemas/src/types/interactions.ts b/packages/schemas/src/types/interactions.ts index 6dcc8f54c..5e950a545 100644 --- a/packages/schemas/src/types/interactions.ts +++ b/packages/schemas/src/types/interactions.ts @@ -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; + +export enum MissingProfile { + username = 'username', + email = 'email', + phone = 'phone', + password = 'password', + emailOrPhone = 'emailOrPhone', +}