mirror of
https://github.com/logto-io/logto.git
synced 2025-01-06 20:40:08 -05:00
feat(core,schemas): koaLogSession middleware (#767)
* feat(core,schemas): koaLogSession middleware * fix(core): koa-log-session call next() first * refactor(core,schemas): merge SessionLogPayload into BaseLogPayload * refactor(core,schemas): rename logSession to addLogContext
This commit is contained in:
parent
907a63b52a
commit
4e60446411
8 changed files with 155 additions and 52 deletions
65
packages/core/src/middleware/koa-log-session.test.ts
Normal file
65
packages/core/src/middleware/koa-log-session.test.ts
Normal file
|
@ -0,0 +1,65 @@
|
|||
import { Provider } from 'oidc-provider';
|
||||
|
||||
import { WithLogContext } from '@/middleware/koa-log';
|
||||
import koaLogSession from '@/middleware/koa-log-session';
|
||||
import { createContextWithRouteParameters } from '@/utils/test-utils';
|
||||
|
||||
const interactionDetails: jest.MockedFunction<() => Promise<unknown>> = jest.fn(async () => ({}));
|
||||
|
||||
jest.mock('oidc-provider', () => ({
|
||||
Provider: jest.fn(() => ({
|
||||
interactionDetails,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('koaLogSession', () => {
|
||||
const sessionId = 'sessionId';
|
||||
const applicationId = 'applicationId';
|
||||
const addLogContext = jest.fn();
|
||||
const log = jest.fn();
|
||||
const next = jest.fn();
|
||||
|
||||
interactionDetails.mockResolvedValue({
|
||||
jti: sessionId,
|
||||
params: {
|
||||
client_id: applicationId,
|
||||
},
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should get session info from the provider', async () => {
|
||||
const ctx: WithLogContext<ReturnType<typeof createContextWithRouteParameters>> = {
|
||||
...createContextWithRouteParameters(),
|
||||
addLogContext,
|
||||
log,
|
||||
};
|
||||
|
||||
await expect(koaLogSession(new Provider(''))(ctx, next)).resolves.not.toThrow();
|
||||
expect(interactionDetails).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log session id and application id', async () => {
|
||||
const ctx: WithLogContext<ReturnType<typeof createContextWithRouteParameters>> = {
|
||||
...createContextWithRouteParameters(),
|
||||
addLogContext,
|
||||
log,
|
||||
};
|
||||
|
||||
await expect(koaLogSession(new Provider(''))(ctx, next)).resolves.not.toThrow();
|
||||
expect(addLogContext).toHaveBeenCalledWith({ sessionId, applicationId });
|
||||
});
|
||||
|
||||
it('should call next', async () => {
|
||||
const ctx: WithLogContext<ReturnType<typeof createContextWithRouteParameters>> = {
|
||||
...createContextWithRouteParameters(),
|
||||
addLogContext,
|
||||
log,
|
||||
};
|
||||
|
||||
await expect(koaLogSession(new Provider(''))(ctx, next)).resolves.not.toThrow();
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
18
packages/core/src/middleware/koa-log-session.ts
Normal file
18
packages/core/src/middleware/koa-log-session.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { MiddlewareType } from 'koa';
|
||||
import { Provider } from 'oidc-provider';
|
||||
|
||||
import { WithLogContext } from '@/middleware/koa-log';
|
||||
|
||||
export default function koaLogSession<StateT, ContextT extends WithLogContext, ResponseBodyT>(
|
||||
provider: Provider
|
||||
): MiddlewareType<StateT, ContextT, ResponseBodyT> {
|
||||
return async (ctx, next) => {
|
||||
await next();
|
||||
|
||||
const {
|
||||
jti,
|
||||
params: { client_id },
|
||||
} = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
ctx.addLogContext({ sessionId: jti, applicationId: String(client_id) });
|
||||
};
|
||||
}
|
|
@ -7,6 +7,7 @@ import koaLog, { WithLogContext } from './koa-log';
|
|||
|
||||
const nanoIdMock = 'mockId';
|
||||
|
||||
const addLogContext = jest.fn();
|
||||
const log = jest.fn();
|
||||
|
||||
jest.mock('@/queries/log', () => ({
|
||||
|
@ -36,7 +37,9 @@ describe('koaLog middleware', () => {
|
|||
it('insert log with success response', async () => {
|
||||
const ctx: WithLogContext<ReturnType<typeof createContextWithRouteParameters>> = {
|
||||
...createContextWithRouteParameters({ headers: { 'user-agent': userAgent } }),
|
||||
log, // Bypass middleware context type assert
|
||||
// Bypass middleware context type assert
|
||||
addLogContext,
|
||||
log,
|
||||
};
|
||||
ctx.request.ip = ip;
|
||||
|
||||
|
@ -60,7 +63,9 @@ describe('koaLog middleware', () => {
|
|||
it('should insert log with failed result if next throws error', async () => {
|
||||
const ctx: WithLogContext<ReturnType<typeof createContextWithRouteParameters>> = {
|
||||
...createContextWithRouteParameters({ headers: { 'user-agent': userAgent } }),
|
||||
log, // Bypass middleware context type assert
|
||||
// Bypass middleware context type assert
|
||||
addLogContext,
|
||||
log,
|
||||
};
|
||||
ctx.request.ip = ip;
|
||||
|
||||
|
|
|
@ -1,13 +1,25 @@
|
|||
import { BaseLogPayload, LogPayload, LogPayloads, LogResult, LogType } from '@logto/schemas';
|
||||
import deepmerge from 'deepmerge';
|
||||
import { MiddlewareType } from 'koa';
|
||||
import { IRouterParamContext } from 'koa-router';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { insertLog } from '@/queries/log';
|
||||
|
||||
type MergeLog = <T extends LogType>(type: T, payload: LogPayloads[T]) => void;
|
||||
|
||||
export type WithLogContext<ContextT> = ContextT & { log: MergeLog };
|
||||
type SessionPayload = {
|
||||
sessionId?: string;
|
||||
applicationId?: string;
|
||||
};
|
||||
|
||||
type AddLogContext = (sessionPayload: SessionPayload) => void;
|
||||
|
||||
export type WithLogContext<ContextT extends IRouterParamContext = IRouterParamContext> =
|
||||
ContextT & {
|
||||
addLogContext: AddLogContext;
|
||||
log: MergeLog;
|
||||
};
|
||||
|
||||
type Logger = {
|
||||
type?: LogType;
|
||||
|
@ -60,11 +72,11 @@ const initLogger = (basePayload?: Readonly<BaseLogPayload>) => {
|
|||
};
|
||||
/* eslint-enable @silverhand/fp/no-mutation */
|
||||
|
||||
export default function koaLog<StateT, ContextT, ResponseBodyT>(): MiddlewareType<
|
||||
export default function koaLog<
|
||||
StateT,
|
||||
WithLogContext<ContextT>,
|
||||
ContextT extends IRouterParamContext,
|
||||
ResponseBodyT
|
||||
> {
|
||||
>(): MiddlewareType<StateT, WithLogContext<ContextT>, ResponseBodyT> {
|
||||
return async (ctx, next) => {
|
||||
const {
|
||||
ip,
|
||||
|
@ -72,6 +84,7 @@ export default function koaLog<StateT, ContextT, ResponseBodyT>(): MiddlewareTyp
|
|||
} = ctx.request;
|
||||
|
||||
const logger = initLogger({ result: LogResult.Success, ip, userAgent });
|
||||
ctx.addLogContext = logger.set;
|
||||
ctx.log = logger.log;
|
||||
|
||||
try {
|
||||
|
|
|
@ -4,6 +4,7 @@ import Router from 'koa-router';
|
|||
import { Provider } from 'oidc-provider';
|
||||
|
||||
import koaAuth from '@/middleware/koa-auth';
|
||||
import koaLogSession from '@/middleware/koa-log-session';
|
||||
import applicationRoutes from '@/routes/application';
|
||||
import connectorRoutes from '@/routes/connector';
|
||||
import resourceRoutes from '@/routes/resource';
|
||||
|
@ -18,23 +19,25 @@ import roleRoutes from './role';
|
|||
import { AnonymousRouter, AuthedRouter } from './types';
|
||||
|
||||
const createRouters = (provider: Provider) => {
|
||||
const anonymousRouter: AnonymousRouter = new Router();
|
||||
const sessionRouter: AnonymousRouter = new Router();
|
||||
sessionRouter.use(koaLogSession(provider));
|
||||
sessionRoutes(sessionRouter, provider);
|
||||
|
||||
const anonymousRouter: AnonymousRouter = new Router();
|
||||
statusRoutes(anonymousRouter);
|
||||
sessionRoutes(anonymousRouter, provider);
|
||||
swaggerRoutes(anonymousRouter);
|
||||
|
||||
const router: AuthedRouter = new Router();
|
||||
router.use(koaAuth());
|
||||
applicationRoutes(router);
|
||||
settingRoutes(router);
|
||||
connectorRoutes(router);
|
||||
resourceRoutes(router);
|
||||
signInExperiencesRoutes(router);
|
||||
adminUserRoutes(router);
|
||||
roleRoutes(router);
|
||||
const authedRouter: AuthedRouter = new Router();
|
||||
authedRouter.use(koaAuth());
|
||||
applicationRoutes(authedRouter);
|
||||
settingRoutes(authedRouter);
|
||||
connectorRoutes(authedRouter);
|
||||
resourceRoutes(authedRouter);
|
||||
signInExperiencesRoutes(authedRouter);
|
||||
adminUserRoutes(authedRouter);
|
||||
roleRoutes(authedRouter);
|
||||
|
||||
return [anonymousRouter, router];
|
||||
return [sessionRouter, anonymousRouter, authedRouter];
|
||||
};
|
||||
|
||||
export default function initRouter(app: Koa, provider: Provider) {
|
||||
|
|
|
@ -171,6 +171,7 @@ describe('sessionRoutes', () => {
|
|||
provider: new Provider(''),
|
||||
middlewares: [
|
||||
async (ctx, next) => {
|
||||
ctx.addLogContext = jest.fn();
|
||||
ctx.log = jest.fn();
|
||||
|
||||
return next();
|
||||
|
|
|
@ -72,10 +72,9 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
|
|||
}),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
const { username, password } = ctx.guard.body;
|
||||
const type = 'SignInUsernamePassword';
|
||||
ctx.log(type, { sessionId: jti, username });
|
||||
ctx.log(type, { username });
|
||||
assertThat(password, 'session.insufficient_info');
|
||||
|
||||
const { id } = await findUserByUsernameAndPassword(username, password);
|
||||
|
@ -94,7 +93,7 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
|
|||
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
const { phone } = ctx.guard.body;
|
||||
const type = 'SignInSmsSendPasscode';
|
||||
ctx.log(type, { sessionId: jti, phone });
|
||||
ctx.log(type, { phone });
|
||||
|
||||
assertThat(
|
||||
await hasUserWithPhone(phone),
|
||||
|
@ -118,7 +117,7 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
|
|||
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
const { phone, code } = ctx.guard.body;
|
||||
const type = 'SignInSms';
|
||||
ctx.log(type, { sessionId: jti, phone, code });
|
||||
ctx.log(type, { phone, code });
|
||||
|
||||
assertThat(
|
||||
await hasUserWithPhone(phone),
|
||||
|
@ -143,7 +142,7 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
|
|||
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
const { email } = ctx.guard.body;
|
||||
const type = 'SignInEmailSendPasscode';
|
||||
ctx.log(type, { sessionId: jti, email });
|
||||
ctx.log(type, { email });
|
||||
|
||||
assertThat(
|
||||
await hasUserWithEmail(email),
|
||||
|
@ -167,7 +166,7 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
|
|||
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
const { email, code } = ctx.guard.body;
|
||||
const type = 'SignInEmail';
|
||||
ctx.log(type, { sessionId: jti, email, code });
|
||||
ctx.log(type, { email, code });
|
||||
|
||||
assertThat(
|
||||
await hasUserWithEmail(email),
|
||||
|
@ -264,12 +263,12 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
|
|||
body: object({ connectorId: string() }),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { jti, result } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
assertThat(result, 'session.connector_session_not_found');
|
||||
|
||||
const { connectorId } = ctx.guard.body;
|
||||
const type = 'SignInSocialBind';
|
||||
ctx.log(type, { sessionId: jti, connectorId });
|
||||
ctx.log(type, { connectorId });
|
||||
|
||||
const userInfo = await getUserInfoFromInteractionResult(connectorId, result);
|
||||
ctx.log(type, { userInfo });
|
||||
|
@ -341,10 +340,9 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
|
|||
}),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
const { username, password } = ctx.guard.body;
|
||||
const type = 'RegisterUsernamePassword';
|
||||
ctx.log(type, { sessionId: jti, username });
|
||||
ctx.log(type, { username });
|
||||
|
||||
assertThat(
|
||||
password,
|
||||
|
@ -398,7 +396,7 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
|
|||
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
const { phone } = ctx.guard.body;
|
||||
const type = 'RegisterSmsSendPasscode';
|
||||
ctx.log(type, { sessionId: jti, phone });
|
||||
ctx.log(type, { phone });
|
||||
|
||||
assertThat(
|
||||
!(await hasUserWithPhone(phone)),
|
||||
|
@ -406,7 +404,7 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
|
|||
);
|
||||
|
||||
const passcode = await createPasscode(jti, PasscodeType.Register, { phone });
|
||||
ctx.log(type, { sessionId: jti, phone, passcode });
|
||||
ctx.log(type, { phone, passcode });
|
||||
|
||||
await sendPasscode(passcode);
|
||||
ctx.status = 204;
|
||||
|
@ -422,7 +420,7 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
|
|||
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
const { phone, code } = ctx.guard.body;
|
||||
const type = 'RegisterSms';
|
||||
ctx.log(type, { sessionId: jti, phone, code });
|
||||
ctx.log(type, { phone, code });
|
||||
|
||||
assertThat(
|
||||
!(await hasUserWithPhone(phone)),
|
||||
|
@ -448,7 +446,7 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
|
|||
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
const { email } = ctx.guard.body;
|
||||
const type = 'RegisterEmailSendPasscode';
|
||||
ctx.log(type, { sessionId: jti, email });
|
||||
ctx.log(type, { email });
|
||||
|
||||
assertThat(
|
||||
!(await hasUserWithEmail(email)),
|
||||
|
@ -472,7 +470,7 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
|
|||
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
const { email, code } = ctx.guard.body;
|
||||
const type = 'RegisterEmail';
|
||||
ctx.log(type, { sessionId: jti, email, code });
|
||||
ctx.log(type, { email, code });
|
||||
|
||||
assertThat(
|
||||
!(await hasUserWithEmail(email)),
|
||||
|
@ -499,7 +497,7 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
|
|||
}),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { jti, result } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
// User can not register with social directly,
|
||||
// need to try to sign in with social first, then confirm to register and continue,
|
||||
// so the result is expected to be exists.
|
||||
|
@ -507,7 +505,7 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
|
|||
|
||||
const { connectorId } = ctx.guard.body;
|
||||
const type = 'RegisterSocial';
|
||||
ctx.log(type, { sessionId: jti, connectorId });
|
||||
ctx.log(type, { connectorId });
|
||||
|
||||
const userInfo = await getUserInfoFromInteractionResult(connectorId, result);
|
||||
ctx.log(type, { userInfo });
|
||||
|
@ -542,14 +540,14 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
|
|||
}),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { jti, result } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
assertThat(result, 'session.connector_session_not_found');
|
||||
const userId = result.login?.accountId;
|
||||
assertThat(userId, 'session.unauthorized');
|
||||
|
||||
const { connectorId } = ctx.guard.body;
|
||||
const type = 'RegisterSocialBind';
|
||||
ctx.log(type, { sessionId: jti, connectorId, userId });
|
||||
ctx.log(type, { connectorId, userId });
|
||||
|
||||
const userInfo = await getUserInfoFromInteractionResult(connectorId, result);
|
||||
ctx.log(type, { userInfo });
|
||||
|
|
|
@ -10,40 +10,40 @@ export interface BaseLogPayload {
|
|||
error?: string;
|
||||
ip?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
interface CommonFields {
|
||||
applicationId?: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
interface RegisterUsernamePasswordLogPayload extends CommonFields {
|
||||
type ArbitraryLogPayload = Record<string, unknown>;
|
||||
|
||||
interface RegisterUsernamePasswordLogPayload extends ArbitraryLogPayload {
|
||||
userId?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
interface RegisterEmailSendPasscodeLogPayload extends CommonFields {
|
||||
interface RegisterEmailSendPasscodeLogPayload extends ArbitraryLogPayload {
|
||||
email?: string;
|
||||
passcode?: Passcode;
|
||||
}
|
||||
|
||||
interface RegisterEmailLogPayload extends CommonFields {
|
||||
interface RegisterEmailLogPayload extends ArbitraryLogPayload {
|
||||
email?: string;
|
||||
code?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
interface RegisterSmsSendPasscodeLogPayload extends CommonFields {
|
||||
interface RegisterSmsSendPasscodeLogPayload extends ArbitraryLogPayload {
|
||||
phone?: string;
|
||||
passcode?: Passcode;
|
||||
}
|
||||
|
||||
interface RegisterSmsLogPayload extends CommonFields {
|
||||
interface RegisterSmsLogPayload extends ArbitraryLogPayload {
|
||||
phone?: string;
|
||||
code?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
interface RegisterSocialBindLogPayload extends CommonFields {
|
||||
interface RegisterSocialBindLogPayload extends ArbitraryLogPayload {
|
||||
connectorId?: string;
|
||||
userInfo?: object;
|
||||
userId?: string;
|
||||
|
@ -56,34 +56,34 @@ interface RegisterSocialLogPayload extends RegisterSocialBindLogPayload {
|
|||
redirectTo?: string;
|
||||
}
|
||||
|
||||
interface SignInUsernamePasswordLogPayload extends CommonFields {
|
||||
interface SignInUsernamePasswordLogPayload extends ArbitraryLogPayload {
|
||||
userId?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
interface SignInEmailSendPasscodeLogPayload extends CommonFields {
|
||||
interface SignInEmailSendPasscodeLogPayload extends ArbitraryLogPayload {
|
||||
email?: string;
|
||||
passcode?: Passcode;
|
||||
}
|
||||
|
||||
interface SignInEmailLogPayload extends CommonFields {
|
||||
interface SignInEmailLogPayload extends ArbitraryLogPayload {
|
||||
email?: string;
|
||||
code?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
interface SignInSmsSendPasscodeLogPayload extends CommonFields {
|
||||
interface SignInSmsSendPasscodeLogPayload extends ArbitraryLogPayload {
|
||||
phone?: string;
|
||||
passcode?: Passcode;
|
||||
}
|
||||
|
||||
interface SignInSmsLogPayload extends CommonFields {
|
||||
interface SignInSmsLogPayload extends ArbitraryLogPayload {
|
||||
phone?: string;
|
||||
code?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
interface SignInSocialBindLogPayload extends CommonFields {
|
||||
interface SignInSocialBindLogPayload extends ArbitraryLogPayload {
|
||||
connectorId?: string;
|
||||
userInfo?: object;
|
||||
userId?: string;
|
||||
|
|
Loading…
Reference in a new issue