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:
parent
0111dd52eb
commit
23d2a3fe80
25 changed files with 852 additions and 109 deletions
|
@ -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,
|
||||
|
|
145
packages/core/src/routes/interaction/index.test.ts
Normal file
145
packages/core/src/routes/interaction/index.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
export { default as verifyUserByPassword } from './verify-user-by-password.js';
|
34
packages/core/src/routes/interaction/utils/index.ts.ts
Normal file
34
packages/core/src/routes/interaction/utils/index.ts.ts
Normal 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;
|
||||
};
|
|
@ -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();
|
||||
}
|
||||
);
|
||||
});
|
|
@ -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);
|
||||
};
|
|
@ -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' });
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
41
packages/core/src/routes/interaction/utils/verify-user.ts
Normal file
41
packages/core/src/routes/interaction/utils/verify-user.ts
Normal 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;
|
||||
};
|
118
packages/core/src/routes/interaction/utils/verity-user.test.ts
Normal file
118
packages/core/src/routes/interaction/utils/verity-user.test.ts
Normal 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);
|
||||
});
|
||||
});
|
|
@ -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' }]);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 },
|
||||
];
|
||||
}
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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}} 암호화 방법을 지원하지 않아요.',
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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}}',
|
||||
|
|
Loading…
Add table
Reference in a new issue