Add admin password change flow (closes #65) #83

Merged
sneak merged 4 commits from issue-65-password-change into main 2026-08-07 23:23:05 +02:00
4 changed files with 102 additions and 19 deletions
Showing only changes of commit 8362ce9ee0 - Show all commits

View File

@@ -32,3 +32,7 @@ func IsClientTLS(r *http.Request) bool {
// LoginRateLimitConst exposes the loginRateLimit constant.
const LoginRateLimitConst = loginRateLimit
// PasswordChangeRateLimitConst exposes the
// passwordChangeRateLimit constant.
const PasswordChangeRateLimitConst = passwordChangeRateLimit

View File

@@ -14,6 +14,16 @@ const (
// loginRateInterval is the time window for the rate limit.
loginRateInterval = 1 * time.Minute
// passwordChangeRateLimit is the maximum number of password
// change attempts per interval. Each attempt verifies the
// current password, so the endpoint must be rate-limited
// like any other password-based authentication endpoint.
passwordChangeRateLimit = 5
// passwordChangeRateInterval is the time window for the
// password change rate limit.
passwordChangeRateInterval = 1 * time.Minute
)
// LoginRateLimit returns middleware that enforces per-IP rate
@@ -24,19 +34,53 @@ const (
// honours X-Forwarded-For, X-Real-IP, and True-Client-IP headers
// for reverse-proxy setups.
func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
limiter := httprate.Limit(
return m.postRateLimit(
loginRateLimit,
loginRateInterval,
"login rate limit exceeded",
"Too many login attempts. Please try again later.",
)
}
// PasswordChangeRateLimit returns middleware that enforces
// per-IP rate limiting on password change attempts. The change
// endpoint verifies the current password, so without a limit a
// stolen session could be used to brute-force it; the limit
// matches the login endpoint's.
func (m *Middleware) PasswordChangeRateLimit() func(http.Handler) http.Handler {
return m.postRateLimit(
passwordChangeRateLimit,
passwordChangeRateInterval,
"password change rate limit exceeded",
"Too many password change attempts. "+
"Please try again later.",
)
}
// postRateLimit builds middleware that enforces a per-IP rate
// limit on POST requests only; all other methods pass through
// unaffected. Requests over the limit receive a 429 with the
// given response message, and each rejection is logged with the
// given log message. IP extraction honours X-Forwarded-For,
// X-Real-IP, and True-Client-IP headers for reverse-proxy
// setups.
func (m *Middleware) postRateLimit(
limit int,
interval time.Duration,
logMessage, responseMessage string,
) func(http.Handler) http.Handler {
limiter := httprate.Limit(
limit,
interval,
httprate.WithKeyFuncs(httprate.KeyByRealIP),
httprate.WithLimitHandler(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
m.log.Warn("login rate limit exceeded",
m.log.Warn(logMessage,
"path", r.URL.Path,
)
http.Error(
w,
"Too many login attempts. "+
"Please try again later.",
responseMessage,
http.StatusTooManyRequests,
)
},
@@ -50,8 +94,7 @@ func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
w http.ResponseWriter,
r *http.Request,
) {
// Only rate-limit POST requests (actual login
// attempts)
// Only rate-limit POST requests.
if r.Method != http.MethodPost {
next.ServeHTTP(w, r)

View File

@@ -46,14 +46,20 @@ func TestLoginRateLimit_AllowsGET(t *testing.T) {
assert.Equal(t, 20, callCount)
}
func TestLoginRateLimit_LimitsPOST(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
// runPostLimitTest exercises a POST-only rate limit middleware:
// the first limit POSTs to path from ip must pass, and the next
// one must be rejected with 429 without reaching the handler.
func runPostLimitTest(
t *testing.T,
mw func(http.Handler) http.Handler,
limit int,
path, ip string,
) {
t.Helper()
var callCount int
handler := m.LoginRateLimit()(http.HandlerFunc(
handler := mw(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
callCount++
@@ -61,13 +67,13 @@ func TestLoginRateLimit_LimitsPOST(t *testing.T) {
},
))
// First loginRateLimit POST requests should succeed
for i := range middleware.LoginRateLimitConst {
// The first limit POST requests should succeed
for i := range limit {
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost, "/pages/login", nil,
http.MethodPost, path, nil,
)
req.RemoteAddr = "10.0.0.1:12345"
req.RemoteAddr = ip
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
@@ -81,9 +87,9 @@ func TestLoginRateLimit_LimitsPOST(t *testing.T) {
// Next POST should be rate-limited
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost, "/pages/login", nil,
http.MethodPost, path, nil,
)
req.RemoteAddr = "10.0.0.1:12345"
req.RemoteAddr = ip
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
@@ -92,7 +98,35 @@ func TestLoginRateLimit_LimitsPOST(t *testing.T) {
t, http.StatusTooManyRequests, w.Code,
"POST after limit should be 429",
)
assert.Equal(t, middleware.LoginRateLimitConst, callCount)
assert.Equal(t, limit, callCount)
}
func TestLoginRateLimit_LimitsPOST(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
runPostLimitTest(
t,
m.LoginRateLimit(),
middleware.LoginRateLimitConst,
"/pages/login",
"10.0.0.1:12345",
)
}
func TestPasswordChangeRateLimit_LimitsPOST(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
runPostLimitTest(
t,
m.PasswordChangeRateLimit(),
middleware.PasswordChangeRateLimitConst,
"/user/admin/password",
"10.0.0.2:12345",
)
}
func TestLoginRateLimit_IndependentPerIP(t *testing.T) {

View File

@@ -110,7 +110,9 @@ func (s *Server) setupUserRoutes() {
r.Use(s.mw.NoCache())
r.Use(s.mw.RequireAuth())
r.Get("/", s.h.HandleProfile())
r.Post("/password", s.h.HandlePasswordChange())
r.With(s.mw.PasswordChangeRateLimit()).Post(
"/password", s.h.HandlePasswordChange(),
)
})
}