make account web page configurable separately from admin, add http auth rate limiting

ideally both account & admin web pages should be on non-public ips (e.g. a
wireguard tunnel). but during setup, users may not have that set up, and they
may want to configure the admin/account pages on their public ip's. the auth
rate limiting should make it less of issue.

users can now also only put the account web page publicly available. useful for
if you're the admin and you have a vpn connection, but your other/external
users do not have a vpn into your mail server. to make the account page more
easily findable, the http root serves the account page. the admin page is still
at /admin/, to prevent clash with potential account pages, but if no account
page is present, you are helpfully redirected from / to /admin/.

this also adds a prometheus metric counting how often auth attempts have been
rate limited.
This commit is contained in:
Mechiel Lukkien 2023-02-13 13:53:47 +01:00
parent 2601766c2f
commit ad51ffc365
No known key found for this signature in database
13 changed files with 154 additions and 64 deletions

View file

@ -108,14 +108,22 @@ type Listener struct {
Enabled bool Enabled bool
Port int `sconf:"optional" sconf-doc:"Default 993."` 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."` } `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 { AdminHTTP struct {
Enabled bool Enabled bool
Port int `sconf:"optional" sconf-doc:"Default 80."` 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 { AdminHTTPS struct {
Enabled bool Enabled bool
Port int `sconf:"optional" sconf-doc:"Default 443."` 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 { MetricsHTTP struct {
Enabled bool Enabled bool
Port int `sconf:"optional" sconf-doc:"Default 8010."` Port int `sconf:"optional" sconf-doc:"Default 8010."`

View file

@ -176,15 +176,31 @@ describe-static" and "mox config describe-domains":
# Default 993. (optional) # Default 993. (optional)
Port: 0 Port: 0
# Admin web interface, for administrators and regular users wanting to change # Account web interface, for email users wanting to change their accounts, e.g.
# their password. (optional) # 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: AdminHTTP:
Enabled: false Enabled: false
# Default 80. (optional) # Default 80. (optional)
Port: 0 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: AdminHTTPS:
Enabled: false Enabled: false

View file

@ -4,11 +4,12 @@ import (
"context" "context"
"encoding/base64" "encoding/base64"
"errors" "errors"
"fmt"
"io" "io"
"net"
"net/http" "net/http"
"os" "os"
"strings" "strings"
"time"
_ "embed" _ "embed"
@ -40,48 +41,75 @@ func init() {
xlog.Fatalx("creating sherpa prometheus collector", err) 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 { if err != nil {
xlog.Fatalx("sherpa handler", err) xlog.Fatalx("sherpa handler", err)
} }
} }
// Account exports web API functions for the account web interface. All its // 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. // Authentication credentials of a user.
type Account struct{} type Account struct{}
func accountHandle(w http.ResponseWriter, r *http.Request) { // check http basic auth, returns account name if valid, and writes http response
ctx := context.WithValue(r.Context(), mlog.CidKey, mox.Cid()) // and returns empty string otherwise.
log := xlog.WithContext(ctx).Fields(mlog.Field("userauth", "")) func checkAccountAuth(ctx context.Context, log *mlog.Log, w http.ResponseWriter, r *http.Request) string {
var accountName string
authResult := "error" authResult := "error"
start := time.Now()
var addr *net.TCPAddr
defer func() { defer func() {
metrics.AuthenticationInc("httpaccount", "httpbasic", authResult) 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 ") { if auth := r.Header.Get("Authorization"); auth == "" || !strings.HasPrefix(auth, "Basic ") {
} else if authBuf, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(auth, "Basic ")); err != nil { } 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 { } 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 { } else if acc, err := store.OpenEmailAuth(t[0], t[1]); err != nil {
if errors.Is(err, store.ErrUnknownCredentials) { if errors.Is(err, store.ErrUnknownCredentials) {
authResult = "badcreds" authResult = "badcreds"
} }
log.Infox("open account", err) log.Errorx("open account", err)
} else { } else {
accountName = acc.Name
authResult = "ok" authResult = "ok"
accName := acc.Name
acc.Close()
return accName
} }
if accountName == "" { // 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"`) w.Header().Set("WWW-Authenticate", `Basic realm="mox account - login with email address and password"`)
w.WriteHeader(http.StatusUnauthorized) http.Error(w, "http 401 - unauthorized - mox account - login with email address and password", http.StatusUnauthorized)
fmt.Fprintln(w, "http 401 - unauthorized - mox account - login with email address and password") 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 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("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache; max-age=0") w.Header().Set("Cache-Control", "no-cache; max-age=0")
// We typically return the embedded admin.html, but during development it's handy // 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 return
} }
accountSherpaHandler.ServeHTTP(w, r.WithContext(context.WithValue(ctx, authCtxKey, accountName))) accountSherpaHandler.ServeHTTP(w, r.WithContext(context.WithValue(ctx, authCtxKey, accName)))
} }
type ctxKey string type ctxKey string

View file

@ -1,6 +1,6 @@
{ {
"Name": "Account", "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": [ "Functions": [
{ {
"Name": "SetPassword", "Name": "SetPassword",

View file

@ -108,21 +108,46 @@ func init() {
// check whether authentication from the config (passwordfile with bcrypt hash) // check whether authentication from the config (passwordfile with bcrypt hash)
// matches the authorization header "authHdr". we don't care about any username. // 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) 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" authResult := "error"
start := time.Now()
var addr *net.TCPAddr
defer func() { defer func() {
metrics.AuthenticationInc("httpadmin", "httpbasic", authResult) 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 return false
} }
authHdr := r.Header.Get("Authorization")
if !strings.HasPrefix(authHdr, "Basic ") || passwordfile == "" {
return respondAuthFail()
}
buf, err := os.ReadFile(passwordfile) buf, err := os.ReadFile(passwordfile)
if err != nil { if err != nil {
log.Errorx("reading admin password file", err, mlog.Field("path", passwordfile)) log.Errorx("reading admin password file", err, mlog.Field("path", passwordfile))
return false return respondAuthFail()
} }
passwordhash := strings.TrimSpace(string(buf)) passwordhash := strings.TrimSpace(string(buf))
authCache.Lock() authCache.Lock()
@ -133,15 +158,15 @@ func checkAdminAuth(ctx context.Context, passwordfile, authHdr string) bool {
} }
auth, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(authHdr, "Basic ")) auth, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(authHdr, "Basic "))
if err != nil { if err != nil {
return false return respondAuthFail()
} }
t := strings.SplitN(string(auth), ":", 2) t := strings.SplitN(string(auth), ":", 2)
if len(t) != 2 || len(t[1]) < 8 { if len(t) != 2 || len(t[1]) < 8 {
return false return respondAuthFail()
} }
if err := bcrypt.CompareHashAndPassword([]byte(passwordhash), []byte(t[1])); err != nil { if err := bcrypt.CompareHashAndPassword([]byte(passwordhash), []byte(t[1])); err != nil {
authResult = "badcreds" authResult = "badcreds"
return false return respondAuthFail()
} }
authCache.lastSuccessHash = passwordhash authCache.lastSuccessHash = passwordhash
authCache.lastSuccessAuth = authHdr authCache.lastSuccessAuth = authHdr
@ -151,10 +176,8 @@ func checkAdminAuth(ctx context.Context, passwordfile, authHdr string) bool {
func adminHandle(w http.ResponseWriter, r *http.Request) { func adminHandle(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), mlog.CidKey, mox.Cid()) ctx := context.WithValue(r.Context(), mlog.CidKey, mox.Cid())
if !checkAdminAuth(ctx, mox.ConfigDirPath(mox.Conf.Static.AdminPasswordFile), r.Header.Get("Authorization")) { if !checkAdminAuth(ctx, mox.ConfigDirPath(mox.Conf.Static.AdminPasswordFile), w, r) {
w.Header().Set("WWW-Authenticate", `Basic realm="mox admin - login with empty username and admin password"`) // Response already sent.
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintln(w, "http 401 - unauthorized - mox admin - login with empty username and admin password")
return return
} }

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"crypto/ed25519" "crypto/ed25519"
"net" "net"
"net/http/httptest"
"os" "os"
"testing" "testing"
"time" "time"
@ -19,7 +20,12 @@ func TestAdminAuth(t *testing.T) {
test := func(passwordfile, authHdr string, expect bool) { test := func(passwordfile, authHdr string, expect bool) {
t.Helper() 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 { if ok != expect {
t.Fatalf("got %v, expected %v", ok, expect) t.Fatalf("got %v, expected %v", ok, expect)
} }

View file

@ -71,17 +71,28 @@ func ListenAndServe() {
ensureServe(true, 443, "acme-tls-alpn01") 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 { if l.AdminHTTP.Enabled {
srv := ensureServe(false, config.Port(l.AdminHTTP.Port, 80), "admin-http") srv := ensureServe(false, config.Port(l.AdminHTTP.Port, 80), "admin-http")
if !l.AccountHTTP.Enabled {
srv.mux.HandleFunc("/", safeHeaders(adminIndex)) srv.mux.HandleFunc("/", safeHeaders(adminIndex))
}
srv.mux.HandleFunc("/admin/", safeHeaders(adminHandle)) srv.mux.HandleFunc("/admin/", safeHeaders(adminHandle))
srv.mux.HandleFunc("/account/", safeHeaders(accountHandle))
} }
if l.AdminHTTPS.Enabled { if l.AdminHTTPS.Enabled {
srv := ensureServe(true, config.Port(l.AdminHTTPS.Port, 443), "admin-https") srv := ensureServe(true, config.Port(l.AdminHTTPS.Port, 443), "admin-https")
if !l.AccountHTTP.Enabled {
srv.mux.HandleFunc("/", safeHeaders(adminIndex)) srv.mux.HandleFunc("/", safeHeaders(adminIndex))
}
srv.mux.HandleFunc("/admin/", safeHeaders(adminHandle)) srv.mux.HandleFunc("/admin/", safeHeaders(adminHandle))
srv.mux.HandleFunc("/account/", safeHeaders(accountHandle))
} }
if l.MetricsHTTP.Enabled { if l.MetricsHTTP.Enabled {
srv := ensureServe(false, config.Port(l.MetricsHTTP.Port, 8010), "metrics-http") 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) { func adminIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" { if r.URL.Path != "/" {
http.NotFound(w, r) 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) http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return return
} }
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
const html = `<!doctype html>
<html>
<head>
<title>mox</title>
<meta name="viewport" content="width=device-width" />
<style>
body, html { font-family: ubuntu, lato, sans-serif; font-size: 16px; padding: 1em; }
* { margin: 0; padding: 0; box-sizing: border-box; }
h1, h2, h3, h4 { margin-bottom: 1ex; }
h1 { font-size: 1.2rem; }
</style>
</head>
<body>
<h1>mox</h1>
<div><a href="/account/">/account/</a>, for regular login</div>
<div><a href="/admin/">/admin/</a>, for adminstrators</div>
</body>
</html>`
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(html))
} }
func listenAndServe(ip string, port int, tlsConfig *tls.Config, name string, kinds []string, mux *http.ServeMux) { func listenAndServe(ip string, port int, tlsConfig *tls.Config, name string, kinds []string, mux *http.ServeMux) {

View file

@ -661,6 +661,7 @@ func serve(listenerName string, cid int64, tlsConfig *tls.Config, nc net.Conn, x
// If remote IP/network resulted in too many authentication failures, refuse to serve. // If remote IP/network resulted in too many authentication failures, refuse to serve.
if !mox.LimiterFailedAuth.CanAdd(c.remoteIP, time.Now(), 1) { if !mox.LimiterFailedAuth.CanAdd(c.remoteIP, time.Now(), 1) {
metrics.AuthenticationRatelimitedInc("imap")
c.log.Debug("refusing connection due to many auth failures", mlog.Field("remoteip", c.remoteIP)) c.log.Debug("refusing connection due to many auth failures", mlog.Field("remoteip", c.remoteIP))
c.writelinef("* BYE too many auth failures") c.writelinef("* BYE too many auth failures")
return return

View file

@ -6,7 +6,7 @@ import (
) )
var ( var (
metricAuthentication = promauto.NewCounterVec( metricAuth = promauto.NewCounterVec(
prometheus.CounterOpts{ prometheus.CounterOpts{
Name: "mox_authentication_total", Name: "mox_authentication_total",
Help: "Authentication attempts and results.", Help: "Authentication attempts and results.",
@ -18,8 +18,22 @@ var (
"result", // ok, baduser, badpassword, badcreds, error, aborted "result", // ok, baduser, badpassword, badcreds, error, aborted
}, },
) )
metricAuthRatelimited = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "mox_authentication_ratelimited_total",
Help: "Authentication attempts that were refused due to rate limiting.",
},
[]string{
"kind", // submission, imap, httpaccount, httpadmin
},
)
) )
func AuthenticationInc(kind, variant, result string) { func AuthenticationInc(kind, variant, result string) {
metricAuthentication.WithLabelValues(kind, variant, result).Inc() metricAuth.WithLabelValues(kind, variant, result).Inc()
}
func AuthenticationRatelimitedInc(kind string) {
metricAuthRatelimited.WithLabelValues(kind).Inc()
} }

View file

@ -475,7 +475,7 @@ func PrepareStaticConfig(ctx context.Context, configFile string, config *Config,
if l.TLS.ACMEConfig != nil { if l.TLS.ACMEConfig != nil {
l.TLS.ACMEConfig.MinVersion = minVersion l.TLS.ACMEConfig.MinVersion = minVersion
} }
} else if l.IMAPS.Enabled || l.SMTP.Enabled && !l.SMTP.NoSTARTTLS || l.Submissions.Enabled || l.Submission.Enabled && !l.Submission.NoRequireSTARTTLS || l.AdminHTTPS.Enabled || l.AutoconfigHTTPS.Enabled || l.MTASTSHTTPS.Enabled { } else if l.IMAPS.Enabled || l.SMTP.Enabled && !l.SMTP.NoSTARTTLS || l.Submissions.Enabled || l.Submission.Enabled && !l.Submission.NoRequireSTARTTLS || l.AccountHTTPS.Enabled || l.AdminHTTPS.Enabled || l.AutoconfigHTTPS.Enabled || l.MTASTSHTTPS.Enabled {
addErrorf("listener %q requires TLS, but does not specify tls config", name) addErrorf("listener %q requires TLS, but does not specify tls config", name)
} }
if l.AutoconfigHTTPS.Enabled && (!l.IMAP.Enabled && !l.IMAPS.Enabled || !l.Submission.Enabled && !l.Submissions.Enabled) { if l.AutoconfigHTTPS.Enabled && (!l.IMAP.Enabled && !l.IMAPS.Enabled || !l.Submission.Enabled && !l.Submissions.Enabled) {

View file

@ -382,6 +382,7 @@ This likely means one of two things:
IPs: privateListenerIPs, IPs: privateListenerIPs,
Hostname: "localhost", Hostname: "localhost",
} }
internal.AccountHTTP.Enabled = true
internal.AdminHTTP.Enabled = true internal.AdminHTTP.Enabled = true
internal.MetricsHTTP.Enabled = true internal.MetricsHTTP.Enabled = true

View file

@ -580,6 +580,7 @@ func serve(listenerName string, cid int64, hostname dns.Domain, tlsConfig *tls.C
// If remote IP/network resulted in too many authentication failures, refuse to serve. // If remote IP/network resulted in too many authentication failures, refuse to serve.
if submission && !mox.LimiterFailedAuth.CanAdd(c.remoteIP, time.Now(), 1) { if submission && !mox.LimiterFailedAuth.CanAdd(c.remoteIP, time.Now(), 1) {
metrics.AuthenticationRatelimitedInc("submission")
c.log.Debug("refusing connection due to many auth failures", mlog.Field("remoteip", c.remoteIP)) c.log.Debug("refusing connection due to many auth failures", mlog.Field("remoteip", c.remoteIP))
c.writecodeline(smtp.C421ServiceUnavail, smtp.SePol7Other0, "too many auth failures", nil) c.writecodeline(smtp.C421ServiceUnavail, smtp.SePol7Other0, "too many auth failures", nil)
return return

View file

@ -131,7 +131,7 @@ func (c *CRAMMD5) UnmarshalBinary(buf []byte) error {
// Password holds credentials in various forms, for logging in with SMTP/IMAP. // Password holds credentials in various forms, for logging in with SMTP/IMAP.
type Password struct { type Password struct {
Hash string // bcrypt hash for IMAP LOGIN and SASL PLAIN authentication. Hash string // bcrypt hash for IMAP LOGIN, SASL PLAIN and HTTP basic authentication.
CRAMMD5 CRAMMD5 // For SASL CRAM-MD5. CRAMMD5 CRAMMD5 // For SASL CRAM-MD5.
SCRAMSHA1 SCRAM // For SASL SCRAM-SHA-1. SCRAMSHA1 SCRAM // For SASL SCRAM-SHA-1.
SCRAMSHA256 SCRAM // For SASL SCRAM-SHA-256. SCRAMSHA256 SCRAM // For SASL SCRAM-SHA-256.