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:
parent
753e8ebdfd
commit
ee2e3f7e1d
6 changed files with 222 additions and 22 deletions
|
@ -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",
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
export const forgotPasswordVerificationTimeout = 10 * 60; // 10 mins.
|
||||
export const passwordlessVerificationTimeout = 10 * 60; // 10 mins.
|
||||
|
|
|
@ -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) }) }),
|
||||
|
|
22
packages/core/src/routes/session/types.ts
Normal file
22
packages/core/src/routes/session/types.ts
Normal 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>;
|
|
@ -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
2
pnpm-lock.yaml
generated
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue