0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-04-14 23:11:31 -05:00

refactor(core): add send passcode api (#2527)

This commit is contained in:
simeng-li 2022-11-28 18:04:13 +08:00 committed by GitHub
parent 0111dd52eb
commit 23d2a3fe80
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 852 additions and 109 deletions

View file

@ -86,7 +86,6 @@ export const sendPasscode = async (passcode: Passcode) => {
export const passcodeExpiration = 10 * 60 * 1000; // 10 minutes.
export const passcodeMaxTryCount = 10;
// TODO: @sijie refactor me
// eslint-disable-next-line complexity
export const verifyPasscode = async (
sessionId: string,

View file

@ -0,0 +1,145 @@
import { ConnectorType } from '@logto/connector-kit';
import { Provider } from 'oidc-provider';
import RequestError from '#src/errors/RequestError/index.js';
import { createRequester } from '#src/utils/test-utils.js';
import interactionRoutes, { verificationPrefix } from './index.js';
import { sendPasscodeToIdentifier } from './utils/passcode-validation.js';
const getLogtoConnectorByIdHelper = jest.fn(async (connectorId: string) => {
const database = {
enabled: connectorId === 'social_enabled',
};
const metadata = {
id:
connectorId === 'social_enabled'
? 'social_enabled'
: connectorId === 'social_disabled'
? 'social_disabled'
: 'others',
};
return {
dbEntry: database,
metadata,
type: connectorId.startsWith('social') ? ConnectorType.Social : ConnectorType.Sms,
getAuthorizationUri: jest.fn(async () => ''),
};
});
jest.mock('#src/connectors.js', () => ({
getLogtoConnectorById: jest.fn(async (connectorId: string) => {
const connector = await getLogtoConnectorByIdHelper(connectorId);
if (connector.type !== ConnectorType.Social) {
throw new RequestError({
code: 'entity.not_found',
status: 404,
});
}
return connector;
}),
}));
jest.mock('./utils/passcode-validation.js', () => ({
sendPasscodeToIdentifier: jest.fn(),
}));
jest.mock('oidc-provider', () => ({
Provider: jest.fn(() => ({
interactionDetails: jest.fn().mockResolvedValue({
jti: 'jti',
}),
})),
}));
const log = jest.fn();
describe('session -> interactionRoutes', () => {
const sessionRequest = createRequester({
anonymousRoutes: interactionRoutes,
provider: new Provider(''),
middlewares: [
async (ctx, next) => {
ctx.addLogContext = jest.fn();
ctx.log = log;
return next();
},
],
});
describe('POST /verification/passcode', () => {
const path = `${verificationPrefix}/passcode`;
it('should call send passcode properly', async () => {
const body = {
event: 'sign-in',
email: 'email@logto.io',
};
const response = await sessionRequest.post(path).send(body);
expect(sendPasscodeToIdentifier).toBeCalledWith(body, 'jti', log);
expect(response.status).toEqual(204);
});
});
describe('POST /verification/social/authorization-uri', () => {
const path = `${verificationPrefix}/social/authorization-uri`;
it('should throw when redirectURI is invalid', async () => {
const response = await sessionRequest.post(path).send({
connectorId: 'social_enabled',
state: 'state',
redirectUri: 'logto.dev',
});
expect(response.statusCode).toEqual(400);
});
it('should return the authorization-uri properly', async () => {
const response = await sessionRequest.post(path).send({
connectorId: 'social_enabled',
state: 'state',
redirectUri: 'https://logto.dev',
});
expect(response.statusCode).toEqual(200);
expect(response.body).toHaveProperty('redirectTo', '');
});
it('throw error when sign-in with social but miss state', async () => {
const response = await sessionRequest.post(path).send({
connectorId: 'social_enabled',
redirectUri: 'https://logto.dev',
});
expect(response.statusCode).toEqual(400);
});
it('throw error when sign-in with social but miss redirectUri', async () => {
const response = await sessionRequest.post(path).send({
connectorId: 'social_enabled',
state: 'state',
});
expect(response.statusCode).toEqual(400);
});
it('throw error when connector is disabled', async () => {
const response = await sessionRequest.post(path).send({
connectorId: 'social_disabled',
state: 'state',
redirectUri: 'https://logto.dev',
});
expect(response.statusCode).toEqual(400);
});
it('throw error when no social connector is found', async () => {
const response = await sessionRequest.post(path).send({
connectorId: 'others',
state: 'state',
redirectUri: 'https://logto.dev',
});
expect(response.statusCode).toEqual(404);
});
});
});

View file

@ -1,19 +1,26 @@
import type { Provider } from 'oidc-provider';
import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.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 { 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';
export const identifierPrefix = '/identifier';
export const verificationPrefix = '/verification';
export default function interactionRoutes<T extends AnonymousRouter>(
router: T,
provider: Provider
) {
router.put(
'/interaction',
identifierPrefix,
koaInteractionBodyGuard(),
koaSessionSignInExperienceGuard(provider),
async (ctx, next) => {
@ -23,7 +30,40 @@ export default function interactionRoutes<T extends AnonymousRouter>(
// PUT method must provides an event type
assertThat(ctx.interactionPayload.event, new RequestError('guard.invalid_input'));
const verifiedIdentifiers = await identifierVerification(ctx);
const verifiedIdentifiers = await identifierVerification(ctx, provider);
ctx.status = 200;
return next();
}
);
router.post(
`${verificationPrefix}/social/authorization-uri`,
koaGuard({ body: getSocialAuthorizationUrlPayloadGuard }),
async (ctx, next) => {
// Check interaction session
await provider.interactionDetails(ctx.req, ctx.res);
const redirectTo = await createSocialAuthorizationUrl(ctx.guard.body);
ctx.body = { redirectTo };
return next();
}
);
router.post(
`${verificationPrefix}/passcode`,
koaGuard({
body: sendPasscodePayloadGuard,
}),
async (ctx, next) => {
// Check interaction session
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
await sendPasscodeToIdentifier(ctx.guard.body, jti, ctx.log);
ctx.status = 204;
return next();
}

View file

@ -1,6 +1,15 @@
import { emailRegEx, phoneRegEx, validateRedirectUrl } from '@logto/core-kit';
import type {
UsernamePasswordPayload,
EmailPasswordPayload,
EmailPasscodePayload,
PhonePasswordPayload,
PhonePasscodePayload,
} from '@logto/schemas';
import { eventGuard, profileGuard, identifierGuard } from '@logto/schemas';
import { z } from 'zod';
// Interaction Route Guard
export const interactionPayloadGuard = z.object({
event: eventGuard.optional(),
identifier: identifierGuard.optional(),
@ -9,3 +18,33 @@ export const interactionPayloadGuard = z.object({
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 Guard
export const sendPasscodePayloadGuard = z.union([
z.object({
event: eventGuard,
email: z.string().regex(emailRegEx),
}),
z.object({
event: eventGuard,
phone: z.string().regex(phoneRegEx),
}),
]);
export type SendPasscodePayload = z.infer<typeof sendPasscodePayloadGuard>;
// Social Authorization Uri Route Guard
export const getSocialAuthorizationUrlPayloadGuard = z.object({
connectorId: z.string(),
state: z.string(),
redirectUri: z.string().refine((url) => validateRedirectUrl(url, 'web')),
});
export type SocialAuthorizationUrlPayload = z.infer<typeof getSocialAuthorizationUrlPayloadGuard>;

View file

@ -1,6 +1,8 @@
import type { Context } from 'koa';
import type { IRouterParamContext } from 'koa-router';
import type { SocialUserInfo } from '#src/connectors/types.js';
import type { WithGuardedIdentifierPayloadContext } from '../middleware/koa-interaction-body-guard.js';
export type Identifier =
@ -26,3 +28,9 @@ type UseInfo = {
};
export type InteractionContext = WithGuardedIdentifierPayloadContext<IRouterParamContext & Context>;
export type UserIdentity =
| { username: string }
| { email: string }
| { phone: string }
| { connectorId: string; userInfo: SocialUserInfo };

View file

@ -0,0 +1,33 @@
import { getLogtoConnectorById } from '#src/connectors/index.js';
import {
findUserByEmail,
findUserByUsername,
findUserByPhone,
findUserByIdentity as findUserBySocialIdentity,
} from '#src/queries/user.js';
import type { UserIdentity } from '../types/index.js';
export default async function findUserByIdentity(identity: UserIdentity) {
if ('username' in identity) {
return findUserByUsername(identity.username);
}
if ('email' in identity) {
return findUserByEmail(identity.email);
}
if ('phone' in identity) {
return findUserByPhone(identity.phone);
}
if ('connectorId' in identity) {
const {
metadata: { target },
} = await getLogtoConnectorById(identity.connectorId);
return findUserBySocialIdentity(target, identity.userInfo.id);
}
return null;
}

View file

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

View file

@ -0,0 +1,34 @@
import type { Profile, SocialConnectorPayload } from '@logto/schemas';
import type {
PasscodeIdentifierPayload,
IdentifierPayload,
PasswordIdentifierPayload,
} from '../types/guard.js';
export const isPasswordIdentifier = (
identifier: IdentifierPayload
): identifier is PasswordIdentifierPayload => 'password' in identifier;
export const isPasscodeIdentifier = (
identifier: IdentifierPayload
): identifier is PasscodeIdentifierPayload => 'passcode' in identifier;
export const isSocialIdentifier = (
identifier: IdentifierPayload
): identifier is SocialConnectorPayload => 'connectorId' in identifier;
export const isProfileIdentifier = (
identifier: PasscodeIdentifierPayload | SocialConnectorPayload,
profile?: Profile
) => {
if ('email' in identifier) {
return profile?.email === identifier.email;
}
if ('phone' in identifier) {
return profile?.phone === identifier.phone;
}
return profile?.connectorId === identifier.connectorId;
};

View file

@ -0,0 +1,57 @@
import { PasscodeType } from '@logto/schemas';
import { createPasscode, sendPasscode } from '#src/lib/passcode.js';
import type { SendPasscodePayload } from '../types/guard.js';
import { sendPasscodeToIdentifier } from './passcode-validation.js';
jest.mock('#src/lib/passcode.js', () => ({
createPasscode: jest.fn(() => ({})),
sendPasscode: jest.fn().mockResolvedValue({ dbEntry: { id: 'foo' } }),
}));
const sendPasscodeTestCase = [
{
payload: { email: 'email', event: 'sign-in' },
createPasscodeParams: [PasscodeType.SignIn, { email: 'email' }],
},
{
payload: { email: 'email', event: 'register' },
createPasscodeParams: [PasscodeType.Register, { email: 'email' }],
},
{
payload: { email: 'email', event: 'forgot-password' },
createPasscodeParams: [PasscodeType.ForgotPassword, { email: 'email' }],
},
{
payload: { phone: 'phone', event: 'sign-in' },
createPasscodeParams: [PasscodeType.SignIn, { phone: 'phone' }],
},
{
payload: { phone: 'phone', event: 'register' },
createPasscodeParams: [PasscodeType.Register, { phone: 'phone' }],
},
{
payload: { phone: 'phone', event: 'forgot-password' },
createPasscodeParams: [PasscodeType.ForgotPassword, { phone: 'phone' }],
},
];
describe('passcode-validation utils', () => {
const createPasscodeMock = createPasscode as jest.Mock;
const sendPasscodeMock = sendPasscode as jest.Mock;
const log = jest.fn();
afterEach(() => {
jest.clearAllMocks();
});
it.each(sendPasscodeTestCase)(
'send passcode successfully',
async ({ payload, createPasscodeParams }) => {
await sendPasscodeToIdentifier(payload as SendPasscodePayload, 'jti', log);
expect(createPasscodeMock).toBeCalledWith('jti', ...createPasscodeParams);
expect(sendPasscodeMock).toBeCalled();
}
);
});

View file

@ -0,0 +1,62 @@
import { PasscodeType } from '@logto/schemas';
import type { Event } from '@logto/schemas';
import { createPasscode, sendPasscode, verifyPasscode } from '#src/lib/passcode.js';
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';
/**
* Refactor Needed:
* This is a work around to map the latest interaction event type to old PasscodeType
* */
const eventToPasscodeTypeMap: Record<Event, PasscodeType> = {
'sign-in': PasscodeType.SignIn,
register: PasscodeType.Register,
'forgot-password': PasscodeType.ForgotPassword,
};
const getPasscodeTypeByEvent = (event: Event): PasscodeType => eventToPasscodeTypeMap[event];
export const sendPasscodeToIdentifier = async (
payload: SendPasscodePayload,
jti: string,
log: LogContext['log']
) => {
const { event, ...identifier } = payload;
const passcodeType = getPasscodeTypeByEvent(event);
const logType = getPasswordlessRelatedLogType(
passcodeType,
'email' in identifier ? 'email' : 'sms',
'send'
);
log(logType, identifier);
const passcode = await createPasscode(jti, passcodeType, identifier);
const { dbEntry } = await sendPasscode(passcode);
log(logType, { connectorId: dbEntry.id });
};
export const verifyIdentifierByPasscode = async (
payload: PasscodeIdentifierPayload & { event: Event },
jti: string,
log: LogContext['log']
) => {
const { event, passcode, ...identifier } = payload;
const passcodeType = getPasscodeTypeByEvent(event);
const logType = getPasswordlessRelatedLogType(
passcodeType,
'email' in identifier ? 'email' : 'sms',
'verify'
);
log(logType, identifier);
await verifyPasscode(jti, passcodeType, passcode, identifier);
};

View file

@ -0,0 +1,35 @@
import { ConnectorType } from '@logto/connector-kit';
import { getUserInfoByAuthCode } from '#src/lib/social.js';
import { verifySocialIdentity } from './social-verification.js';
jest.mock('#src/lib/social.js', () => ({
getUserInfoByAuthCode: jest.fn().mockResolvedValue({ id: 'foo' }),
}));
jest.mock('#src/connectors.js', () => ({
getLogtoConnectorById: jest.fn().mockResolvedValue({
dbEntry: {
enabled: true,
},
metadata: {
id: 'social',
},
type: ConnectorType.Social,
getAuthorizationUri: jest.fn(async () => ''),
}),
}));
const log = jest.fn();
describe('social-verification', () => {
it('verifySocialIdentity', async () => {
const connectorId = 'connector';
const connectorData = { authCode: 'code' };
const userInfo = await verifySocialIdentity({ connectorId, connectorData }, log);
expect(getUserInfoByAuthCode).toBeCalledWith(connectorId, connectorData);
expect(userInfo).toEqual({ id: 'foo' });
});
});

View file

@ -0,0 +1,36 @@
import type { SocialConnectorPayload, LogType } from '@logto/schemas';
import { ConnectorType } from '@logto/schemas';
import { getLogtoConnectorById } from '#src/connectors/index.js';
import type { SocialUserInfo } from '#src/connectors/types.js';
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';
export const createSocialAuthorizationUrl = async (payload: SocialAuthorizationUrlPayload) => {
const { connectorId, state, redirectUri } = payload;
assertThat(state && redirectUri, 'session.insufficient_info');
const connector = await getLogtoConnectorById(connectorId);
assertThat(connector.dbEntry.enabled, 'connector.not_enabled');
assertThat(connector.type === ConnectorType.Social, 'connector.unexpected_type');
return connector.getAuthorizationUri({ state, redirectUri });
};
export const verifySocialIdentity = async (
{ connectorId, connectorData }: SocialConnectorPayload,
log: LogContext['log']
): Promise<SocialUserInfo> => {
const logType: LogType = 'SignInSocial';
log(logType, { connectorId, connectorData });
const userInfo = await getUserInfoByAuthCode(connectorId, connectorData);
log(logType, userInfo);
return userInfo;
};

View file

@ -1,37 +0,0 @@
import { verifyUserPassword } from '#src/lib/user.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 verifyUserPasswordMock = verifyUserPassword as jest.Mock;
const mockUser = { id: 'mock_user', isSuspended: false };
it('should return userId', async () => {
findUser.mockResolvedValueOnce(mockUser);
verifyUserPasswordMock.mockResolvedValueOnce(mockUser);
const userId = await verifyUserByPassword('foo', 'password', findUser);
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,
});
await expect(verifyUserByPassword('foo', 'password', findUser)).rejects.toThrow();
expect(findUser).toBeCalledWith('foo');
expect(verifyUserPasswordMock).toBeCalledWith(mockUser, 'password');
});
});

View file

@ -1,19 +0,0 @@
import type { 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';
export default async function verifyUserByPassword(
identifier: string,
password: string,
findUser: (identifier: string) => Promise<Nullable<User>>
) {
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,41 @@
import type { SocialUserInfo } from '#src/connectors/types.js';
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 { PasswordIdentifierPayload } from '../types/guard.js';
import type { UserIdentity } from '../types/index.js';
import findUserByIdentity from './find-user-by-identity.js';
export const verifyUserByVerifiedPasscodeIdentity = async (identifier: UserIdentity) => {
const user = await findUserByIdentity(identifier);
assertThat(user, new RequestError({ code: 'user.user_not_exist', status: 404 }));
const { id, isSuspended } = user;
assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
return id;
};
export const verifyUserByIdentityAndPassword = async ({
password,
...identity
}: PasswordIdentifierPayload) => {
const user = await findUserByIdentity(identity);
const verifiedUser = await verifyUserPassword(user, password);
const { isSuspended, id } = verifiedUser;
assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
return id;
};
export const verifyUserBySocialIdentity = async (connectorId: string, userInfo: SocialUserInfo) => {
const user = await findUserByIdentity({ connectorId, userInfo });
assertThat(user, new RequestError({ code: 'user.user_not_exist', status: 404 }));
const { id, isSuspended } = user;
assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
return id;
};

View file

@ -0,0 +1,118 @@
import { verifyUserPassword } from '#src/lib/user.js';
import { findUserByUsername, findUserByEmail, findUserByPhone } from '#src/queries/user.js';
import {
verifyUserByIdentityAndPassword,
verifyUserByVerifiedPasscodeIdentity,
} from './verify-user.js';
jest.mock('#src/lib/user.js', () => ({
verifyUserPassword: jest.fn(),
}));
jest.mock('#src/queries/user.js', () => ({
findUserByUsername: jest.fn(),
findUserByEmail: jest.fn(),
findUserByPhone: jest.fn(),
}));
describe('verifyUserByIdentityAndPassword', () => {
const findUserByUsernameMock = findUserByUsername as jest.Mock;
const findUserByEmailMock = findUserByEmail as jest.Mock;
const findUserByPhoneMock = findUserByPhone as jest.Mock;
const verifyUserPasswordMock = verifyUserPassword as jest.Mock;
const mockUser = { id: 'mock_user', isSuspended: false };
const usernameIdentifier = {
username: 'username',
password: 'password',
};
const emailIdentifier = {
email: 'email',
password: 'password',
};
const phoneIdentifier = {
phone: 'phone',
password: 'password',
};
it('username password', async () => {
findUserByUsernameMock.mockResolvedValueOnce(mockUser);
verifyUserPasswordMock.mockResolvedValueOnce(mockUser);
const userId = await verifyUserByIdentityAndPassword(usernameIdentifier);
expect(findUserByUsernameMock).toBeCalledWith(usernameIdentifier.username);
expect(verifyUserPasswordMock).toBeCalledWith(mockUser, usernameIdentifier.password);
expect(userId).toEqual(mockUser.id);
});
it('should reject if user is suspended', async () => {
findUserByUsernameMock.mockResolvedValueOnce(mockUser);
verifyUserPasswordMock.mockResolvedValueOnce({
...mockUser,
isSuspended: true,
});
await expect(verifyUserByIdentityAndPassword(usernameIdentifier)).rejects.toThrow();
expect(findUserByUsernameMock).toBeCalledWith(usernameIdentifier.username);
expect(verifyUserPasswordMock).toBeCalledWith(mockUser, usernameIdentifier.password);
});
it('email password', async () => {
findUserByEmailMock.mockResolvedValueOnce(mockUser);
verifyUserPasswordMock.mockResolvedValueOnce(mockUser);
const userId = await verifyUserByIdentityAndPassword(emailIdentifier);
expect(findUserByEmailMock).toBeCalledWith(emailIdentifier.email);
expect(verifyUserPasswordMock).toBeCalledWith(mockUser, emailIdentifier.password);
expect(userId).toEqual(mockUser.id);
});
it('phone password', async () => {
findUserByPhoneMock.mockResolvedValueOnce(mockUser);
verifyUserPasswordMock.mockResolvedValueOnce(mockUser);
const userId = await verifyUserByIdentityAndPassword(phoneIdentifier);
expect(findUserByPhoneMock).toBeCalledWith(phoneIdentifier.phone);
expect(verifyUserPasswordMock).toBeCalledWith(mockUser, phoneIdentifier.password);
expect(userId).toEqual(mockUser.id);
});
});
describe('verifyUserByVerifiedPasscodeIdentity', () => {
const findUserByEmailMock = findUserByEmail as jest.Mock;
const findUserByPhoneMock = findUserByPhone as jest.Mock;
const mockUser = { id: 'mock_user', isSuspended: false };
const emailIdentifier = {
email: 'email',
};
const phoneIdentifier = {
phone: 'phone',
};
it('verified email', async () => {
findUserByEmailMock.mockResolvedValueOnce(mockUser);
const userId = await verifyUserByVerifiedPasscodeIdentity(emailIdentifier);
expect(findUserByEmailMock).toBeCalledWith(emailIdentifier.email);
expect(userId).toEqual(mockUser.id);
});
it('verified phone', async () => {
findUserByPhoneMock.mockResolvedValueOnce(mockUser);
const userId = await verifyUserByVerifiedPasscodeIdentity(phoneIdentifier);
expect(findUserByPhoneMock).toBeCalledWith(phoneIdentifier.phone);
expect(userId).toEqual(mockUser.id);
});
});

View file

@ -1,78 +1,196 @@
import { findUserByUsername, findUserByEmail, findUserByPhone } from '#src/queries/user.js';
import { Provider } from 'oidc-provider';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import { verifyUserByPassword } from '../utils/index.js';
import { verifyIdentifierByPasscode } from '../utils/passcode-validation.js';
import {
verifyUserByIdentityAndPassword,
verifyUserByVerifiedPasscodeIdentity,
} from '../utils/verify-user.js';
import identifierVerification from './identifier-verification.js';
jest.mock('../utils/index.js', () => ({
verifyUserByPassword: jest.fn(),
jest.mock('../utils/verify-user.js', () => ({
verifyUserByIdentityAndPassword: jest.fn().mockResolvedValue('userId'),
verifyUserByVerifiedPasscodeIdentity: jest.fn().mockResolvedValue('userId'),
}));
jest.mock('../utils/passcode-validation.js', () => ({
verifyIdentifierByPasscode: jest.fn(),
}));
jest.mock('oidc-provider', () => ({
Provider: jest.fn(() => ({
interactionDetails: jest.fn(async () => ({ params: {}, jti: 'jti' })),
})),
}));
const log = jest.fn();
describe('identifier verification', () => {
const baseCtx = createContextWithRouteParameters();
const verifyUserByPasswordMock = verifyUserByPassword as jest.Mock;
const baseCtx = { ...createContextWithRouteParameters(), log };
const verifyUserByPasswordMock = verifyUserByIdentityAndPassword as jest.Mock;
const verifyUserByVerifiedPasscodeIdentityMock =
verifyUserByVerifiedPasscodeIdentity as jest.Mock;
const verifyIdentifierByPasscodeMock = verifyIdentifierByPasscode as jest.Mock;
afterEach(() => {
jest.clearAllMocks();
});
it('username password', async () => {
verifyUserByPasswordMock.mockResolvedValueOnce('userId');
const identifier = {
username: 'username',
password: 'password',
};
const ctx = {
...baseCtx,
interactionPayload: Object.freeze({
event: 'sign-in',
identifier: {
username: 'username',
password: 'password',
},
identifier,
}),
};
const result = await identifierVerification(ctx);
expect(verifyUserByPasswordMock).toBeCalledWith('username', 'password', findUserByUsername);
const result = await identifierVerification(ctx, new Provider(''));
expect(verifyUserByPasswordMock).toBeCalledWith(identifier);
expect(result).toEqual([{ key: 'accountId', value: 'userId' }]);
});
it('email password', async () => {
verifyUserByPasswordMock.mockResolvedValueOnce('userId');
const identifier = {
email: 'email',
password: 'password',
};
const ctx = {
...baseCtx,
interactionPayload: Object.freeze({
event: 'sign-in',
identifier: {
email: 'email',
password: 'password',
},
identifier,
}),
};
const result = await identifierVerification(ctx);
expect(verifyUserByPasswordMock).toBeCalledWith('email', 'password', findUserByEmail);
const result = await identifierVerification(ctx, new Provider(''));
expect(verifyUserByPasswordMock).toBeCalledWith(identifier);
expect(result).toEqual([{ key: 'accountId', value: 'userId' }]);
});
it('phone password', async () => {
verifyUserByPasswordMock.mockResolvedValueOnce('userId');
const identifier = {
phone: 'phone',
password: 'password',
};
const ctx = {
...baseCtx,
interactionPayload: Object.freeze({
event: 'sign-in',
identifier: {
phone: '123456',
password: 'password',
identifier,
}),
};
const result = await identifierVerification(ctx, new Provider(''));
expect(verifyUserByPasswordMock).toBeCalledWith(identifier);
expect(result).toEqual([{ key: 'accountId', value: 'userId' }]);
});
it('email passcode with out profile', async () => {
const identifier = { email: 'email', passcode: 'passcode' };
const ctx = {
...baseCtx,
interactionPayload: Object.freeze({
event: 'sign-in',
identifier,
}),
};
const result = await identifierVerification(ctx, new Provider(''));
expect(verifyIdentifierByPasscodeMock).toBeCalledWith(
{ ...identifier, event: 'sign-in' },
'jti',
log
);
expect(verifyUserByVerifiedPasscodeIdentityMock).toBeCalledWith(identifier);
expect(result).toEqual([
{ key: 'accountId', value: 'userId' },
{ key: 'verifiedEmail', value: 'email' },
]);
});
it('email passcode with profile', async () => {
const identifier = { email: 'email', passcode: 'passcode' };
const ctx = {
...baseCtx,
interactionPayload: Object.freeze({
event: 'sign-in',
identifier,
profile: {
email: 'email',
},
}),
};
const result = await identifierVerification(ctx);
const result = await identifierVerification(ctx, new Provider(''));
expect(verifyIdentifierByPasscodeMock).toBeCalledWith(
{ ...identifier, event: 'sign-in' },
'jti',
log
);
expect(verifyUserByVerifiedPasscodeIdentityMock).not.toBeCalled();
expect(verifyUserByPasswordMock).toBeCalledWith('123456', 'password', findUserByPhone);
expect(result).toEqual([{ key: 'accountId', value: 'userId' }]);
expect(result).toEqual([{ key: 'verifiedEmail', value: 'email' }]);
});
it('phone passcode with out profile', async () => {
const identifier = { phone: 'phone', passcode: 'passcode' };
const ctx = {
...baseCtx,
interactionPayload: Object.freeze({
event: 'sign-in',
identifier,
}),
};
const result = await identifierVerification(ctx, new Provider(''));
expect(verifyIdentifierByPasscodeMock).toBeCalledWith(
{ ...identifier, event: 'sign-in' },
'jti',
log
);
expect(verifyUserByVerifiedPasscodeIdentityMock).toBeCalledWith(identifier);
expect(result).toEqual([
{ key: 'accountId', value: 'userId' },
{ key: 'verifiedPhone', value: 'phone' },
]);
});
it('phone passcode with profile', async () => {
const identifier = { phone: 'phone', passcode: 'passcode' };
const ctx = {
...baseCtx,
interactionPayload: Object.freeze({
event: 'sign-in',
identifier,
profile: {
phone: 'phone',
},
}),
};
const result = await identifierVerification(ctx, new Provider(''));
expect(verifyIdentifierByPasscodeMock).toBeCalledWith(
{ ...identifier, event: 'sign-in' },
'jti',
log
);
expect(verifyUserByVerifiedPasscodeIdentityMock).not.toBeCalled();
expect(result).toEqual([{ key: 'verifiedPhone', value: 'phone' }]);
});
});

View file

@ -1,42 +1,70 @@
import RequestError from '#src/errors/RequestError/index.js';
import { findUserByEmail, findUserByPhone, findUserByUsername } from '#src/queries/user.js';
import type { Provider } from 'oidc-provider';
import type { InteractionContext, Identifier } from '../types/index.js';
import { verifyUserByPassword } from '../utils/index.js';
import {
isPasscodeIdentifier,
isPasswordIdentifier,
isProfileIdentifier,
} from '../utils/index.ts.js';
import { verifyIdentifierByPasscode } from '../utils/passcode-validation.js';
import { verifySocialIdentity } from '../utils/social-verification.js';
import {
verifyUserByIdentityAndPassword,
verifyUserByVerifiedPasscodeIdentity,
verifyUserBySocialIdentity,
} from '../utils/verify-user.js';
// eslint-disable-next-line complexity
export default async function identifierVerification(
ctx: InteractionContext
ctx: InteractionContext,
provider: Provider
): Promise<Identifier[]> {
const { identifier } = ctx.interactionPayload;
const { identifier, event, profile } = ctx.interactionPayload;
if (!identifier) {
if (!identifier || !event) {
return [];
}
if ('username' in identifier) {
const { username, password } = identifier;
const accountId = await verifyUserByPassword(username, password, findUserByUsername);
if (isPasswordIdentifier(identifier)) {
const accountId = await verifyUserByIdentityAndPassword(identifier);
return [{ key: 'accountId', value: accountId }];
}
if ('phone' in identifier && 'password' in identifier) {
const { phone, password } = identifier;
if (isPasscodeIdentifier(identifier)) {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const accountId = await verifyUserByPassword(phone, password, findUserByPhone);
await verifyIdentifierByPasscode({ ...identifier, event }, jti, ctx.log);
return [{ key: 'accountId', value: accountId }];
const verifiedPasscodeIdentifier: Identifier =
'email' in identifier
? { key: 'verifiedEmail', value: identifier.email }
: { key: 'verifiedPhone', value: identifier.phone };
// Return the verified identity directly if it is new profile identities
if (isProfileIdentifier(identifier, profile)) {
return [verifiedPasscodeIdentifier];
}
// Find userAccount and return
const accountId = await verifyUserByVerifiedPasscodeIdentity(identifier);
return [{ key: 'accountId', value: accountId }, verifiedPasscodeIdentifier];
}
if ('email' in identifier && 'password' in identifier) {
const { email, password } = identifier;
// Social Identifier
const socialUserInfo = await verifySocialIdentity(identifier, ctx.log);
const accountId = await verifyUserByPassword(email, password, findUserByEmail);
const { connectorId } = identifier;
return [{ key: 'accountId', value: accountId }];
if (isProfileIdentifier(identifier, profile)) {
return [{ key: 'social', connectorId, value: socialUserInfo }];
}
// Invalid identifier input
throw new RequestError('guard.invalid_input', identifier);
const accountId = await verifyUserBySocialIdentity(connectorId, socialUserInfo);
return [
{ key: 'accountId', value: accountId },
{ key: 'social', connectorId, value: socialUserInfo },
];
}

View file

@ -56,6 +56,7 @@ const errors = {
sms_exists: 'This phone number is associated with an existing account.', // UNTRANSLATED
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,
},
password: {
unsupported_encryption_method: 'Die Verschlüsselungsmethode {{name}} wird nicht unterstützt.',

View file

@ -56,6 +56,7 @@ const errors = {
sms_exists: 'This phone number is associated with an existing account.',
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',
},
password: {
unsupported_encryption_method: 'The encryption method {{name}} is not supported.',

View file

@ -57,6 +57,7 @@ const errors = {
sms_exists: 'This phone number is associated with an existing account.', // UNTRANSLATED
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,
},
password: {
unsupported_encryption_method: "La méthode de cryptage {{name}} n'est pas prise en charge.",

View file

@ -55,6 +55,7 @@ const errors = {
sms_exists: 'This phone number is associated with an existing account.', // UNTRANSLATED
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,
},
password: {
unsupported_encryption_method: '{{name}} 암호화 방법을 지원하지 않아요.',

View file

@ -55,6 +55,7 @@ const errors = {
sms_exists: 'This phone number is associated with an existing account.', // UNTRANSLATED
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,
},
password: {
unsupported_encryption_method: 'O método de enncriptação {{name}} não é suportado.',

View file

@ -56,6 +56,7 @@ const errors = {
sms_exists: 'This phone number is associated with an existing account.', // UNTRANSLATED
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,
},
password: {
unsupported_encryption_method: '{{name}} şifreleme metodu desteklenmiyor.',

View file

@ -55,6 +55,7 @@ const errors = {
sms_exists: '该手机号码已被其它账户绑定',
require_email_or_sms: '请绑定邮箱地址或手机号码',
suspended: '账号已被禁用',
user_not_exist: 'User with {{ identity }} has not been registered yet', // UNTRANSLATED,
},
password: {
unsupported_encryption_method: '不支持的加密方法 {{name}}',