fix(security): invalidate sessions on password change and remove hash from permission API responses
- setUserPassword and resetPassword now call activeSession.deleteMany after updating the passwordHash, so any pre-change sessions are immediately revoked (CWE-613 session fixation after credential change) - setUserPermissions and resetUserPermissions now use explicit Prisma select to exclude passwordHash and totpSecret from the returned user object Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import { z } from "zod";
|
|||||||
import { createTRPCRouter, publicProcedure } from "../trpc.js";
|
import { createTRPCRouter, publicProcedure } from "../trpc.js";
|
||||||
import { getAppBaseUrl } from "../lib/app-base-url.js";
|
import { getAppBaseUrl } from "../lib/app-base-url.js";
|
||||||
import { sendEmail } from "../lib/email.js";
|
import { sendEmail } from "../lib/email.js";
|
||||||
|
import { authRateLimiter } from "../middleware/rate-limit.js";
|
||||||
|
|
||||||
const RESET_TTL_MS = 60 * 60 * 1000; // 1 hour
|
const RESET_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||||
|
|
||||||
@@ -26,6 +27,14 @@ export const authRouter = createTRPCRouter({
|
|||||||
requestPasswordReset: publicProcedure
|
requestPasswordReset: publicProcedure
|
||||||
.input(z.object({ email: z.string().email() }))
|
.input(z.object({ email: z.string().email() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const rl = await authRateLimiter(input.email);
|
||||||
|
if (!rl.allowed) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "TOO_MANY_REQUESTS",
|
||||||
|
message: "Too many password reset attempts. Please wait before trying again.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const user = await ctx.db.user.findUnique({
|
const user = await ctx.db.user.findUnique({
|
||||||
where: { email: input.email },
|
where: { email: input.email },
|
||||||
select: { id: true, email: true },
|
select: { id: true, email: true },
|
||||||
@@ -69,6 +78,14 @@ export const authRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const rl = await authRateLimiter(input.token);
|
||||||
|
if (!rl.allowed) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "TOO_MANY_REQUESTS",
|
||||||
|
message: "Too many password reset attempts. Please wait before trying again.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const record = await ctx.db.passwordResetToken.findUnique({
|
const record = await ctx.db.passwordResetToken.findUnique({
|
||||||
where: { token: input.token },
|
where: { token: input.token },
|
||||||
});
|
});
|
||||||
@@ -86,11 +103,16 @@ export const authRouter = createTRPCRouter({
|
|||||||
const { hash } = await import("@node-rs/argon2");
|
const { hash } = await import("@node-rs/argon2");
|
||||||
const passwordHash = await hash(input.password);
|
const passwordHash = await hash(input.password);
|
||||||
|
|
||||||
await ctx.db.user.update({
|
const updatedUser = await ctx.db.user.update({
|
||||||
where: { email: record.email },
|
where: { email: record.email },
|
||||||
data: { passwordHash },
|
data: { passwordHash },
|
||||||
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Invalidate all active sessions so any session obtained before the
|
||||||
|
// password reset cannot be reused (CWE-613).
|
||||||
|
await ctx.db.activeSession.deleteMany({ where: { userId: updatedUser.id } });
|
||||||
|
|
||||||
await ctx.db.passwordResetToken.update({
|
await ctx.db.passwordResetToken.update({
|
||||||
where: { token: input.token },
|
where: { token: input.token },
|
||||||
data: { usedAt: new Date() },
|
data: { usedAt: new Date() },
|
||||||
|
|||||||
@@ -171,6 +171,10 @@ export async function setUserPassword(
|
|||||||
data: { passwordHash },
|
data: { passwordHash },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Invalidate all active sessions so any compromised session cannot be
|
||||||
|
// reused after the password is changed (CWE-613).
|
||||||
|
await ctx.db.activeSession.deleteMany({ where: { userId: input.userId } });
|
||||||
|
|
||||||
audit({
|
audit({
|
||||||
entityType: "User",
|
entityType: "User",
|
||||||
entityId: user.id,
|
entityId: user.id,
|
||||||
@@ -381,6 +385,7 @@ export async function setUserPermissions(
|
|||||||
const user = await ctx.db.user.update({
|
const user = await ctx.db.user.update({
|
||||||
where: { id: input.userId },
|
where: { id: input.userId },
|
||||||
data: { permissionOverrides: input.overrides ?? Prisma.DbNull },
|
data: { permissionOverrides: input.overrides ?? Prisma.DbNull },
|
||||||
|
select: { id: true, name: true, email: true, permissionOverrides: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
audit({
|
audit({
|
||||||
@@ -414,6 +419,7 @@ export async function resetUserPermissions(
|
|||||||
const updated = await ctx.db.user.update({
|
const updated = await ctx.db.user.update({
|
||||||
where: { id: input.userId },
|
where: { id: input.userId },
|
||||||
data: { permissionOverrides: Prisma.DbNull },
|
data: { permissionOverrides: Prisma.DbNull },
|
||||||
|
select: { id: true, name: true, email: true, permissionOverrides: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
audit({
|
audit({
|
||||||
|
|||||||
Reference in New Issue
Block a user