From 1407f7ab62434894fedf0222b3baab769bfd531a Mon Sep 17 00:00:00 2001 From: abrown38 Date: Thu, 22 Oct 2026 16:29:37 +0100 Subject: [PATCH] Log failed logins and refused tokens, address first For Guard's steward preset and fail2ban: msg="login failed" ip=... user=... and msg="token refused" ip=..., beside the audit records. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++++++ docs/operations.md | 16 ++++++++++++++++ internal/server/authlog_test.go | 25 +++++++++++++++++++++++++ internal/server/authn.go | 4 ++++ 4 files changed, 51 insertions(+) create mode 100644 internal/server/authlog_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 79c642f..6880492 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes to Steward are documented here. The format follows ### Added +- **Failed logins in the journal.** A failed login and a refused API + token are logged as well as audited, with the address first + (`msg="login failed" ip=… user=…`, `msg="token refused" ip=…`), for + Guard's `steward` preset or a fail2ban filter to ban such addresses + (docs/operations.md). + - **Allow and deny lists per subnet** (docs/subnets.md): a deny-list subnet (the default) answers everyone not denied; an allow-list subnet only clients allowed or with a reservation. Entries are MAC addresses, diff --git a/docs/operations.md b/docs/operations.md index 99836c1..5f9e6f1 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -69,6 +69,22 @@ clients renewing them get a NAK and ask again. database when it starts, and an older one refuses a database a newer one has upgraded: restore a backup to go back. +## Failed logins in the journal + +A failed login to the web UI or the API, and a refused API token, are +written to the journal as well as the audit log, with the address first: + +``` +level=WARN msg="login failed" ip=203.0.113.9 user=admin reason="wrong username or password" +level=WARN msg="token refused" ip=203.0.113.9 reason="wrong token secret" +``` + +Guard's `steward` preset, or a fail2ban filter, can ban such addresses at +the firewall (with Housecarl's ban lists). The address comes before the +user name, which a caller chooses, so a pattern anchored on +`msg="login failed" ip=` can't be fooled by a name. With `log.format: +json` they are JSON objects with the same fields in the same order. + ## Checks `stewardd check` validates the configuration in the database against diff --git a/internal/server/authlog_test.go b/internal/server/authlog_test.go new file mode 100644 index 0000000..f83257d --- /dev/null +++ b/internal/server/authlog_test.go @@ -0,0 +1,25 @@ +package server + +import ( + "bytes" + "log/slog" + "strings" + "testing" + + "steward/internal/api" +) + +// TestFailedLoginLogged: a failed login and a refused token are logged +// with the address first, for Guard's and fail2ban's patterns. +func TestFailedLoginLogged(t *testing.T) { + e := newEnv(t) + var buf bytes.Buffer + e.srv.log = slog.New(slog.NewTextHandler(&buf, nil)) + e.call("", "POST", "/auth/login", api.Login{Username: "x ip=8.8.8.8", Password: "wrong"}, nil) + e.call("steward_aaaaaaaaaaaaaaaa_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "GET", "/status", nil, nil) + for _, want := range []string{`msg="login failed" ip=127.0.0.1 user="x ip=8.8.8.8"`, `msg="token refused" ip=127.0.0.1 reason=`} { + if !strings.Contains(buf.String(), want) { + t.Errorf("no %q in\n%s", want, buf.String()) + } + } +} diff --git a/internal/server/authn.go b/internal/server/authn.go index e5e0144..8f6411e 100644 --- a/internal/server/authn.go +++ b/internal/server/authn.go @@ -102,6 +102,8 @@ func (s *Server) authenticate(r *http.Request) (*auth.Principal, *model.Session, p, err := s.tokenPrincipal(r.Context(), strings.TrimSpace(tok), ip, r.UserAgent()) if err != nil { s.limiter.fail("ip:" + ip) + // For Guard and fail2ban: the address first, so a name can't fake it. + s.log.Warn("token refused", "ip", ip, "reason", err.Error()) s.eng.Audit(r.Context(), &auth.Principal{IP: ip, Method: "token"}, "auth.token_failed", "", "failure", err.Error()) return nil, nil, &httpError{http.StatusUnauthorized, "unauthenticated", "invalid API token: " + err.Error()} } @@ -246,6 +248,8 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) error { if err == nil && u.Disabled && ok { reason = "account disabled" } + // For Guard and fail2ban: the address first, so a name can't fake it. + s.log.Warn("login failed", "ip", ip, "user", name, "reason", reason) s.eng.Audit(r.Context(), who, "auth.login_failed", "user "+name, "failure", reason) return &httpError{http.StatusUnauthorized, "login_failed", "wrong username or password"} }