0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-04-07 23:01:25 -05:00

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

This commit is contained in:
Darcy Ye 2022-09-28 16:55:25 +08:00
parent ee2e3f7e1d
commit 6f18d890ca
No known key found for this signature in database
GPG key ID: B46F4C07EDEFC610
11 changed files with 269 additions and 104 deletions

View file

@ -0,0 +1,70 @@
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 koaRegisterAction<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

@ -0,0 +1,81 @@
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 koaSignInAction<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,6 +1,5 @@
import { emailRegEx, phoneRegEx } from '@logto/core-kit';
import { PasscodeType, LogType, User } from '@logto/schemas';
import camelcase from 'camelcase';
import { PasscodeType } from '@logto/schemas';
import dayjs from 'dayjs';
import { Provider } from 'oidc-provider';
import { object, string } from 'zod';
@ -21,8 +20,10 @@ import assertThat from '@/utils/assert-that';
import { AnonymousRouter } from '../types';
import { passwordlessVerificationTimeout } from './consts';
import { flowTypeGuard, viaGuard, passwordlessVerificationGuard, PasscodePayload } from './types';
import { getRoutePrefix, getPasscodeType } from './utils';
import { flowTypeGuard, viaGuard, PasscodePayload } from './types';
import { getRoutePrefix, getPasscodeType, getPasswordlessRelatedLogType } from './utils';
// Import koaSignInAction from './middleware/koa-sign-in-action';
// import koaRegisterAction from './middleware/koa-register-action';
export const registerRoute = getRoutePrefix('register', 'passwordless');
export const signInRoute = getRoutePrefix('sign-in', 'passwordless');
@ -31,6 +32,11 @@ export default function passwordlessRoutes<T extends AnonymousRouter>(
router: T,
provider: Provider
) {
// Router.use(`${signInRoute}/sms`, koaSignInAction(provider, 'sms'));
// router.use(`${signInRoute}/email`, koaSignInAction(provider, 'email'));
// router.use(`${registerRoute}/sms`, koaRegisterAction(provider, 'sms'));
// router.use(`${registerRoute}/email`, koaRegisterAction(provider, 'email'));
router.post(
`/passwordless/:via/send`,
koaGuard({
@ -61,9 +67,7 @@ export default function passwordlessRoutes<T extends AnonymousRouter>(
payload = { phone };
}
const type: LogType = `${camelcase(flow, { pascalCase: true })}${camelcase(via, {
pascalCase: true,
})}SendPasscode`;
const type = getPasswordlessRelatedLogType(flow, via, 'send');
ctx.log(type, payload);
const passcodeType = getPasscodeType(flow);
@ -107,9 +111,7 @@ export default function passwordlessRoutes<T extends AnonymousRouter>(
payload = { phone };
}
const type: LogType = `${camelcase(flow, { pascalCase: true })}${camelcase(via, {
pascalCase: true,
})}`;
const type = getPasswordlessRelatedLogType(flow, via, 'verify');
ctx.log(type, payload);
const passcodeType = getPasscodeType(flow);
@ -129,58 +131,44 @@ export default function passwordlessRoutes<T extends AnonymousRouter>(
);
router.post(
`${signInRoute}/:via`,
koaGuard({ params: object({ via: viaGuard }) }),
`${signInRoute}/sms/send-passcode`,
koaGuard({ body: object({ phone: string().regex(phoneRegEx) }) }),
async (ctx, next) => {
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
const { via } = ctx.guard.params;
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 = `SignIn${camelcase(via, { pascalCase: true })}`;
ctx.log(type, { email, phone, flow, expiresAt });
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { phone } = ctx.guard.body;
const type = 'SignInSmsSendPasscode';
ctx.log(type, { phone });
assertThat(
flow === 'sign-in',
new RequestError({ code: 'session.passwordless_not_verified', status: 401 })
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(
dayjs(expiresAt).isValid() && dayjs(expiresAt).isAfter(dayjs()),
new RequestError({ code: 'session.passwordless_verification_expired', status: 401 })
await hasUserWithPhone(phone),
new RequestError({ code: 'user.phone_not_exists', status: 422 })
);
// 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;
await verifyPasscode(jti, PasscodeType.SignIn, code, { phone });
const { id } = await findUserByPhone(phone);
ctx.log(type, { userId: id });
await updateUserById(id, { lastSignInAt: Date.now() });
@ -190,54 +178,6 @@ 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

@ -8,6 +8,10 @@ export const viaGuard = z.enum(['email', 'sms']);
export type Via = z.infer<typeof viaGuard>;
export const operationGuard = z.enum(['send', 'verify']);
export type Operation = z.infer<typeof operationGuard>;
export type PasscodePayload = { email: string } | { phone: string };
export const passwordlessVerificationGuard = z.object({

View file

@ -1,7 +1,10 @@
import { PasscodeType } from '@logto/schemas';
import { logTypeGuard, LogType, PasscodeType } from '@logto/schemas';
import { Truthy } from '@silverhand/essentials';
import { FlowType } from './types';
import RequestError from '@/errors/RequestError';
import assertThat from '@/utils/assert-that';
import { FlowType, Operation, Via } from './types';
export const getRoutePrefix = (
type: FlowType,
@ -20,3 +23,19 @@ export const getPasscodeType = (type: FlowType) => {
? PasscodeType.Register
: PasscodeType.ForgotPassword;
};
export const getPasswordlessRelatedLogType = (
flow: FlowType,
via: Via,
operation?: Operation
): LogType => {
const prefix =
flow === 'register' ? 'Register' : flow === 'sign-in' ? 'SignIn' : 'ForgotPassword';
const body = via === 'email' ? 'Email' : 'Sms';
const suffix = operation === 'send' ? 'SendPasscode' : '';
const result = logTypeGuard.safeParse(prefix + body + suffix);
assertThat(result.success, new RequestError('log.invalid_type'));
return result.data;
};

View file

@ -60,6 +60,12 @@ const errors = {
'Forgot password session not found. Please go back and verify.',
forgot_password_verification_expired:
'Forgot password verification has expired. Please go back and verify again.',
passwordless_verification_session_not_found:
'Passwordless verification session not found. Please go back and retry.',
passwordless_not_verified:
'Passwordless of {{flow}} flow is not verified. Please go back and verify.',
passwordless_verification_expired:
'Passwordless verification has expired. Please go back and verify again.',
unauthorized: 'Please sign in first.',
unsupported_prompt_name: 'Unsupported prompt name.',
},
@ -121,6 +127,9 @@ const errors = {
not_exists_with_id: 'The {{name}} with ID `{{id}}` does not exist.',
not_found: 'The resource does not exist.',
},
log: {
invalid_type: 'The log type is invalid.',
},
};
export default errors;

View file

@ -65,6 +65,12 @@ const errors = {
'Forgot password session not found. Please go back and verify.', // UNTRANSLATED
forgot_password_verification_expired:
'Forgot password verification has expired. Please go back and verify again.', // UNTRANSLATED
passwordless_verification_session_not_found:
'Passwordless verification session not found. Please go back and retry.', // UNTRANSLATED
passwordless_not_verified:
'Passwordless of {{flow}} flow is not verified. Please go back and verify.', // UNTRANSLATED
passwordless_verification_expired:
'Passwordless verification has expired. Please go back and verify again.', // UNTRANSLATED
unauthorized: "Veuillez vous enregistrer d'abord.",
unsupported_prompt_name: "Nom d'invite non supporté.",
},
@ -129,6 +135,9 @@ const errors = {
not_exists_with_id: "Le {{name}} avec l'ID `{{id}}` n'existe pas.",
not_found: "La ressource n'existe pas.",
},
log: {
invalid_type: 'The log type is invalid.', // UNTRANSLATED
},
};
export default errors;

View file

@ -59,6 +59,12 @@ const errors = {
'Forgot password session not found. Please go back and verify.', // UNTRANSLATED
forgot_password_verification_expired:
'Forgot password verification has expired. Please go back and verify again.', // UNTRANSLATED
passwordless_verification_session_not_found:
'Passwordless verification session not found. Please go back and retry.', // UNTRANSLATED
passwordless_not_verified:
'Passwordless of {{flow}} flow is not verified. Please go back and verify.', // UNTRANSLATED
passwordless_verification_expired:
'Passwordless verification has expired. Please go back and verify again.', // UNTRANSLATED
unauthorized: '로그인을 먼저 해주세요.',
unsupported_prompt_name: '지원하지 않는 Prompt 이름이예요.',
},
@ -118,6 +124,9 @@ const errors = {
not_exists_with_id: '{{id}} ID를 가진 {{name}}는 존재하지 않아요.',
not_found: '리소스가 존재하지 않아요.',
},
log: {
invalid_type: 'The log type is invalid.', // UNTRANSLATED
},
};
export default errors;

View file

@ -61,6 +61,12 @@ const errors = {
'Forgot password session not found. Please go back and verify.', // UNTRANSLATED
forgot_password_verification_expired:
'Forgot password verification has expired. Please go back and verify again.', // UNTRANSLATED
passwordless_verification_session_not_found:
'Passwordless verification session not found. Please go back and retry.', // UNTRANSLATED
passwordless_not_verified:
'Passwordless of {{flow}} flow is not verified. Please go back and verify.', // UNTRANSLATED
passwordless_verification_expired:
'Passwordless verification has expired. Please go back and verify again.', // UNTRANSLATED
unauthorized: 'Faça login primeiro.',
unsupported_prompt_name: 'Nome de prompt não suportado.',
},
@ -124,6 +130,9 @@ const errors = {
not_exists_with_id: '{{name}} com o ID `{{id}}` não existe.',
not_found: 'O recurso não existe.',
},
log: {
invalid_type: 'The log type is invalid.', // UNTRANSLATED
},
};
export default errors;

View file

@ -61,6 +61,12 @@ const errors = {
'Forgot password session not found. Please go back and verify.', // UNTRANSLATED
forgot_password_verification_expired:
'Forgot password verification has expired. Please go back and verify again.', // UNTRANSLATED
passwordless_verification_session_not_found:
'Passwordless verification session not found. Please go back and retry.', // UNTRANSLATED
passwordless_not_verified:
'Passwordless of {{flow}} flow is not verified. Please go back and verify.', // UNTRANSLATED
passwordless_verification_expired:
'Passwordless verification has expired. Please go back and verify again.', // UNTRANSLATED
unauthorized: 'Lütfen önce oturum açın.',
unsupported_prompt_name: 'Desteklenmeyen prompt adı.',
},
@ -123,6 +129,9 @@ const errors = {
not_exists_with_id: ' `{{id}}` id kimliğine sahip {{name}} mevcut değil.',
not_found: 'Kaynak mevcut değil.',
},
log: {
invalid_type: 'The log type is invalid.', // UNTRANSLATED
},
};
export default errors;

View file

@ -57,6 +57,9 @@ const errors = {
connector_session_not_found: '无法找到连接器登录信息,请尝试重新登录。',
forgot_password_session_not_found: '无法找到忘记密码验证信息,请尝试重新验证。',
forgot_password_verification_expired: '忘记密码验证已过期,请尝试重新验证。',
passwordless_verification_session_not_found: '无法找到无密码流程验证信息,请尝试重新验证。',
passwordless_not_verified: '无密码验证 {{flow}} 流程没找到。请返回并验证。',
passwordless_verification_expired: '无密码验证已过期。请返回重新验证。',
unauthorized: '请先登录',
unsupported_prompt_name: '不支持的 prompt name',
},
@ -112,6 +115,9 @@ const errors = {
not_exists_with_id: 'ID 为 `{{id}}` 的 {{name}} 不存在',
not_found: '该资源不存在',
},
log: {
invalid_type: 'The log type is invalid.', // UNTRANSLATED
},
};
export default errors;