diff --git a/config/config.go b/config/config.go index 4a014c1..100b115 100644 --- a/config/config.go +++ b/config/config.go @@ -108,14 +108,22 @@ type Listener struct { Enabled bool Port int `sconf:"optional" sconf-doc:"Default 993."` } `sconf:"optional" sconf-doc:"IMAP over TLS for reading email, by email applications. Requires a TLS config."` + AccountHTTP struct { + Enabled bool + Port int `sconf:"optional" sconf-doc:"Default 80."` + } `sconf:"optional" sconf-doc:"Account web interface, for email users wanting to change their accounts, e.g. set new password, set new delivery rulesets."` + AccountHTTPS struct { + Enabled bool + Port int `sconf:"optional" sconf-doc:"Default 80."` + } `sconf:"optional" sconf-doc:"Account web interface listener for HTTPS. Requires a TLS config."` AdminHTTP struct { Enabled bool Port int `sconf:"optional" sconf-doc:"Default 80."` - } `sconf:"optional" sconf-doc:"Admin web interface, for administrators and regular users wanting to change their password."` + } `sconf:"optional" sconf-doc:"Admin web interface, for managing domains, accounts, etc. Served at /admin/. Preferrably only enable on non-public IPs."` AdminHTTPS struct { Enabled bool Port int `sconf:"optional" sconf-doc:"Default 443."` - } `sconf:"optional" sconf-doc:"Admin web interface listener for HTTPS. Requires a TLS config."` + } `sconf:"optional" sconf-doc:"Admin web interface listener for HTTPS. Requires a TLS config. Preferrably only enable on non-public IPs."` MetricsHTTP struct { Enabled bool Port int `sconf:"optional" sconf-doc:"Default 8010."` diff --git a/config/doc.go b/config/doc.go index 73d590e..8717f71 100644 --- a/config/doc.go +++ b/config/doc.go @@ -176,15 +176,31 @@ describe-static" and "mox config describe-domains": # Default 993. (optional) Port: 0 - # Admin web interface, for administrators and regular users wanting to change - # their password. (optional) + # Account web interface, for email users wanting to change their accounts, e.g. + # set new password, set new delivery rulesets. (optional) + AccountHTTP: + Enabled: false + + # Default 80. (optional) + Port: 0 + + # Account web interface listener for HTTPS. Requires a TLS config. (optional) + AccountHTTPS: + Enabled: false + + # Default 80. (optional) + Port: 0 + + # Admin web interface, for managing domains, accounts, etc. Served at /admin/. + # Preferrably only enable on non-public IPs. (optional) AdminHTTP: Enabled: false # Default 80. (optional) Port: 0 - # Admin web interface listener for HTTPS. Requires a TLS config. (optional) + # Admin web interface listener for HTTPS. Requires a TLS config. Preferrably only + # enable on non-public IPs. (optional) AdminHTTPS: Enabled: false diff --git a/http/account.go b/http/account.go index ef4bc71..a739ebf 100644 --- a/http/account.go +++ b/http/account.go @@ -4,11 +4,12 @@ import ( "context" "encoding/base64" "errors" - "fmt" "io" + "net" "net/http" "os" "strings" + "time" _ "embed" @@ -40,48 +41,75 @@ func init() { xlog.Fatalx("creating sherpa prometheus collector", err) } - accountSherpaHandler, err = sherpa.NewHandler("/account/api/", moxvar.Version, Account{}, &accountDoc, &sherpa.HandlerOpts{Collector: collector, AdjustFunctionNames: "none"}) + accountSherpaHandler, err = sherpa.NewHandler("/api/", moxvar.Version, Account{}, &accountDoc, &sherpa.HandlerOpts{Collector: collector, AdjustFunctionNames: "none"}) if err != nil { xlog.Fatalx("sherpa handler", err) } } // Account exports web API functions for the account web interface. All its -// methods are exported under /account/api/. Function calls require valid HTTP +// methods are exported under /api/. Function calls require valid HTTP // Authentication credentials of a user. type Account struct{} -func accountHandle(w http.ResponseWriter, r *http.Request) { - ctx := context.WithValue(r.Context(), mlog.CidKey, mox.Cid()) - log := xlog.WithContext(ctx).Fields(mlog.Field("userauth", "")) - var accountName string +// check http basic auth, returns account name if valid, and writes http response +// and returns empty string otherwise. +func checkAccountAuth(ctx context.Context, log *mlog.Log, w http.ResponseWriter, r *http.Request) string { authResult := "error" + start := time.Now() + var addr *net.TCPAddr defer func() { metrics.AuthenticationInc("httpaccount", "httpbasic", authResult) + if authResult == "ok" && addr != nil { + mox.LimiterFailedAuth.Reset(addr.IP, start) + } }() - // todo: should probably add a cache here instead of looking up password in database all the time, just like in admin.go + + var err error + addr, err = net.ResolveTCPAddr("tcp", r.RemoteAddr) + if err != nil { + log.Errorx("parsing remote address", err, mlog.Field("addr", r.RemoteAddr)) + } + if addr != nil && !mox.LimiterFailedAuth.Add(addr.IP, start, 1) { + metrics.AuthenticationRatelimitedInc("httpaccount") + http.Error(w, "http 429 - too many auth attempts", http.StatusTooManyRequests) + return "" + } + + // store.OpenEmailAuth has an auth cache, so we don't bcrypt for every auth attempt. if auth := r.Header.Get("Authorization"); auth == "" || !strings.HasPrefix(auth, "Basic ") { } else if authBuf, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(auth, "Basic ")); err != nil { - log.Infox("parsing base64", err) + log.Debugx("parsing base64", err) } else if t := strings.SplitN(string(authBuf), ":", 2); len(t) != 2 { - log.Info("bad user:pass form") + log.Debug("bad user:pass form") } else if acc, err := store.OpenEmailAuth(t[0], t[1]); err != nil { if errors.Is(err, store.ErrUnknownCredentials) { authResult = "badcreds" } - log.Infox("open account", err) + log.Errorx("open account", err) } else { - accountName = acc.Name authResult = "ok" + accName := acc.Name + acc.Close() + return accName } - if accountName == "" { - w.Header().Set("WWW-Authenticate", `Basic realm="mox account - login with email address and password"`) - w.WriteHeader(http.StatusUnauthorized) - fmt.Fprintln(w, "http 401 - unauthorized - mox account - login with email address and password") + // note: browsers don't display the realm to prevent users getting confused by malicious realm messages. + w.Header().Set("WWW-Authenticate", `Basic realm="mox account - login with email address and password"`) + http.Error(w, "http 401 - unauthorized - mox account - login with email address and password", http.StatusUnauthorized) + return "" +} + +func accountHandle(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), mlog.CidKey, mox.Cid()) + log := xlog.WithContext(ctx).Fields(mlog.Field("userauth", "")) + + accName := checkAccountAuth(ctx, log, w, r) + if accName == "" { + // Response already sent. return } - if r.Method == "GET" && r.URL.Path == "/account/" { + if r.Method == "GET" && r.URL.Path == "/" { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-cache; max-age=0") // We typically return the embedded admin.html, but during development it's handy @@ -95,7 +123,7 @@ func accountHandle(w http.ResponseWriter, r *http.Request) { } return } - accountSherpaHandler.ServeHTTP(w, r.WithContext(context.WithValue(ctx, authCtxKey, accountName))) + accountSherpaHandler.ServeHTTP(w, r.WithContext(context.WithValue(ctx, authCtxKey, accName))) } type ctxKey string diff --git a/http/accountapi.json b/http/accountapi.json index 1fed71c..8a29dcb 100644 --- a/http/accountapi.json +++ b/http/accountapi.json @@ -1,6 +1,6 @@ { "Name": "Account", - "Docs": "Account exports web API functions for the account web interface. All its\nmethods are exported under /account/api/. Function calls require valid HTTP\nAuthentication credentials of a user.", + "Docs": "Account exports web API functions for the account web interface. All its\nmethods are exported under /api/. Function calls require valid HTTP\nAuthentication credentials of a user.", "Functions": [ { "Name": "SetPassword", diff --git a/http/admin.go b/http/admin.go index 4c16caf..9e3a67f 100644 --- a/http/admin.go +++ b/http/admin.go @@ -108,21 +108,46 @@ func init() { // check whether authentication from the config (passwordfile with bcrypt hash) // matches the authorization header "authHdr". we don't care about any username. -func checkAdminAuth(ctx context.Context, passwordfile, authHdr string) bool { +// on (auth) failure, a http response is sent and false returned. +func checkAdminAuth(ctx context.Context, passwordfile string, w http.ResponseWriter, r *http.Request) bool { log := xlog.WithContext(ctx) + respondAuthFail := func() bool { + // note: browsers don't display the realm to prevent users getting confused by malicious realm messages. + w.Header().Set("WWW-Authenticate", `Basic realm="mox admin - login with empty username and admin password"`) + http.Error(w, "http 401 - unauthorized - mox admin - login with empty username and admin password", http.StatusUnauthorized) + return false + } + authResult := "error" + start := time.Now() + var addr *net.TCPAddr defer func() { metrics.AuthenticationInc("httpadmin", "httpbasic", authResult) + if authResult == "ok" && addr != nil { + mox.LimiterFailedAuth.Reset(addr.IP, start) + } }() - if !strings.HasPrefix(authHdr, "Basic ") || passwordfile == "" { + var err error + addr, err = net.ResolveTCPAddr("tcp", r.RemoteAddr) + if err != nil { + log.Errorx("parsing remote address", err, mlog.Field("addr", r.RemoteAddr)) + } + if addr != nil && !mox.LimiterFailedAuth.Add(addr.IP, start, 1) { + metrics.AuthenticationRatelimitedInc("httpadmin") + http.Error(w, "http 429 - too many auth attempts", http.StatusTooManyRequests) return false } + + authHdr := r.Header.Get("Authorization") + if !strings.HasPrefix(authHdr, "Basic ") || passwordfile == "" { + return respondAuthFail() + } buf, err := os.ReadFile(passwordfile) if err != nil { log.Errorx("reading admin password file", err, mlog.Field("path", passwordfile)) - return false + return respondAuthFail() } passwordhash := strings.TrimSpace(string(buf)) authCache.Lock() @@ -133,15 +158,15 @@ func checkAdminAuth(ctx context.Context, passwordfile, authHdr string) bool { } auth, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(authHdr, "Basic ")) if err != nil { - return false + return respondAuthFail() } t := strings.SplitN(string(auth), ":", 2) if len(t) != 2 || len(t[1]) < 8 { - return false + return respondAuthFail() } if err := bcrypt.CompareHashAndPassword([]byte(passwordhash), []byte(t[1])); err != nil { authResult = "badcreds" - return false + return respondAuthFail() } authCache.lastSuccessHash = passwordhash authCache.lastSuccessAuth = authHdr @@ -151,10 +176,8 @@ func checkAdminAuth(ctx context.Context, passwordfile, authHdr string) bool { func adminHandle(w http.ResponseWriter, r *http.Request) { ctx := context.WithValue(r.Context(), mlog.CidKey, mox.Cid()) - if !checkAdminAuth(ctx, mox.ConfigDirPath(mox.Conf.Static.AdminPasswordFile), r.Header.Get("Authorization")) { - w.Header().Set("WWW-Authenticate", `Basic realm="mox admin - login with empty username and admin password"`) - w.WriteHeader(http.StatusUnauthorized) - fmt.Fprintln(w, "http 401 - unauthorized - mox admin - login with empty username and admin password") + if !checkAdminAuth(ctx, mox.ConfigDirPath(mox.Conf.Static.AdminPasswordFile), w, r) { + // Response already sent. return } diff --git a/http/admin_test.go b/http/admin_test.go index c83c3d7..f4cd43d 100644 --- a/http/admin_test.go +++ b/http/admin_test.go @@ -4,6 +4,7 @@ import ( "context" "crypto/ed25519" "net" + "net/http/httptest" "os" "testing" "time" @@ -19,7 +20,12 @@ func TestAdminAuth(t *testing.T) { test := func(passwordfile, authHdr string, expect bool) { t.Helper() - ok := checkAdminAuth(context.Background(), passwordfile, authHdr) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/ignored", nil) + if authHdr != "" { + r.Header.Add("Authorization", authHdr) + } + ok := checkAdminAuth(context.Background(), passwordfile, w, r) if ok != expect { t.Fatalf("got %v, expected %v", ok, expect) } diff --git a/http/web.go b/http/web.go index 5d9da03..473b8b0 100644 --- a/http/web.go +++ b/http/web.go @@ -71,17 +71,28 @@ func ListenAndServe() { ensureServe(true, 443, "acme-tls-alpn01") } + if l.AccountHTTP.Enabled { + srv := ensureServe(false, config.Port(l.AccountHTTP.Port, 80), "account-http") + srv.mux.HandleFunc("/", safeHeaders(accountHandle)) + } + if l.AccountHTTPS.Enabled { + srv := ensureServe(true, config.Port(l.AccountHTTP.Port, 443), "account-https") + srv.mux.HandleFunc("/", safeHeaders(accountHandle)) + } + if l.AdminHTTP.Enabled { srv := ensureServe(false, config.Port(l.AdminHTTP.Port, 80), "admin-http") - srv.mux.HandleFunc("/", safeHeaders(adminIndex)) + if !l.AccountHTTP.Enabled { + srv.mux.HandleFunc("/", safeHeaders(adminIndex)) + } srv.mux.HandleFunc("/admin/", safeHeaders(adminHandle)) - srv.mux.HandleFunc("/account/", safeHeaders(accountHandle)) } if l.AdminHTTPS.Enabled { srv := ensureServe(true, config.Port(l.AdminHTTPS.Port, 443), "admin-https") - srv.mux.HandleFunc("/", safeHeaders(adminIndex)) + if !l.AccountHTTP.Enabled { + srv.mux.HandleFunc("/", safeHeaders(adminIndex)) + } srv.mux.HandleFunc("/admin/", safeHeaders(adminHandle)) - srv.mux.HandleFunc("/account/", safeHeaders(accountHandle)) } if l.MetricsHTTP.Enabled { srv := ensureServe(false, config.Port(l.MetricsHTTP.Port, 8010), "metrics-http") @@ -174,6 +185,7 @@ func ListenAndServe() { } } +// Only used when the account page is not active on the same listener. func adminIndex(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) @@ -183,27 +195,7 @@ func adminIndex(w http.ResponseWriter, r *http.Request) { http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) return } - - const html = ` - -
-