0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-10 22:22:45 -05:00

refactor(core): fix

This commit is contained in:
Darcy Ye 2022-09-29 16:06:03 +08:00
parent 7156836d19
commit aaa592fccb
No known key found for this signature in database
GPG key ID: B46F4C07EDEFC610
5 changed files with 133 additions and 150 deletions

View file

@ -17,7 +17,7 @@
"add-connector": "node build/cli/add-connector.js",
"add-official-connectors": "node build/cli/add-official-connectors.js",
"alteration": "node build/cli/alteration.js",
"test": "jest --testPathIgnorePatterns=/core/connectors/",
"test": "jest",
"test:coverage": "jest --coverage --silent",
"test:report": "codecov -F core"
},

View file

@ -8,7 +8,7 @@ import RequestError from '@/errors/RequestError';
import { createRequester } from '@/utils/test-utils';
import { verificationTimeout } from './consts';
import passwordlessRoutes, { registerRoute, signInRoute } from './passwordless';
import passwordlessRoutes, { signInRoute, registerRoute } from './passwordless';
const insertUser = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));
const findUserById = jest.fn(async (): Promise<User> => mockUser);
@ -51,10 +51,6 @@ jest.mock('oidc-provider', () => ({
})),
}));
afterEach(() => {
interactionResult.mockClear();
});
describe('session -> passwordlessRoutes', () => {
const sessionRequest = createRequester({
anonymousRoutes: passwordlessRoutes,
@ -105,12 +101,6 @@ describe('session -> passwordlessRoutes', () => {
.send({ flow: 'register' });
expect(response.statusCode).toEqual(400);
});
it('throw when email given in input params', async () => {
const response = await sessionRequest
.post('/session/passwordless/sms/send')
.send({ email: 'a@a.com', phone: '13000000000', flow: 'register' });
expect(response.statusCode).toEqual(400);
});
});
describe('POST /session/passwordless/email/send', () => {
@ -149,12 +139,6 @@ describe('session -> passwordlessRoutes', () => {
.send({ flow: 'register' });
expect(response.statusCode).toEqual(400);
});
it('throw when phone given in input params', async () => {
const response = await sessionRequest
.post('/session/passwordless/email/send')
.send({ email: 'a@a.com', phone: '13000000000', flow: 'register' });
expect(response.statusCode).toEqual(400);
});
});
describe('POST /session/passwordless/sms/verify', () => {
@ -212,18 +196,6 @@ describe('session -> passwordlessRoutes', () => {
.send({ phone: '13000000000', code: '1231', flow: 'sign-in' });
expect(response.statusCode).toEqual(400);
});
it('throw when phone not given in input params', async () => {
const response = await sessionRequest
.post('/session/passwordless/sms/verify')
.send({ code: '1234', flow: 'register' });
expect(response.statusCode).toEqual(400);
});
it('throw when email given in input params', async () => {
const response = await sessionRequest
.post('/session/passwordless/sms/verify')
.send({ email: 'a@a.com', phone: '13000000000', code: '1234', flow: 'register' });
expect(response.statusCode).toEqual(400);
});
});
describe('POST /session/passwordless/email/verify', () => {
@ -281,23 +253,11 @@ describe('session -> passwordlessRoutes', () => {
.send({ email: 'a@a.com', code: '1231', flow: 'sign-in' });
expect(response.statusCode).toEqual(400);
});
it('throw when phone not given in input params', async () => {
const response = await sessionRequest
.post('/session/passwordless/email/verify')
.send({ code: '1234', flow: 'register' });
expect(response.statusCode).toEqual(400);
});
it('throw when email given in input params', async () => {
const response = await sessionRequest
.post('/session/passwordless/email/verify')
.send({ email: 'a@a.com', phone: '13000000000', code: '1234', flow: 'register' });
expect(response.statusCode).toEqual(400);
});
});
describe('POST /session/sign-in/passwordless/sms', () => {
afterEach(() => {
jest.clearAllMocks();
beforeEach(() => {
jest.resetAllMocks();
});
it('should call interactionResult', async () => {
interactionDetails.mockResolvedValueOnce({
@ -399,8 +359,8 @@ describe('session -> passwordlessRoutes', () => {
});
describe('POST /session/sign-in/passwordless/email', () => {
afterEach(() => {
jest.clearAllMocks();
beforeEach(() => {
jest.resetAllMocks();
});
it('should call interactionResult', async () => {
interactionDetails.mockResolvedValueOnce({

View file

@ -15,17 +15,17 @@ import {
hasUserWithEmail,
hasUserWithPhone,
} from '@/queries/user';
import {
verificationGuard,
flowTypeGuard,
viaGuard,
PasscodePayload,
} from '@/routes/session/types';
import { verificationGuard, flowTypeGuard } from '@/routes/session/types';
import assertThat from '@/utils/assert-that';
import { AnonymousRouter } from '../types';
import { verificationTimeout } from './consts';
import { getRoutePrefix, getPasscodeType, getPasswordlessRelatedLogType } from './utils';
import {
getRoutePrefix,
getPasscodeType,
getPasswordlessRelatedLogType,
verificationSessionCheckByFlow,
} from './utils';
export const registerRoute = getRoutePrefix('register', 'passwordless');
export const signInRoute = getRoutePrefix('sign-in', 'passwordless');
@ -35,40 +35,24 @@ export default function passwordlessRoutes<T extends AnonymousRouter>(
provider: Provider
) {
router.post(
'/session/passwordless/:via/send',
'/session/passwordless/sms/send',
koaGuard({
body: object({
phone: string().regex(phoneRegEx).optional(),
email: string().regex(emailRegEx).optional(),
phone: string().regex(phoneRegEx),
flow: flowTypeGuard,
}),
params: object({ via: viaGuard }),
}),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const {
body: { email, phone, flow },
params: { via },
body: { phone, flow },
} = ctx.guard;
// eslint-disable-next-line @silverhand/fp/no-let
let payload: PasscodePayload;
if (via === 'email') {
assertThat(email && !phone, new RequestError({ code: 'guard.invalid_input' }));
// eslint-disable-next-line @silverhand/fp/no-mutation
payload = { email };
} else {
assertThat(!email && phone, new RequestError({ code: 'guard.invalid_input' }));
// eslint-disable-next-line @silverhand/fp/no-mutation
payload = { phone };
}
const type = getPasswordlessRelatedLogType(flow, via, 'send');
ctx.log(type, payload);
const type = getPasswordlessRelatedLogType(flow, 'sms', 'send');
ctx.log(type, { phone });
const passcodeType = getPasscodeType(flow);
const passcode = await createPasscode(jti, passcodeType, payload);
const passcode = await createPasscode(jti, passcodeType, { phone });
const { dbEntry } = await sendPasscode(passcode);
ctx.log(type, { connectorId: dbEntry.id });
ctx.status = 204;
@ -78,47 +62,92 @@ export default function passwordlessRoutes<T extends AnonymousRouter>(
);
router.post(
'/session/passwordless/:via/verify',
'/session/passwordless/email/send',
koaGuard({
body: object({
phone: string().regex(phoneRegEx).optional(),
email: string().regex(emailRegEx).optional(),
code: string(),
email: string().regex(emailRegEx),
flow: flowTypeGuard,
}),
params: object({ via: viaGuard }),
}),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const {
body: { email, phone, code, flow },
params: { via },
body: { email, flow },
} = ctx.guard;
// eslint-disable-next-line @silverhand/fp/no-let
let payload: PasscodePayload;
if (via === 'email') {
assertThat(email && !phone, new RequestError({ code: 'guard.invalid_input' }));
// eslint-disable-next-line @silverhand/fp/no-mutation
payload = { email };
} else {
assertThat(!email && phone, new RequestError({ code: 'guard.invalid_input' }));
// eslint-disable-next-line @silverhand/fp/no-mutation
payload = { phone };
}
const type = getPasswordlessRelatedLogType(flow, via, 'verify');
ctx.log(type, payload);
const type = getPasswordlessRelatedLogType(flow, 'email', 'send');
ctx.log(type, { email });
const passcodeType = getPasscodeType(flow);
await verifyPasscode(jti, passcodeType, code, payload);
const passcode = await createPasscode(jti, passcodeType, { email });
const { dbEntry } = await sendPasscode(passcode);
ctx.log(type, { connectorId: dbEntry.id });
ctx.status = 204;
return next();
}
);
router.post(
'/session/passwordless/sms/verify',
koaGuard({
body: object({
phone: string().regex(phoneRegEx),
code: string(),
flow: flowTypeGuard,
}),
}),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const {
body: { phone, code, flow },
} = ctx.guard;
const type = getPasswordlessRelatedLogType(flow, 'sms', 'verify');
ctx.log(type, { phone });
const passcodeType = getPasscodeType(flow);
await verifyPasscode(jti, passcodeType, code, { phone });
await provider.interactionResult(ctx.req, ctx.res, {
verification: {
flow,
expiresAt: dayjs().add(verificationTimeout, 'second').toISOString(),
...payload,
phone,
},
});
ctx.status = 204;
return next();
}
);
router.post(
'/session/passwordless/email/verify',
koaGuard({
body: object({
email: string().regex(emailRegEx),
code: string(),
flow: flowTypeGuard,
}),
}),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const {
body: { email, code, flow },
} = ctx.guard;
const type = getPasswordlessRelatedLogType(flow, 'email', 'verify');
ctx.log(type, { email });
const passcodeType = getPasscodeType(flow);
await verifyPasscode(jti, passcodeType, code, { email });
await provider.interactionResult(ctx.req, ctx.res, {
verification: {
flow,
expiresAt: dayjs().add(verificationTimeout, 'second').toISOString(),
email,
},
});
ctx.status = 204;
@ -130,7 +159,6 @@ export default function passwordlessRoutes<T extends AnonymousRouter>(
router.post(`${signInRoute}/sms`, async (ctx, next) => {
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
console.log(result);
const verificationResult = verificationGuard.safeParse(result);
assertThat(
verificationResult.success,
@ -147,20 +175,13 @@ export default function passwordlessRoutes<T extends AnonymousRouter>(
const type = getPasswordlessRelatedLogType('sign-in', 'sms');
ctx.log(type, { phone, flow, expiresAt });
assertThat(
flow === 'sign-in',
new RequestError({ code: 'session.passwordless_not_verified', status: 401 })
);
assertThat(
dayjs(expiresAt).isValid() && dayjs(expiresAt).isAfter(dayjs()),
new RequestError({ code: 'session.verification_expired', status: 401 })
);
verificationSessionCheckByFlow('sign-in', { flow, expiresAt });
assertThat(
phone && (await hasUserWithPhone(phone)),
new RequestError({ code: 'user.phone_not_exists', status: 422 })
);
const { id } = await findUserByPhone(phone);
ctx.log(type, { userId: id });
@ -189,15 +210,7 @@ export default function passwordlessRoutes<T extends AnonymousRouter>(
const type = getPasswordlessRelatedLogType('sign-in', 'email');
ctx.log(type, { email, flow, expiresAt });
assertThat(
flow === 'sign-in',
new RequestError({ code: 'session.passwordless_not_verified', status: 401 })
);
assertThat(
dayjs(expiresAt).isValid() && dayjs(expiresAt).isAfter(dayjs()),
new RequestError({ code: 'session.verification_expired', status: 401 })
);
verificationSessionCheckByFlow('sign-in', { flow, expiresAt });
assertThat(
email && (await hasUserWithEmail(email)),
@ -232,15 +245,7 @@ export default function passwordlessRoutes<T extends AnonymousRouter>(
const type = getPasswordlessRelatedLogType('register', 'sms');
ctx.log(type, { phone, flow, expiresAt });
assertThat(
flow === 'register',
new RequestError({ code: 'session.passwordless_not_verified', status: 401 })
);
assertThat(
dayjs(expiresAt).isValid() && dayjs(expiresAt).isAfter(dayjs()),
new RequestError({ code: 'session.verification_expired', status: 401 })
);
verificationSessionCheckByFlow('register', { flow, expiresAt });
assertThat(
phone && !(await hasUserWithPhone(phone)),
@ -275,15 +280,7 @@ export default function passwordlessRoutes<T extends AnonymousRouter>(
const type = getPasswordlessRelatedLogType('register', 'email');
ctx.log(type, { email, flow, expiresAt });
assertThat(
flow === 'register',
new RequestError({ code: 'session.passwordless_not_verified', status: 401 })
);
assertThat(
dayjs(expiresAt).isValid() && dayjs(expiresAt).isAfter(dayjs()),
new RequestError({ code: 'session.verification_expired', status: 401 })
);
verificationSessionCheckByFlow('register', { flow, expiresAt });
assertThat(
email && !(await hasUserWithEmail(email)),

View file

@ -14,13 +14,17 @@ export type Operation = z.infer<typeof operationGuard>;
export type PasscodePayload = { email: string } | { phone: string };
export const verificationStorageGuard = z.object({
email: z.string().optional(),
phone: z.string().optional(),
flow: flowTypeGuard,
expiresAt: z.string(),
});
export type VerificationStorage = z.infer<typeof verificationStorageGuard>;
export const verificationGuard = z.object({
verification: z.object({
email: z.string().optional(),
phone: z.string().optional(),
flow: flowTypeGuard,
expiresAt: z.string(),
}),
verification: verificationStorageGuard,
});
export type Verification = z.infer<typeof verificationGuard>;

View file

@ -1,10 +1,12 @@
import { logTypeGuard, LogType, PasscodeType } from '@logto/schemas';
import { Truthy } from '@silverhand/essentials';
import camelcase from 'camelcase';
import dayjs from 'dayjs';
import RequestError from '@/errors/RequestError';
import assertThat from '@/utils/assert-that';
import { FlowType, Operation, Via } from './types';
import { FlowType, Operation, VerificationStorage, Via } from './types';
export const getRoutePrefix = (
type: FlowType,
@ -17,11 +19,15 @@ export const getRoutePrefix = (
};
export const getPasscodeType = (type: FlowType) => {
return type === 'sign-in'
? PasscodeType.SignIn
: type === 'register'
? PasscodeType.Register
: PasscodeType.ForgotPassword;
if (type === 'sign-in') {
return PasscodeType.SignIn;
}
if (type === 'register') {
return PasscodeType.Register;
}
return PasscodeType.ForgotPassword;
};
export const getPasswordlessRelatedLogType = (
@ -29,8 +35,7 @@ export const getPasswordlessRelatedLogType = (
via: Via,
operation?: Operation
): LogType => {
const prefix =
flow === 'register' ? 'Register' : flow === 'sign-in' ? 'SignIn' : 'ForgotPassword';
const prefix = camelcase(flow, { pascalCase: true });
const body = via === 'email' ? 'Email' : 'Sms';
const suffix = operation === 'send' ? 'SendPasscode' : '';
@ -39,3 +44,20 @@ export const getPasswordlessRelatedLogType = (
return result.data;
};
export const verificationSessionCheckByFlow = (
currentFlow: FlowType,
payload: Pick<VerificationStorage, 'flow' | 'expiresAt'>
) => {
const { flow, expiresAt } = payload;
assertThat(
flow === currentFlow,
new RequestError({ code: 'session.passwordless_not_verified', status: 401 })
);
assertThat(
dayjs(expiresAt).isValid() && dayjs(expiresAt).isAfter(dayjs()),
new RequestError({ code: 'session.verification_expired', status: 401 })
);
};