diff --git a/docs/installation/index.md b/docs/installation/index.md index 467b82bad..e747afc4d 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -59,6 +59,8 @@ For password-only login: Homepage does not apply application-level rate limiting to password attempts. Deployments exposed outside a trusted network should configure their reverse proxy or ingress to rate limit POST requests to `/api/auth/callback/credentials`. + Each failed attempt is logged at `warn` level as ` Failed password sign-in attempt`, which can be used as a fail2ban or CrowdSec filter. Note that a failed and a successful sign-in are both a `302` response, so a reverse proxy access log alone cannot distinguish them. No client address is recorded in this message: `X-Forwarded-For` is caller-supplied and a ban rule keyed on it could be tricked into blocking arbitrary addresses. Correlate the log timestamp with your reverse proxy's access log to identify the source. + For OIDC login (overrides password login): - `HOMEPAGE_OIDC_ISSUER` (OIDC issuer URL, e.g., `https://auth.example.com/realms/homepage`) diff --git a/src/__tests__/pages/api/auth/[...nextauth].test.js b/src/__tests__/pages/api/auth/[...nextauth].test.js index c1a4446cb..6d1c9482f 100644 --- a/src/__tests__/pages/api/auth/[...nextauth].test.js +++ b/src/__tests__/pages/api/auth/[...nextauth].test.js @@ -148,6 +148,28 @@ describe("pages/api/auth/[...nextauth]", () => { await expect(provider.options.authorize({ password: 123 })).resolves.toBeNull(); }); + it("logs failed password sign-in attempts without recording client-supplied data", async () => { + process.env.HOMEPAGE_AUTH_ENABLED = "true"; + process.env.HOMEPAGE_AUTH_PASSWORD = "secret"; + process.env.HOMEPAGE_AUTH_SECRET = "auth-secret"; + process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example"; + + const mod = await import("pages/api/auth/[...nextauth]"); + const [provider] = mod.default.options.providers; + + await provider.options.authorize({ password: "wrong" }); + await provider.options.authorize({ password: 123 }); + + expect(warnMock).toHaveBeenCalledTimes(2); + expect(warnMock).toHaveBeenCalledWith("Failed password sign-in attempt"); + // the attempted password must never reach the logs + expect(JSON.stringify(warnMock.mock.calls)).not.toContain("wrong"); + + warnMock.mockClear(); + await provider.options.authorize({ password: "secret" }); + expect(warnMock).not.toHaveBeenCalled(); + }); + it("compares multibyte passwords without throwing on unequal byte lengths", async () => { process.env.HOMEPAGE_AUTH_ENABLED = "true"; process.env.HOMEPAGE_AUTH_PASSWORD = "é"; diff --git a/src/pages/api/auth/[...nextauth].js b/src/pages/api/auth/[...nextauth].js index 0952dd3a5..6be0c059a 100644 --- a/src/pages/api/auth/[...nextauth].js +++ b/src/pages/api/auth/[...nextauth].js @@ -65,6 +65,11 @@ if (authEnabled) { } } +// Give fail2ban / CrowdSec etc something to match on +function logFailedPasswordSignIn() { + createLogger("nextauth").warn("Failed password sign-in attempt"); +} + let providers = []; if (authEnabled) { if (hasOidcConfig) { @@ -104,11 +109,13 @@ if (authEnabled) { async authorize(credentials) { const provided = credentials?.password; if (!homepageAuthPasswordDigest || typeof provided !== "string") { + logFailedPasswordSignIn(); return null; } const providedDigest = createHash("sha256").update(provided, "utf8").digest(); const isMatch = timingSafeEqual(providedDigest, homepageAuthPasswordDigest); if (!isMatch) { + logFailedPasswordSignIn(); return null; } return {