96 lines
3.6 KiB
Go
96 lines
3.6 KiB
Go
package webui
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"net/http"
|
||
|
|
"net/http/httptest"
|
||
|
|
"net/url"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
)
|
||
|
|
|
||
|
|
// TestWebmailLoginAccountLockout confirms repeated wrong-password attempts against
|
||
|
|
// one account eventually get refused with a generic lockout message rather than
|
||
|
|
// checking the password at all, and that the lockout doesn't touch a different
|
||
|
|
// account from the same IP (the per-IP throttle, not this per-account layer, would
|
||
|
|
// apply there).
|
||
|
|
func TestWebmailLoginAccountLockout(t *testing.T) {
|
||
|
|
app := newTestApp(t)
|
||
|
|
app.Cfg.Section("Auth").Key("login_attempt_limit").SetValue("3")
|
||
|
|
mux := app.Mux()
|
||
|
|
domains, _ := app.DB.ListDomains()
|
||
|
|
victimID := createTestMailboxWithPassword(t, app, "lockout-victim@example.com", domains[0].ID, "the-real-password-1!")
|
||
|
|
otherID := createTestMailboxWithPassword(t, app, "lockout-other@example.com", domains[0].ID, "another-password-1!")
|
||
|
|
|
||
|
|
attempt := func(email, password string) *httptest.ResponseRecorder {
|
||
|
|
form := url.Values{"email": {email}, "password": {password}}
|
||
|
|
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/login", strings.NewReader(form.Encode()))
|
||
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
|
|
req.RemoteAddr = "203.0.113.9:12345"
|
||
|
|
rec := httptest.NewRecorder()
|
||
|
|
mux.ServeHTTP(rec, req)
|
||
|
|
return rec
|
||
|
|
}
|
||
|
|
|
||
|
|
for i := 0; i < 3; i++ {
|
||
|
|
rec := attempt("lockout-victim@example.com", "wrong password")
|
||
|
|
if !strings.Contains(rec.Body.String(), "Incorrect email or password") {
|
||
|
|
t.Fatalf("attempt %d: expected a normal wrong-password error, got: %s", i, rec.Body.String())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
locked := attempt("lockout-victim@example.com", "the-real-password-1!") // even the CORRECT password now
|
||
|
|
if !strings.Contains(locked.Body.String(), "Too many failed attempts") {
|
||
|
|
t.Fatalf("expected the account locked out after repeated failures, got: %s", locked.Body.String())
|
||
|
|
}
|
||
|
|
if locked.Result().Cookies() != nil {
|
||
|
|
for _, c := range locked.Result().Cookies() {
|
||
|
|
if c.Name == mailboxSessionCookieName && c.Value != "" {
|
||
|
|
t.Fatal("expected no session granted while locked out, even with the correct password")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// A different account from the same IP is unaffected by the per-account lockout.
|
||
|
|
rec := attempt("lockout-other@example.com", "another-password-1!")
|
||
|
|
found := false
|
||
|
|
for _, c := range rec.Result().Cookies() {
|
||
|
|
if c.Name == mailboxSessionCookieName {
|
||
|
|
found = true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if !found {
|
||
|
|
t.Fatal("expected a different account from the same IP to log in normally")
|
||
|
|
}
|
||
|
|
_ = victimID
|
||
|
|
_ = otherID
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestLoginRateLimitPerIP confirms the per-IP throttle kicks in independent of which
|
||
|
|
// account is being tried, once enough requests arrive from one source IP.
|
||
|
|
func TestLoginRateLimitPerIP(t *testing.T) {
|
||
|
|
app := newTestApp(t)
|
||
|
|
app.loginLimiter = newIPRateLimiter(3, 1<<62) // tiny limit, effectively-infinite window for a deterministic test
|
||
|
|
mux := app.Mux()
|
||
|
|
domains, _ := app.DB.ListDomains()
|
||
|
|
createTestMailboxWithPassword(t, app, "ratelimit@example.com", domains[0].ID, "correct-password-1!")
|
||
|
|
|
||
|
|
attempt := func() *httptest.ResponseRecorder {
|
||
|
|
form := url.Values{"email": {"ratelimit@example.com"}, "password": {"correct-password-1!"}}
|
||
|
|
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/login", strings.NewReader(form.Encode()))
|
||
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
|
|
req.RemoteAddr = "203.0.113.10:12345"
|
||
|
|
rec := httptest.NewRecorder()
|
||
|
|
mux.ServeHTTP(rec, req)
|
||
|
|
return rec
|
||
|
|
}
|
||
|
|
|
||
|
|
for i := 0; i < 3; i++ {
|
||
|
|
if rec := attempt(); rec.Code == http.StatusTooManyRequests {
|
||
|
|
t.Fatalf("attempt %d: unexpectedly rate-limited early", i)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if rec := attempt(); rec.Code != http.StatusTooManyRequests {
|
||
|
|
t.Fatalf("expected 429 past the per-IP limit, got %d", rec.Code)
|
||
|
|
}
|
||
|
|
}
|