diff --git a/CHANGELOG.md b/CHANGELOG.md index 21457b8..9f39839 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ All notable changes to Jarl are documented here. The format follows ## [Unreleased] +### Added + +- **Failed logins in the journal.** A failed login (a wrong password, or + a wrong two-factor code) 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 `jarl` preset or a fail2ban + filter to ban such addresses (docs/operations.md). + ## [0.6.3] - 2026-10-21 ### Added diff --git a/docs/operations.md b/docs/operations.md index 194d60f..7a69c6a 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -360,6 +360,23 @@ cp /etc/bind/named.conf.pre-jarl- /etc/bind/named.conf rndc reconfig ``` +## Failed logins in the journal + +A failed login to the web UI or the API (a wrong password, or a wrong +two-factor code), 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 `jarl` 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. + ## Monitoring * `GET /healthz` (no authentication) reports that jarld is up. diff --git a/internal/server/authlog_test.go b/internal/server/authlog_test.go new file mode 100644 index 0000000..6ed99e6 --- /dev/null +++ b/internal/server/authlog_test.go @@ -0,0 +1,23 @@ +package server + +import ( + "bytes" + "log/slog" + "strings" + "testing" +) + +// 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 := newAPIEnv(t) + var buf bytes.Buffer + e.srv.log = slog.New(slog.NewTextHandler(&buf, nil)) + e.req("POST", "/auth/login", "", map[string]string{"username": "x ip=8.8.8.8", "password": "wrong"}) + e.req("GET", "/zones", "jarl_aaaaaaaaaaaaaaaa_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", 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 d6e5736..dc4761f 100644 --- a/internal/server/authn.go +++ b/internal/server/authn.go @@ -136,6 +136,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, Agent: r.UserAgent(), Method: "token"}, "auth.token_failed", "", 0, "", "failure", err.Error()) return nil, nil, &httpError{http.StatusUnauthorized, "unauthenticated", "invalid API token: " + err.Error()} } @@ -328,6 +330,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(), &auth.Principal{User: &model.User{Username: name}, IP: ip, Agent: r.UserAgent(), Method: "session"}, "auth.login_failed", "user", 0, name, "failure", reason) return &httpError{http.StatusUnauthorized, "login_failed", "wrong username or password"} } diff --git a/internal/server/h_acme.go b/internal/server/h_acme.go index d2ea0e8..3375094 100644 --- a/internal/server/h_acme.go +++ b/internal/server/h_acme.go @@ -198,6 +198,8 @@ func (s *Server) httpreqPrincipal(w http.ResponseWriter, r *http.Request) (*auth } fail := func(reason string) error { 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", "httpreq: "+reason) s.eng.Audit(r.Context(), &auth.Principal{User: &model.User{Username: user}, IP: ip, Agent: r.UserAgent(), Method: "token"}, "auth.token_failed", "", 0, "", "failure", "httpreq: "+reason) w.Header().Set("WWW-Authenticate", httpreqRealm) return &httpError{http.StatusUnauthorized, "unauthenticated", "invalid credentials: " + reason} diff --git a/internal/server/h_totp.go b/internal/server/h_totp.go index 42f96e6..65ad680 100644 --- a/internal/server/h_totp.go +++ b/internal/server/h_totp.go @@ -206,6 +206,8 @@ func (s *Server) loginTOTP(w http.ResponseWriter, r *http.Request) error { if err != nil { s.limiter.fail("ip:"+ip, userKey) left := s.pendingLogins.fail(in.Challenge) + // For Guard and fail2ban: the address first, so a name can't fake it. + s.log.Warn("login failed", "ip", ip, "user", u.Username, "reason", "wrong code: "+err.Error()) s.eng.Audit(r.Context(), who, "auth.totp_failed", "user", u.ID, u.Username, "failure", err.Error()) msg := "wrong code: use the current code from your authenticator app, or a recovery code" if left == 0 {