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:
parent
7156836d19
commit
aaa592fccb
5 changed files with 133 additions and 150 deletions
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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)),
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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 })
|
||||
);
|
||||
};
|
||||
|
|
Loading…
Add table
Reference in a new issue