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:
parent
a9af74bae7
commit
ee48155589
9 changed files with 213 additions and 71 deletions
|
@ -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",
|
||||
|
|
40
packages/core/src/errors/RequestError.ts
Normal file
40
packages/core/src/errors/RequestError.ts
Normal 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 };
|
||||
}
|
||||
}
|
12
packages/core/src/include.d/koa-body.d.ts
vendored
Normal file
12
packages/core/src/include.d/koa-body.d.ts
vendored
Normal 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;
|
||||
}
|
|
@ -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);
|
||||
|
|
22
packages/core/src/middleware/koa-error-handler.ts
Normal file
22
packages/core/src/middleware/koa-error-handler.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
}
|
56
packages/core/src/middleware/koa-guard.ts
Normal file
56
packages/core/src/middleware/koa-guard.ts
Normal 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;
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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==
|
||||
|
|
Loading…
Add table
Reference in a new issue