234 lines
7.4 KiB
Go
234 lines
7.4 KiB
Go
package webui
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/pquerna/otp/totp"
|
|
"mailgoserver/internal/db"
|
|
)
|
|
|
|
// authLogsFor issues an authenticated GET /logs?type=auth and returns the raw body,
|
|
// used below to check which auth-log rows a given admin session can see.
|
|
func authLogsFor(t *testing.T, mux http.Handler, cookie *http.Cookie) string {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodGet, Prefix+"/logs?type=auth", nil)
|
|
req.AddCookie(cookie)
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("/logs?type=auth: status=%d", rec.Code)
|
|
}
|
|
return rec.Body.String()
|
|
}
|
|
|
|
// TestAdminLoginLogsAuthAttempts confirms both a failed and a successful admin
|
|
// dashboard login are recorded to the audit log.
|
|
func TestAdminLoginLogsAuthAttempts(t *testing.T) {
|
|
app := newTestApp(t)
|
|
mux := app.Mux()
|
|
|
|
hash, err := db.HashPassword("correct-horse-battery-1!")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := app.DB.CreateAdminUser("audituser", hash, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Wrong password.
|
|
form := url.Values{"username": {"audituser"}, "password": {"wrong-password"}}
|
|
req := httptest.NewRequest(http.MethodPost, Prefix+"/login", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
mux.ServeHTTP(httptest.NewRecorder(), req)
|
|
|
|
// Correct password.
|
|
form = url.Values{"username": {"audituser"}, "password": {"correct-horse-battery-1!"}}
|
|
req = httptest.NewRequest(http.MethodPost, Prefix+"/login", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
mux.ServeHTTP(httptest.NewRecorder(), req)
|
|
|
|
logs, err := app.DB.ListRecentAuthLogs(50)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var sawFail, sawSuccess bool
|
|
for _, l := range logs {
|
|
if l.AuthType != "admin_login" || l.Identifier != "audituser" {
|
|
continue
|
|
}
|
|
if !l.Success {
|
|
sawFail = true
|
|
} else {
|
|
sawSuccess = true
|
|
}
|
|
}
|
|
if !sawFail {
|
|
t.Error("expected a failed admin_login entry for the wrong-password attempt")
|
|
}
|
|
if !sawSuccess {
|
|
t.Error("expected a successful admin_login entry for the correct-password attempt")
|
|
}
|
|
}
|
|
|
|
// TestAdminMFAEventsLogged confirms enabling/disabling TOTP, and an admin resetting
|
|
// another admin's MFA, all produce admin_mfa audit entries.
|
|
func TestAdminMFAEventsLogged(t *testing.T) {
|
|
app := newTestApp(t)
|
|
mux := app.Mux()
|
|
cookie := loginSession(t, app)
|
|
|
|
sess, err := app.DB.GetSession(cookie.Value)
|
|
if err != nil || sess == nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := app.DB.SetAdminTOTPSecret(sess.UserID, "JBSWY3DPEHPK3PXP", false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, Prefix+"/account/totp/confirm", strings.NewReader(url.Values{"code": {totpCodeFor(t, "JBSWY3DPEHPK3PXP")}}.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.AddCookie(cookie)
|
|
mux.ServeHTTP(httptest.NewRecorder(), req)
|
|
|
|
req = httptest.NewRequest(http.MethodPost, Prefix+"/account/totp/disable", nil)
|
|
req.AddCookie(cookie)
|
|
mux.ServeHTTP(httptest.NewRecorder(), req)
|
|
|
|
targetID, err := app.DB.CreateAdminUser("reset-target", mustHash(t), false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := app.DB.SetAdminTOTPSecret(targetID, "JBSWY3DPEHPK3PXP", true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req = httptest.NewRequest(http.MethodPost, Prefix+"/admins/"+strconv.FormatInt(targetID, 10)+"/reset_mfa", nil)
|
|
req.AddCookie(cookie)
|
|
mux.ServeHTTP(httptest.NewRecorder(), req)
|
|
|
|
logs, err := app.DB.ListRecentAuthLogs(50)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var sawEnabled, sawDisabled, sawReset bool
|
|
for _, l := range logs {
|
|
if l.AuthType != "admin_mfa" {
|
|
continue
|
|
}
|
|
switch {
|
|
case strings.Contains(l.Message, "enabled") && l.Identifier == "test-admin":
|
|
sawEnabled = true
|
|
case strings.Contains(l.Message, "disabled") && l.Identifier == "test-admin":
|
|
sawDisabled = true
|
|
case strings.Contains(l.Message, "reset by admin") && l.Identifier == "reset-target":
|
|
sawReset = true
|
|
}
|
|
}
|
|
if !sawEnabled {
|
|
t.Error("expected an admin_mfa entry for TOTP enabled")
|
|
}
|
|
if !sawDisabled {
|
|
t.Error("expected an admin_mfa entry for TOTP disabled")
|
|
}
|
|
if !sawReset {
|
|
t.Error("expected an admin_mfa entry for the admin-initiated reset")
|
|
}
|
|
}
|
|
|
|
// TestWebmailLoginLogsAuthAttempts mirrors TestAdminLoginLogsAuthAttempts for the
|
|
// self-service webmail portal.
|
|
func TestWebmailLoginLogsAuthAttempts(t *testing.T) {
|
|
app := newTestApp(t)
|
|
mux := app.Mux()
|
|
|
|
mailboxes, err := app.DB.ListMailboxes()
|
|
if err != nil || len(mailboxes) == 0 {
|
|
t.Fatal("no seeded mailbox")
|
|
}
|
|
email := mailboxes[0].Email
|
|
|
|
form := url.Values{"email": {email}, "password": {"wrong-password"}}
|
|
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/login", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
mux.ServeHTTP(httptest.NewRecorder(), req)
|
|
|
|
form = url.Values{"email": {email}, "password": {"testpass123"}}
|
|
req = httptest.NewRequest(http.MethodPost, MailboxPrefix+"/login", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
mux.ServeHTTP(httptest.NewRecorder(), req)
|
|
|
|
logs, err := app.DB.ListRecentAuthLogs(50)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var sawFail, sawSuccess bool
|
|
for _, l := range logs {
|
|
if l.AuthType != "webmail_login" || l.Identifier != email {
|
|
continue
|
|
}
|
|
if !l.Success {
|
|
sawFail = true
|
|
} else {
|
|
sawSuccess = true
|
|
}
|
|
}
|
|
if !sawFail {
|
|
t.Error("expected a failed webmail_login entry for the wrong-password attempt")
|
|
}
|
|
if !sawSuccess {
|
|
t.Error("expected a successful webmail_login entry for the correct-password attempt")
|
|
}
|
|
}
|
|
|
|
// TestAdminAuditLogsHiddenFromScopedAdmins confirms admin_login/admin_mfa entries
|
|
// (identified by admin username, with no domain to attribute them to) are visible
|
|
// only to global admins, while webmail_login/mailbox_mfa entries (identified by
|
|
// mailbox email) remain visible to a scoped admin for their own domain.
|
|
func TestAdminAuditLogsHiddenFromScopedAdmins(t *testing.T) {
|
|
app := newTestApp(t)
|
|
mux := app.Mux()
|
|
domains, err := app.DB.ListDomains()
|
|
if err != nil || len(domains) == 0 {
|
|
t.Fatal("no seeded domain")
|
|
}
|
|
domainName := domains[0].DomainName
|
|
|
|
if err := app.DB.LogAuthAttempt("admin_login", "some-admin-username", "127.0.0.1", true, "Login successful"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := app.DB.LogAuthAttempt("webmail_login", "owner@"+domainName, "127.0.0.1", true, "Login successful"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
scopedCookie := scopedLogin(t, app, "scoped-log-viewer", []int64{domains[0].ID})
|
|
scopedBody := authLogsFor(t, mux, scopedCookie)
|
|
if strings.Contains(scopedBody, "some-admin-username") {
|
|
t.Error("a scoped admin should not see admin_login entries at all")
|
|
}
|
|
if !strings.Contains(scopedBody, "owner@"+domainName) {
|
|
t.Error("a scoped admin should see webmail_login entries for their own domain")
|
|
}
|
|
|
|
globalCookie := loginSession(t, app)
|
|
globalBody := authLogsFor(t, mux, globalCookie)
|
|
if !strings.Contains(globalBody, "some-admin-username") {
|
|
t.Error("a global admin should see admin_login entries")
|
|
}
|
|
}
|
|
|
|
// totpCodeFor generates a valid current TOTP code for a secret — used to drive
|
|
// account.go's totpSetupConfirm through a real form submission.
|
|
func totpCodeFor(t *testing.T, secret string) string {
|
|
t.Helper()
|
|
code, err := totp.GenerateCode(secret, time.Now())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return code
|
|
}
|