0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-24 22:41:28 -05:00

refactor: update comments and fix tests

This commit is contained in:
Gao Sun 2022-12-17 22:59:10 +08:00
parent 981ca84b9b
commit 93f4ae10ec
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
18 changed files with 120 additions and 61 deletions

View file

@ -21,6 +21,15 @@ export class LogEntry {
};
}
/** Update payload by spreading `data` first, then spreading `this.payload`. */
prepend(data: Readonly<LogPayload>) {
this.payload = {
...removeUndefinedKeys(data),
...this.payload,
};
}
/** Update payload by spreading `this.payload` first, then spreading `data`. */
append(data: Readonly<LogPayload>) {
this.payload = {
...this.payload,
@ -33,6 +42,7 @@ export type LogPayload = Partial<LogContextPayload> & Record<string, unknown>;
export type LogContext = {
createLog: (key: LogKey) => LogEntry;
prependAllLogEntries: (payload: LogPayload) => void;
};
export type WithLogContext<ContextT extends IRouterParamContext = IRouterParamContext> = ContextT &
@ -40,46 +50,54 @@ export type WithLogContext<ContextT extends IRouterParamContext = IRouterParamCo
/**
* The factory to create a new audit log middleware function.
* It will inject a {@link LogFunction} property named `log` to the context to enable audit logging.
* It will inject a `createLog` function the context to enable audit logging.
*
* #### Set log key
* #### Create a log entry
*
* You need to explicitly call `ctx.log.setKey()` to set a {@link LogKey} thus the log can be categorized and indexed in database:
* You need to explicitly call `ctx.createLog()` to create a new {@link LogEntry} instance,
* which accepts a read-only parameter {@link LogKey} thus the log can be categorized and indexed in database.
*
* ```ts
* ctx.log.setKey('SignIn.Submit'); // Key is typed
* const log = ctx.createLog('Interaction.Create'); // Key is typed
* ```
*
* If log key is {@link LogKeyUnknown} in the end, it will not be recorded to the persist storage.
* Note every time you call `ctx.createLog()`, it will create a new log entry instance for inserting. So multiple log entries may be inserted within one request.
*
* Remember to keep the log entry instance properly if you want to collect log data from multiple places.
*
* #### Log data
*
* To log data, call `ctx.log()`. It'll use object spread operators to update data (i.e. merge with one-level overwrite and shallow copy).
* To update log payload, call `log.append()`. It will use object spread operators to update payload (i.e. merge with one-level overwrite and shallow copy).
*
* ```ts
* ctx.log({ applicationId: 'foo' });
* log.append({ applicationId: 'foo' });
* ```
*
* The data has a initial value:
* This function can be called multiple times.
*
* #### Log context
*
* By default, before inserting the logs, it will extract the request context and prepend request IP and User Agent to every log entry:
*
* ```ts
* {
* key: LogKeyUnknown,
* result: LogResult.Success,
* ip, // Extract from request
* userAgent, // Extract from request
* ip: 'request-ip-addr',
* userAgent: 'request-user-agent',
* ...log.payload,
* }
* ```
*
* Note: Both of the functions can be called multiple times.
* To add more common data to log entries, try to create another middleware function after this one, and call `ctx.prependAllLogEntries()`.
*
* @returns An audit log middleware function.
* @see {@link LogKey} for all available log keys, and {@link LogResult} for result enums.
* @see {@link LogContextPayload} for the basic type suggestion of log data.
* @returns An audit log middleware function.
*/
export default function koaAuditLog<StateT, ContextT extends IRouterParamContext, ResponseBodyT>(
dumpLogContext?: (ctx: ContextT) => Promise<Record<string, unknown>> | Record<string, unknown>
): MiddlewareType<StateT, WithLogContext<ContextT>, ResponseBodyT> {
export default function koaAuditLog<
StateT,
ContextT extends IRouterParamContext,
ResponseBodyT
>(): MiddlewareType<StateT, WithLogContext<ContextT>, ResponseBodyT> {
return async (ctx, next) => {
const entries: LogEntry[] = [];
@ -91,6 +109,12 @@ export default function koaAuditLog<StateT, ContextT extends IRouterParamContext
return entry;
};
ctx.prependAllLogEntries = (payload) => {
for (const entry of entries) {
entry.prepend(payload);
}
};
try {
await next();
} catch (error: unknown) {
@ -111,13 +135,12 @@ export default function koaAuditLog<StateT, ContextT extends IRouterParamContext
headers: { 'user-agent': userAgent },
} = ctx.request;
const logContext = { ip, userAgent, ...(await dumpLogContext?.(ctx)) };
await Promise.all(
entries.map(async ({ payload }) => {
return insertLog({
id: nanoid(),
type: payload.key,
payload: { ...logContext, ...payload },
payload: { ip, userAgent, ...payload },
});
})
);

View file

@ -12,6 +12,7 @@ import snakecaseKeys from 'snakecase-keys';
import envSet from '#src/env-set/index.js';
import { addOidcEventListeners } from '#src/event-listeners/index.js';
import koaAuditLog from '#src/middleware/koa-audit-log.js';
import postgresAdapter from '#src/oidc/adapter.js';
import { isOriginAllowed, validateCustomClientMetadata } from '#src/oidc/utils.js';
import { findApplicationById } from '#src/queries/application.js';
@ -188,6 +189,9 @@ export default async function initOidc(app: Koa): Promise<Provider> {
addOidcEventListeners(oidc);
// Provide audit log context for event listeners
oidc.use(koaAuditLog());
app.use(mount('/oidc', oidc.app));
return oidc;

View file

@ -4,9 +4,7 @@ import mount from 'koa-mount';
import Router from 'koa-router';
import type { Provider } from 'oidc-provider';
import { extractInteractionContext } from '#src/event-listeners/utils.js';
import koaAuditLogLegacy from '#src/middleware/koa-audit-log-legacy.js';
import koaAuditLog from '#src/middleware/koa-audit-log.js';
import koaAuth from '../middleware/koa-auth.js';
import koaLogSessionLegacy from '../middleware/koa-log-session-legacy.js';
@ -36,7 +34,6 @@ const createRouters = (provider: Provider) => {
sessionRoutes(sessionRouter, provider);
const interactionRouter: AnonymousRouter = new Router();
interactionRouter.use(koaAuditLog(extractInteractionContext));
interactionRoutes(interactionRouter, provider);
const managementRouter: AuthedRouter = new Router();

View file

@ -1,6 +1,7 @@
import { Event } from '@logto/schemas';
import { mockEsm, pickDefault } from '@logto/shared/esm';
import { createMockLogContext } from '#src/test-utils/koa-log.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
@ -51,10 +52,9 @@ jest.useFakeTimers().setSystemTime(now);
describe('submit action', () => {
const provider = createMockProvider();
const log = jest.fn();
const ctx: InteractionContext = {
...createContextWithRouteParameters(),
log,
...createMockLogContext(),
interactionPayload: { event: Event.SignIn },
};
const profile = {

View file

@ -5,6 +5,8 @@ import { mockEsm, mockEsmDefault, mockEsmWithActual, pickDefault } from '@logto/
import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
import RequestError from '#src/errors/RequestError/index.js';
import type koaAuditLog from '#src/middleware/koa-audit-log.js';
import { createMockLogContext } from '#src/test-utils/koa-log.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createRequester } from '#src/utils/test-utils.js';
@ -76,7 +78,17 @@ const { getInteractionStorage } = mockEsm('./utils/interaction.js', () => ({
getInteractionStorage: jest.fn(),
}));
const log = jest.fn();
const { createLog, prependAllLogEntries } = createMockLogContext();
mockEsmDefault(
'#src/middleware/koa-audit-log.js',
// eslint-disable-next-line unicorn/consistent-function-scoping
(): typeof koaAuditLog => () => async (ctx, next) => {
ctx.createLog = createLog;
ctx.prependAllLogEntries = prependAllLogEntries;
return next();
}
);
const koaInteractionBodyGuard = await pickDefault(
import('./middleware/koa-interaction-body-guard.js')
@ -107,14 +119,6 @@ describe('session -> interactionRoutes', () => {
provider: createMockProvider(
jest.fn().mockResolvedValue({ params: {}, jti: 'jti', client_id: demoAppApplicationId })
),
middlewares: [
async (ctx, next) => {
ctx.addLogContext = jest.fn();
ctx.log = log;
return next();
},
],
});
afterEach(() => {
@ -244,7 +248,7 @@ describe('session -> interactionRoutes', () => {
};
const response = await sessionRequest.post(path).send(body);
expect(sendPasscodeToIdentifier).toBeCalledWith(body, 'jti', log);
expect(sendPasscodeToIdentifier).toBeCalledWith(body, 'jti', createLog);
expect(response.status).toEqual(204);
});
});

View file

@ -1,9 +1,11 @@
import type { LogtoErrorCode } from '@logto/phrases';
import { Event } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import type { Provider } from 'oidc-provider';
import RequestError from '#src/errors/RequestError/index.js';
import { assignInteractionResults } from '#src/libraries/session.js';
import koaAuditLog from '#src/middleware/koa-audit-log.js';
import koaGuard from '#src/middleware/koa-guard.js';
import assertThat from '#src/utils/assert-that.js';
@ -28,6 +30,24 @@ export default function interactionRoutes<T extends AnonymousRouter>(
router: T,
provider: Provider
) {
router.use(koaAuditLog(), async (ctx, next) => {
await next();
// Prepend interaction context to log entries
try {
const {
jti,
params: { client_id },
} = await provider.interactionDetails(ctx.req, ctx.res);
ctx.prependAllLogEntries({
sessionId: jti,
applicationId: conditional(typeof client_id === 'string' && client_id),
});
} catch (error: unknown) {
console.error(`Failed to get oidc provider interaction details`, error);
}
});
router.put(
interactionPrefix,
koaInteractionBodyGuard(),
@ -117,7 +137,7 @@ export default function interactionRoutes<T extends AnonymousRouter>(
async (ctx, next) => {
// Check interaction session
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
await sendPasscodeToIdentifier(ctx.guard.body, jti, ctx.log);
await sendPasscodeToIdentifier(ctx.guard.body, jti, ctx.createLog);
ctx.status = 204;

View file

@ -11,6 +11,7 @@ import type { IRouterParamContext } from 'koa-router';
import type { z } from 'zod';
import type { SocialUserInfo } from '#src/connectors/types.js';
import type { WithLogContext } from '#src/middleware/koa-audit-log.js';
import type { WithGuardedIdentifierPayloadContext } from '../middleware/koa-interaction-body-guard.js';
import type {
@ -108,7 +109,9 @@ export type VerifiedInteractionResult =
| VerifiedSignInInteractionResult
| VerifiedForgotPasswordInteractionResult;
export type InteractionContext = WithGuardedIdentifierPayloadContext<IRouterParamContext & Context>;
export type InteractionContext = WithGuardedIdentifierPayloadContext<
WithLogContext<IRouterParamContext & Context>
>;
export type UserIdentity =
| { username: string }

View file

@ -2,6 +2,7 @@ import { Event } from '@logto/schemas';
import { mockEsm, mockEsmDefault, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
import RequestError from '#src/errors/RequestError/index.js';
import { createMockLogContext } from '#src/test-utils/koa-log.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
@ -31,10 +32,10 @@ const identifierPayloadVerification = await pickDefault(
import('./identifier-payload-verification.js')
);
const log = jest.fn();
const logContext = createMockLogContext();
describe('identifier verification', () => {
const baseCtx = { ...createContextWithRouteParameters(), log };
const baseCtx = { ...createContextWithRouteParameters(), ...logContext };
afterEach(() => {
jest.clearAllMocks();
@ -152,7 +153,7 @@ describe('identifier verification', () => {
expect(verifyIdentifierByPasscode).toBeCalledWith(
{ ...identifier, event: Event.SignIn },
'jti',
log
logContext.createLog
);
expect(result).toEqual({
@ -176,7 +177,7 @@ describe('identifier verification', () => {
expect(verifyIdentifierByPasscode).toBeCalledWith(
{ ...identifier, event: Event.SignIn },
'jti',
log
logContext.createLog
);
expect(result).toEqual({
@ -198,7 +199,7 @@ describe('identifier verification', () => {
const result = await identifierPayloadVerification(ctx, createMockProvider());
expect(verifySocialIdentity).toBeCalledWith(identifier, log);
expect(verifySocialIdentity).toBeCalledWith(identifier, logContext.createLog);
expect(findUserByIdentifier).not.toBeCalled();
expect(result).toEqual({
@ -323,7 +324,7 @@ describe('identifier verification', () => {
expect(verifyIdentifierByPasscode).toBeCalledWith(
{ ...identifier, event: Event.SignIn },
'jti',
log
logContext.createLog
);
expect(result).toEqual({

View file

@ -46,7 +46,7 @@ const verifyPasscodeIdentifier = async (
): Promise<VerifiedEmailIdentifier | VerifiedPhoneIdentifier> => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
await verifyIdentifierByPasscode({ ...identifier, event }, jti, ctx.log);
await verifyIdentifierByPasscode({ ...identifier, event }, jti, ctx.createLog);
return 'email' in identifier
? { key: 'emailVerified', value: identifier.email }
@ -57,7 +57,7 @@ const verifySocialIdentifier = async (
identifier: SocialConnectorPayload,
ctx: InteractionContext
): Promise<SocialIdentifier> => {
const userInfo = await verifySocialIdentity(identifier, ctx.log);
const userInfo = await verifySocialIdentity(identifier, ctx.createLog);
return { key: 'social', connectorId: identifier.connectorId, userInfo };
};

View file

@ -2,6 +2,7 @@ import { Event } from '@logto/schemas';
import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
import RequestError from '#src/errors/RequestError/index.js';
import { createMockLogContext } from '#src/test-utils/koa-log.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
@ -25,7 +26,7 @@ const verifyProfile = await pickDefault(import('./profile-verification.js'));
describe('forgot password interaction profile verification', () => {
const provider = createMockProvider();
const baseCtx = createContextWithRouteParameters();
const baseCtx = { ...createContextWithRouteParameters(), ...createMockLogContext() };
const interaction = {
event: Event.ForgotPassword,

View file

@ -2,6 +2,7 @@ import { Event } from '@logto/schemas';
import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
import RequestError from '#src/errors/RequestError/index.js';
import { createMockLogContext } from '#src/test-utils/koa-log.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
@ -32,7 +33,7 @@ const verifyProfile = await pickDefault(import('./profile-verification.js'));
describe('Should throw when providing existing identifiers in profile', () => {
const provider = createMockProvider();
const baseCtx = createContextWithRouteParameters();
const baseCtx = { ...createContextWithRouteParameters(), ...createMockLogContext() };
const identifiers: Identifier[] = [
{ key: 'accountId', value: 'foo' },
{ key: 'emailVerified', value: 'email' },

View file

@ -2,6 +2,7 @@ import { Event } from '@logto/schemas';
import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
import RequestError from '#src/errors/RequestError/index.js';
import { createMockLogContext } from '#src/test-utils/koa-log.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
@ -34,7 +35,7 @@ mockEsm('#src/connectors/index.js', () => ({
const verifyProfile = await pickDefault(import('./profile-verification.js'));
const baseCtx = createContextWithRouteParameters();
const baseCtx = { ...createContextWithRouteParameters(), ...createMockLogContext() };
const identifiers: Identifier[] = [
{ key: 'accountId', value: 'foo' },
{ key: 'emailVerified', value: 'email@logto.io' },

View file

@ -2,6 +2,7 @@ import { Event } from '@logto/schemas';
import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
import RequestError from '#src/errors/RequestError/index.js';
import { createMockLogContext } from '#src/test-utils/koa-log.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
@ -29,7 +30,7 @@ mockEsm('#src/connectors/index.js', () => ({
const verifyProfile = await pickDefault(import('./profile-verification.js'));
describe('profile protected identifier verification', () => {
const baseCtx = createContextWithRouteParameters();
const baseCtx = { ...createContextWithRouteParameters(), ...createMockLogContext() };
const interaction = { event: Event.SignIn, accountId: 'foo' };
const provider = createMockProvider();

View file

@ -2,6 +2,7 @@ import { Event } from '@logto/schemas';
import { mockEsm, mockEsmDefault, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
import RequestError from '#src/errors/RequestError/index.js';
import { createMockLogContext } from '#src/test-utils/koa-log.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
@ -26,6 +27,7 @@ describe('userAccountVerification', () => {
const ctx: InteractionContext = {
...createContextWithRouteParameters(),
...createMockLogContext(),
interactionPayload: {
event: Event.SignIn,
},

View file

@ -1,17 +1,17 @@
import type { ExtendableContext } from 'koa';
import type Router from 'koa-router';
import type { KoaContextWithOIDC } from 'oidc-provider';
import type { WithLogContextLegacy } from '#src/middleware/koa-audit-log-legacy.js';
import type { WithLogContext } from '#src/middleware/koa-audit-log.js';
import type { WithAuthContext } from '#src/middleware/koa-auth.js';
import type { WithI18nContext } from '#src/middleware/koa-i18next.js';
export type AnonymousRouter = Router<
unknown,
WithLogContext & WithI18nContext & KoaContextWithOIDC
>;
export type AnonymousRouter = Router<unknown, WithLogContext & WithI18nContext & ExtendableContext>;
/** @deprecated This will be removed soon. Use `kua-log-session.js` instead. */
export type AnonymousRouterLegacy = Router<unknown, WithLogContextLegacy & WithI18nContext>;
export type AuthedRouter = Router<unknown, WithAuthContext & WithLogContext & WithI18nContext>;
export type AuthedRouter = Router<
unknown,
WithAuthContext & WithLogContext & WithI18nContext & ExtendableContext
>;

View file

@ -1,3 +1,4 @@
import type { LogContext } from '#src/middleware/koa-audit-log.js';
import { LogEntry } from '#src/middleware/koa-audit-log.js';
const { jest } = import.meta;
@ -6,8 +7,12 @@ class MockLogEntry extends LogEntry {
append = jest.fn();
}
export const createMockLogContext = () => {
export const createMockLogContext = (): LogContext & { mockAppend: jest.Mock } => {
const mockLogEntry = new MockLogEntry('Unknown');
return { createLog: jest.fn(() => mockLogEntry), mockAppend: mockLogEntry.append };
return {
createLog: jest.fn(() => mockLogEntry),
prependAllLogEntries: jest.fn(),
mockAppend: mockLogEntry.append,
};
};

View file

@ -20,9 +20,7 @@ describe('admin console logs (legacy)', () => {
const logs = await getLogs();
const registerLog = logs.filter(
({ type, payload }) =>
type === 'RegisterUsernamePassword' &&
(payload as Record<string, unknown>).username === username
({ type, payload }) => type === 'RegisterUsernamePassword' && payload.username === username
);
expect(registerLog.length).toBeGreaterThan(0);

View file

@ -1,7 +1,5 @@
import type { Event } from '../interactions.js';
export { Event } from '../interactions.js';
export type Prefix = 'Interaction';
export const prefix: Prefix = 'Interaction';