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

refactor(core): decouple passwordless verification flow w/o fixing tests

This commit is contained in:
Darcy Ye 2022-09-28 23:18:31 +08:00
parent e1ec8804b2
commit ac562c0c94
No known key found for this signature in database
GPG key ID: B46F4C07EDEFC610
4 changed files with 146 additions and 321 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",
"test": "jest --testPathIgnorePatterns=/core/connectors/",
"test:coverage": "jest --coverage --silent",
"test:report": "codecov -F core"
},

View file

@ -1,70 +0,0 @@
import dayjs from 'dayjs';
import { Provider } from 'oidc-provider';
import RequestError from '@/errors/RequestError';
import { assignInteractionResults } from '@/lib/session';
import { generateUserId, insertUser } from '@/lib/user';
import koaLog from '@/middleware/koa-log';
import { hasUserWithPhone, hasUserWithEmail } from '@/queries/user';
import { passwordlessVerificationGuard, Via } from '@/routes/session/types';
import { getPasswordlessRelatedLogType } from '@/routes/session/utils';
import assertThat from '@/utils/assert-that';
type MiddlewareReturnType = ReturnType<typeof koaLog>;
export default function koaPasswordlessRegisterAction<StateT, ContextT, ResponseBodyT>(
provider: Provider,
via: Via
): MiddlewareReturnType {
return async (ctx, next) => {
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
const passwordlessVerificationResult = passwordlessVerificationGuard.safeParse(result);
assertThat(
passwordlessVerificationResult.success,
new RequestError({
code: 'session.passwordless_verification_session_not_found',
status: 404,
})
);
const {
passwordlessVerification: { email, phone, flow, expiresAt },
} = passwordlessVerificationResult.data;
const type = getPasswordlessRelatedLogType('register', via);
ctx.log(type, { email, 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.passwordless_verification_expired', status: 401 })
);
if (via === 'sms') {
assertThat(
phone && !(await hasUserWithPhone(phone)),
new RequestError({ code: 'user.phone_exists_register', status: 422 })
);
} else {
assertThat(
email && !(await hasUserWithEmail(email)),
new RequestError({ code: 'user.email_exists_register', status: 422 })
);
}
const id = await generateUserId();
ctx.log(type, { userId: id });
await (via === 'sms'
? insertUser({ id, primaryPhone: phone, lastSignInAt: Date.now() })
: insertUser({ id, primaryEmail: email, lastSignInAt: Date.now() }));
await assignInteractionResults(ctx, provider, { login: { accountId: id } }, true);
return next();
};
}

View file

@ -1,81 +0,0 @@
import { User } from '@logto/schemas';
import dayjs from 'dayjs';
import { Provider } from 'oidc-provider';
import RequestError from '@/errors/RequestError';
import { assignInteractionResults } from '@/lib/session';
import koaLog from '@/middleware/koa-log';
import {
updateUserById,
hasUserWithPhone,
findUserByPhone,
hasUserWithEmail,
findUserByEmail,
} from '@/queries/user';
import { passwordlessVerificationGuard, Via } from '@/routes/session/types';
import { getPasswordlessRelatedLogType } from '@/routes/session/utils';
import assertThat from '@/utils/assert-that';
type MiddlewareReturnType = ReturnType<typeof koaLog>;
export default function koaPasswordlessSignInAction<StateT, ContextT, ResponseBodyT>(
provider: Provider,
via: Via
): MiddlewareReturnType {
return async (ctx, next) => {
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
const passwordlessVerificationResult = passwordlessVerificationGuard.safeParse(result);
assertThat(
passwordlessVerificationResult.success,
new RequestError({
code: 'session.passwordless_verification_session_not_found',
status: 404,
})
);
const {
passwordlessVerification: { email, phone, flow, expiresAt },
} = passwordlessVerificationResult.data;
const type = getPasswordlessRelatedLogType('sign-in', via);
ctx.log(type, { email, 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.passwordless_verification_expired', status: 401 })
);
// eslint-disable-next-line @silverhand/fp/no-let
let user: User;
if (via === 'sms') {
assertThat(
phone && (await hasUserWithPhone(phone)),
new RequestError({ code: 'user.phone_not_exists', status: 422 })
);
// eslint-disable-next-line @silverhand/fp/no-mutation
user = await findUserByPhone(phone);
} else {
assertThat(
email && (await hasUserWithEmail(email)),
new RequestError({ code: 'user.email_not_exists', status: 422 })
);
// eslint-disable-next-line @silverhand/fp/no-mutation
user = await findUserByEmail(email);
}
const { id } = user;
ctx.log(type, { userId: id });
await updateUserById(id, { lastSignInAt: Date.now() });
await assignInteractionResults(ctx, provider, { login: { accountId: id } }, true);
return next();
};
}

View file

@ -1,5 +1,4 @@
import { emailRegEx, phoneRegEx } from '@logto/core-kit';
import { PasscodeType } from '@logto/schemas';
import dayjs from 'dayjs';
import { Provider } from 'oidc-provider';
import { object, string } from 'zod';
@ -11,19 +10,22 @@ import { generateUserId, insertUser } from '@/lib/user';
import koaGuard from '@/middleware/koa-guard';
import {
updateUserById,
hasUserWithEmail,
hasUserWithPhone,
findUserByEmail,
findUserByPhone,
hasUserWithEmail,
hasUserWithPhone,
} from '@/queries/user';
import {
passwordlessVerificationGuard,
flowTypeGuard,
viaGuard,
PasscodePayload,
} from '@/routes/session/types';
import assertThat from '@/utils/assert-that';
import { AnonymousRouter } from '../types';
import { passwordlessVerificationTimeout } from './consts';
import { flowTypeGuard, viaGuard, PasscodePayload } from './types';
import { getRoutePrefix, getPasscodeType, getPasswordlessRelatedLogType } from './utils';
// Import koaPasswordlessSignInAction from './middleware/koa-sign-in-action';
// import koaPasswordlessRegisterAction from './middleware/koa-register-action';
export const registerRoute = getRoutePrefix('register', 'passwordless');
export const signInRoute = getRoutePrefix('sign-in', 'passwordless');
@ -32,13 +34,8 @@ export default function passwordlessRoutes<T extends AnonymousRouter>(
router: T,
provider: Provider
) {
// Router.use(`${signInRoute}/sms`, koaPasswordlessSignInAction(provider, 'sms'));
// router.use(`${signInRoute}/email`, koaPasswordlessSignInAction(provider, 'email'));
// router.use(`${registerRoute}/sms`, koaPasswordlessRegisterAction(provider, 'sms'));
// router.use(`${registerRoute}/email`, koaPasswordlessRegisterAction(provider, 'email'));
router.post(
`/passwordless/:via/send`,
'/passwordless/:via/send',
koaGuard({
body: object({
phone: string().regex(phoneRegEx).optional(),
@ -81,7 +78,7 @@ export default function passwordlessRoutes<T extends AnonymousRouter>(
);
router.post(
`/passwordless/:via/verify`,
'/passwordless/:via/verify',
koaGuard({
body: object({
phone: string().regex(phoneRegEx).optional(),
@ -130,195 +127,174 @@ export default function passwordlessRoutes<T extends AnonymousRouter>(
}
);
router.post(
`${signInRoute}/sms/send-passcode`,
koaGuard({ body: object({ phone: string().regex(phoneRegEx) }) }),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { phone } = ctx.guard.body;
const type = 'SignInSmsSendPasscode';
ctx.log(type, { phone });
router.post(`${signInRoute}/sms`, async (ctx, next) => {
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
assertThat(
await hasUserWithPhone(phone),
new RequestError({ code: 'user.phone_not_exists', status: 422 })
);
const passwordlessVerificationResult = passwordlessVerificationGuard.safeParse(result);
assertThat(
passwordlessVerificationResult.success,
new RequestError({
code: 'session.passwordless_verification_session_not_found',
status: 404,
})
);
const passcode = await createPasscode(jti, PasscodeType.SignIn, { phone });
const { dbEntry } = await sendPasscode(passcode);
ctx.log(type, { connectorId: dbEntry.id });
ctx.status = 204;
const {
passwordlessVerification: { phone, flow, expiresAt },
} = passwordlessVerificationResult.data;
return next();
}
);
const type = getPasswordlessRelatedLogType('sign-in', 'sms');
ctx.log(type, { phone, flow, expiresAt });
router.post(
`${signInRoute}/sms/verify-passcode`,
koaGuard({ body: object({ phone: string().regex(phoneRegEx), code: string() }) }),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { phone, code } = ctx.guard.body;
const type = 'SignInSms';
ctx.log(type, { phone, code });
assertThat(
flow === 'sign-in',
new RequestError({ code: 'session.passwordless_not_verified', status: 401 })
);
assertThat(
await hasUserWithPhone(phone),
new RequestError({ code: 'user.phone_not_exists', status: 422 })
);
assertThat(
dayjs(expiresAt).isValid() && dayjs(expiresAt).isAfter(dayjs()),
new RequestError({ code: 'session.passwordless_verification_expired', status: 401 })
);
await verifyPasscode(jti, PasscodeType.SignIn, code, { phone });
const { id } = await findUserByPhone(phone);
ctx.log(type, { userId: id });
assertThat(
phone && (await hasUserWithPhone(phone)),
new RequestError({ code: 'user.phone_not_exists', status: 422 })
);
const { id } = await findUserByPhone(phone);
ctx.log(type, { userId: id });
await updateUserById(id, { lastSignInAt: Date.now() });
await assignInteractionResults(ctx, provider, { login: { accountId: id } }, true);
await updateUserById(id, { lastSignInAt: Date.now() });
await assignInteractionResults(ctx, provider, { login: { accountId: id } }, true);
return next();
}
);
return next();
});
router.post(
`${signInRoute}/email/send-passcode`,
koaGuard({ body: object({ email: string().regex(emailRegEx) }) }),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { email } = ctx.guard.body;
const type = 'SignInEmailSendPasscode';
ctx.log(type, { email });
router.post(`${signInRoute}/email`, async (ctx, next) => {
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
assertThat(
await hasUserWithEmail(email),
new RequestError({ code: 'user.email_not_exists', status: 422 })
);
const passwordlessVerificationResult = passwordlessVerificationGuard.safeParse(result);
assertThat(
passwordlessVerificationResult.success,
new RequestError({
code: 'session.passwordless_verification_session_not_found',
status: 404,
})
);
const passcode = await createPasscode(jti, PasscodeType.SignIn, { email });
const { dbEntry } = await sendPasscode(passcode);
ctx.log(type, { connectorId: dbEntry.id });
ctx.status = 204;
const {
passwordlessVerification: { email, flow, expiresAt },
} = passwordlessVerificationResult.data;
return next();
}
);
const type = getPasswordlessRelatedLogType('sign-in', 'email');
ctx.log(type, { email, flow, expiresAt });
router.post(
`${signInRoute}/email/verify-passcode`,
koaGuard({ body: object({ email: string().regex(emailRegEx), code: string() }) }),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { email, code } = ctx.guard.body;
const type = 'SignInEmail';
ctx.log(type, { email, code });
assertThat(
flow === 'sign-in',
new RequestError({ code: 'session.passwordless_not_verified', status: 401 })
);
assertThat(
await hasUserWithEmail(email),
new RequestError({ code: 'user.email_not_exists', status: 422 })
);
assertThat(
dayjs(expiresAt).isValid() && dayjs(expiresAt).isAfter(dayjs()),
new RequestError({ code: 'session.passwordless_verification_expired', status: 401 })
);
await verifyPasscode(jti, PasscodeType.SignIn, code, { email });
const { id } = await findUserByEmail(email);
ctx.log(type, { userId: id });
assertThat(
email && (await hasUserWithEmail(email)),
new RequestError({ code: 'user.email_not_exists', status: 422 })
);
const { id } = await findUserByEmail(email);
await updateUserById(id, { lastSignInAt: Date.now() });
await assignInteractionResults(ctx, provider, { login: { accountId: id } }, true);
ctx.log(type, { userId: id });
return next();
}
);
await updateUserById(id, { lastSignInAt: Date.now() });
await assignInteractionResults(ctx, provider, { login: { accountId: id } }, true);
router.post(
`${registerRoute}/sms/send-passcode`,
koaGuard({ body: object({ phone: string().regex(phoneRegEx) }) }),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { phone } = ctx.guard.body;
const type = 'RegisterSmsSendPasscode';
ctx.log(type, { phone });
return next();
});
assertThat(
!(await hasUserWithPhone(phone)),
new RequestError({ code: 'user.phone_exists_register', status: 422 })
);
router.post(`${registerRoute}/sms`, async (ctx, next) => {
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
const passcode = await createPasscode(jti, PasscodeType.Register, { phone });
const { dbEntry } = await sendPasscode(passcode);
ctx.log(type, { connectorId: dbEntry.id });
ctx.status = 204;
const passwordlessVerificationResult = passwordlessVerificationGuard.safeParse(result);
assertThat(
passwordlessVerificationResult.success,
new RequestError({
code: 'session.passwordless_verification_session_not_found',
status: 404,
})
);
return next();
}
);
const {
passwordlessVerification: { email, phone, flow, expiresAt },
} = passwordlessVerificationResult.data;
router.post(
`${registerRoute}/sms/verify-passcode`,
koaGuard({ body: object({ phone: string().regex(phoneRegEx), code: string() }) }),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { phone, code } = ctx.guard.body;
const type = 'RegisterSms';
ctx.log(type, { phone, code });
const type = getPasswordlessRelatedLogType('register', 'sms');
ctx.log(type, { phone, flow, expiresAt });
assertThat(
!(await hasUserWithPhone(phone)),
new RequestError({ code: 'user.phone_exists_register', status: 422 })
);
assertThat(
flow === 'register',
new RequestError({ code: 'session.passwordless_not_verified', status: 401 })
);
await verifyPasscode(jti, PasscodeType.Register, code, { phone });
const id = await generateUserId();
ctx.log(type, { userId: id });
assertThat(
dayjs(expiresAt).isValid() && dayjs(expiresAt).isAfter(dayjs()),
new RequestError({ code: 'session.passwordless_verification_expired', status: 401 })
);
await insertUser({ id, primaryPhone: phone, lastSignInAt: Date.now() });
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
assertThat(
phone && !(await hasUserWithPhone(phone)),
new RequestError({ code: 'user.phone_exists_register', status: 422 })
);
return next();
}
);
const id = await generateUserId();
ctx.log(type, { userId: id });
router.post(
`${registerRoute}/email/send-passcode`,
koaGuard({ body: object({ email: string().regex(emailRegEx) }) }),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { email } = ctx.guard.body;
const type = 'RegisterEmailSendPasscode';
ctx.log(type, { email });
await insertUser({ id, primaryPhone: phone, lastSignInAt: Date.now() });
await assignInteractionResults(ctx, provider, { login: { accountId: id } }, true);
assertThat(
!(await hasUserWithEmail(email)),
new RequestError({ code: 'user.email_exists_register', status: 422 })
);
return next();
});
const passcode = await createPasscode(jti, PasscodeType.Register, { email });
const { dbEntry } = await sendPasscode(passcode);
ctx.log(type, { connectorId: dbEntry.id });
ctx.status = 204;
router.post(`${registerRoute}/email`, async (ctx, next) => {
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
return next();
}
);
const passwordlessVerificationResult = passwordlessVerificationGuard.safeParse(result);
assertThat(
passwordlessVerificationResult.success,
new RequestError({
code: 'session.passwordless_verification_session_not_found',
status: 404,
})
);
router.post(
`${registerRoute}/email/verify-passcode`,
koaGuard({ body: object({ email: string().regex(emailRegEx), code: string() }) }),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { email, code } = ctx.guard.body;
const type = 'RegisterEmail';
ctx.log(type, { email, code });
const {
passwordlessVerification: { email, flow, expiresAt },
} = passwordlessVerificationResult.data;
assertThat(
!(await hasUserWithEmail(email)),
new RequestError({ code: 'user.email_exists_register', status: 422 })
);
const type = getPasswordlessRelatedLogType('register', 'email');
ctx.log(type, { email, flow, expiresAt });
await verifyPasscode(jti, PasscodeType.Register, code, { email });
const id = await generateUserId();
ctx.log(type, { userId: id });
assertThat(
flow === 'register',
new RequestError({ code: 'session.passwordless_not_verified', status: 401 })
);
await insertUser({ id, primaryEmail: email, lastSignInAt: Date.now() });
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
assertThat(
dayjs(expiresAt).isValid() && dayjs(expiresAt).isAfter(dayjs()),
new RequestError({ code: 'session.passwordless_verification_expired', status: 401 })
);
return next();
}
);
assertThat(
email && !(await hasUserWithEmail(email)),
new RequestError({ code: 'user.email_exists_register', status: 422 })
);
const id = await generateUserId();
ctx.log(type, { userId: id });
await insertUser({ id, primaryEmail: email, lastSignInAt: Date.now() });
await assignInteractionResults(ctx, provider, { login: { accountId: id } }, true);
return next();
});
}