0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-03 21:48:55 -05:00

refactor: add guard and error handler middleware

This commit is contained in:
Gao Sun 2021-07-06 23:12:35 +08:00 committed by Gao Sun
parent a9af74bae7
commit ee48155589
9 changed files with 213 additions and 71 deletions

View file

@ -12,13 +12,15 @@
"dev": "tsc-watch --onSuccess \"node ./build/index.js\""
},
"dependencies": {
"@logto/essentials": "^1.0.5",
"@logto/essentials": "^1.0.7",
"@logto/schemas": "^1.1.0-rc.0",
"dayjs": "^1.10.5",
"dotenv": "^10.0.0",
"formidable": "^1.2.2",
"got": "^11.8.2",
"koa": "^2.13.1",
"koa-body": "^4.2.0",
"koa-compose": "^4.1.0",
"koa-logger": "^3.2.1",
"koa-mount": "^4.0.0",
"koa-proxies": "^0.12.1",
@ -34,6 +36,7 @@
"@commitlint/cli": "^12.1.4",
"@commitlint/config-conventional": "^12.1.4",
"@types/koa": "^2.13.3",
"@types/koa-compose": "^3.2.5",
"@types/koa-logger": "^3.1.1",
"@types/koa-mount": "^4.0.0",
"@types/koa-router": "^7.4.2",

View file

@ -0,0 +1,40 @@
export enum GuardErrorCode {
InvalidInput = 'guard.invalid_input',
}
export enum RegisterErrorCode {
UsernameExists = 'register.username_exists',
}
export type RequestErrorCode = GuardErrorCode | RegisterErrorCode;
const requestErrorMessage: Record<RequestErrorCode, string> = {
[RegisterErrorCode.UsernameExists]: 'The username already exists.',
[GuardErrorCode.InvalidInput]: 'The request input is invalid.',
};
export type RequestErrorMetadata = {
code: RequestErrorCode;
status?: number;
};
export type RequestErrorBody = { message: string; data: unknown };
export default class RequestError extends Error {
code: RequestErrorCode;
status: number;
expose: boolean;
body: RequestErrorBody;
constructor(input: RequestErrorMetadata | RequestErrorCode, data?: unknown) {
const { code, status = 400 } = typeof input === 'string' ? { code: input } : input;
const message = requestErrorMessage[code];
super(message);
this.expose = true;
this.code = code;
this.status = status;
this.body = { message, data };
}
}

View file

@ -0,0 +1,12 @@
declare module 'koa-body' {
import { IKoaBodyOptions } from 'node_modules/koa-body';
import { Middleware } from 'koa';
declare function koaBody<
StateT = Record<string, unknown>,
ContextT = Record<string, unknown>,
ResponseBodyT = any
>(options?: IKoaBodyOptions): Middleware<StateT, ContextT, ResponseBodyT>;
export = koaBody;
}

View file

@ -1,11 +1,13 @@
import Koa from 'koa';
import logger from 'koa-logger';
import koaLogger from 'koa-logger';
import koaErrorHandler from '@/middleware/koa-error-handler';
import initOidc from './oidc';
import initRouter from './router';
export default async function initApp(app: Koa, port: number): Promise<void> {
app.use(logger());
app.use(koaErrorHandler());
app.use(koaLogger());
const provider = await initOidc(app, port);
initRouter(app, provider);

View file

@ -0,0 +1,22 @@
import RequestError, { RequestErrorBody } from '@/errors/RequestError';
import { Middleware } from 'koa';
export default function koaErrorHandler<StateT, ContextT>(): Middleware<
StateT,
ContextT,
RequestErrorBody
> {
return async (ctx, next) => {
try {
await next();
} catch (error: unknown) {
if (error instanceof RequestError) {
ctx.status = error.status;
ctx.body = error.body;
return;
}
throw error;
}
};
}

View file

@ -0,0 +1,56 @@
import RequestError, { GuardErrorCode } from '@/errors/RequestError';
import { Middleware } from 'koa';
import koaBody from 'koa-body';
import compose from 'koa-compose';
import { IRouterParamContext } from 'koa-router';
import { ZodType } from 'zod';
export type GuardConfig<QueryT, BodyT, ParametersT> = {
query?: ZodType<QueryT>;
body?: ZodType<BodyT>;
params?: ZodType<ParametersT>;
};
export type Guarded<QueryT, BodyT, ParametersT> = {
query: QueryT;
body: BodyT;
params: ParametersT;
};
export default function koaGuard<
StateT,
ContextT extends IRouterParamContext,
ResponseBodyT,
GuardQueryT = undefined,
GuardBodyT = undefined,
GuardParametersT = undefined
>({
query,
body,
params,
}: GuardConfig<GuardQueryT, GuardBodyT, GuardParametersT>): Middleware<
StateT,
ContextT & { guard: Guarded<GuardQueryT, GuardBodyT, GuardParametersT> },
ResponseBodyT
> {
const guard: Middleware<
StateT,
ContextT & { guard: Guarded<GuardQueryT, GuardBodyT, GuardParametersT> },
ResponseBodyT
> = async (ctx, next) => {
try {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
ctx.guard = {
query: query?.parse(ctx.request.query),
body: body?.parse(ctx.request.body),
params: params?.parse(ctx.params),
} as Guarded<GuardQueryT, GuardBodyT, GuardParametersT>; // Have to do this since it's too complicated for TS
} catch (error: unknown) {
throw new RequestError(GuardErrorCode.InvalidInput, error);
}
await next();
};
return body ? compose([koaBody(), guard]) : guard;
}

View file

@ -1,10 +1,11 @@
import Router from 'koa-router';
import koaBody from 'koa-body';
import { object, string } from 'zod';
import { encryptPassword } from '@/utils/password';
import { hasUser, hasUserWithId, insertUser } from '@/queries/user';
import { customAlphabet, nanoid } from 'nanoid';
import { PasswordEncryptionMethod } from '@logto/schemas';
import koaGuard from '@/middleware/koa-guard';
import RequestError, { RegisterErrorCode } from '@/errors/RequestError';
const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
const userId = customAlphabet(alphabet, 12);
@ -24,37 +25,42 @@ const generateUserId = async (maxRetries = 500) => {
export default function createRegisterRoutes() {
const router = new Router();
router.post('/register', koaBody(), async (ctx) => {
const RegisterBody = object({
username: string().min(3),
password: string().min(6),
});
const { username, password } = RegisterBody.parse(ctx.request.body);
router.post(
'/register',
koaGuard({
body: object({
username: string().min(3),
password: string().min(6),
}),
}),
async (ctx) => {
const { username, password } = ctx.guard.body;
if (await hasUser(username)) {
throw new Error('Username already exists');
if (await hasUser(username)) {
throw new RequestError(RegisterErrorCode.UsernameExists);
}
const id = await generateUserId();
const passwordEncryptionSalt = nanoid();
const passwordEncryptionMethod = PasswordEncryptionMethod.SaltAndPepper;
const passwordEncrypted = encryptPassword(
id,
password,
passwordEncryptionSalt,
passwordEncryptionMethod
);
await insertUser({
id,
username,
passwordEncrypted,
passwordEncryptionMethod,
passwordEncryptionSalt,
});
ctx.body = { id };
}
const id = await generateUserId();
const passwordEncryptionSalt = nanoid();
const passwordEncryptionMethod = PasswordEncryptionMethod.SaltAndPepper;
const passwordEncrypted = encryptPassword(
id,
password,
passwordEncryptionSalt,
passwordEncryptionMethod
);
await insertUser({
id,
username,
passwordEncrypted,
passwordEncryptionMethod,
passwordEncryptionSalt,
});
ctx.body = { id };
});
);
return router.routes();
}

View file

@ -1,51 +1,52 @@
import assert from 'assert';
import Router from 'koa-router';
import koaBody from 'koa-body';
import { object, string } from 'zod';
import { encryptPassword } from '@/utils/password';
import { findUserById } from '@/queries/user';
import { Provider } from 'oidc-provider';
import { conditional } from '@logto/essentials';
import koaGuard from '@/middleware/koa-guard';
export default function createSignInRoutes(provider: Provider) {
const router = new Router();
router.post('/sign-in', koaBody(), async (ctx) => {
const {
prompt: { name },
} = await provider.interactionDetails(ctx.req, ctx.res);
router.post(
'/sign-in',
koaGuard({ body: object({ id: string().optional(), password: string().optional() }) }),
async (ctx) => {
const {
prompt: { name },
} = await provider.interactionDetails(ctx.req, ctx.res);
if (name === 'login') {
const SignInBody = object({
id: string().min(1),
password: string().min(1),
});
const { id, password } = SignInBody.parse(ctx.request.body);
if (name === 'login') {
const { id, password } = ctx.guard.body;
const { passwordEncrypted, passwordEncryptionMethod, passwordEncryptionSalt } =
await findUserById(id);
assert(id && password, 'Insufficent sign-in info.');
const { passwordEncrypted, passwordEncryptionMethod, passwordEncryptionSalt } =
await findUserById(id);
assert(passwordEncrypted && passwordEncryptionMethod && passwordEncryptionSalt);
assert(
encryptPassword(id, password, passwordEncryptionSalt, passwordEncryptionMethod) ===
passwordEncrypted
);
assert(passwordEncrypted && passwordEncryptionMethod && passwordEncryptionSalt);
assert(
encryptPassword(id, password, passwordEncryptionSalt, passwordEncryptionMethod) ===
passwordEncrypted
);
const redirectTo = await provider.interactionResult(
ctx.req,
ctx.res,
{
login: { accountId: id },
},
{ mergeWithLastSubmission: false }
);
ctx.body = { redirectTo };
} else if (name === 'consent') {
ctx.body = { redirectTo: ctx.request.origin + '/sign-in/consent' };
} else {
throw new Error(`Prompt not supported: ${name}`);
const redirectTo = await provider.interactionResult(
ctx.req,
ctx.res,
{
login: { accountId: id },
},
{ mergeWithLastSubmission: false }
);
ctx.body = { redirectTo };
} else if (name === 'consent') {
ctx.body = { redirectTo: ctx.request.origin + '/sign-in/consent' };
} else {
throw new Error(`Prompt not supported: ${name}`);
}
}
});
);
router.post('/sign-in/consent', async (ctx) => {
const { session, grantId, params, prompt } = await provider.interactionDetails(

View file

@ -388,10 +388,10 @@
dependencies:
vary "^1.1.2"
"@logto/essentials@^1.0.5":
version "1.0.5"
resolved "https://registry.yarnpkg.com/@logto/essentials/-/essentials-1.0.5.tgz#edc1a376cf82e8829adcf26e8b98db1235cfbfcd"
integrity sha512-zft8VodNOtkhyyQTHiOlksk6UonQBY68dON4cQaUOlpN5JS9z5/zchi4I0j+XB6EeQ7l8ZtVXct2VZBp8m03kw==
"@logto/essentials@^1.0.7":
version "1.0.7"
resolved "https://registry.yarnpkg.com/@logto/essentials/-/essentials-1.0.7.tgz#17d06000949d6bfe02fe18fbeb1d2610f120c805"
integrity sha512-fwTrqCivq+7aqh3IgXfkdMbeoFmV5omVhM67/8jcZMV6/NcjTU2AiKkvWDPr/HoFH13kdjfArurE8gnRKPUanA==
dependencies:
lodash.orderby "^4.6.0"
lodash.pick "^4.4.0"
@ -590,7 +590,7 @@
dependencies:
"@types/node" "*"
"@types/koa-compose@*":
"@types/koa-compose@*", "@types/koa-compose@^3.2.5":
version "3.2.5"
resolved "https://registry.yarnpkg.com/@types/koa-compose/-/koa-compose-3.2.5.tgz#85eb2e80ac50be95f37ccf8c407c09bbe3468e9d"
integrity sha512-B8nG/OoE1ORZqCkBVsup/AKcvjdgoHnfi4pZMn5UwAPCbhk/96xyv284eBYW8JlQbQ7zDmnpFr68I/40mFoIBQ==
@ -2401,7 +2401,7 @@ for-in@^1.0.2:
resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
integrity sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=
formidable@^1.1.1:
formidable@^1.1.1, formidable@^1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/formidable/-/formidable-1.2.2.tgz#bf69aea2972982675f00865342b982986f6b8dd9"
integrity sha512-V8gLm+41I/8kguQ4/o1D3RIHRmhYFG4pnNyonvua+40rqcEmT4+V71yaZ3B457xbbgCsCfjSPi65u/W6vK1U5Q==