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. // LoginRateLimitConst exposes the loginRateLimit constant.
const LoginRateLimitConst = loginRateLimit 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 is the time window for the rate limit.
loginRateInterval = 1 * time.Minute 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 // 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 // honours X-Forwarded-For, X-Real-IP, and True-Client-IP headers
// for reverse-proxy setups. // for reverse-proxy setups.
func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler { func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
limiter := httprate.Limit( return m.postRateLimit(
loginRateLimit, loginRateLimit,
loginRateInterval, 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.WithKeyFuncs(httprate.KeyByRealIP),
httprate.WithLimitHandler(http.HandlerFunc( httprate.WithLimitHandler(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
m.log.Warn("login rate limit exceeded", m.log.Warn(logMessage,
"path", r.URL.Path, "path", r.URL.Path,
) )
http.Error( http.Error(
w, w,
"Too many login attempts. "+ responseMessage,
"Please try again later.",
http.StatusTooManyRequests, http.StatusTooManyRequests,
) )
}, },
@@ -50,8 +94,7 @@ func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request, r *http.Request,
) { ) {
// Only rate-limit POST requests (actual login // Only rate-limit POST requests.
// attempts)
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)

View File

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