Auth: log failed attempts

This commit is contained in:
shamoon
2026-08-07 08:13:57 -07:00
parent 550441b455
commit d8b2a33d5b
3 changed files with 31 additions and 0 deletions
+2
View File
@@ -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`. 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 `<nextauth> 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): For OIDC login (overrides password login):
- `HOMEPAGE_OIDC_ISSUER` (OIDC issuer URL, e.g., `https://auth.example.com/realms/homepage`) - `HOMEPAGE_OIDC_ISSUER` (OIDC issuer URL, e.g., `https://auth.example.com/realms/homepage`)
@@ -148,6 +148,28 @@ describe("pages/api/auth/[...nextauth]", () => {
await expect(provider.options.authorize({ password: 123 })).resolves.toBeNull(); 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 () => { it("compares multibyte passwords without throwing on unequal byte lengths", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true"; process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_AUTH_PASSWORD = "é"; process.env.HOMEPAGE_AUTH_PASSWORD = "é";
+7
View File
@@ -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 = []; let providers = [];
if (authEnabled) { if (authEnabled) {
if (hasOidcConfig) { if (hasOidcConfig) {
@@ -104,11 +109,13 @@ if (authEnabled) {
async authorize(credentials) { async authorize(credentials) {
const provided = credentials?.password; const provided = credentials?.password;
if (!homepageAuthPasswordDigest || typeof provided !== "string") { if (!homepageAuthPasswordDigest || typeof provided !== "string") {
logFailedPasswordSignIn();
return null; return null;
} }
const providedDigest = createHash("sha256").update(provided, "utf8").digest(); const providedDigest = createHash("sha256").update(provided, "utf8").digest();
const isMatch = timingSafeEqual(providedDigest, homepageAuthPasswordDigest); const isMatch = timingSafeEqual(providedDigest, homepageAuthPasswordDigest);
if (!isMatch) { if (!isMatch) {
logFailedPasswordSignIn();
return null; return null;
} }
return { return {