0
Fork 0
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:
IceHe.xyz 2022-05-13 20:37:36 +08:00 committed by GitHub
parent 907a63b52a
commit 4e60446411
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 155 additions and 52 deletions

View 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();
});
});

View 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) });
};
}

View file

@ -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;

View file

@ -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 {

View file

@ -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) {

View file

@ -171,6 +171,7 @@ describe('sessionRoutes', () => {
provider: new Provider(''),
middlewares: [
async (ctx, next) => {
ctx.addLogContext = jest.fn();
ctx.log = jest.fn();
return next();

View file

@ -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 });

View file

@ -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;