Skip to content

Commit

Permalink
feat(core,schemas): add auditLogs to experience API (#6361)
Browse files Browse the repository at this point in the history
* refactor(core): refactor backup code generate flow

refactor backup code generate flow

* fix(core): fix api payload

fix api payload

* fix(core): fix rebase issue

fix rebase issue

* feat(core,schemas): add auditLogs to experience API

add auditLogs to experience API
  • Loading branch information
simeng-li authored Jul 31, 2024
1 parent 84f7e13 commit cf31e3a
Show file tree
Hide file tree
Showing 16 changed files with 341 additions and 35 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { conditional } from '@silverhand/essentials';
import { z } from 'zod';

import RequestError from '#src/errors/RequestError/index.js';
import { type LogEntry } from '#src/middleware/koa-audit-log.js';
import type TenantContext from '#src/tenants/TenantContext.js';
import assertThat from '#src/utils/assert-that.js';

Expand Down Expand Up @@ -180,9 +181,13 @@ export default class ExperienceInteraction {
* @see {@link identifyExistingUser} for more exceptions that can be thrown in the SignIn and ForgotPassword events.
* @see {@link createNewUser} for more exceptions that can be thrown in the Register event.
**/
public async identifyUser(verificationId: string, linkSocialIdentity?: boolean) {
public async identifyUser(verificationId: string, linkSocialIdentity?: boolean, log?: LogEntry) {
const verificationRecord = this.getVerificationRecordById(verificationId);

log?.append({
verification: verificationRecord?.toJson(),
});

assertThat(
this.interactionEvent,
new RequestError({ code: 'session.interaction_not_found', status: 404 })
Expand Down Expand Up @@ -442,6 +447,8 @@ export default class ExperienceInteraction {
// Note: The profile data is not saved to the user profile until the user submits the interaction.
// Also no need to validate the synced profile data availability as it is already validated during the identification process.
if (syncedProfile) {
const log = this.ctx.createLog(`Interaction.${this.interactionEvent}.Profile.Update`);
log.append({ syncedProfile });
this.profile.unsafeSet(syncedProfile);
}
}
Expand Down
21 changes: 18 additions & 3 deletions packages/core/src/routes/experience/classes/mfa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { deduplicate } from '@silverhand/essentials';
import { z } from 'zod';

import RequestError from '#src/errors/RequestError/index.js';
import { type LogEntry } from '#src/middleware/koa-audit-log.js';
import type Libraries from '#src/tenants/Libraries.js';
import type Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js';
Expand Down Expand Up @@ -170,11 +171,16 @@ export class Mfa {
*
* - Any existing TOTP factor will be replaced with the new one.
*/
async addTotpByVerificationId(verificationId: string) {
async addTotpByVerificationId(verificationId: string, log?: LogEntry) {
const verificationRecord = this.interactionContext.getVerificationRecordByTypeAndId(
VerificationType.TOTP,
verificationId
);

log?.append({
verification: verificationRecord.toJson(),
});

const bindTotp = verificationRecord.toBindMfa();

await this.checkMfaFactorsEnabledInSignInExperience([MfaFactor.TOTP]);
Expand All @@ -198,11 +204,16 @@ export class Mfa {
* @throws {RequestError} with status 404 if the verification record is not found
* @throws {RequestError} with status 400 if WebAuthn is not enabled in the sign-in experience
*/
async addWebAuthnByVerificationId(verificationId: string) {
async addWebAuthnByVerificationId(verificationId: string, log?: LogEntry) {
const verificationRecord = this.interactionContext.getVerificationRecordByTypeAndId(
VerificationType.WebAuthn,
verificationId
);

log?.append({
verification: verificationRecord.toJson(),
});

const bindWebAuthn = verificationRecord.toBindMfa();

await this.checkMfaFactorsEnabledInSignInExperience([MfaFactor.WebAuthn]);
Expand All @@ -218,12 +229,16 @@ export class Mfa {
* @throws {RequestError} with status 400 if Backup Code is not enabled in the sign-in experience
* @throws {RequestError} with status 422 if the backup code is the only MFA factor
*/
async addBackupCodeByVerificationId(verificationId: string) {
async addBackupCodeByVerificationId(verificationId: string, log?: LogEntry) {
const verificationRecord = this.interactionContext.getVerificationRecordByTypeAndId(
VerificationType.BackupCode,
verificationId
);

log?.append({
verification: verificationRecord.toJson(),
});

await this.checkMfaFactorsEnabledInSignInExperience([MfaFactor.BackupCode]);

const { mfaVerifications } = await this.interactionContext.getIdentifiedUser();
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/routes/experience/classes/profile.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { type VerificationType } from '@logto/schemas';

import RequestError from '#src/errors/RequestError/index.js';
import { type LogEntry } from '#src/middleware/koa-audit-log.js';
import type Libraries from '#src/tenants/Libraries.js';
import type Queries from '#src/tenants/Queries.js';

Expand Down Expand Up @@ -38,12 +39,18 @@ export class Profile {
*/
async setProfileByVerificationRecord(
type: VerificationType.EmailVerificationCode | VerificationType.PhoneVerificationCode,
verificationId: string
verificationId: string,
log?: LogEntry
) {
const verificationRecord = this.interactionContext.getVerificationRecordByTypeAndId(
type,
verificationId
);

log?.append({
verification: verificationRecord.toJson(),
});

const profile = verificationRecord.toUserProfile();
await this.setProfileWithValidation(profile);
}
Expand Down
33 changes: 27 additions & 6 deletions packages/core/src/routes/experience/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,16 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
const { interactionEvent } = ctx.guard.body;
const { createLog } = ctx;

createLog(`Interaction.${interactionEvent}.Update`);
createLog(`Interaction.${interactionEvent}.Create`);

const experienceInteraction = new ExperienceInteraction(ctx, tenant, interactionEvent);

// Save new experience interaction instance.
// This will overwrite any existing interaction data in the storage.
await experienceInteraction.save();

ctx.experienceInteraction = experienceInteraction;

ctx.status = 204;

return next();
Expand All @@ -89,13 +91,12 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
const { createLog, experienceInteraction } = ctx;

const eventLog = createLog(`Interaction.${experienceInteraction.interactionEvent}.Update`);

await experienceInteraction.setInteractionEvent(interactionEvent);

eventLog.append({
interactionEvent,
});

await experienceInteraction.setInteractionEvent(interactionEvent);

await experienceInteraction.save();

ctx.status = 204;
Expand All @@ -112,9 +113,20 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
}),
async (ctx, next) => {
const { verificationId, linkSocialIdentity } = ctx.guard.body;
const { experienceInteraction } = ctx;
const { experienceInteraction, createLog } = ctx;

await experienceInteraction.identifyUser(verificationId, linkSocialIdentity);
const log = createLog(
`Interaction.${experienceInteraction.interactionEvent}.Identifier.Submit`
);

log.append({
payload: {
verificationId,
linkSocialIdentity,
},
});

await experienceInteraction.identifyUser(verificationId, linkSocialIdentity, log);

await experienceInteraction.save();

Expand All @@ -136,7 +148,16 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
.optional(),
}),
async (ctx, next) => {
const { createLog, experienceInteraction } = ctx;

const log = createLog(`Interaction.${experienceInteraction.interactionEvent}.Submit`);

await ctx.experienceInteraction.submit();

log.append({
interaction: ctx.experienceInteraction.toJson(),
});

ctx.status = 200;
return next();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import type { MiddlewareType } from 'koa';
import { type IRouterParamContext } from 'koa-router';

import { type WithLogContext } from '#src/middleware/koa-audit-log.js';
import type TenantContext from '#src/tenants/TenantContext.js';

import ExperienceInteraction from '../classes/experience-interaction.js';
import { experienceRoutes } from '../const.js';
import { type WithHooksAndLogsContext } from '../types.js';

export type WithExperienceInteractionContext<ContextT extends WithLogContext = WithLogContext> =
ContextT & {
experienceInteraction: ExperienceInteraction;
};
export type WithExperienceInteractionContext<
ContextT extends IRouterParamContext = IRouterParamContext,
> = ContextT & {
experienceInteraction: ExperienceInteraction;
};

/**
* @overview This middleware initializes the `ExperienceInteraction` for the current request.
Expand Down Expand Up @@ -40,6 +41,17 @@ export default function koaExperienceInteraction<

ctx.experienceInteraction = new ExperienceInteraction(ctx, tenant, interactionDetails);

return next();
try {
await next();
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- make sure the interaction is initialized
if (ctx.experienceInteraction) {
ctx.prependAllLogEntries({
interaction: ctx.experienceInteraction.toJson(),
});
}

throw error;
}
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { type VerificationType } from '@logto/schemas';
import { type Action } from '@logto/schemas/lib/types/log/interaction.js';
import { type MiddlewareType } from 'koa';
import { type IRouterParamContext } from 'koa-router';

import { type LogContext, type LogEntry } from '#src/middleware/koa-audit-log.js';

import { type WithExperienceInteractionContext } from './koa-experience-interaction.js';

type WithExperienceVerificationAuditLogContext<ContextT extends IRouterParamContext> = ContextT & {
verificationAuditLog: LogEntry;
};

export default function koaExperienceVerificationsAuditLog<
StateT,
ContextT extends WithExperienceInteractionContext & LogContext,
ResponseT,
>({
type,
action,
}: {
type: VerificationType;
action: Action;
}): MiddlewareType<StateT, WithExperienceVerificationAuditLogContext<ContextT>, ResponseT> {
return async (ctx, next) => {
const { experienceInteraction, createLog } = ctx;

const log = createLog(
`Interaction.${experienceInteraction.interactionEvent}.Verification.${type}.${action}`
);

ctx.verificationAuditLog = log;

return next();
};
}
26 changes: 21 additions & 5 deletions packages/core/src/routes/experience/profile-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,15 @@ export default function interactionProfileRoutes<T extends ExperienceInteraction
}),
verifiedInteractionGuard(),
async (ctx, next) => {
const { experienceInteraction, guard } = ctx;
const { experienceInteraction, guard, createLog } = ctx;
const profilePayload = guard.body;

const log = createLog(`Interaction.${experienceInteraction.interactionEvent}.Profile.Update`);

log.append({
payload: profilePayload,
});

switch (profilePayload.type) {
case SignInIdentifier.Email:
case SignInIdentifier.Phone: {
Expand Down Expand Up @@ -100,7 +106,7 @@ export default function interactionProfileRoutes<T extends ExperienceInteraction
status: [204, 400, 404, 422],
}),
async (ctx, next) => {
const { experienceInteraction, guard } = ctx;
const { experienceInteraction, guard, createLog } = ctx;
const { password } = guard.body;

assertThat(
Expand All @@ -111,6 +117,8 @@ export default function interactionProfileRoutes<T extends ExperienceInteraction
})
);

createLog(`Interaction.ForgotPassword.Profile.Update`);

// Guard interaction is identified
assertThat(
experienceInteraction.identifiedUserId,
Expand Down Expand Up @@ -159,17 +167,25 @@ export default function interactionProfileRoutes<T extends ExperienceInteraction
const { experienceInteraction, guard } = ctx;
const { type, verificationId } = guard.body;

const log = ctx.createLog(
`Interaction.${experienceInteraction.interactionEvent}.BindMfa.${type}.Submit`
);

log.append({
verificationId,
});

switch (type) {
case MfaFactor.TOTP: {
await experienceInteraction.mfa.addTotpByVerificationId(verificationId);
await experienceInteraction.mfa.addTotpByVerificationId(verificationId, log);
break;
}
case MfaFactor.WebAuthn: {
await experienceInteraction.mfa.addWebAuthnByVerificationId(verificationId);
await experienceInteraction.mfa.addWebAuthnByVerificationId(verificationId, log);
break;
}
case MfaFactor.BackupCode: {
await experienceInteraction.mfa.addBackupCodeByVerificationId(verificationId);
await experienceInteraction.mfa.addBackupCodeByVerificationId(verificationId, log);
break;
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { backupCodeVerificationVerifyPayloadGuard } from '@logto/schemas';
import { backupCodeVerificationVerifyPayloadGuard, VerificationType } from '@logto/schemas';
import { Action } from '@logto/schemas/lib/types/log/interaction.js';
import type Router from 'koa-router';
import { z } from 'zod';

Expand All @@ -8,6 +9,7 @@ import assertThat from '#src/utils/assert-that.js';

import { BackupCodeVerification } from '../classes/verifications/backup-code-verification.js';
import { experienceRoutes } from '../const.js';
import koaExperienceVerificationsAuditLog from '../middleware/koa-experience-verifications-audit-log.js';
import { type ExperienceInteractionRouterContext } from '../types.js';

export default function backupCodeVerificationRoutes<T extends ExperienceInteractionRouterContext>(
Expand All @@ -25,6 +27,10 @@ export default function backupCodeVerificationRoutes<T extends ExperienceInterac
codes: z.array(z.string()),
}),
}),
koaExperienceVerificationsAuditLog({
type: VerificationType.BackupCode,
action: Action.Create,
}),
async (ctx, next) => {
const { experienceInteraction } = ctx;

Expand Down Expand Up @@ -60,10 +66,20 @@ export default function backupCodeVerificationRoutes<T extends ExperienceInterac
}),
status: [200, 400, 404],
}),
koaExperienceVerificationsAuditLog({
type: VerificationType.BackupCode,
action: Action.Submit,
}),
async (ctx, next) => {
const { experienceInteraction } = ctx;
const { experienceInteraction, verificationAuditLog } = ctx;
const { code } = ctx.guard.body;

verificationAuditLog.append({
payload: {
code,
},
});

assertThat(experienceInteraction.identifiedUserId, 'session.identifier_not_found');

const backupCodeVerificationRecord = BackupCodeVerification.create(
Expand Down
Loading

0 comments on commit cf31e3a

Please sign in to comment.