For Guard's jarl preset and fail2ban: msg="login failed" ip=... user=... and msg="token refused" ip=..., beside the audit records. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
95125e7f50
commit
71ebed2b73
6 files changed
+56
No files matched your search
@@ -6,6 +6,14 @@ All notable changes to Jarl are documented here. The format follows
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.6.3] - 2026-10-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -360,6 +360,23 @@ cp /etc/bind/named.conf.pre-jarl-<date> /etc/bind/named.conf
|
|||||||
rndc reconfig
|
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
|
## Monitoring
|
||||||
|
|
||||||
* `GET /healthz` (no authentication) reports that jarld is up.
|
* `GET /healthz` (no authentication) reports that jarld is up.
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
p, err := s.tokenPrincipal(r.Context(), strings.TrimSpace(tok), ip, r.UserAgent())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.limiter.fail("ip:" + ip)
|
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())
|
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()}
|
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 {
|
if err == nil && u.Disabled && ok {
|
||||||
reason = "account disabled"
|
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)
|
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"}
|
return &httpError{http.StatusUnauthorized, "login_failed", "wrong username or password"}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,6 +198,8 @@ func (s *Server) httpreqPrincipal(w http.ResponseWriter, r *http.Request) (*auth
|
|||||||
}
|
}
|
||||||
fail := func(reason string) error {
|
fail := func(reason string) error {
|
||||||
s.limiter.fail("ip:" + ip)
|
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)
|
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)
|
w.Header().Set("WWW-Authenticate", httpreqRealm)
|
||||||
return &httpError{http.StatusUnauthorized, "unauthenticated", "invalid credentials: " + reason}
|
return &httpError{http.StatusUnauthorized, "unauthenticated", "invalid credentials: " + reason}
|
||||||
|
|||||||
@@ -206,6 +206,8 @@ func (s *Server) loginTOTP(w http.ResponseWriter, r *http.Request) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
s.limiter.fail("ip:"+ip, userKey)
|
s.limiter.fail("ip:"+ip, userKey)
|
||||||
left := s.pendingLogins.fail(in.Challenge)
|
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())
|
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"
|
msg := "wrong code: use the current code from your authenticator app, or a recovery code"
|
||||||
if left == 0 {
|
if left == 0 {
|
||||||
|
|||||||
Reference in new issue
Block a user