Harden authentication, tenant isolation and security boundaries
This commit is contained in:
+45
-42
@@ -1,6 +1,7 @@
|
||||
import NextAuth from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import { compare } from "bcryptjs";
|
||||
import { limitPublicAction } from "@/lib/rate-limit";
|
||||
import { z } from "zod";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -14,12 +15,12 @@ import { prisma } from "@/lib/prisma";
|
||||
// =====================================================================
|
||||
|
||||
const credentialsSchema = z.object({
|
||||
email: z.string().email().toLowerCase().trim(),
|
||||
password: z.string().min(1),
|
||||
email: z.string().trim().max(254).email().toLowerCase(),
|
||||
password: z.string().min(1).refine(value => Buffer.byteLength(value, "utf8") <= 72),
|
||||
});
|
||||
|
||||
// Brute-Force-Schutz auf Account-Ebene (planung.md §5.5).
|
||||
// Globales Rate-Limiting ergänzen wir später per Middleware.
|
||||
// Zusätzlich greifen atomare, instanzübergreifende DB-Limits vor bcrypt.
|
||||
const MAX_FAILED_ATTEMPTS = 10;
|
||||
const LOCKOUT_MINUTES = 15;
|
||||
|
||||
@@ -40,8 +41,14 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
if (!parsed.success) return null;
|
||||
const { email, password } = parsed.data;
|
||||
|
||||
if (!(await limitPublicAction("login", email, 20, 900))) return null;
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
if (!user) return null;
|
||||
if (!user) {
|
||||
// Match the cost for nonexistent accounts without exposing account existence.
|
||||
await compare(password, "$2b$12$KbQiLuL6QbybTw/WNx4Ige2yCZcjVccoruuDx3vmkJt8YYUpphdIu");
|
||||
return null;
|
||||
}
|
||||
if (user.disabledAt || !user.emailVerifiedAt) return null;
|
||||
|
||||
// Account temporär gesperrt → Login pauschal ablehnen.
|
||||
if (user.lockedUntil && user.lockedUntil > new Date()) {
|
||||
@@ -50,23 +57,18 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
|
||||
const passwordOk = await compare(password, user.passwordHash);
|
||||
if (!passwordOk) {
|
||||
const nextAttempts = user.failedLoginAttempts + 1;
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
failedLoginAttempts: nextAttempts,
|
||||
lockedUntil:
|
||||
nextAttempts >= MAX_FAILED_ATTEMPTS
|
||||
? new Date(Date.now() + LOCKOUT_MINUTES * 60_000)
|
||||
: user.lockedUntil,
|
||||
},
|
||||
});
|
||||
await prisma.$executeRaw`
|
||||
UPDATE users SET "failedLoginAttempts" = "failedLoginAttempts" + 1,
|
||||
"lockedUntil" = CASE WHEN "failedLoginAttempts" + 1 >= ${MAX_FAILED_ATTEMPTS}
|
||||
THEN NOW() + make_interval(mins => ${LOCKOUT_MINUTES}::int) ELSE "lockedUntil" END
|
||||
WHERE id = ${user.id}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Erfolgreicher Login → Counter zurücksetzen, lastLogin aktualisieren.
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
const admitted = await prisma.user.updateMany({
|
||||
where: { id: user.id, disabledAt: null, sessionVersion: user.sessionVersion, passwordHash: user.passwordHash, emailVerifiedAt: { not: null },
|
||||
OR: [{ lockedUntil: null }, { lockedUntil: { lte: new Date() } }] },
|
||||
data: {
|
||||
failedLoginAttempts: 0,
|
||||
lockedUntil: null,
|
||||
@@ -74,7 +76,10 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
},
|
||||
});
|
||||
|
||||
if (admitted.count !== 1) return null;
|
||||
return {
|
||||
sessionVersion: user.sessionVersion,
|
||||
authenticatedAt: Date.now(),
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: `${user.firstName} ${user.lastName}`.trim(),
|
||||
@@ -86,6 +91,13 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
async redirect({ url, baseUrl }) {
|
||||
if (/[\\\x00-\x20]/.test(url)) return baseUrl;
|
||||
try {
|
||||
const destination = new URL(url, baseUrl);
|
||||
return destination.origin === new URL(baseUrl).origin ? destination.href : baseUrl;
|
||||
} catch { return baseUrl; }
|
||||
},
|
||||
/**
|
||||
* Wird beim Login (mit `user`) und bei jedem Token-Refresh (ohne `user`)
|
||||
* aufgerufen. Beim Login schreiben wir die mandantenrelevanten Felder
|
||||
@@ -96,31 +108,20 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
token.role = user.role;
|
||||
token.kitaId = user.kitaId;
|
||||
token.familyId = user.familyId;
|
||||
return token;
|
||||
token.sessionVersion = user.sessionVersion;
|
||||
token.authenticatedAt = user.authenticatedAt;
|
||||
}
|
||||
|
||||
const tokenUserId = token.id ?? token.sub;
|
||||
if (tokenUserId) {
|
||||
const fresh = await prisma.user.findUnique({
|
||||
where: { id: tokenUserId },
|
||||
select: { role: true, kitaId: true, familyId: true },
|
||||
});
|
||||
if (!fresh) {
|
||||
// User wurde gelöscht → Token entwerten.
|
||||
// (Auth.js erkennt den fehlenden `sub`/`id` und meldet ab.)
|
||||
delete (token as Partial<typeof token>).id;
|
||||
delete (token as Partial<typeof token>).sub;
|
||||
return token;
|
||||
}
|
||||
token.id = tokenUserId;
|
||||
token.role = fresh.role;
|
||||
token.kitaId = fresh.kitaId;
|
||||
token.familyId = fresh.familyId;
|
||||
}
|
||||
|
||||
const id = token.id ?? token.sub;
|
||||
if (typeof id !== "string" || !id || id.length > 64 || !Number.isSafeInteger(token.sessionVersion) || !Number.isFinite(token.authenticatedAt) || token.authenticatedAt > Date.now() + 60_000) return null;
|
||||
const fresh = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: { role: true, kitaId: true, familyId: true, disabledAt: true, sessionVersion: true, emailVerifiedAt: true },
|
||||
});
|
||||
if (!fresh || fresh.disabledAt || !fresh.emailVerifiedAt || fresh.sessionVersion !== token.sessionVersion) return null;
|
||||
token.id = id;
|
||||
token.role = fresh.role;
|
||||
token.kitaId = fresh.kitaId;
|
||||
token.familyId = fresh.familyId;
|
||||
return token;
|
||||
},
|
||||
|
||||
@@ -131,7 +132,9 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
*/
|
||||
async session({ session, token }) {
|
||||
if (token && session.user) {
|
||||
session.user.id = token.id ?? token.sub;
|
||||
session.user.id = token.id;
|
||||
session.user.sessionVersion = token.sessionVersion;
|
||||
session.user.authenticatedAt = token.authenticatedAt;
|
||||
session.user.role = token.role;
|
||||
session.user.kitaId = token.kitaId;
|
||||
session.user.familyId = token.familyId;
|
||||
|
||||
Reference in New Issue
Block a user