Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/api/src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -665,7 +665,7 @@ describe("AuthService.refresh", () => {
}),
);
expect(jwtService.signAsync).toHaveBeenCalledWith(
expect.objectContaining({ sub: user.id }),
expect.objectContaining({ sub: user.id, sid: "rt-1" }),
expect.objectContaining({
algorithm: "HS256",
issuer: "loomkeep-api",
Expand Down
17 changes: 13 additions & 4 deletions apps/api/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,7 @@ export class AuthService {
);
}

const signed = await this.signTokens(stored.user);
const signed = await this.signTokens(stored.user, stored.id);
const rotated = await this.prisma.$transaction(async (tx) => {
const update = await tx.refreshToken.updateMany({
where: { id: stored.id, tokenHash },
Expand Down Expand Up @@ -680,13 +680,20 @@ export class AuthService {
}

/** Signs a fresh access/refresh pair. Persistence is the caller's job. */
private async signTokens(user: User): Promise<{
private async signTokens(
user: User,
sessionId: string,
): Promise<{
accessToken: string;
refreshToken: string;
jti: string;
expiresAt: Date;
}> {
const payload: JwtPayload = { sub: user.id, email: user.email };
const payload: JwtPayload = {
sub: user.id,
email: user.email,
sid: sessionId,
};

const accessToken = await this.jwtService.signAsync(payload, {
secret: this.configService.getOrThrow<string>("JWT_ACCESS_SECRET"),
Expand Down Expand Up @@ -776,12 +783,14 @@ export class AuthService {
user: User,
userAgent?: string,
): Promise<AuthTokensDto> {
const signed = await this.signTokens(user);
const sessionId = randomUUID();
const signed = await this.signTokens(user, sessionId);
await this.prisma.refreshToken.deleteMany({
where: { userId: user.id, userAgent: userAgent ?? null },
});
await this.prisma.refreshToken.create({
data: {
id: sessionId,
userId: user.id,
tokenHash: hashToken(signed.refreshToken),
jti: signed.jti,
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/auth/decorators/current-user.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export interface JwtPayload {
/** User ID. */
sub: string;
email: string;
/** Stable server-side session ID. */
sid?: string;
}

export interface AuthenticatedRequest extends FastifyRequest {
Expand Down
18 changes: 12 additions & 6 deletions apps/api/src/auth/mfa.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,15 +141,21 @@
}),
);

const first = await service.confirmTotp("user-1", code);
const first = await service.confirmTotp("user-1", code, "session-1");

Check warning on line 144 in apps/api/src/auth/mfa.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/auth/mfa.service.spec.ts#L144

Added line #L144 was not covered by tests
expect(first.recoveryCodes).toHaveLength(RECOVERY_CODE_COUNT);
expect(prisma.refreshToken.deleteMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
where: { userId: "user-1", id: { not: "session-1" } },
});

const second = await service.setEmailMfaEnabled("user-1", true);
const second = await service.setEmailMfaEnabled(

Check warning on line 150 in apps/api/src/auth/mfa.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/auth/mfa.service.spec.ts#L150

Added line #L150 was not covered by tests
"user-1",
true,
"session-1",
);
expect(second.recoveryCodes).toBeUndefined();
expect(prisma.refreshToken.deleteMany).toHaveBeenCalledTimes(2);
expect(prisma.refreshToken.deleteMany).toHaveBeenLastCalledWith({

Check warning on line 156 in apps/api/src/auth/mfa.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/auth/mfa.service.spec.ts#L156

Added line #L156 was not covered by tests
where: { userId: "user-1", id: { not: "session-1" } },
});
});

it("confirmTotp rejects an invalid code", async () => {
Expand Down Expand Up @@ -185,14 +191,14 @@
makeUser({ passwordHash: await bcrypt.hash("correct", 4) }),
);

await service.disableTotp("user-1", "correct");
await service.disableTotp("user-1", "correct", "session-1");

Check warning on line 194 in apps/api/src/auth/mfa.service.spec.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/auth/mfa.service.spec.ts#L194

Added line #L194 was not covered by tests

expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { mfaTotpEnabled: false, mfaTotpSecretEnc: null },
});
expect(prisma.refreshToken.deleteMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
where: { userId: "user-1", id: { not: "session-1" } },
});
});
});
38 changes: 31 additions & 7 deletions apps/api/src/auth/mfa.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export class MfaService {
async confirmTotp(
userId: string,
code: string,
currentSessionId?: string,
): Promise<{ recoveryCodes?: string[] }> {
const user = await this.prisma.user.findUniqueOrThrow({
where: { id: userId },
Expand All @@ -97,14 +98,18 @@ export class MfaService {
where: { id: userId },
data: { mfaTotpEnabled: true },
}),
this.prisma.refreshToken.deleteMany({ where: { userId } }),
this.deleteOtherSessionsQuery(userId, currentSessionId),
]);

const recoveryCodes = await this.ensureRecoveryCodes(userId);
return { recoveryCodes };
}

async disableTotp(userId: string, currentPassword: string): Promise<void> {
async disableTotp(
userId: string,
currentPassword: string,
currentSessionId?: string,
): Promise<void> {
const user = await this.prisma.user.findUniqueOrThrow({
where: { id: userId },
});
Expand All @@ -121,20 +126,21 @@ export class MfaService {
where: { id: userId },
data: { mfaTotpEnabled: false, mfaTotpSecretEnc: null },
}),
this.prisma.refreshToken.deleteMany({ where: { userId } }),
this.deleteOtherSessionsQuery(userId, currentSessionId),
]);
}

async setEmailMfaEnabled(
userId: string,
enabled: boolean,
currentSessionId?: string,
): Promise<{ recoveryCodes?: string[] }> {
await this.prisma.$transaction([
this.prisma.user.update({
where: { id: userId },
data: { mfaEmailEnabled: enabled },
}),
this.prisma.refreshToken.deleteMany({ where: { userId } }),
this.deleteOtherSessionsQuery(userId, currentSessionId),
]);

const recoveryCodes = enabled
Expand Down Expand Up @@ -163,10 +169,14 @@ export class MfaService {
}

/** Generates a fresh batch of 10, deleting any existing ones first. */
async regenerateRecoveryCodes(userId: string): Promise<string[]> {
async regenerateRecoveryCodes(
userId: string,
currentSessionId?: string,
): Promise<string[]> {
const codes = await this.generateRecoveryCodes(userId, {
deleteExisting: true,
revokeSessions: true,
currentSessionId,
});
return codes;
}
Expand All @@ -182,6 +192,7 @@ export class MfaService {
return this.generateRecoveryCodes(userId, {
deleteExisting: false,
revokeSessions: false,
currentSessionId: undefined,
});
}

Expand All @@ -190,7 +201,12 @@ export class MfaService {
{
deleteExisting,
revokeSessions,
}: { deleteExisting: boolean; revokeSessions: boolean },
currentSessionId,
}: {
deleteExisting: boolean;
revokeSessions: boolean;
currentSessionId?: string;
},
): Promise<string[]> {
const codes = Array.from({ length: RECOVERY_CODE_COUNT }, () =>
generateRecoveryCode(),
Expand All @@ -207,13 +223,21 @@ export class MfaService {
this.prisma.mfaRecoveryCode.create({ data: { userId, codeHash } }),
),
...(revokeSessions
? [this.prisma.refreshToken.deleteMany({ where: { userId } })]
? [this.deleteOtherSessionsQuery(userId, currentSessionId)]
: []),
]);

return codes;
}

private deleteOtherSessionsQuery(userId: string, currentSessionId?: string) {
return this.prisma.refreshToken.deleteMany({
where: currentSessionId
? { userId, id: { not: currentSessionId } }
: { userId },
});
}

/** Normalizes (strips separators, uppercases), matches, and deletes the consumed row. */
async verifyRecoveryCode(userId: string, rawCode: string): Promise<boolean> {
const normalized = rawCode.replace(/[\s-]/g, "").toUpperCase();
Expand Down
19 changes: 15 additions & 4 deletions apps/api/src/users/mfa.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,19 @@
@CurrentUser() payload: JwtPayload,
@Body() dto: ConfirmTotpDto,
): Promise<ConfirmTotpResponseDto> {
return this.mfaService.confirmTotp(payload.sub, dto.code);
return this.mfaService.confirmTotp(payload.sub, dto.code, payload.sid);

Check warning on line 46 in apps/api/src/users/mfa.controller.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/users/mfa.controller.ts#L46

Added line #L46 was not covered by tests
}

@Post("totp/disable")
async disableTotp(
@CurrentUser() payload: JwtPayload,
@Body() dto: DisableTotpDto,
): Promise<void> {
await this.mfaService.disableTotp(payload.sub, dto.currentPassword);
await this.mfaService.disableTotp(

Check warning on line 54 in apps/api/src/users/mfa.controller.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/users/mfa.controller.ts#L54

Added line #L54 was not covered by tests
payload.sub,
dto.currentPassword,
payload.sid,
);
}

@Patch("email")
Expand All @@ -60,7 +64,11 @@
@CurrentUser() payload: JwtPayload,
@Body() dto: SetEmailMfaDto,
): Promise<SetEmailMfaResponseDto> {
return this.mfaService.setEmailMfaEnabled(payload.sub, dto.enabled);
return this.mfaService.setEmailMfaEnabled(

Check warning on line 67 in apps/api/src/users/mfa.controller.ts

View check run for this annotation

Codecov / codecov/patch

apps/api/src/users/mfa.controller.ts#L67

Added line #L67 was not covered by tests
payload.sub,
dto.enabled,
payload.sid,
);
}

// Authenticated-only, but still a sensitive/spammy-if-abused action.
Expand All @@ -71,7 +79,10 @@
@CurrentUser() payload: JwtPayload,
): Promise<RegenerateRecoveryCodesResponseDto> {
return {
codes: await this.mfaService.regenerateRecoveryCodes(payload.sub),
codes: await this.mfaService.regenerateRecoveryCodes(
payload.sub,
payload.sid,
),
};
}
}
Loading