0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-17 22:04:19 -05:00

refactor(ui): add password identifier verification (#2515)

This commit is contained in:
simeng-li 2022-11-24 11:39:01 +08:00 committed by GitHub
parent 9e2c536a33
commit fb6fd19021
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 363 additions and 16 deletions

View file

@ -1,8 +1,12 @@
import type { Provider } from 'oidc-provider';
import RequestError from '#src/errors/RequestError/index.js';
import assertThat from '#src/utils/assert-that.js';
import type { AnonymousRouter } from '../types.js';
import koaInteractionBodyGuard from './middleware/koa-interaction-body-guard.js';
import koaSessionSignInExperienceGuard from './middleware/koa-session-sign-in-experience-guard.js';
import { identifierVerification } from './verifications/index.js';
export default function interactionRoutes<T extends AnonymousRouter>(
router: T,
@ -16,19 +20,10 @@ export default function interactionRoutes<T extends AnonymousRouter>(
// Check interaction session
await provider.interactionDetails(ctx.req, ctx.res);
const { event } = ctx.interactionPayload;
// PUT method must provides an event type
assertThat(ctx.interactionPayload.event, new RequestError('guard.invalid_input'));
if (event === 'sign-in') {
// Sign-in flow
return next();
}
if (event === 'register') {
// Register flow
return next();
}
// Forgot password flow
const verifiedIdentifiers = await identifierVerification(ctx);
return next();
}

View file

@ -12,7 +12,9 @@ import type { WithGuardedIdentifierPayloadContext } from './koa-interaction-body
const forbiddenEventError = new RequestError({ code: 'auth.forbidden', status: 403 });
export type WithSignInExperienceContext<ContextT> = ContextT & {
export type WithSignInExperienceContext<
ContextT extends WithGuardedIdentifierPayloadContext<IRouterParamContext>
> = ContextT & {
signInExperience: SignInExperience;
};

View file

@ -1,4 +1,22 @@
import { eventGuard, profileGuard, identifierGuard } from '@logto/schemas';
import type {
UsernamePasswordPayload,
EmailPasscodePayload,
PhonePasswordPayload,
EmailPasswordPayload,
PhonePasscodePayload,
SocialConnectorPayload,
} from '@logto/schemas';
import {
eventGuard,
profileGuard,
identifierGuard,
usernamePasswordPayloadGuard,
emailPasswordPayloadGuard,
phonePasswordPayloadGuard,
emailPasscodePayloadGuard,
phonePasscodePayloadGuard,
socialConnectorPayloadGuard,
} from '@logto/schemas';
import { z } from 'zod';
export const interactionPayloadGuard = z.object({
@ -8,3 +26,29 @@ export const interactionPayloadGuard = z.object({
});
export type InteractionPayload = z.infer<typeof interactionPayloadGuard>;
export const isUsernamePassword = (
identifier: InteractionPayload['identifier']
): identifier is UsernamePasswordPayload =>
usernamePasswordPayloadGuard.safeParse(identifier).success;
export const isEmailPassword = (
identifier: InteractionPayload['identifier']
): identifier is EmailPasswordPayload => emailPasswordPayloadGuard.safeParse(identifier).success;
export const isPhonePassword = (
identifier: InteractionPayload['identifier']
): identifier is PhonePasswordPayload => phonePasswordPayloadGuard.safeParse(identifier).success;
export const isEmailPasscode = (
identifier: InteractionPayload['identifier']
): identifier is EmailPasscodePayload => emailPasscodePayloadGuard.safeParse(identifier).success;
export const isPhonePasscode = (
identifier: InteractionPayload['identifier']
): identifier is PhonePasscodePayload => phonePasscodePayloadGuard.safeParse(identifier).success;
export const isSocialConnector = (
identifier: InteractionPayload['identifier']
): identifier is SocialConnectorPayload =>
socialConnectorPayloadGuard.safeParse(identifier).success;

View file

@ -1,3 +1,9 @@
import type { Context } from 'koa';
import type { IRouterParamContext } from 'koa-router';
import type { WithGuardedIdentifierPayloadContext } from '../middleware/koa-interaction-body-guard.js';
import type { WithSignInExperienceContext } from '../middleware/koa-session-sign-in-experience-guard.js';
export type Identifier =
| AccountIdIdentifier
| VerifiedEmailIdentifier
@ -19,3 +25,7 @@ type UseInfo = {
avatar?: string;
id: string;
};
export type InteractionContext = WithSignInExperienceContext<
WithGuardedIdentifierPayloadContext<IRouterParamContext & Context>
>;

View file

@ -0,0 +1 @@
export { default as verifyUserByPassword } from './verify-user-by-password.js';

View file

@ -0,0 +1,104 @@
import { SignInIdentifier } from '@logto/schemas';
import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
import { verifyUserPassword } from '#src/lib/user.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import verifyUserByPassword from './verify-user-by-password.js';
jest.mock('#src/lib/user.js', () => ({
verifyUserPassword: jest.fn(),
}));
describe('verifyUserByPassword', () => {
const findUser = jest.fn();
const baseCtx = createContextWithRouteParameters();
const verifyUserPasswordMock = verifyUserPassword as jest.Mock;
const mockUser = { id: 'mock_user', isSuspended: false };
it('should throw if target sign-in method is not enabled', async () => {
const ctx = {
...baseCtx,
interactionPayload: {},
signInExperience: {
...mockSignInExperience,
signIn: {
methods: mockSignInExperience.signIn.methods.filter(
({ identifier }) => identifier === SignInIdentifier.Username
),
},
},
};
await expect(
verifyUserByPassword(ctx, {
identifier: 'foo',
password: 'password',
findUser,
identifierType: SignInIdentifier.Email,
})
).rejects.toThrow();
});
it('should return userId', async () => {
findUser.mockResolvedValueOnce(mockUser);
verifyUserPasswordMock.mockResolvedValueOnce(mockUser);
const ctx = {
...baseCtx,
interactionPayload: {},
signInExperience: {
...mockSignInExperience,
signIn: {
methods: mockSignInExperience.signIn.methods.filter(
({ identifier }) => identifier === SignInIdentifier.Username
),
},
},
};
const userId = await verifyUserByPassword(ctx, {
identifier: 'foo',
password: 'password',
findUser,
identifierType: SignInIdentifier.Username,
});
expect(findUser).toBeCalledWith('foo');
expect(verifyUserPasswordMock).toBeCalledWith(mockUser, 'password');
expect(userId).toEqual(mockUser.id);
});
it('should reject if user is suspended', async () => {
findUser.mockResolvedValueOnce(mockUser);
verifyUserPasswordMock.mockResolvedValueOnce({
...mockUser,
isSuspended: true,
});
const ctx = {
...baseCtx,
interactionPayload: {},
signInExperience: {
...mockSignInExperience,
signIn: {
methods: mockSignInExperience.signIn.methods.filter(
({ identifier }) => identifier === SignInIdentifier.Username
),
},
},
};
await expect(
verifyUserByPassword(ctx, {
identifier: 'foo',
password: 'password',
findUser,
identifierType: SignInIdentifier.Username,
})
).rejects.toThrow();
expect(findUser).toBeCalledWith('foo');
expect(verifyUserPasswordMock).toBeCalledWith(mockUser, 'password');
});
});

View file

@ -0,0 +1,39 @@
import type { SignInIdentifier, User } from '@logto/schemas';
import type { Nullable } from '@silverhand/essentials';
import RequestError from '#src/errors/RequestError/index.js';
import { verifyUserPassword } from '#src/lib/user.js';
import assertThat from '#src/utils/assert-that.js';
import type { InteractionContext } from '../types/index.js';
type Parameters = {
identifier: string;
password: string;
findUser: (identifier: string) => Promise<Nullable<User>>;
identifierType: SignInIdentifier;
};
export default async function verifyUserByPassword(
ctx: InteractionContext,
{ identifier, password, findUser, identifierType }: Parameters
) {
const { signIn } = ctx.signInExperience;
assertThat(
signIn.methods.some(
({ identifier: method, password }) => method === identifierType && password
),
new RequestError({
code: 'user.sign_in_method_not_enabled',
status: 422,
})
);
const user = await findUser(identifier);
const verifiedUser = await verifyUserPassword(user, password);
const { isSuspended, id } = verifiedUser;
assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
return id;
}

View file

@ -0,0 +1,95 @@
import { SignInIdentifier } from '@logto/schemas';
import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
import { findUserByUsername, findUserByEmail, findUserByPhone } from '#src/queries/user.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import { verifyUserByPassword } from '../utils/index.js';
import identifierVerification from './identifier-verification.js';
jest.mock('../utils/index.js', () => ({
verifyUserByPassword: jest.fn(),
}));
describe('identifier verification', () => {
const baseCtx = createContextWithRouteParameters();
const verifyUserByPasswordMock = verifyUserByPassword as jest.Mock;
it('username password', async () => {
verifyUserByPasswordMock.mockResolvedValueOnce('userId');
const ctx = {
...baseCtx,
signInExperience: mockSignInExperience,
interactionPayload: Object.freeze({
event: 'sign-in',
identifier: {
username: 'username',
password: 'password',
},
}),
};
const result = await identifierVerification(ctx);
expect(verifyUserByPasswordMock).toBeCalledWith(ctx, {
findUser: findUserByUsername,
identifier: 'username',
identifierType: SignInIdentifier.Username,
password: 'password',
});
expect(result).toEqual([{ key: 'accountId', value: 'userId' }]);
});
it('email password', async () => {
verifyUserByPasswordMock.mockResolvedValueOnce('userId');
const ctx = {
...baseCtx,
signInExperience: mockSignInExperience,
interactionPayload: Object.freeze({
event: 'sign-in',
identifier: {
email: 'email',
password: 'password',
},
}),
};
const result = await identifierVerification(ctx);
expect(verifyUserByPasswordMock).toBeCalledWith(ctx, {
findUser: findUserByEmail,
identifier: 'email',
identifierType: SignInIdentifier.Email,
password: 'password',
});
expect(result).toEqual([{ key: 'accountId', value: 'userId' }]);
});
it('phone password', async () => {
verifyUserByPasswordMock.mockResolvedValueOnce('userId');
const ctx = {
...baseCtx,
signInExperience: mockSignInExperience,
interactionPayload: Object.freeze({
event: 'sign-in',
identifier: {
phone: '123456',
password: 'password',
},
}),
};
const result = await identifierVerification(ctx);
expect(verifyUserByPasswordMock).toBeCalledWith(ctx, {
findUser: findUserByPhone,
identifier: '123456',
identifierType: SignInIdentifier.Sms,
password: 'password',
});
expect(result).toEqual([{ key: 'accountId', value: 'userId' }]);
});
});

View file

@ -0,0 +1,56 @@
import { SignInIdentifier } from '@logto/schemas';
import RequestError from '#src/errors/RequestError/index.js';
import { findUserByEmail, findUserByPhone, findUserByUsername } from '#src/queries/user.js';
import { isUsernamePassword, isPhonePassword, isEmailPassword } from '../types/guard.js';
import type { InteractionContext, Identifier } from '../types/index.js';
import { verifyUserByPassword } from '../utils/index.js';
export default async function identifierVerification(
ctx: InteractionContext
): Promise<Identifier[]> {
const { identifier } = ctx.interactionPayload;
if (isUsernamePassword(identifier)) {
const { username, password } = identifier;
const accountId = await verifyUserByPassword(ctx, {
identifier: username,
password,
findUser: findUserByUsername,
identifierType: SignInIdentifier.Username,
});
return [{ key: 'accountId', value: accountId }];
}
if (isPhonePassword(identifier)) {
const { phone, password } = identifier;
const accountId = await verifyUserByPassword(ctx, {
identifier: phone,
password,
findUser: findUserByPhone,
identifierType: SignInIdentifier.Sms,
});
return [{ key: 'accountId', value: accountId }];
}
if (isEmailPassword(identifier)) {
const { email, password } = identifier;
const accountId = await verifyUserByPassword(ctx, {
identifier: email,
password,
findUser: findUserByEmail,
identifierType: SignInIdentifier.Email,
});
return [{ key: 'accountId', value: accountId }];
}
// Invalid identifier input
throw new RequestError('guard.invalid_input', identifier);
}

View file

@ -0,0 +1 @@
export { default as identifierVerification } from './identifier-verification.js';

View file

@ -24,13 +24,13 @@ export const phonePasswordPayloadGuard = z.object({
export type PhonePasswordPayload = z.infer<typeof phonePasswordPayloadGuard>;
export const emailPasscodePayloadGuard = z.object({
email: z.string().min(1),
email: z.string().regex(emailRegEx),
passcode: z.string().min(1),
});
export type EmailPasscodePayload = z.infer<typeof emailPasscodePayloadGuard>;
export const phonePasscodePayloadGuard = z.object({
phone: z.string().min(1),
phone: z.string().regex(phoneRegEx),
passcode: z.string().min(1),
});
export type PhonePasscodePayload = z.infer<typeof phonePasscodePayloadGuard>;