mirror of
https://github.com/gethomepage/homepage.git
synced 2026-09-27 14:31:17 -07:00
Auth: log failed attempts
This commit is contained in:
@@ -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 = "é";
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user