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

refactor(core): draft for decouple passwordless verification flow

This commit is contained in:
Darcy Ye 2022-09-28 10:40:45 +08:00
parent 753e8ebdfd
commit ee2e3f7e1d
No known key found for this signature in database
GPG key ID: B46F4C07EDEFC610
6 changed files with 222 additions and 22 deletions

View file

@ -89,6 +89,7 @@
"@types/rimraf": "^3.0.2",
"@types/supertest": "^2.0.11",
"@types/tar": "^6.1.2",
"camelcase": "^6.2.0",
"copyfiles": "^2.4.1",
"eslint": "^8.21.0",
"http-errors": "^1.6.3",

View file

@ -1 +1,2 @@
export const forgotPasswordVerificationTimeout = 10 * 60; // 10 mins.
export const passwordlessVerificationTimeout = 10 * 60; // 10 mins.

View file

@ -1,5 +1,7 @@
import { emailRegEx, phoneRegEx } from '@logto/core-kit';
import { PasscodeType } from '@logto/schemas';
import { PasscodeType, LogType, User } from '@logto/schemas';
import camelcase from 'camelcase';
import dayjs from 'dayjs';
import { Provider } from 'oidc-provider';
import { object, string } from 'zod';
@ -18,7 +20,9 @@ import {
import assertThat from '@/utils/assert-that';
import { AnonymousRouter } from '../types';
import { getRoutePrefix } from './utils';
import { passwordlessVerificationTimeout } from './consts';
import { flowTypeGuard, viaGuard, passwordlessVerificationGuard, PasscodePayload } from './types';
import { getRoutePrefix, getPasscodeType } from './utils';
export const registerRoute = getRoutePrefix('register', 'passwordless');
export const signInRoute = getRoutePrefix('sign-in', 'passwordless');
@ -28,20 +32,42 @@ export default function passwordlessRoutes<T extends AnonymousRouter>(
provider: Provider
) {
router.post(
`${signInRoute}/sms/send-passcode`,
koaGuard({ body: object({ phone: string().regex(phoneRegEx) }) }),
`/passwordless/:via/send`,
koaGuard({
body: object({
phone: string().regex(phoneRegEx).optional(),
email: string().regex(emailRegEx).optional(),
flow: flowTypeGuard,
}),
params: object({ via: viaGuard }),
}),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { phone } = ctx.guard.body;
const type = 'SignInSmsSendPasscode';
ctx.log(type, { phone });
const {
body: { email, phone, flow },
params: { via },
} = ctx.guard;
assertThat(
await hasUserWithPhone(phone),
new RequestError({ code: 'user.phone_not_exists', status: 422 })
);
// eslint-disable-next-line @silverhand/fp/no-let
let payload: PasscodePayload;
const passcode = await createPasscode(jti, PasscodeType.SignIn, { phone });
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: LogType = `${camelcase(flow, { pascalCase: true })}${camelcase(via, {
pascalCase: true,
})}SendPasscode`;
ctx.log(type, payload);
const passcodeType = getPasscodeType(flow);
const passcode = await createPasscode(jti, passcodeType, payload);
const { dbEntry } = await sendPasscode(passcode);
ctx.log(type, { connectorId: dbEntry.id });
ctx.status = 204;
@ -51,21 +77,110 @@ export default function passwordlessRoutes<T extends AnonymousRouter>(
);
router.post(
`${signInRoute}/sms/verify-passcode`,
koaGuard({ body: object({ phone: string().regex(phoneRegEx), code: string() }) }),
`/passwordless/:via/verify`,
koaGuard({
body: object({
phone: string().regex(phoneRegEx).optional(),
email: string().regex(emailRegEx).optional(),
code: string(),
flow: flowTypeGuard,
}),
params: object({ via: viaGuard }),
}),
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 });
const {
body: { email, phone, code, flow },
params: { via },
} = 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: LogType = `${camelcase(flow, { pascalCase: true })}${camelcase(via, {
pascalCase: true,
})}`;
ctx.log(type, payload);
const passcodeType = getPasscodeType(flow);
await verifyPasscode(jti, passcodeType, code, payload);
await provider.interactionResult(ctx.req, ctx.res, {
passwordlessVerification: {
flow,
expiresAt: dayjs().add(passwordlessVerificationTimeout, 'second').toISOString(),
...payload,
},
});
ctx.status = 204;
return next();
}
);
router.post(
`${signInRoute}/:via`,
koaGuard({ params: object({ via: viaGuard }) }),
async (ctx, next) => {
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
const { via } = ctx.guard.params;
const passwordlessVerificationResult = passwordlessVerificationGuard.safeParse(result);
assertThat(
await hasUserWithPhone(phone),
new RequestError({ code: 'user.phone_not_exists', status: 422 })
passwordlessVerificationResult.success,
new RequestError({
code: 'session.passwordless_verification_session_not_found',
status: 404,
})
);
await verifyPasscode(jti, PasscodeType.SignIn, code, { phone });
const { id } = await findUserByPhone(phone);
const {
passwordlessVerification: { email, phone, flow, expiresAt },
} = passwordlessVerificationResult.data;
const type = `SignIn${camelcase(via, { pascalCase: true })}`;
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() });
@ -75,6 +190,54 @@ 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 });
// assertThat(
// await hasUserWithPhone(phone),
// new RequestError({ code: 'user.phone_not_exists', status: 422 })
// );
// const passcode = await createPasscode(jti, PasscodeType.SignIn, { phone });
// const { dbEntry } = await sendPasscode(passcode);
// ctx.log(type, { connectorId: dbEntry.id });
// ctx.status = 204;
// return next();
// }
// );
// 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(
// await hasUserWithPhone(phone),
// new RequestError({ code: 'user.phone_not_exists', status: 422 })
// );
// await verifyPasscode(jti, PasscodeType.SignIn, code, { phone });
// const { id } = await findUserByPhone(phone);
// ctx.log(type, { userId: id });
// await updateUserById(id, { lastSignInAt: Date.now() });
// await assignInteractionResults(ctx, provider, { login: { accountId: id } }, true);
// return next();
// }
// );
router.post(
`${signInRoute}/email/send-passcode`,
koaGuard({ body: object({ email: string().regex(emailRegEx) }) }),

View file

@ -0,0 +1,22 @@
import { z } from 'zod';
export const flowTypeGuard = z.enum(['sign-in', 'register', 'forgot-password']);
export type FlowType = z.infer<typeof flowTypeGuard>;
export const viaGuard = z.enum(['email', 'sms']);
export type Via = z.infer<typeof viaGuard>;
export type PasscodePayload = { email: string } | { phone: string };
export const passwordlessVerificationGuard = z.object({
passwordlessVerification: z.object({
email: z.string().optional(),
phone: z.string().optional(),
flow: flowTypeGuard,
expiresAt: z.string(),
}),
});
export type PasswordlessVerification = z.infer<typeof passwordlessVerificationGuard>;

View file

@ -1,7 +1,10 @@
import { PasscodeType } from '@logto/schemas';
import { Truthy } from '@silverhand/essentials';
import { FlowType } from './types';
export const getRoutePrefix = (
type: 'sign-in' | 'register' | 'forgot-password',
type: FlowType,
method?: 'passwordless' | 'username-password' | 'social'
) => {
return ['session', type, method]
@ -9,3 +12,11 @@ export const getRoutePrefix = (
.map((value) => '/' + value)
.join('');
};
export const getPasscodeType = (type: FlowType) => {
return type === 'sign-in'
? PasscodeType.SignIn
: type === 'register'
? PasscodeType.Register
: PasscodeType.ForgotPassword;
};

2
pnpm-lock.yaml generated
View file

@ -177,6 +177,7 @@ importers:
'@types/rimraf': ^3.0.2
'@types/supertest': ^2.0.11
'@types/tar': ^6.1.2
camelcase: ^6.2.0
chalk: ^4
copyfiles: ^2.4.1
dayjs: ^1.10.5
@ -294,6 +295,7 @@ importers:
'@types/rimraf': 3.0.2
'@types/supertest': 2.0.11
'@types/tar': 6.1.2
camelcase: 6.3.0
copyfiles: 2.4.1
eslint: 8.21.0
http-errors: 1.8.1