diff --git a/admin/admin.go b/admin/admin.go index 207f8ef..49650f1 100644 --- a/admin/admin.go +++ b/admin/admin.go @@ -740,6 +740,11 @@ func AccountRemove(ctx context.Context, account string) (rerr error) { return fmt.Errorf("account removed, but removing tls public keys failed: %v", err) } + if err := store.LoginAttemptRemoveAccount(context.Background(), account); err != nil { + log.Errorx("removing historic login attempts for removed account", err) + return fmt.Errorf("account removed, but removing historic login attempts failed: %v", err) + } + log.Info("account removed", slog.String("account", account)) return nil } diff --git a/ctl.go b/ctl.go index 0489194..023467f 100644 --- a/ctl.go +++ b/ctl.go @@ -329,7 +329,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, cid int64, shutdown func()) { */ to := ctl.xread() - a, addr, err := store.OpenEmail(log, to, false) + a, _, addr, err := store.OpenEmail(log, to, false) ctl.xcheck(err, "lookup destination address") msgFile, err := store.CreateMessageTemp(log, "ctl-deliver") @@ -1155,7 +1155,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, cid int64, shutdown func()) { if name != "" { tlspubkey.Name = name } - acc, _, err := store.OpenEmail(ctl.log, loginAddress, false) + acc, _, _, err := store.OpenEmail(ctl.log, loginAddress, false) ctl.xcheck(err, "open account for address") defer func() { err := acc.Close() diff --git a/imapserver/server.go b/imapserver/server.go index 29f5230..6ce94e7 100644 --- a/imapserver/server.go +++ b/imapserver/server.go @@ -196,6 +196,11 @@ type conn struct { // ../rfc/5182:13 ../rfc/9051:4040 searchResult []store.UID + // Set during authentication, typically picked up by the ID command that + // immediately follows, or will be flushed after any other command after + // authentication instead. + loginAttempt *store.LoginAttempt + // Only set when connection has been authenticated. These can be set even when // c.state is stateNotAuthenticated, for TLS client certificate authentication. In // that case, credentials aren't used until the authentication command with the @@ -787,7 +792,7 @@ func serve(listenerName string, cid int64, tlsConfig *tls.Config, nc net.Conn, x defer mox.Connections.Unregister(nc) if preauthAddress != "" { - acc, _, err := store.OpenEmail(c.log, preauthAddress, false) + acc, _, _, err := store.OpenEmail(c.log, preauthAddress, false) if err != nil { c.log.Debugx("open account for preauth address", err, slog.String("address", preauthAddress)) c.writelinef("* BYE open account for address: %s", err) @@ -805,9 +810,31 @@ func serve(listenerName string, cid int64, tlsConfig *tls.Config, nc net.Conn, x c.writelinef("* OK [CAPABILITY %s] mox imap", c.capabilities()) } + // Ensure any pending loginAttempt is written before we stop. + defer func() { + if c.loginAttempt != nil { + store.LoginAttemptAdd(context.Background(), c.log, *c.loginAttempt) + c.loginAttempt = nil + } + }() + + var storeLoginAttempt bool for { c.command() c.xflush() // For flushing errors, or possibly commands that did not flush explicitly. + + // After an authentication command, we will have a c.loginAttempt. We typically get + // an "ID" command with the user-agent immediately after. So we wait for one more + // command after seeing a loginAttempt to gather it. + if storeLoginAttempt { + storeLoginAttempt = false + if c.loginAttempt != nil { + store.LoginAttemptAdd(context.Background(), c.log, *c.loginAttempt) + c.loginAttempt = nil + } + } else if c.loginAttempt != nil { + storeLoginAttempt = true + } } } @@ -817,6 +844,36 @@ func isClosed(err error) bool { return errors.Is(err, errIO) || errors.Is(err, errProtocol) || moxio.IsClosed(err) } +// newLoginAttempt initializes a c.loginAttempt, for adding to the store after +// filling in the results and other details. +func (c *conn) newLoginAttempt(useTLS bool, authMech string) { + if c.loginAttempt != nil { + store.LoginAttemptAdd(context.Background(), c.log, *c.loginAttempt) + c.loginAttempt = nil + } + + var state *tls.ConnectionState + if tc, ok := c.conn.(*tls.Conn); ok && useTLS { + v := tc.ConnectionState() + state = &v + } + + localAddr := c.conn.LocalAddr().String() + localIP, _, _ := net.SplitHostPort(localAddr) + if localIP == "" { + localIP = localAddr + } + + c.loginAttempt = &store.LoginAttempt{ + RemoteIP: c.remoteIP.String(), + LocalIP: localIP, + TLS: store.LoginAttemptTLS(state), + Protocol: "imap", + AuthMech: authMech, + Result: store.AuthError, // Replaced by caller. + } +} + // makeTLSConfig makes a new tls config that is bound to the connection for // possible client certificate authentication. func (c *conn) makeTLSConfig() *tls.Config { @@ -871,10 +928,31 @@ func (c *conn) tlsClientAuthVerifyPeerCertParsed(cert *x509.Certificate) error { return fmt.Errorf("cannot authenticate with tls client certificate after previous authentication") } - authResult := "error" + // todo: it would be nice to postpone storing the loginattempt for tls pubkey auth until we have the ID command. but delaying is complicated because we can't get the tls information in this function. that's why we store the login attempt in a goroutine below, where it can can get a lock when accessing the tls connection only when this function has returned. we can't access c.loginAttempt (we would turn it into a slice) in a goroutine without adding more locking. for now we'll do without user-agent/id for tls pub key auth. + c.newLoginAttempt(false, "tlsclientauth") defer func() { - metrics.AuthenticationInc("imap", "tlsclientauth", authResult) - if authResult == "ok" { + // Get TLS connection state in goroutine because we are called while performing the + // TLS handshake, which already has the tls connection locked. + conn := c.conn.(*tls.Conn) + la := *c.loginAttempt + c.loginAttempt = nil + go func() { + defer func() { + // In case of panic don't take the whole program down. + x := recover() + if x != nil { + c.log.Error("recover from panic", slog.Any("panic", x)) + debug.PrintStack() + metrics.PanicInc(metrics.Imapserver) + } + }() + + state := conn.ConnectionState() + la.TLS = store.LoginAttemptTLS(&state) + store.LoginAttemptAdd(context.Background(), c.log, la) + }() + + if la.Result == store.AuthSuccess { mox.LimiterFailedAuth.Reset(c.remoteIP, time.Now()) } else { mox.LimiterFailedAuth.Add(c.remoteIP, time.Now(), 1) @@ -896,21 +974,27 @@ func (c *conn) tlsClientAuthVerifyPeerCertParsed(cert *x509.Certificate) error { shabuf := sha256.Sum256(cert.RawSubjectPublicKeyInfo) fp := base64.RawURLEncoding.EncodeToString(shabuf[:]) + c.loginAttempt.TLSPubKeyFingerprint = fp pubKey, err := store.TLSPublicKeyGet(context.TODO(), fp) if err != nil { if err == bstore.ErrAbsent { - authResult = "badcreds" + c.loginAttempt.Result = store.AuthBadCredentials } return fmt.Errorf("looking up tls public key with fingerprint %s: %v", fp, err) } + c.loginAttempt.LoginAddress = pubKey.LoginAddress // Verify account exists and still matches address. We don't check for account // login being disabled if preauth is disabled. In that case, sasl external auth // will be done before credentials can be used, and login disabled will be checked // then, where it will result in a more helpful error message. checkLoginDisabled := !pubKey.NoIMAPPreauth - acc, _, err := store.OpenEmail(c.log, pubKey.LoginAddress, checkLoginDisabled) + acc, accName, _, err := store.OpenEmail(c.log, pubKey.LoginAddress, checkLoginDisabled) + c.loginAttempt.AccountName = accName if err != nil { + if errors.Is(err, store.ErrLoginDisabled) { + c.loginAttempt.Result = store.AuthLoginDisabled + } // note: we cannot send a more helpful error message to the client. return fmt.Errorf("opening account for address %s for public key %s: %w", pubKey.LoginAddress, fp, err) } @@ -920,11 +1004,13 @@ func (c *conn) tlsClientAuthVerifyPeerCertParsed(cert *x509.Certificate) error { c.xsanity(err, "close account") } }() + c.loginAttempt.AccountName = acc.Name if acc.Name != pubKey.Account { return fmt.Errorf("tls client public key %s is for account %s, but email address %s is for account %s", fp, pubKey.Account, pubKey.LoginAddress, acc.Name) } - authResult = "ok" + c.loginAttempt.Result = store.AuthSuccess + c.authFailed = 0 c.noPreauth = pubKey.NoIMAPPreauth c.account = acc @@ -1646,6 +1732,7 @@ func (c *conn) cmdID(tag, cmd string, p *parser) { // Request syntax: ../rfc/2971:241 p.xspace() var params map[string]string + var values []string if p.take("(") { params = map[string]string{} for !p.take(")") { @@ -1659,12 +1746,21 @@ func (c *conn) cmdID(tag, cmd string, p *parser) { xsyntaxErrorf("duplicate key %q", k) } params[k] = v + values = append(values, fmt.Sprintf("%s=%q", k, v)) } } else { p.xnil() } p.xempty() + // The ID command is typically sent immediately after authentication. So we've + // prepared the LoginAttempt and write it now. + if c.loginAttempt != nil { + c.loginAttempt.UserAgent = strings.Join(values, " ") + store.LoginAttemptAdd(context.Background(), c.log, *c.loginAttempt) + c.loginAttempt = nil + } + // We just log the client id. c.log.Info("client id", slog.Any("params", params)) @@ -1744,11 +1840,9 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) { } }() - var authVariant string // Only known strings, used in metrics. - authResult := "error" + c.newLoginAttempt(true, "") defer func() { - metrics.AuthenticationInc("imap", authVariant, authResult) - if authResult == "ok" { + if c.loginAttempt.Result == store.AuthSuccess { mox.LimiterFailedAuth.Reset(c.remoteIP, time.Now()) } else if !missingDerivedSecrets { mox.LimiterFailedAuth.Add(c.remoteIP, time.Now(), 1) @@ -1775,7 +1869,7 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) { } // ../rfc/9051:1442 ../rfc/3501:1553 if line == "*" { - authResult = "aborted" + c.loginAttempt.Result = store.AuthAborted xsyntaxErrorf("authenticate aborted by client") } buf, err := base64.StdEncoding.DecodeString(line) @@ -1788,7 +1882,7 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) { xreadContinuation := func() []byte { line := c.readline(false) if line == "*" { - authResult = "aborted" + c.loginAttempt.Result = store.AuthAborted xsyntaxErrorf("authenticate aborted by client") } buf, err := base64.StdEncoding.DecodeString(line) @@ -1812,7 +1906,7 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) { switch strings.ToUpper(authType) { case "PLAIN": - authVariant = "plain" + c.loginAttempt.AuthMech = "plain" if !c.noRequireSTARTTLS && !c.tls { // ../rfc/9051:5194 @@ -1830,16 +1924,17 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) { authz := string(plain[0]) username = string(plain[1]) password := string(plain[2]) + c.loginAttempt.LoginAddress = username if authz != "" && authz != username { xusercodeErrorf("AUTHORIZATIONFAILED", "cannot assume role") } var err error - account, err = store.OpenEmailAuth(c.log, username, password, false) + account, c.loginAttempt.AccountName, err = store.OpenEmailAuth(c.log, username, password, false) if err != nil { if errors.Is(err, store.ErrUnknownCredentials) { - authResult = "badcreds" + c.loginAttempt.Result = store.AuthBadCredentials c.log.Info("authentication failed", slog.String("username", username)) xusercodeErrorf("AUTHENTICATIONFAILED", "bad credentials") } @@ -1847,7 +1942,7 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) { } case "CRAM-MD5": - authVariant = strings.ToLower(authType) + c.loginAttempt.AuthMech = strings.ToLower(authType) // ../rfc/9051:1462 p.xempty() @@ -1862,16 +1957,17 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) { xsyntaxErrorf("malformed cram-md5 response") } username = t[0] + c.loginAttempt.LoginAddress = username c.log.Debug("cram-md5 auth", slog.String("address", username)) var err error - account, _, err = store.OpenEmail(c.log, username, false) + account, c.loginAttempt.AccountName, _, err = store.OpenEmail(c.log, username, false) if err != nil { if errors.Is(err, store.ErrUnknownCredentials) { - authResult = "badcreds" + c.loginAttempt.Result = store.AuthBadCredentials c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP)) xusercodeErrorf("AUTHENTICATIONFAILED", "bad credentials") } else if errors.Is(err, store.ErrLoginDisabled) { - authResult = "logindisabled" + c.loginAttempt.Result = store.AuthLoginDisabled c.log.Info("account login disabled", slog.String("username", username)) // No error code, we don't want to cause prompt for new password // (AUTHENTICATIONFAILED) and don't want to trigger message suppression with ALERT. @@ -1919,9 +2015,9 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) { // No plaintext credentials, we can log these normally. - authVariant = strings.ToLower(authType) + c.loginAttempt.AuthMech = strings.ToLower(authType) var h func() hash.Hash - switch authVariant { + switch c.loginAttempt.AuthMech { case "scram-sha-1", "scram-sha-1-plus": h = sha1.New case "scram-sha-256", "scram-sha-256-plus": @@ -1931,7 +2027,7 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) { } var cs *tls.ConnectionState - requireChannelBinding := strings.HasSuffix(authVariant, "-plus") + requireChannelBinding := strings.HasSuffix(c.loginAttempt.AuthMech, "-plus") if requireChannelBinding && !c.tls { xuserErrorf("cannot use plus variant with tls channel binding without tls") } @@ -1946,9 +2042,10 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) { xuserErrorf("scram protocol error: %s", err) } username = ss.Authentication + c.loginAttempt.LoginAddress = username c.log.Debug("scram auth", slog.String("authentication", username)) // We check for login being disabled when finishing. - account, _, err = store.OpenEmail(c.log, username, false) + account, c.loginAttempt.AccountName, _, err = store.OpenEmail(c.log, username, false) if err != nil { // todo: we could continue scram with a generated salt, deterministically generated // from the username. that way we don't have to store anything but attackers cannot @@ -1967,7 +2064,7 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) { xusercodeErrorf("AUTHENTICATIONFAILED", "bad credentials") } xcheckf(err, "fetching credentials") - switch authVariant { + switch c.loginAttempt.AuthMech { case "scram-sha-1", "scram-sha-1-plus": xscram = password.SCRAMSHA1 case "scram-sha-256", "scram-sha-256-plus": @@ -1995,15 +2092,15 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) { if err != nil { c.readline(false) // Should be "*" for cancellation. if errors.Is(err, scram.ErrInvalidProof) { - authResult = "badcreds" + c.loginAttempt.Result = store.AuthBadCredentials c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP)) xusercodeErrorf("AUTHENTICATIONFAILED", "bad credentials") } else if errors.Is(err, scram.ErrChannelBindingsDontMatch) { - authResult = "badchanbind" + c.loginAttempt.Result = store.AuthBadChannelBinding c.log.Warn("bad channel binding during authentication, potential mitm", slog.String("username", username), slog.Any("remote", c.remoteIP)) xusercodeErrorf("AUTHENTICATIONFAILED", "channel bindings do not match, potential mitm") } else if errors.Is(err, scram.ErrInvalidEncoding) { - authResult = "badprotocol" + c.loginAttempt.Result = store.AuthBadProtocol c.log.Infox("bad scram protocol message", err, slog.String("username", username), slog.Any("remote", c.remoteIP)) xuserErrorf("bad scram protocol message: %s", err) } @@ -2015,11 +2112,12 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) { xreadContinuation() case "EXTERNAL": - authVariant = strings.ToLower(authType) + c.loginAttempt.AuthMech = "external" // ../rfc/4422:1618 buf := xreadInitial() username = string(buf) + c.loginAttempt.LoginAddress = username if !c.tls { xusercodeErrorf("AUTHENTICATIONFAILED", "tls required for tls client certificate authentication") @@ -2030,19 +2128,21 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) { if username == "" { username = c.username + c.loginAttempt.LoginAddress = username } var err error - account, _, err = store.OpenEmail(c.log, username, false) + account, c.loginAttempt.AccountName, _, err = store.OpenEmail(c.log, username, false) xcheckf(err, "looking up username from tls client authentication") default: + c.loginAttempt.AuthMech = "(unrecognized)" xuserErrorf("method not supported") } if accConf, ok := account.Conf(); !ok { xserverErrorf("cannot get account config") } else if accConf.LoginDisabled != "" { - authResult = "logindisabled" + c.loginAttempt.Result = store.AuthLoginDisabled c.log.Info("account login disabled", slog.String("username", username)) // No AUTHENTICATIONFAILED code, clients could prompt users for different password. xuserErrorf("%w: %s", store.ErrLoginDisabled, accConf.LoginDisabled) @@ -2055,7 +2155,7 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) { if c.account != nil { if account != c.account { c.log.Debug("sasl authentication for different account than tls client authentication, aborting connection", - slog.String("saslmechanism", authVariant), + slog.String("saslmechanism", c.loginAttempt.AuthMech), slog.String("saslaccount", account.Name), slog.String("tlsaccount", c.account.Name), slog.String("saslusername", username), @@ -2064,7 +2164,7 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) { xusercodeErrorf("AUTHENTICATIONFAILED", "authentication failed, tls client certificate public key belongs to another account") } else if username != c.username { c.log.Debug("sasl authentication for different username than tls client certificate authentication, switching to sasl username", - slog.String("saslmechanism", authVariant), + slog.String("saslmechanism", c.loginAttempt.AuthMech), slog.String("saslusername", username), slog.String("tlsusername", c.username), slog.String("account", c.account.Name), @@ -2080,7 +2180,9 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) { } c.setSlow(false) - authResult = "ok" + c.loginAttempt.AccountName = c.account.Name + c.loginAttempt.LoginAddress = c.username + c.loginAttempt.Result = store.AuthSuccess c.authFailed = 0 c.state = stateAuthenticated c.writeresultf("%s OK [CAPABILITY %s] authenticate done", tag, c.capabilities()) @@ -2092,10 +2194,9 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) { func (c *conn) cmdLogin(tag, cmd string, p *parser) { // Command: ../rfc/9051:1597 ../rfc/3501:1663 - authResult := "error" + c.newLoginAttempt(true, "login") defer func() { - metrics.AuthenticationInc("imap", "login", authResult) - if authResult == "ok" { + if c.loginAttempt.Result == store.AuthSuccess { mox.LimiterFailedAuth.Reset(c.remoteIP, time.Now()) } else { mox.LimiterFailedAuth.Add(c.remoteIP, time.Now(), 1) @@ -2107,6 +2208,7 @@ func (c *conn) cmdLogin(tag, cmd string, p *parser) { // Request syntax: ../rfc/9051:6667 ../rfc/3501:4804 p.xspace() username := p.xastring() + c.loginAttempt.LoginAddress = username p.xspace() password := p.xastring() p.xempty() @@ -2129,15 +2231,16 @@ func (c *conn) cmdLogin(tag, cmd string, p *parser) { } }() - account, err := store.OpenEmailAuth(c.log, username, password, true) + account, accName, err := store.OpenEmailAuth(c.log, username, password, true) + c.loginAttempt.AccountName = accName if err != nil { var code string if errors.Is(err, store.ErrUnknownCredentials) { - authResult = "badcreds" + c.loginAttempt.Result = store.AuthBadCredentials code = "AUTHENTICATIONFAILED" c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP)) } else if errors.Is(err, store.ErrLoginDisabled) { - authResult = "logindisabled" + c.loginAttempt.Result = store.AuthLoginDisabled c.log.Info("account login disabled", slog.String("username", username)) // There is no specific code for "account disabled" in IMAP. AUTHORIZATIONFAILED is // not a good idea, it will prompt users for a password. ALERT seems reasonable, @@ -2184,10 +2287,12 @@ func (c *conn) cmdLogin(tag, cmd string, p *parser) { if c.comm == nil { c.comm = store.RegisterComm(c.account) } + c.loginAttempt.LoginAddress = c.username + c.loginAttempt.AccountName = c.account.Name + c.loginAttempt.Result = store.AuthSuccess c.authFailed = 0 c.setSlow(false) c.state = stateAuthenticated - authResult = "ok" c.writeresultf("%s OK [CAPABILITY %s] login done", tag, c.capabilities()) } diff --git a/localserve.go b/localserve.go index de72125..3fc24ae 100644 --- a/localserve.go +++ b/localserve.go @@ -482,7 +482,7 @@ func writeLocalConfig(log mlog.Log, dir, ip string) (rerr error) { loadLoglevel(log, "info") // Set password on account. - a, _, err := store.OpenEmail(log, "mox@localhost", false) + a, _, _, err := store.OpenEmail(log, "mox@localhost", false) xcheck(err, "opening account to set password") password := "moxmoxmox" err = a.SetPassword(log, password) diff --git a/metrics/auth.go b/metrics/auth.go index bbc428a..8696eaa 100644 --- a/metrics/auth.go +++ b/metrics/auth.go @@ -16,7 +16,7 @@ var ( "kind", // submission, imap, webmail, webapi, webaccount, webadmin (formerly httpaccount, httpadmin) "variant", // login, plain, scram-sha-256, scram-sha-1, cram-md5, weblogin, websessionuse, httpbasic, tlsclientauth. // todo: we currently only use badcreds, but known baduser can be helpful - "result", // ok, baduser, badpassword, badcreds, badchanbind, error, aborted, badprotocol, logindisabled + "result", // ok, baduser, badpassword, badcreds, badchanbind, error, aborted, badprotocol, logindisabled; see ../store/loginattempt.go:/AuthResult. }, ) diff --git a/quickstart.go b/quickstart.go index 9b8660a..e835c20 100644 --- a/quickstart.go +++ b/quickstart.go @@ -957,7 +957,7 @@ and check the admin page for the needed DNS records.`) fatalf("cannot find domain in new config") } - acc, _, err := store.OpenEmail(c.log, args[0], false) + acc, _, _, err := store.OpenEmail(c.log, args[0], false) if err != nil { fatalf("open account: %s", err) } diff --git a/smtpserver/alias_test.go b/smtpserver/alias_test.go index 8d70beb..a47005f 100644 --- a/smtpserver/alias_test.go +++ b/smtpserver/alias_test.go @@ -72,7 +72,7 @@ func TestAliasSubmitMsgFromDenied(t *testing.T) { defer ts.close() // Trying to open account by alias should result in proper error. - _, _, err := store.OpenEmail(pkglog, "public@mox.example", false) + _, _, _, err := store.OpenEmail(pkglog, "public@mox.example", false) if err == nil || !errors.Is(err, store.ErrUnknownCredentials) { t.Fatalf("opening alias, got err %v, expected store.ErrUnknownCredentials", err) } diff --git a/smtpserver/server.go b/smtpserver/server.go index 60873d7..d3476e5 100644 --- a/smtpserver/server.go +++ b/smtpserver/server.go @@ -401,6 +401,25 @@ func isClosed(err error) bool { return errors.Is(err, errIO) || moxio.IsClosed(err) } +// loginAttempt initializes a store.LoginAttempt, for adding to the store after +// filling in the results and other details. +func (c *conn) loginAttempt(useTLS bool, authMech string) store.LoginAttempt { + var state *tls.ConnectionState + if tc, ok := c.conn.(*tls.Conn); ok && useTLS { + v := tc.ConnectionState() + state = &v + } + + return store.LoginAttempt{ + RemoteIP: c.remoteIP.String(), + LocalIP: c.localIP.String(), + TLS: store.LoginAttemptTLS(state), + Protocol: "submission", + AuthMech: authMech, + Result: store.AuthError, // Replaced by caller. + } +} + // makeTLSConfig makes a new tls config that is bound to the connection for // possible client certificate authentication in case of submission. func (c *conn) makeTLSConfig() *tls.Config { @@ -459,10 +478,28 @@ func (c *conn) tlsClientAuthVerifyPeerCertParsed(cert *x509.Certificate) error { return fmt.Errorf("cannot authenticate with tls client certificate after previous authentication") } - authResult := "error" + la := c.loginAttempt(false, "tlsclientauth") defer func() { - metrics.AuthenticationInc("submission", "tlsclientauth", authResult) - if authResult == "ok" { + // Get TLS connection state in goroutine because we are called while performing the + // TLS handshake, which already has the tls connection locked. + conn := c.conn.(*tls.Conn) + go func() { + defer func() { + // In case of panic don't take the whole program down. + x := recover() + if x != nil { + c.log.Error("recover from panic", slog.Any("panic", x)) + debug.PrintStack() + metrics.PanicInc(metrics.Smtpserver) + } + }() + + state := conn.ConnectionState() + la.TLS = store.LoginAttemptTLS(&state) + store.LoginAttemptAdd(context.Background(), c.log, la) + }() + + if la.Result == store.AuthSuccess { mox.LimiterFailedAuth.Reset(c.remoteIP, time.Now()) } else { mox.LimiterFailedAuth.Add(c.remoteIP, time.Now(), 1) @@ -484,21 +521,27 @@ func (c *conn) tlsClientAuthVerifyPeerCertParsed(cert *x509.Certificate) error { shabuf := sha256.Sum256(cert.RawSubjectPublicKeyInfo) fp := base64.RawURLEncoding.EncodeToString(shabuf[:]) + la.TLSPubKeyFingerprint = fp pubKey, err := store.TLSPublicKeyGet(context.TODO(), fp) if err != nil { if err == bstore.ErrAbsent { - authResult = "badcreds" + la.Result = store.AuthBadCredentials } return fmt.Errorf("looking up tls public key with fingerprint %s: %v", fp, err) } + la.LoginAddress = pubKey.LoginAddress // Verify account exists and still matches address. We don't check for account // login being disabled if preauth is disabled. In that case, sasl external auth // will be done before credentials can be used, and login disabled will be checked // then, where it will result in a more helpful error message. checkLoginDisabled := !pubKey.NoIMAPPreauth - acc, _, err := store.OpenEmail(c.log, pubKey.LoginAddress, checkLoginDisabled) + acc, accName, _, err := store.OpenEmail(c.log, pubKey.LoginAddress, checkLoginDisabled) + la.AccountName = accName if err != nil { + if errors.Is(err, store.ErrLoginDisabled) { + la.Result = store.AuthLoginDisabled + } return fmt.Errorf("opening account for address %s for public key %s: %w", pubKey.LoginAddress, fp, err) } defer func() { @@ -507,16 +550,17 @@ func (c *conn) tlsClientAuthVerifyPeerCertParsed(cert *x509.Certificate) error { c.log.Check(err, "close account") } }() + la.AccountName = acc.Name if acc.Name != pubKey.Account { return fmt.Errorf("tls client public key %s is for account %s, but email address %s is for account %s", fp, pubKey.Account, pubKey.LoginAddress, acc.Name) } - authResult = "ok" c.authFailed = 0 c.account = acc acc = nil // Prevent cleanup by defer. c.username = pubKey.LoginAddress c.authTLS = true + la.Result = store.AuthSuccess c.log.Debug("tls client authenticated with client certificate", slog.String("fingerprint", fp), slog.String("username", c.username), @@ -1248,11 +1292,10 @@ func (c *conn) cmdAuth(p *parser) { } }() - var authVariant string // Only known strings, used in metrics. - authResult := "error" + la := c.loginAttempt(true, "") defer func() { - metrics.AuthenticationInc("submission", authVariant, authResult) - if authResult == "ok" { + store.LoginAttemptAdd(context.Background(), c.log, la) + if la.Result == store.AuthSuccess { mox.LimiterFailedAuth.Reset(c.remoteIP, time.Now()) } else if !missingDerivedSecrets { mox.LimiterFailedAuth.Add(c.remoteIP, time.Now(), 1) @@ -1273,7 +1316,7 @@ func (c *conn) cmdAuth(p *parser) { auth = c.readline() if auth == "*" { // ../rfc/4954:193 - authResult = "aborted" + la.Result = store.AuthAborted xsmtpUserErrorf(smtp.C501BadParamSyntax, smtp.SeProto5Other0, "authentication aborted") } } else { @@ -1304,7 +1347,7 @@ func (c *conn) cmdAuth(p *parser) { xreadContinuation := func() []byte { line := c.readline() if line == "*" { - authResult = "aborted" + la.Result = store.AuthAborted xsmtpUserErrorf(smtp.C501BadParamSyntax, smtp.SeProto5Other0, "authentication aborted") } buf, err := base64.StdEncoding.DecodeString(line) @@ -1329,7 +1372,7 @@ func (c *conn) cmdAuth(p *parser) { switch mech { case "PLAIN": - authVariant = "plain" + la.AuthMech = "plain" // ../rfc/4954:343 // ../rfc/4954:326 @@ -1347,18 +1390,19 @@ func (c *conn) cmdAuth(p *parser) { } authz := norm.NFC.String(string(plain[0])) username = norm.NFC.String(string(plain[1])) + la.LoginAddress = username password := string(plain[2]) if authz != "" && authz != username { - authResult = "badcreds" + la.Result = store.AuthBadCredentials xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "cannot assume other role") } var err error - account, err = store.OpenEmailAuth(c.log, username, password, false) + account, la.AccountName, err = store.OpenEmailAuth(c.log, username, password, false) if err != nil && errors.Is(err, store.ErrUnknownCredentials) { // ../rfc/4954:274 - authResult = "badcreds" + la.Result = store.AuthBadCredentials c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP)) xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "bad user/pass") } @@ -1369,7 +1413,7 @@ func (c *conn) cmdAuth(p *parser) { // clients, see Internet-Draft (I-D): // https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00 - authVariant = "login" + la.LoginAddress = "login" // ../rfc/4954:343 // ../rfc/4954:326 @@ -1386,6 +1430,7 @@ func (c *conn) cmdAuth(p *parser) { encChal := base64.StdEncoding.EncodeToString([]byte("Username:")) username = string(xreadInitial(encChal)) username = norm.NFC.String(username) + la.LoginAddress = username // Again, client should ignore the challenge, we send the same as the example in // the I-D. @@ -1397,17 +1442,17 @@ func (c *conn) cmdAuth(p *parser) { c.xtrace(mlog.LevelTrace) // Restore. var err error - account, err = store.OpenEmailAuth(c.log, username, password, false) + account, la.AccountName, err = store.OpenEmailAuth(c.log, username, password, false) if err != nil && errors.Is(err, store.ErrUnknownCredentials) { // ../rfc/4954:274 - authResult = "badcreds" + la.Result = store.AuthBadCredentials c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP)) xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "bad user/pass") } xcheckf(err, "verifying credentials") case "CRAM-MD5": - authVariant = strings.ToLower(mech) + la.AuthMech = strings.ToLower(mech) p.xempty() @@ -1421,15 +1466,17 @@ func (c *conn) cmdAuth(p *parser) { xsmtpUserErrorf(smtp.C501BadParamSyntax, smtp.SeProto5BadParams4, "malformed cram-md5 response") } username = norm.NFC.String(t[0]) + la.LoginAddress = username c.log.Debug("cram-md5 auth", slog.String("username", username)) var err error - account, _, err = store.OpenEmail(c.log, username, false) + account, la.AccountName, _, err = store.OpenEmail(c.log, username, false) if err != nil && errors.Is(err, store.ErrUnknownCredentials) { - authResult = "badcreds" + la.Result = store.AuthBadCredentials c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP)) xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "bad user/pass") } xcheckf(err, "looking up address") + la.AccountName = account.Name var ipadhash, opadhash hash.Hash account.WithRLock(func() { err := account.DB.Read(context.TODO(), func(tx *bstore.Tx) error { @@ -1470,9 +1517,9 @@ func (c *conn) cmdAuth(p *parser) { // Passwords cannot be retrieved or replayed from the trace. - authVariant = strings.ToLower(mech) + la.AuthMech = strings.ToLower(mech) var h func() hash.Hash - switch authVariant { + switch la.AuthMech { case "scram-sha-1", "scram-sha-1-plus": h = sha1.New case "scram-sha-256", "scram-sha-256-plus": @@ -1482,7 +1529,7 @@ func (c *conn) cmdAuth(p *parser) { } var cs *tls.ConnectionState - channelBindingRequired := strings.HasSuffix(authVariant, "-plus") + channelBindingRequired := strings.HasSuffix(la.AuthMech, "-plus") if channelBindingRequired && !c.tls { // ../rfc/4954:630 xsmtpUserErrorf(smtp.C538EncReqForAuth, smtp.SePol7EncReqForAuth11, "scram plus mechanism requires tls connection") @@ -1498,8 +1545,9 @@ func (c *conn) cmdAuth(p *parser) { xsmtpUserErrorf(smtp.C455BadParams, smtp.SePol7Other0, "scram protocol error: %s", err) } username = norm.NFC.String(ss.Authentication) + la.LoginAddress = username c.log.Debug("scram auth", slog.String("authentication", username)) - account, _, err = store.OpenEmail(c.log, username, false) + account, la.AccountName, _, err = store.OpenEmail(c.log, username, false) if err != nil { // todo: we could continue scram with a generated salt, deterministically generated // from the username. that way we don't have to store anything but attackers cannot @@ -1519,7 +1567,7 @@ func (c *conn) cmdAuth(p *parser) { xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "bad user/pass") } xcheckf(err, "fetching credentials") - switch authVariant { + switch la.AuthMech { case "scram-sha-1", "scram-sha-1-plus": xscram = password.SCRAMSHA1 case "scram-sha-256", "scram-sha-256-plus": @@ -1548,15 +1596,15 @@ func (c *conn) cmdAuth(p *parser) { if err != nil { c.readline() // Should be "*" for cancellation. if errors.Is(err, scram.ErrInvalidProof) { - authResult = "badcreds" + la.Result = store.AuthBadCredentials c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP)) xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "bad credentials") } else if errors.Is(err, scram.ErrChannelBindingsDontMatch) { - authResult = "badchanbind" + la.Result = store.AuthBadChannelBinding c.log.Warn("bad channel binding during authentication, potential mitm", slog.String("username", username), slog.Any("remote", c.remoteIP)) xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7MsgIntegrity7, "channel bindings do not match, potential mitm") } else if errors.Is(err, scram.ErrInvalidEncoding) { - authResult = "badprotocol" + la.Result = store.AuthBadProtocol c.log.Infox("bad scram protocol message", err, slog.String("username", username), slog.Any("remote", c.remoteIP)) xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7Other0, "bad scram protocol message") } @@ -1568,11 +1616,12 @@ func (c *conn) cmdAuth(p *parser) { xreadContinuation() case "EXTERNAL": - authVariant = strings.ToLower(mech) + la.AuthMech = "external" // ../rfc/4422:1618 buf := xreadInitial("") username = string(buf) + la.LoginAddress = username if !c.tls { // ../rfc/4954:630 @@ -1584,12 +1633,14 @@ func (c *conn) cmdAuth(p *parser) { if username == "" { username = c.username + la.LoginAddress = username } var err error - account, _, err = store.OpenEmail(c.log, username, false) + account, la.AccountName, _, err = store.OpenEmail(c.log, username, false) xcheckf(err, "looking up username from tls client authentication") default: + la.AuthMech = "(unrecognized)" // ../rfc/4954:176 xsmtpUserErrorf(smtp.C504ParamNotImpl, smtp.SeProto5BadParams4, "mechanism %s not supported", mech) } @@ -1597,7 +1648,7 @@ func (c *conn) cmdAuth(p *parser) { if accConf, ok := account.Conf(); !ok { xcheckf(errors.New("cannot find account"), "get account config") } else if accConf.LoginDisabled != "" { - authResult = "logindisabled" + la.Result = store.AuthLoginDisabled c.log.Info("account login disabled", slog.String("username", username)) xsmtpUserErrorf(smtp.C525AccountDisabled, smtp.SePol7AccountDisabled13, "%w: %s", store.ErrLoginDisabled, accConf.LoginDisabled) } @@ -1607,7 +1658,7 @@ func (c *conn) cmdAuth(p *parser) { if c.account != nil { if account != c.account { c.log.Debug("sasl authentication for different account than tls client authentication, aborting connection", - slog.String("saslmechanism", authVariant), + slog.String("saslmechanism", la.AuthMech), slog.String("saslaccount", account.Name), slog.String("tlsaccount", c.account.Name), slog.String("saslusername", username), @@ -1616,7 +1667,7 @@ func (c *conn) cmdAuth(p *parser) { xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "authentication failed, tls client certificate public key belongs to another account") } else if username != c.username { c.log.Debug("sasl authentication for different username than tls client certificate authentication, switching to sasl username", - slog.String("saslmechanism", authVariant), + slog.String("saslmechanism", la.AuthMech), slog.String("saslusername", username), slog.String("tlsusername", c.username), slog.String("account", c.account.Name), @@ -1628,7 +1679,9 @@ func (c *conn) cmdAuth(p *parser) { } c.username = username - authResult = "ok" + la.LoginAddress = c.username + la.AccountName = c.account.Name + la.Result = store.AuthSuccess c.authSASL = true c.authFailed = 0 c.setSlow(false) diff --git a/store/account.go b/store/account.go index 4958197..b013bac 100644 --- a/store/account.go +++ b/store/account.go @@ -2222,48 +2222,50 @@ func manageAuthCache() { // OpenEmailAuth opens an account given an email address and password. // // The email address may contain a catchall separator. -func OpenEmailAuth(log mlog.Log, email string, password string, checkLoginDisabled bool) (acc *Account, rerr error) { - password, err := precis.OpaqueString.String(password) - if err != nil { - return nil, ErrUnknownCredentials - } - +// For invalid credentials, a nil account is returned, but accName may be +// non-empty. +func OpenEmailAuth(log mlog.Log, email string, password string, checkLoginDisabled bool) (acc *Account, accName string, rerr error) { // We check for LoginDisabled after verifying the password. Otherwise users can get // messages about the account being disabled without knowing the password. - acc, _, rerr = OpenEmail(log, email, false) + acc, accName, _, rerr = OpenEmail(log, email, false) if rerr != nil { return } defer func() { - if rerr != nil && acc != nil { + if rerr != nil { err := acc.Close() log.Check(err, "closing account after open auth failure") acc = nil } }() + password, err := precis.OpaqueString.String(password) + if err != nil { + return nil, accName, ErrUnknownCredentials + } + pw, err := bstore.QueryDB[Password](context.TODO(), acc.DB).Get() if err != nil { if err == bstore.ErrAbsent { - return acc, ErrUnknownCredentials + return acc, accName, ErrUnknownCredentials } - return acc, fmt.Errorf("looking up password: %v", err) + return acc, accName, fmt.Errorf("looking up password: %v", err) } authCache.Lock() ok := len(password) >= 8 && authCache.success[authKey{email, pw.Hash}] == password authCache.Unlock() if !ok { if err := bcrypt.CompareHashAndPassword([]byte(pw.Hash), []byte(password)); err != nil { - return acc, ErrUnknownCredentials + return acc, accName, ErrUnknownCredentials } } if checkLoginDisabled { conf, aok := acc.Conf() if !aok { - return acc, fmt.Errorf("cannot find config for account") + return acc, accName, fmt.Errorf("cannot find config for account") } else if conf.LoginDisabled != "" { - return acc, fmt.Errorf("%w: %s", ErrLoginDisabled, conf.LoginDisabled) + return acc, accName, fmt.Errorf("%w: %s", ErrLoginDisabled, conf.LoginDisabled) } } authCache.Lock() @@ -2275,22 +2277,24 @@ func OpenEmailAuth(log mlog.Log, email string, password string, checkLoginDisabl // OpenEmail opens an account given an email address. // // The email address may contain a catchall separator. -func OpenEmail(log mlog.Log, email string, checkLoginDisabled bool) (*Account, config.Destination, error) { +// +// Returns account on success, may return non-empty account name even on error. +func OpenEmail(log mlog.Log, email string, checkLoginDisabled bool) (*Account, string, config.Destination, error) { addr, err := smtp.ParseAddress(email) if err != nil { - return nil, config.Destination{}, fmt.Errorf("%w: %v", ErrUnknownCredentials, err) + return nil, "", config.Destination{}, fmt.Errorf("%w: %v", ErrUnknownCredentials, err) } accountName, _, _, dest, err := mox.LookupAddress(addr.Localpart, addr.Domain, false, false, false) if err != nil && (errors.Is(err, mox.ErrAddressNotFound) || errors.Is(err, mox.ErrDomainNotFound)) { - return nil, config.Destination{}, ErrUnknownCredentials + return nil, accountName, config.Destination{}, ErrUnknownCredentials } else if err != nil { - return nil, config.Destination{}, fmt.Errorf("looking up address: %v", err) + return nil, accountName, config.Destination{}, fmt.Errorf("looking up address: %v", err) } acc, err := OpenAccount(log, accountName, checkLoginDisabled) if err != nil { - return nil, config.Destination{}, err + return nil, accountName, config.Destination{}, err } - return acc, dest, nil + return acc, accountName, dest, nil } // 64 characters, must be power of 2 for MessagePath diff --git a/store/account_test.go b/store/account_test.go index 09b0aed..3fe3e07 100644 --- a/store/account_test.go +++ b/store/account_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "reflect" "regexp" "strings" "testing" @@ -28,6 +29,13 @@ func tcheck(t *testing.T, err error, msg string) { } } +func tcompare(t *testing.T, got, expect any) { + t.Helper() + if !reflect.DeepEqual(got, expect) { + t.Fatalf("got:\n%#v\nexpected:\n%#v", got, expect) + } +} + func TestMailbox(t *testing.T) { log := mlog.New("store", nil) os.RemoveAll("../testdata/store/data") @@ -224,30 +232,30 @@ func TestMailbox(t *testing.T) { // Run the auth tests twice for possible cache effects. for i := 0; i < 2; i++ { - _, err := OpenEmailAuth(log, "mjl@mox.example", "bogus", false) + _, _, err := OpenEmailAuth(log, "mjl@mox.example", "bogus", false) if err != ErrUnknownCredentials { t.Fatalf("got %v, expected ErrUnknownCredentials", err) } } for i := 0; i < 2; i++ { - acc2, err := OpenEmailAuth(log, "mjl@mox.example", "testtest", false) + acc2, _, err := OpenEmailAuth(log, "mjl@mox.example", "testtest", false) tcheck(t, err, "open for email with auth") err = acc2.Close() tcheck(t, err, "close account") } - acc2, err := OpenEmailAuth(log, "other@mox.example", "testtest", false) + acc2, _, err := OpenEmailAuth(log, "other@mox.example", "testtest", false) tcheck(t, err, "open for email with auth") err = acc2.Close() tcheck(t, err, "close account") - _, err = OpenEmailAuth(log, "bogus@mox.example", "testtest", false) + _, _, err = OpenEmailAuth(log, "bogus@mox.example", "testtest", false) if err != ErrUnknownCredentials { t.Fatalf("got %v, expected ErrUnknownCredentials", err) } - _, err = OpenEmailAuth(log, "mjl@test.example", "testtest", false) + _, _, err = OpenEmailAuth(log, "mjl@test.example", "testtest", false) if err != ErrUnknownCredentials { t.Fatalf("got %v, expected ErrUnknownCredentials", err) } diff --git a/store/init.go b/store/init.go new file mode 100644 index 0000000..343288a --- /dev/null +++ b/store/init.go @@ -0,0 +1,78 @@ +package store + +import ( + "context" + "fmt" + "log/slog" + "os" + "path/filepath" + "runtime/debug" + "time" + + "github.com/mjl-/bstore" + + "github.com/mjl-/mox/metrics" + "github.com/mjl-/mox/mlog" + "github.com/mjl-/mox/mox-" + "github.com/mjl-/mox/moxvar" +) + +// AuthDB and AuthDBTypes are exported for ../backup.go. +var AuthDB *bstore.DB +var AuthDBTypes = []any{TLSPublicKey{}, LoginAttempt{}, LoginAttemptState{}} + +// Init opens auth.db. +func Init(ctx context.Context) error { + if AuthDB != nil { + return fmt.Errorf("already initialized") + } + pkglog := mlog.New("store", nil) + p := mox.DataDirPath("auth.db") + os.MkdirAll(filepath.Dir(p), 0770) + opts := bstore.Options{Timeout: 5 * time.Second, Perm: 0660, RegisterLogger: moxvar.RegisterLogger(p, pkglog.Logger)} + var err error + AuthDB, err = bstore.Open(ctx, p, &opts, AuthDBTypes...) + if err != nil { + return err + } + + startLoginAttemptWriter(ctx) + + go func() { + defer func() { + x := recover() + if x == nil { + return + } + + mlog.New("store", nil).Error("unhandled panic in LoginAttemptCleanup", slog.Any("err", x)) + debug.PrintStack() + metrics.PanicInc(metrics.Store) + + }() + + t := time.NewTicker(24 * time.Hour) + for { + err := LoginAttemptCleanup(ctx) + pkglog.Check(err, "cleaning up old historic login attempts") + + select { + case <-t.C: + case <-ctx.Done(): + return + } + } + }() + + return nil +} + +// Close closes auth.db. +func Close() error { + if AuthDB == nil { + return fmt.Errorf("not open") + } + err := AuthDB.Close() + AuthDB = nil + return err +} diff --git a/store/loginattempt.go b/store/loginattempt.go new file mode 100644 index 0000000..0c81f1f --- /dev/null +++ b/store/loginattempt.go @@ -0,0 +1,361 @@ +package store + +import ( + "context" + "crypto/sha256" + "crypto/tls" + "fmt" + "log/slog" + "runtime/debug" + "strings" + "time" + + "github.com/mjl-/bstore" + + "github.com/mjl-/mox/metrics" + "github.com/mjl-/mox/mlog" + "github.com/mjl-/mox/mox-" +) + +var loginAttemptsMaxPerAccount = 10 * 1000 // Lower during tests. + +// LoginAttempt is a successful or failed login attempt, stored for auditing +// purposes. +// +// At most 10000 failed attempts are stored per account, to prevent unbounded +// growth of the database by third parties. +type LoginAttempt struct { + // Hash of all fields after "Count" below. We store a single entry per key, + // updating its Last and Count fields. + Key []byte + + // Last has an index for efficient removal of entries after 30 days. + Last time.Time `bstore:"nonzero,default now,index"` + First time.Time `bstore:"nonzero,default now"` + Count int64 // Number of login attempts for the combination of fields below. + + // Admin logins use "(admin)". If no account is known, "-" is used. + // AccountName has an index for efficiently removing failed login attempts at the + // end of the list when there are too many, and for efficiently removing all records + // for an account. + AccountName string `bstore:"index AccountName+Last"` + + LoginAddress string // Empty for attempts to login in as admin. + RemoteIP string + LocalIP string + TLS string // Empty if no TLS, otherwise contains version, algorithm, properties, etc. + TLSPubKeyFingerprint string + Protocol string // "submission", "imap", "webmail", "webaccount", "webadmin" + UserAgent string // From HTTP header, or IMAP ID command. + AuthMech string // "plain", "login", "cram-md5", "scram-sha-256-plus", "(unrecognized)", etc + Result AuthResult + + log mlog.Log // For passing the logger to the goroutine that writes and logs. +} + +func (a LoginAttempt) calculateKey() []byte { + h := sha256.New() + l := []string{ + a.AccountName, + a.LoginAddress, + a.RemoteIP, + a.LocalIP, + a.TLS, + a.TLSPubKeyFingerprint, + a.Protocol, + a.UserAgent, + a.AuthMech, + string(a.Result), + } + // We don't add field separators. It allows us to add fields in the future that are + // empty by default without changing existing keys. + for _, s := range l { + h.Write([]byte(s)) + } + return h.Sum(nil) +} + +// LoginAttemptState keeps track of the number of failed LoginAttempt records +// per account. For efficiently removing records beyond 10000. +type LoginAttemptState struct { + AccountName string // "-" is used when no account is present, for unknown addresses. + + // Number of LoginAttempt records for login failures. For preventing unbounded + // growth of logs. + RecordsFailed int +} + +// AuthResult is the result of a login attempt. +type AuthResult string + +const ( + AuthSuccess AuthResult = "ok" + AuthBadUser AuthResult = "baduser" + AuthBadPassword AuthResult = "badpassword" + AuthBadCredentials AuthResult = "badcreds" + AuthBadChannelBinding AuthResult = "badchanbind" + AuthBadProtocol AuthResult = "badprotocol" + AuthLoginDisabled AuthResult = "logindisabled" + AuthError AuthResult = "error" + AuthAborted AuthResult = "aborted" +) + +var writeLoginAttempt chan LoginAttempt +var writeLoginAttemptStopped chan struct{} // For synchronizing with tests. + +func startLoginAttemptWriter(ctx context.Context) { + writeLoginAttempt = make(chan LoginAttempt, 100) + writeLoginAttemptStopped = make(chan struct{}, 1) + + go func() { + defer func() { + writeLoginAttemptStopped <- struct{}{} + + x := recover() + if x == nil { + return + } + + mlog.New("store", nil).Error("unhandled panic in LoginAttemptAdd", slog.Any("err", x)) + debug.PrintStack() + metrics.PanicInc(metrics.Store) + }() + + done := ctx.Done() + for { + select { + case <-done: + return + + case la := <-writeLoginAttempt: + l := []LoginAttempt{la} + // Gather all that we can write now. + All: + for { + select { + case la = <-writeLoginAttempt: + l = append(l, la) + default: + break All + } + } + + loginAttemptWrite(l...) + } + } + }() +} + +// LoginAttemptAdd logs a login attempt (with result), and upserts it in the +// database and possibly cleans up old entries in the database. +// +// Use account name "(admin)" for admin logins. +// +// Writes are done in a background routine, unless we are shutting down or when +// there are many pending writes. +func LoginAttemptAdd(ctx context.Context, log mlog.Log, a LoginAttempt) { + metrics.AuthenticationInc(a.Protocol, a.AuthMech, string(a.Result)) + + a.log = log + select { + case <-mox.Context.Done(): + // During shutdown, don't return before writing. + loginAttemptWrite(a) + default: + // Send login attempt to writer. Only blocks if there are lots of login attempts. + writeLoginAttempt <- a + } +} + +func loginAttemptWrite(l ...LoginAttempt) { + // Log on the way out, for "count" fetched from database. + defer func() { + for _, a := range l { + if a.AuthMech == "websession" { + // Prevent superfluous logging. + continue + } + + a.log.Info("login attempt", + slog.String("address", a.LoginAddress), + slog.String("account", a.AccountName), + slog.String("protocol", a.Protocol), + slog.String("authmech", a.AuthMech), + slog.String("result", string(a.Result)), + slog.String("remoteip", a.RemoteIP), + slog.String("localip", a.LocalIP), + slog.String("tls", a.TLS), + slog.String("useragent", a.UserAgent), + slog.String("tlspubkeyfp", a.TLSPubKeyFingerprint), + slog.Int64("count", a.Count), + ) + } + }() + + for i := range l { + if l[i].AccountName == "" { + l[i].AccountName = "-" + } + l[i].Key = l[i].calculateKey() + } + + err := AuthDB.Write(context.Background(), func(tx *bstore.Tx) error { + for i := range l { + err := loginAttemptWriteTx(tx, &l[i]) + l[i].log.Check(err, "adding login attempt") + } + return nil + }) + l[0].log.Check(err, "storing login attempt") +} + +func loginAttemptWriteTx(tx *bstore.Tx, a *LoginAttempt) error { + xa := LoginAttempt{Key: a.Key} + var insert bool + if err := tx.Get(&xa); err == bstore.ErrAbsent { + a.First = time.Time{} + a.Count = 1 + insert = true + if err := tx.Insert(a); err != nil { + return fmt.Errorf("inserting login attempt: %v", err) + } + } else if err != nil { + return fmt.Errorf("get loginattempt: %v", err) + } else { + log := a.log + last := a.Last + *a = xa + a.log = log + a.Last = last + if a.Last.IsZero() { + a.Last = time.Now() + } + a.Count++ + if err := tx.Update(a); err != nil { + return fmt.Errorf("updating login attempt: %v", err) + } + } + + // Update state with its RecordsFailed. + origstate := LoginAttemptState{AccountName: a.AccountName} + var newstate bool + if err := tx.Get(&origstate); err == bstore.ErrAbsent { + newstate = true + } else if err != nil { + return fmt.Errorf("get login attempt state: %v", err) + } + state := origstate + if insert && a.Result != AuthSuccess { + state.RecordsFailed++ + } + + if state.RecordsFailed > loginAttemptsMaxPerAccount { + q := bstore.QueryTx[LoginAttempt](tx) + q.FilterNonzero(LoginAttempt{AccountName: a.AccountName}) + q.FilterNotEqual("Result", AuthSuccess) + q.SortAsc("Last") + q.Limit(state.RecordsFailed - loginAttemptsMaxPerAccount) + if n, err := q.Delete(); err != nil { + return fmt.Errorf("deleting too many failed login attempts: %v", err) + } else { + state.RecordsFailed -= n + } + } + + if state == origstate { + return nil + } + if newstate { + if err := tx.Insert(&state); err != nil { + return fmt.Errorf("inserting login attempt state: %v", err) + } + return nil + } + if err := tx.Update(&state); err != nil { + return fmt.Errorf("updating login attempt state: %v", err) + } + return nil +} + +// LoginAttemptCleanup removes any LoginAttempt entries older than 30 days, for +// all accounts. +func LoginAttemptCleanup(ctx context.Context) error { + return AuthDB.Write(ctx, func(tx *bstore.Tx) error { + var removed []LoginAttempt + q := bstore.QueryTx[LoginAttempt](tx) + q.FilterLess("Last", time.Now().Add(-30*24*time.Hour)) + q.Gather(&removed) + _, err := q.Delete() + if err != nil { + return fmt.Errorf("deleting old login attempts: %v", err) + } + + deleted := map[string]int{} + for _, r := range removed { + if r.Result != AuthSuccess { + deleted[r.AccountName]++ + } + } + + for accName, n := range deleted { + state := LoginAttemptState{AccountName: accName} + if err := tx.Get(&state); err != nil { + return fmt.Errorf("get login attempt state for account %v: %v", accName, err) + } + state.RecordsFailed -= n + if err := tx.Update(&state); err != nil { + return fmt.Errorf("update login attempt state for account %v: %v", accName, err) + } + } + + return nil + }) +} + +// LoginAttemptRemoveAccount removes all LoginAttempt records for an account +// (value must be non-empty). +func LoginAttemptRemoveAccount(ctx context.Context, accountName string) error { + return AuthDB.Write(ctx, func(tx *bstore.Tx) error { + q := bstore.QueryTx[LoginAttempt](tx) + q.FilterNonzero(LoginAttempt{AccountName: accountName}) + _, err := q.Delete() + return err + }) +} + +// LoginAttemptList returns LoginAttempt records for the accountName. If +// accountName is empty, all records are returned. Use "(admin)" for admin +// logins. Use "-" for login attempts for which no account was found. +// If limit is greater than 0, at most limit records, most recent first, are returned. +func LoginAttemptList(ctx context.Context, accountName string, limit int) ([]LoginAttempt, error) { + var l []LoginAttempt + err := AuthDB.Read(ctx, func(tx *bstore.Tx) error { + q := bstore.QueryTx[LoginAttempt](tx) + if accountName != "" { + q.FilterNonzero(LoginAttempt{AccountName: accountName}) + } + q.SortDesc("Last") + if limit > 0 { + q.Limit(limit) + } + var err error + l, err = q.List() + return err + }) + return l, err +} + +// LoginAttemptTLS returns a string for use as LoginAttempt.TLS. Returns an empty +// string if "c" is not a TLS connection. +func LoginAttemptTLS(state *tls.ConnectionState) string { + if state == nil { + return "" + } + + return fmt.Sprintf("version=%s ciphersuite=%s sni=%s resumed=%v alpn=%s", + strings.ReplaceAll(strings.ToLower(tls.VersionName(state.Version)), " ", ""), // e.g. tls1.3 + strings.ToLower(tls.CipherSuiteName(state.CipherSuite)), + state.ServerName, + state.DidResume, + state.NegotiatedProtocol) +} diff --git a/store/loginattempt_test.go b/store/loginattempt_test.go new file mode 100644 index 0000000..da25d61 --- /dev/null +++ b/store/loginattempt_test.go @@ -0,0 +1,112 @@ +package store + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/mjl-/mox/mox-" +) + +func TestLoginAttempt(t *testing.T) { + os.RemoveAll("../testdata/store/data") + mox.ConfigStaticPath = filepath.FromSlash("../testdata/store/mox.conf") + mox.MustLoadConfig(true, false) + + xctx, xcancel := context.WithCancel(ctxbg) + err := Init(xctx) + tcheck(t, err, "store init") + // Stop the background LoginAttempt writer for synchronous tests. + xcancel() + <-writeLoginAttemptStopped + defer func() { + err := Close() + tcheck(t, err, "store close") + }() + + a1 := LoginAttempt{ + Last: time.Now(), + First: time.Now(), + AccountName: "mjl1", + UserAgent: "0", // "0" so we update instead of insert when testing automatic cleanup below. + Result: AuthError, + } + a2 := a1 + a2.AccountName = "mjl2" + a3 := a1 + a3.AccountName = "mjl3" + a3.Last = a3.Last.Add(-31 * 24 * time.Hour) // Will be cleaned up. + a3.First = a3.Last + LoginAttemptAdd(ctxbg, pkglog, a1) + LoginAttemptAdd(ctxbg, pkglog, a2) + LoginAttemptAdd(ctxbg, pkglog, a3) + + // Ensure there are no LoginAttempts that still need to be written. + loginAttemptDrain := func() { + for { + select { + case la := <-writeLoginAttempt: + loginAttemptWrite(la) + default: + return + } + } + } + + loginAttemptDrain() + + l, err := LoginAttemptList(ctxbg, "", 0) + tcheck(t, err, "list login attempts") + tcompare(t, len(l), 3) + + // Test limit. + l, err = LoginAttemptList(ctxbg, "", 2) + tcheck(t, err, "list login attempts") + tcompare(t, len(l), 2) + + // Test account filter. + l, err = LoginAttemptList(ctxbg, "mjl1", 2) + tcheck(t, err, "list login attempts") + tcompare(t, len(l), 1) + + // Cleanup will remove the entry for mjl3 and leave others. + err = LoginAttemptCleanup(ctxbg) + tcheck(t, err, "cleanup login attempt") + l, err = LoginAttemptList(ctxbg, "", 0) + tcheck(t, err, "list login attempts") + tcompare(t, len(l), 2) + + // Removing account will keep last entry for mjl2. + err = LoginAttemptRemoveAccount(ctxbg, "mjl1") + tcheck(t, err, "remove login attempt account") + l, err = LoginAttemptList(ctxbg, "", 0) + tcheck(t, err, "list login attempts") + tcompare(t, len(l), 1) + + l, err = LoginAttemptList(ctxbg, "mjl2", 0) + tcheck(t, err, "list login attempts") + tcompare(t, len(l), 1) + + // Insert 3 failing entries. Then add another and see we still have 3. + loginAttemptsMaxPerAccount = 3 + for i := 0; i < loginAttemptsMaxPerAccount; i++ { + a := a2 + a.UserAgent = fmt.Sprintf("%d", i) + LoginAttemptAdd(ctxbg, pkglog, a) + } + loginAttemptDrain() + l, err = LoginAttemptList(ctxbg, "", 0) + tcheck(t, err, "list login attempts") + tcompare(t, len(l), loginAttemptsMaxPerAccount) + + a := a2 + a.UserAgent = fmt.Sprintf("%d", loginAttemptsMaxPerAccount) + LoginAttemptAdd(ctxbg, pkglog, a) + loginAttemptDrain() + l, err = LoginAttemptList(ctxbg, "", 0) + tcheck(t, err, "list login attempts") + tcompare(t, len(l), loginAttemptsMaxPerAccount) +} diff --git a/store/tlspubkey.go b/store/tlspubkey.go index 146f7ff..729d320 100644 --- a/store/tlspubkey.go +++ b/store/tlspubkey.go @@ -9,16 +9,11 @@ import ( "crypto/x509" "encoding/base64" "fmt" - "os" - "path/filepath" "strings" "time" "github.com/mjl-/bstore" - "github.com/mjl-/mox/mlog" - "github.com/mjl-/mox/mox-" - "github.com/mjl-/mox/moxvar" "github.com/mjl-/mox/smtp" ) @@ -43,34 +38,6 @@ type TLSPublicKey struct { LoginAddress string `bstore:"nonzero"` // Must belong to account. } -// AuthDB and AuthDBTypes are exported for ../backup.go. -var AuthDB *bstore.DB -var AuthDBTypes = []any{TLSPublicKey{}} - -// Init opens auth.db. -func Init(ctx context.Context) error { - if AuthDB != nil { - return fmt.Errorf("already initialized") - } - pkglog := mlog.New("store", nil) - p := mox.DataDirPath("auth.db") - os.MkdirAll(filepath.Dir(p), 0770) - opts := bstore.Options{Timeout: 5 * time.Second, Perm: 0660, RegisterLogger: moxvar.RegisterLogger(p, pkglog.Logger)} - var err error - AuthDB, err = bstore.Open(ctx, p, &opts, AuthDBTypes...) - return err -} - -// Close closes auth.db. -func Close() error { - if AuthDB == nil { - return fmt.Errorf("not open") - } - err := AuthDB.Close() - AuthDB = nil - return err -} - // ParseTLSPublicKeyCert parses a certificate, preparing a TLSPublicKey for // insertion into the database. Caller must set fields that are not in the // certificat, such as Account and LoginAddress. diff --git a/webaccount/account.go b/webaccount/account.go index c04f7ff..be728ae 100644 --- a/webaccount/account.go +++ b/webaccount/account.go @@ -731,7 +731,7 @@ func (Account) TLSPublicKeyUpdate(ctx context.Context, pubKey store.TLSPublicKey reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo) tpk := xtlspublickey(ctx, reqInfo.AccountName, pubKey.Fingerprint) log := pkglog.WithContext(ctx) - acc, _, err := store.OpenEmail(log, pubKey.LoginAddress, false) + acc, _, _, err := store.OpenEmail(log, pubKey.LoginAddress, false) if err == nil && acc.Name != reqInfo.AccountName { err = store.ErrUnknownCredentials } @@ -749,3 +749,10 @@ func (Account) TLSPublicKeyUpdate(ctx context.Context, pubKey store.TLSPublicKey xcheckf(ctx, err, "updating tls public key") return nil } + +func (Account) LoginAttempts(ctx context.Context, limit int) []store.LoginAttempt { + reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo) + l, err := store.LoginAttemptList(ctx, reqInfo.AccountName, limit) + xcheckf(ctx, err, "listing login attempts") + return l +} diff --git a/webaccount/account.js b/webaccount/account.js index 0dc6d3d..2c6e583 100644 --- a/webaccount/account.js +++ b/webaccount/account.js @@ -255,8 +255,21 @@ var api; // per-outgoing-message address used for sending. OutgoingEvent["EventUnrecognized"] = "unrecognized"; })(OutgoingEvent = api.OutgoingEvent || (api.OutgoingEvent = {})); - api.structTypes = { "Account": true, "Address": true, "AddressAlias": true, "Alias": true, "AliasAddress": true, "AutomaticJunkFlags": true, "Destination": true, "Domain": true, "ImportProgress": true, "Incoming": true, "IncomingMeta": true, "IncomingWebhook": true, "JunkFilter": true, "NameAddress": true, "Outgoing": true, "OutgoingWebhook": true, "Route": true, "Ruleset": true, "Structure": true, "SubjectPass": true, "Suppression": true, "TLSPublicKey": true }; - api.stringsTypes = { "CSRFToken": true, "Localpart": true, "OutgoingEvent": true }; + // AuthResult is the result of a login attempt. + let AuthResult; + (function (AuthResult) { + AuthResult["AuthSuccess"] = "ok"; + AuthResult["AuthBadUser"] = "baduser"; + AuthResult["AuthBadPassword"] = "badpassword"; + AuthResult["AuthBadCredentials"] = "badcreds"; + AuthResult["AuthBadChannelBinding"] = "badchanbind"; + AuthResult["AuthBadProtocol"] = "badprotocol"; + AuthResult["AuthLoginDisabled"] = "logindisabled"; + AuthResult["AuthError"] = "error"; + AuthResult["AuthAborted"] = "aborted"; + })(AuthResult = api.AuthResult || (api.AuthResult = {})); + api.structTypes = { "Account": true, "Address": true, "AddressAlias": true, "Alias": true, "AliasAddress": true, "AutomaticJunkFlags": true, "Destination": true, "Domain": true, "ImportProgress": true, "Incoming": true, "IncomingMeta": true, "IncomingWebhook": true, "JunkFilter": true, "LoginAttempt": true, "NameAddress": true, "Outgoing": true, "OutgoingWebhook": true, "Route": true, "Ruleset": true, "Structure": true, "SubjectPass": true, "Suppression": true, "TLSPublicKey": true }; + api.stringsTypes = { "AuthResult": true, "CSRFToken": true, "Localpart": true, "OutgoingEvent": true }; api.intsTypes = {}; api.types = { "Account": { "Name": "Account", "Docs": "", "Fields": [{ "Name": "OutgoingWebhook", "Docs": "", "Typewords": ["nullable", "OutgoingWebhook"] }, { "Name": "IncomingWebhook", "Docs": "", "Typewords": ["nullable", "IncomingWebhook"] }, { "Name": "FromIDLoginAddresses", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "KeepRetiredMessagePeriod", "Docs": "", "Typewords": ["int64"] }, { "Name": "KeepRetiredWebhookPeriod", "Docs": "", "Typewords": ["int64"] }, { "Name": "LoginDisabled", "Docs": "", "Typewords": ["string"] }, { "Name": "Domain", "Docs": "", "Typewords": ["string"] }, { "Name": "Description", "Docs": "", "Typewords": ["string"] }, { "Name": "FullName", "Docs": "", "Typewords": ["string"] }, { "Name": "Destinations", "Docs": "", "Typewords": ["{}", "Destination"] }, { "Name": "SubjectPass", "Docs": "", "Typewords": ["SubjectPass"] }, { "Name": "QuotaMessageSize", "Docs": "", "Typewords": ["int64"] }, { "Name": "RejectsMailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "KeepRejects", "Docs": "", "Typewords": ["bool"] }, { "Name": "AutomaticJunkFlags", "Docs": "", "Typewords": ["AutomaticJunkFlags"] }, { "Name": "JunkFilter", "Docs": "", "Typewords": ["nullable", "JunkFilter"] }, { "Name": "MaxOutgoingMessagesPerDay", "Docs": "", "Typewords": ["int32"] }, { "Name": "MaxFirstTimeRecipientsPerDay", "Docs": "", "Typewords": ["int32"] }, { "Name": "NoFirstTimeSenderDelay", "Docs": "", "Typewords": ["bool"] }, { "Name": "Routes", "Docs": "", "Typewords": ["[]", "Route"] }, { "Name": "DNSDomain", "Docs": "", "Typewords": ["Domain"] }, { "Name": "Aliases", "Docs": "", "Typewords": ["[]", "AddressAlias"] }] }, @@ -281,9 +294,11 @@ var api; "Structure": { "Name": "Structure", "Docs": "", "Fields": [{ "Name": "ContentType", "Docs": "", "Typewords": ["string"] }, { "Name": "ContentTypeParams", "Docs": "", "Typewords": ["{}", "string"] }, { "Name": "ContentID", "Docs": "", "Typewords": ["string"] }, { "Name": "ContentDisposition", "Docs": "", "Typewords": ["string"] }, { "Name": "Filename", "Docs": "", "Typewords": ["string"] }, { "Name": "DecodedSize", "Docs": "", "Typewords": ["int64"] }, { "Name": "Parts", "Docs": "", "Typewords": ["[]", "Structure"] }] }, "IncomingMeta": { "Name": "IncomingMeta", "Docs": "", "Fields": [{ "Name": "MsgID", "Docs": "", "Typewords": ["int64"] }, { "Name": "MailFrom", "Docs": "", "Typewords": ["string"] }, { "Name": "MailFromValidated", "Docs": "", "Typewords": ["bool"] }, { "Name": "MsgFromValidated", "Docs": "", "Typewords": ["bool"] }, { "Name": "RcptTo", "Docs": "", "Typewords": ["string"] }, { "Name": "DKIMVerifiedDomains", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "RemoteIP", "Docs": "", "Typewords": ["string"] }, { "Name": "Received", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "MailboxName", "Docs": "", "Typewords": ["string"] }, { "Name": "Automated", "Docs": "", "Typewords": ["bool"] }] }, "TLSPublicKey": { "Name": "TLSPublicKey", "Docs": "", "Fields": [{ "Name": "Fingerprint", "Docs": "", "Typewords": ["string"] }, { "Name": "Created", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "Type", "Docs": "", "Typewords": ["string"] }, { "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "NoIMAPPreauth", "Docs": "", "Typewords": ["bool"] }, { "Name": "CertDER", "Docs": "", "Typewords": ["nullable", "string"] }, { "Name": "Account", "Docs": "", "Typewords": ["string"] }, { "Name": "LoginAddress", "Docs": "", "Typewords": ["string"] }] }, + "LoginAttempt": { "Name": "LoginAttempt", "Docs": "", "Fields": [{ "Name": "Key", "Docs": "", "Typewords": ["nullable", "string"] }, { "Name": "Last", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "First", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "Count", "Docs": "", "Typewords": ["int64"] }, { "Name": "AccountName", "Docs": "", "Typewords": ["string"] }, { "Name": "LoginAddress", "Docs": "", "Typewords": ["string"] }, { "Name": "RemoteIP", "Docs": "", "Typewords": ["string"] }, { "Name": "LocalIP", "Docs": "", "Typewords": ["string"] }, { "Name": "TLS", "Docs": "", "Typewords": ["string"] }, { "Name": "TLSPubKeyFingerprint", "Docs": "", "Typewords": ["string"] }, { "Name": "Protocol", "Docs": "", "Typewords": ["string"] }, { "Name": "UserAgent", "Docs": "", "Typewords": ["string"] }, { "Name": "AuthMech", "Docs": "", "Typewords": ["string"] }, { "Name": "Result", "Docs": "", "Typewords": ["AuthResult"] }] }, "CSRFToken": { "Name": "CSRFToken", "Docs": "", "Values": null }, "Localpart": { "Name": "Localpart", "Docs": "", "Values": null }, "OutgoingEvent": { "Name": "OutgoingEvent", "Docs": "", "Values": [{ "Name": "EventDelivered", "Value": "delivered", "Docs": "" }, { "Name": "EventSuppressed", "Value": "suppressed", "Docs": "" }, { "Name": "EventDelayed", "Value": "delayed", "Docs": "" }, { "Name": "EventFailed", "Value": "failed", "Docs": "" }, { "Name": "EventRelayed", "Value": "relayed", "Docs": "" }, { "Name": "EventExpanded", "Value": "expanded", "Docs": "" }, { "Name": "EventCanceled", "Value": "canceled", "Docs": "" }, { "Name": "EventUnrecognized", "Value": "unrecognized", "Docs": "" }] }, + "AuthResult": { "Name": "AuthResult", "Docs": "", "Values": [{ "Name": "AuthSuccess", "Value": "ok", "Docs": "" }, { "Name": "AuthBadUser", "Value": "baduser", "Docs": "" }, { "Name": "AuthBadPassword", "Value": "badpassword", "Docs": "" }, { "Name": "AuthBadCredentials", "Value": "badcreds", "Docs": "" }, { "Name": "AuthBadChannelBinding", "Value": "badchanbind", "Docs": "" }, { "Name": "AuthBadProtocol", "Value": "badprotocol", "Docs": "" }, { "Name": "AuthLoginDisabled", "Value": "logindisabled", "Docs": "" }, { "Name": "AuthError", "Value": "error", "Docs": "" }, { "Name": "AuthAborted", "Value": "aborted", "Docs": "" }] }, }; api.parser = { Account: (v) => api.parse("Account", v), @@ -308,9 +323,11 @@ var api; Structure: (v) => api.parse("Structure", v), IncomingMeta: (v) => api.parse("IncomingMeta", v), TLSPublicKey: (v) => api.parse("TLSPublicKey", v), + LoginAttempt: (v) => api.parse("LoginAttempt", v), CSRFToken: (v) => api.parse("CSRFToken", v), Localpart: (v) => api.parse("Localpart", v), OutgoingEvent: (v) => api.parse("OutgoingEvent", v), + AuthResult: (v) => api.parse("AuthResult", v), }; // Account exports web API functions for the account web interface. All its // methods are exported under api/. Function calls require valid HTTP @@ -555,6 +572,13 @@ var api; const params = [pubKey]; return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); } + async LoginAttempts(limit) { + const fn = "LoginAttempts"; + const paramTypes = [["int32"]]; + const returnTypes = [["[]", "LoginAttempt"]]; + const params = [limit]; + return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); + } } api.Client = Client; api.defaultBaseURL = (function () { @@ -1059,7 +1083,7 @@ const domainString = (d) => { const box = (color, ...l) => [ dom.div(style({ display: 'inline-block', - padding: '.25em .5em', + padding: '.125em .25em', backgroundColor: color, borderRadius: '3px', margin: '.5ex 0', @@ -1122,9 +1146,10 @@ const formatQuotaSize = (v) => { return '' + v; }; const index = async () => { - const [[acc, storageUsed, storageLimit, suppressions], tlspubkeys0] = await Promise.all([ + const [[acc, storageUsed, storageLimit, suppressions], tlspubkeys0, recentLoginAttempts] = await Promise.all([ client.Account(), client.TLSPublicKeys(), + client.LoginAttempts(10), ]); const tlspubkeys = tlspubkeys0 || []; let fullNameForm; @@ -1440,7 +1465,7 @@ const index = async () => { }), dom.br(), dom.h2('Addresses'), dom.ul(Object.entries(acc.Destinations || {}).length === 0 ? dom.li('(None, login disabled)') : [], Object.entries(acc.Destinations || {}).sort().map(t => dom.li(dom.a(prewrap(t[0]), attr.href('#destinations/' + encodeURIComponent(t[0]))), t[0].startsWith('@') ? ' (catchall)' : []))), dom.br(), dom.h2('Aliases/lists'), dom.table(dom.thead(dom.tr(dom.th('Alias address', attr.title('Messages sent to this address will be delivered to all members of the alias/list. A member does not receive a message if their address is in the message From header.')), dom.th('Subscription address', attr.title('Address subscribed to the alias/list.')), dom.th('Allowed senders', attr.title('Whether only members can send through the alias/list, or anyone.')), dom.th('Send as alias address', attr.title('If enabled, messages can be sent with the alias address in the message "From" header.')), dom.th())), (acc.Aliases || []).length === 0 ? dom.tr(dom.td(attr.colspan('5'), 'None')) : [], (acc.Aliases || []).sort((a, b) => a.Alias.LocalpartStr < b.Alias.LocalpartStr ? -1 : (domainName(a.Alias.Domain) < domainName(b.Alias.Domain) ? -1 : 1)).map(a => dom.tr(dom.td(prewrap(a.Alias.LocalpartStr, '@', domainName(a.Alias.Domain))), dom.td(prewrap(a.SubscriptionAddress)), dom.td(a.Alias.PostPublic ? 'Anyone' : 'Members only'), dom.td(a.Alias.AllowMsgFrom ? 'Yes' : 'No'), dom.td((a.MemberAddresses || []).length === 0 ? [] : dom.clickbutton('Show members', function click() { popup(dom.h1('Members of alias ', prewrap(a.Alias.LocalpartStr, '@', domainName(a.Alias.Domain))), dom.ul((a.MemberAddresses || []).map(addr => dom.li(prewrap(addr))))); - }))))), dom.br(), dom.h2('Change password'), passwordForm = dom.form(passwordFieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), 'New password', dom.br(), password1 = dom.input(attr.type('password'), attr.autocomplete('new-password'), attr.required(''), function focus() { + }))))), dom.br(), dom.h2('Recent login attempts', attr.title('Login attempts are stored for 30 days. At most 10000 failed login attempts are stored to prevent unlimited growth of the database.')), renderLoginAttempts(recentLoginAttempts || []), dom.br(), recentLoginAttempts && recentLoginAttempts.length >= 10 ? dom.p('See ', dom.a(attr.href('#loginattempts'), 'all login attempts'), '.') : dom.br(), dom.h2('Change password'), passwordForm = dom.form(passwordFieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), 'New password', dom.br(), password1 = dom.input(attr.type('password'), attr.autocomplete('new-password'), attr.required(''), function focus() { passwordHint.style.display = ''; })), ' ', dom.label(style({ display: 'inline-block' }), 'New password repeat', dom.br(), password2 = dom.input(attr.type('password'), attr.autocomplete('new-password'), attr.required(''))), ' ', dom.submitbutton('Change password')), passwordHint = dom.div(style({ display: 'none', marginTop: '.5ex' }), dom.clickbutton('Generate random password', function click(e) { e.preventDefault(); @@ -1754,6 +1779,14 @@ openssl pkcs12 \\ })(); return root; }; +const renderLoginAttempts = (loginAttempts) => { + // todo: pagination and search + return dom.table(dom.thead(dom.tr(dom.th('Time'), dom.th('Result'), dom.th('Count'), dom.th('LoginAddress'), dom.th('Protocol'), dom.th('Mechanism'), dom.th('User Agent'), dom.th('Remote IP'), dom.th('Local IP'), dom.th('TLS'), dom.th('TLS pubkey fingerprint'), dom.th('First seen'))), dom.tbody(loginAttempts.length ? [] : dom.tr(dom.td(attr.colspan('11'), 'No login attempts in past 30 days.')), loginAttempts.map(la => dom.tr(dom.td(age(la.Last)), dom.td(la.Result === 'ok' ? la.Result : box(red, la.Result)), dom.td('' + la.Count), dom.td(la.LoginAddress), dom.td(la.Protocol), dom.td(la.AuthMech), dom.td(la.UserAgent), dom.td(la.RemoteIP), dom.td(la.LocalIP), dom.td(la.TLS), dom.td(la.TLSPubKeyFingerprint), dom.td(age(la.First)))))); +}; +const loginattempts = async () => { + const loginAttempts = await client.LoginAttempts(0); + return dom.div(crumbs(crumblink('Mox Account', '#'), 'Login attempts'), dom.h2('Login attempts'), dom.p('Login attempts are stored for 30 days. At most 10000 failed login attempts are stored to prevent unlimited growth of the database.'), renderLoginAttempts(loginAttempts || [])); +}; const destination = async (name) => { const [acc] = await client.Account(); let dest = (acc.Destinations || {})[name]; @@ -1885,6 +1918,9 @@ const init = async () => { if (h === '') { root = await index(); } + else if (t[0] === 'loginattempts' && t.length === 1) { + root = await loginattempts(); + } else if (t[0] === 'destinations' && t.length === 2) { root = await destination(t[1]); } diff --git a/webaccount/account.ts b/webaccount/account.ts index 10e818a..171182e 100644 --- a/webaccount/account.ts +++ b/webaccount/account.ts @@ -229,7 +229,7 @@ const box = (color: string, ...l: ElemArg[]) => [ dom.div( style({ display: 'inline-block', - padding: '.25em .5em', + padding: '.125em .25em', backgroundColor: color, borderRadius: '3px', margin: '.5ex 0', @@ -298,9 +298,10 @@ const formatQuotaSize = (v: number) => { } const index = async () => { - const [[acc, storageUsed, storageLimit, suppressions], tlspubkeys0] = await Promise.all([ + const [[acc, storageUsed, storageLimit, suppressions], tlspubkeys0, recentLoginAttempts] = await Promise.all([ client.Account(), client.TLSPublicKeys(), + client.LoginAttempts(10), ]) const tlspubkeys = tlspubkeys0 || [] @@ -821,6 +822,11 @@ const index = async () => { ), dom.br(), + dom.h2('Recent login attempts', attr.title('Login attempts are stored for 30 days. At most 10000 failed login attempts are stored to prevent unlimited growth of the database.')), + renderLoginAttempts(recentLoginAttempts || []), + dom.br(), + recentLoginAttempts && recentLoginAttempts.length >= 10 ? dom.p('See ', dom.a(attr.href('#loginattempts'), 'all login attempts'), '.') : dom.br(), + dom.h2('Change password'), passwordForm=dom.form( passwordFieldset=dom.fieldset( @@ -1628,6 +1634,62 @@ openssl pkcs12 \\ return root } +const renderLoginAttempts = (loginAttempts: api.LoginAttempt[]) => { + // todo: pagination and search + + return dom.table( + dom.thead( + dom.tr( + dom.th('Time'), + dom.th('Result'), + dom.th('Count'), + dom.th('LoginAddress'), + dom.th('Protocol'), + dom.th('Mechanism'), + dom.th('User Agent'), + dom.th('Remote IP'), + dom.th('Local IP'), + dom.th('TLS'), + dom.th('TLS pubkey fingerprint'), + dom.th('First seen'), + ), + ), + dom.tbody( + loginAttempts.length ? [] : dom.tr(dom.td(attr.colspan('11'), 'No login attempts in past 30 days.')), + loginAttempts.map(la => + dom.tr( + dom.td(age(la.Last)), + dom.td(la.Result === 'ok' ? la.Result : box(red, la.Result)), + dom.td(''+la.Count), + dom.td(la.LoginAddress), + dom.td(la.Protocol), + dom.td(la.AuthMech), + dom.td(la.UserAgent), + dom.td(la.RemoteIP), + dom.td(la.LocalIP), + dom.td(la.TLS), + dom.td(la.TLSPubKeyFingerprint), + dom.td(age(la.First)), + ), + ), + ), + ) +} + +const loginattempts = async () => { + const loginAttempts = await client.LoginAttempts(0) + + return dom.div( + crumbs( + crumblink('Mox Account', '#'), + 'Login attempts', + ), + dom.h2('Login attempts'), + dom.p('Login attempts are stored for 30 days. At most 10000 failed login attempts are stored to prevent unlimited growth of the database.'), + renderLoginAttempts(loginAttempts || []) + ) +} + const destination = async (name: string) => { const [acc] = await client.Account() let dest = (acc.Destinations || {})[name] @@ -1881,6 +1943,8 @@ const init = async () => { let root: HTMLElement if (h === '') { root = await index() + } else if (t[0] === 'loginattempts' && t.length === 1) { + root = await loginattempts() } else if (t[0] === 'destinations' && t.length === 2) { root = await destination(t[1]) } else { diff --git a/webaccount/account_test.go b/webaccount/account_test.go index 684b210..d11b849 100644 --- a/webaccount/account_test.go +++ b/webaccount/account_test.go @@ -97,6 +97,12 @@ func TestAccount(t *testing.T) { mox.ConfigStaticPath = filepath.FromSlash("../testdata/httpaccount/mox.conf") mox.ConfigDynamicPath = filepath.Join(filepath.Dir(mox.ConfigStaticPath), "domains.conf") mox.MustLoadConfig(true, false) + err := store.Init(ctxbg) + tcheck(t, err, "store init") + defer func() { + err := store.Close() + tcheck(t, err, "store close") + }() log := mlog.New("webaccount", nil) acc, err := store.OpenAccount(log, "mjl☺", false) tcheck(t, err, "open account") @@ -511,13 +517,6 @@ func TestAccount(t *testing.T) { tcheck(t, err, "encoding certificate as pem") certPEM := b.String() - err = store.Init(ctx) - tcheck(t, err, "store init") - defer func() { - err := store.Close() - tcheck(t, err, "store close") - }() - tpkl, err := api.TLSPublicKeys(ctx) tcheck(t, err, "list tls public keys") tcompare(t, len(tpkl), 0) diff --git a/webaccount/api.json b/webaccount/api.json index 39d98a2..d590e67 100644 --- a/webaccount/api.json +++ b/webaccount/api.json @@ -528,6 +528,27 @@ } ], "Returns": [] + }, + { + "Name": "LoginAttempts", + "Docs": "", + "Params": [ + { + "Name": "limit", + "Typewords": [ + "int32" + ] + } + ], + "Returns": [ + { + "Name": "r0", + "Typewords": [ + "[]", + "LoginAttempt" + ] + } + ] } ], "Sections": [], @@ -1679,6 +1700,111 @@ ] } ] + }, + { + "Name": "LoginAttempt", + "Docs": "LoginAttempt is a successful or failed login attempt, stored for auditing\npurposes.\n\nAt most 10000 failed attempts are stored per account, to prevent unbounded\ngrowth of the database by third parties.", + "Fields": [ + { + "Name": "Key", + "Docs": "Hash of all fields after \"Count\" below. We store a single entry per key, updating its Last and Count fields.", + "Typewords": [ + "[]", + "uint8" + ] + }, + { + "Name": "Last", + "Docs": "Last has an index for efficient removal of entries after 30 days.", + "Typewords": [ + "timestamp" + ] + }, + { + "Name": "First", + "Docs": "", + "Typewords": [ + "timestamp" + ] + }, + { + "Name": "Count", + "Docs": "Number of login attempts for the combination of fields below.", + "Typewords": [ + "int64" + ] + }, + { + "Name": "AccountName", + "Docs": "Admin logins use \"(admin)\". If no account is known, \"-\" is used. AccountName has an index for efficiently removing failed login attempts at the end of the list when there are too many, and for efficiently removing all records for an account.", + "Typewords": [ + "string" + ] + }, + { + "Name": "LoginAddress", + "Docs": "Empty for attempts to login in as admin.", + "Typewords": [ + "string" + ] + }, + { + "Name": "RemoteIP", + "Docs": "", + "Typewords": [ + "string" + ] + }, + { + "Name": "LocalIP", + "Docs": "", + "Typewords": [ + "string" + ] + }, + { + "Name": "TLS", + "Docs": "Empty if no TLS, otherwise contains version, algorithm, properties, etc.", + "Typewords": [ + "string" + ] + }, + { + "Name": "TLSPubKeyFingerprint", + "Docs": "", + "Typewords": [ + "string" + ] + }, + { + "Name": "Protocol", + "Docs": "\"submission\", \"imap\", \"webmail\", \"webaccount\", \"webadmin\"", + "Typewords": [ + "string" + ] + }, + { + "Name": "UserAgent", + "Docs": "From HTTP header, or IMAP ID command.", + "Typewords": [ + "string" + ] + }, + { + "Name": "AuthMech", + "Docs": "\"plain\", \"login\", \"cram-md5\", \"scram-sha-256-plus\", \"(unrecognized)\", etc", + "Typewords": [ + "string" + ] + }, + { + "Name": "Result", + "Docs": "", + "Typewords": [ + "AuthResult" + ] + } + ] } ], "Ints": [], @@ -1738,6 +1864,57 @@ "Docs": "An incoming message was received that was either a DSN with an unknown event\ntype (\"action\"), or an incoming non-DSN-message was received for the unique\nper-outgoing-message address used for sending." } ] + }, + { + "Name": "AuthResult", + "Docs": "AuthResult is the result of a login attempt.", + "Values": [ + { + "Name": "AuthSuccess", + "Value": "ok", + "Docs": "" + }, + { + "Name": "AuthBadUser", + "Value": "baduser", + "Docs": "" + }, + { + "Name": "AuthBadPassword", + "Value": "badpassword", + "Docs": "" + }, + { + "Name": "AuthBadCredentials", + "Value": "badcreds", + "Docs": "" + }, + { + "Name": "AuthBadChannelBinding", + "Value": "badchanbind", + "Docs": "" + }, + { + "Name": "AuthBadProtocol", + "Value": "badprotocol", + "Docs": "" + }, + { + "Name": "AuthLoginDisabled", + "Value": "logindisabled", + "Docs": "" + }, + { + "Name": "AuthError", + "Value": "error", + "Docs": "" + }, + { + "Name": "AuthAborted", + "Value": "aborted", + "Docs": "" + } + ] } ], "SherpaVersion": 0, diff --git a/webaccount/api.ts b/webaccount/api.ts index cf9f1e0..a7b7c19 100644 --- a/webaccount/api.ts +++ b/webaccount/api.ts @@ -221,6 +221,28 @@ export interface TLSPublicKey { LoginAddress: string // Must belong to account. } +// LoginAttempt is a successful or failed login attempt, stored for auditing +// purposes. +// +// At most 10000 failed attempts are stored per account, to prevent unbounded +// growth of the database by third parties. +export interface LoginAttempt { + Key?: string | null // Hash of all fields after "Count" below. We store a single entry per key, updating its Last and Count fields. + Last: Date // Last has an index for efficient removal of entries after 30 days. + First: Date + Count: number // Number of login attempts for the combination of fields below. + AccountName: string // Admin logins use "(admin)". If no account is known, "-" is used. AccountName has an index for efficiently removing failed login attempts at the end of the list when there are too many, and for efficiently removing all records for an account. + LoginAddress: string // Empty for attempts to login in as admin. + RemoteIP: string + LocalIP: string + TLS: string // Empty if no TLS, otherwise contains version, algorithm, properties, etc. + TLSPubKeyFingerprint: string + Protocol: string // "submission", "imap", "webmail", "webaccount", "webadmin" + UserAgent: string // From HTTP header, or IMAP ID command. + AuthMech: string // "plain", "login", "cram-md5", "scram-sha-256-plus", "(unrecognized)", etc + Result: AuthResult +} + export type CSRFToken = string // Localpart is a decoded local part of an email address, before the "@". @@ -255,8 +277,21 @@ export enum OutgoingEvent { EventUnrecognized = "unrecognized", } -export const structTypes: {[typename: string]: boolean} = {"Account":true,"Address":true,"AddressAlias":true,"Alias":true,"AliasAddress":true,"AutomaticJunkFlags":true,"Destination":true,"Domain":true,"ImportProgress":true,"Incoming":true,"IncomingMeta":true,"IncomingWebhook":true,"JunkFilter":true,"NameAddress":true,"Outgoing":true,"OutgoingWebhook":true,"Route":true,"Ruleset":true,"Structure":true,"SubjectPass":true,"Suppression":true,"TLSPublicKey":true} -export const stringsTypes: {[typename: string]: boolean} = {"CSRFToken":true,"Localpart":true,"OutgoingEvent":true} +// AuthResult is the result of a login attempt. +export enum AuthResult { + AuthSuccess = "ok", + AuthBadUser = "baduser", + AuthBadPassword = "badpassword", + AuthBadCredentials = "badcreds", + AuthBadChannelBinding = "badchanbind", + AuthBadProtocol = "badprotocol", + AuthLoginDisabled = "logindisabled", + AuthError = "error", + AuthAborted = "aborted", +} + +export const structTypes: {[typename: string]: boolean} = {"Account":true,"Address":true,"AddressAlias":true,"Alias":true,"AliasAddress":true,"AutomaticJunkFlags":true,"Destination":true,"Domain":true,"ImportProgress":true,"Incoming":true,"IncomingMeta":true,"IncomingWebhook":true,"JunkFilter":true,"LoginAttempt":true,"NameAddress":true,"Outgoing":true,"OutgoingWebhook":true,"Route":true,"Ruleset":true,"Structure":true,"SubjectPass":true,"Suppression":true,"TLSPublicKey":true} +export const stringsTypes: {[typename: string]: boolean} = {"AuthResult":true,"CSRFToken":true,"Localpart":true,"OutgoingEvent":true} export const intsTypes: {[typename: string]: boolean} = {} export const types: TypenameMap = { "Account": {"Name":"Account","Docs":"","Fields":[{"Name":"OutgoingWebhook","Docs":"","Typewords":["nullable","OutgoingWebhook"]},{"Name":"IncomingWebhook","Docs":"","Typewords":["nullable","IncomingWebhook"]},{"Name":"FromIDLoginAddresses","Docs":"","Typewords":["[]","string"]},{"Name":"KeepRetiredMessagePeriod","Docs":"","Typewords":["int64"]},{"Name":"KeepRetiredWebhookPeriod","Docs":"","Typewords":["int64"]},{"Name":"LoginDisabled","Docs":"","Typewords":["string"]},{"Name":"Domain","Docs":"","Typewords":["string"]},{"Name":"Description","Docs":"","Typewords":["string"]},{"Name":"FullName","Docs":"","Typewords":["string"]},{"Name":"Destinations","Docs":"","Typewords":["{}","Destination"]},{"Name":"SubjectPass","Docs":"","Typewords":["SubjectPass"]},{"Name":"QuotaMessageSize","Docs":"","Typewords":["int64"]},{"Name":"RejectsMailbox","Docs":"","Typewords":["string"]},{"Name":"KeepRejects","Docs":"","Typewords":["bool"]},{"Name":"AutomaticJunkFlags","Docs":"","Typewords":["AutomaticJunkFlags"]},{"Name":"JunkFilter","Docs":"","Typewords":["nullable","JunkFilter"]},{"Name":"MaxOutgoingMessagesPerDay","Docs":"","Typewords":["int32"]},{"Name":"MaxFirstTimeRecipientsPerDay","Docs":"","Typewords":["int32"]},{"Name":"NoFirstTimeSenderDelay","Docs":"","Typewords":["bool"]},{"Name":"Routes","Docs":"","Typewords":["[]","Route"]},{"Name":"DNSDomain","Docs":"","Typewords":["Domain"]},{"Name":"Aliases","Docs":"","Typewords":["[]","AddressAlias"]}]}, @@ -281,9 +316,11 @@ export const types: TypenameMap = { "Structure": {"Name":"Structure","Docs":"","Fields":[{"Name":"ContentType","Docs":"","Typewords":["string"]},{"Name":"ContentTypeParams","Docs":"","Typewords":["{}","string"]},{"Name":"ContentID","Docs":"","Typewords":["string"]},{"Name":"ContentDisposition","Docs":"","Typewords":["string"]},{"Name":"Filename","Docs":"","Typewords":["string"]},{"Name":"DecodedSize","Docs":"","Typewords":["int64"]},{"Name":"Parts","Docs":"","Typewords":["[]","Structure"]}]}, "IncomingMeta": {"Name":"IncomingMeta","Docs":"","Fields":[{"Name":"MsgID","Docs":"","Typewords":["int64"]},{"Name":"MailFrom","Docs":"","Typewords":["string"]},{"Name":"MailFromValidated","Docs":"","Typewords":["bool"]},{"Name":"MsgFromValidated","Docs":"","Typewords":["bool"]},{"Name":"RcptTo","Docs":"","Typewords":["string"]},{"Name":"DKIMVerifiedDomains","Docs":"","Typewords":["[]","string"]},{"Name":"RemoteIP","Docs":"","Typewords":["string"]},{"Name":"Received","Docs":"","Typewords":["timestamp"]},{"Name":"MailboxName","Docs":"","Typewords":["string"]},{"Name":"Automated","Docs":"","Typewords":["bool"]}]}, "TLSPublicKey": {"Name":"TLSPublicKey","Docs":"","Fields":[{"Name":"Fingerprint","Docs":"","Typewords":["string"]},{"Name":"Created","Docs":"","Typewords":["timestamp"]},{"Name":"Type","Docs":"","Typewords":["string"]},{"Name":"Name","Docs":"","Typewords":["string"]},{"Name":"NoIMAPPreauth","Docs":"","Typewords":["bool"]},{"Name":"CertDER","Docs":"","Typewords":["nullable","string"]},{"Name":"Account","Docs":"","Typewords":["string"]},{"Name":"LoginAddress","Docs":"","Typewords":["string"]}]}, + "LoginAttempt": {"Name":"LoginAttempt","Docs":"","Fields":[{"Name":"Key","Docs":"","Typewords":["nullable","string"]},{"Name":"Last","Docs":"","Typewords":["timestamp"]},{"Name":"First","Docs":"","Typewords":["timestamp"]},{"Name":"Count","Docs":"","Typewords":["int64"]},{"Name":"AccountName","Docs":"","Typewords":["string"]},{"Name":"LoginAddress","Docs":"","Typewords":["string"]},{"Name":"RemoteIP","Docs":"","Typewords":["string"]},{"Name":"LocalIP","Docs":"","Typewords":["string"]},{"Name":"TLS","Docs":"","Typewords":["string"]},{"Name":"TLSPubKeyFingerprint","Docs":"","Typewords":["string"]},{"Name":"Protocol","Docs":"","Typewords":["string"]},{"Name":"UserAgent","Docs":"","Typewords":["string"]},{"Name":"AuthMech","Docs":"","Typewords":["string"]},{"Name":"Result","Docs":"","Typewords":["AuthResult"]}]}, "CSRFToken": {"Name":"CSRFToken","Docs":"","Values":null}, "Localpart": {"Name":"Localpart","Docs":"","Values":null}, "OutgoingEvent": {"Name":"OutgoingEvent","Docs":"","Values":[{"Name":"EventDelivered","Value":"delivered","Docs":""},{"Name":"EventSuppressed","Value":"suppressed","Docs":""},{"Name":"EventDelayed","Value":"delayed","Docs":""},{"Name":"EventFailed","Value":"failed","Docs":""},{"Name":"EventRelayed","Value":"relayed","Docs":""},{"Name":"EventExpanded","Value":"expanded","Docs":""},{"Name":"EventCanceled","Value":"canceled","Docs":""},{"Name":"EventUnrecognized","Value":"unrecognized","Docs":""}]}, + "AuthResult": {"Name":"AuthResult","Docs":"","Values":[{"Name":"AuthSuccess","Value":"ok","Docs":""},{"Name":"AuthBadUser","Value":"baduser","Docs":""},{"Name":"AuthBadPassword","Value":"badpassword","Docs":""},{"Name":"AuthBadCredentials","Value":"badcreds","Docs":""},{"Name":"AuthBadChannelBinding","Value":"badchanbind","Docs":""},{"Name":"AuthBadProtocol","Value":"badprotocol","Docs":""},{"Name":"AuthLoginDisabled","Value":"logindisabled","Docs":""},{"Name":"AuthError","Value":"error","Docs":""},{"Name":"AuthAborted","Value":"aborted","Docs":""}]}, } export const parser = { @@ -309,9 +346,11 @@ export const parser = { Structure: (v: any) => parse("Structure", v) as Structure, IncomingMeta: (v: any) => parse("IncomingMeta", v) as IncomingMeta, TLSPublicKey: (v: any) => parse("TLSPublicKey", v) as TLSPublicKey, + LoginAttempt: (v: any) => parse("LoginAttempt", v) as LoginAttempt, CSRFToken: (v: any) => parse("CSRFToken", v) as CSRFToken, Localpart: (v: any) => parse("Localpart", v) as Localpart, OutgoingEvent: (v: any) => parse("OutgoingEvent", v) as OutgoingEvent, + AuthResult: (v: any) => parse("AuthResult", v) as AuthResult, } // Account exports web API functions for the account web interface. All its @@ -586,6 +625,14 @@ export class Client { const params: any[] = [pubKey] return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void } + + async LoginAttempts(limit: number): Promise { + const fn: string = "LoginAttempts" + const paramTypes: string[][] = [["int32"]] + const returnTypes: string[][] = [["[]","LoginAttempt"]] + const params: any[] = [limit] + return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as LoginAttempt[] | null + } } export const defaultBaseURL = (function() { diff --git a/webadmin/admin.go b/webadmin/admin.go index 44ce4b7..d0a5ebf 100644 --- a/webadmin/admin.go +++ b/webadmin/admin.go @@ -2712,3 +2712,9 @@ func (Admin) AliasAddressesRemove(ctx context.Context, aliaslp string, domainNam func (Admin) TLSPublicKeys(ctx context.Context, accountOpt string) ([]store.TLSPublicKey, error) { return store.TLSPublicKeyList(ctx, accountOpt) } + +func (Admin) LoginAttempts(ctx context.Context, accountName string, limit int) []store.LoginAttempt { + l, err := store.LoginAttemptList(ctx, accountName, limit) + xcheckf(ctx, err, "listing login attempts") + return l +} diff --git a/webadmin/admin.js b/webadmin/admin.js index 92e9a7e..1126c02 100644 --- a/webadmin/admin.js +++ b/webadmin/admin.js @@ -250,8 +250,21 @@ var api; Mode["ModeTesting"] = "testing"; Mode["ModeNone"] = "none"; })(Mode = api.Mode || (api.Mode = {})); - api.structTypes = { "Account": true, "Address": true, "AddressAlias": true, "Alias": true, "AliasAddress": true, "AuthResults": true, "AutoconfCheckResult": true, "AutodiscoverCheckResult": true, "AutodiscoverSRV": true, "AutomaticJunkFlags": true, "Canonicalization": true, "CheckResult": true, "ClientConfigs": true, "ClientConfigsEntry": true, "ConfigDomain": true, "DANECheckResult": true, "DKIM": true, "DKIMAuthResult": true, "DKIMCheckResult": true, "DKIMRecord": true, "DMARC": true, "DMARCCheckResult": true, "DMARCRecord": true, "DMARCSummary": true, "DNSSECResult": true, "DateRange": true, "Destination": true, "Directive": true, "Domain": true, "DomainFeedback": true, "Dynamic": true, "Evaluation": true, "EvaluationStat": true, "Extension": true, "FailureDetails": true, "Filter": true, "HoldRule": true, "Hook": true, "HookFilter": true, "HookResult": true, "HookRetired": true, "HookRetiredFilter": true, "HookRetiredSort": true, "HookSort": true, "IPDomain": true, "IPRevCheckResult": true, "Identifiers": true, "IncomingWebhook": true, "JunkFilter": true, "MTASTS": true, "MTASTSCheckResult": true, "MTASTSRecord": true, "MX": true, "MXCheckResult": true, "Modifier": true, "Msg": true, "MsgResult": true, "MsgRetired": true, "OutgoingWebhook": true, "Pair": true, "Policy": true, "PolicyEvaluated": true, "PolicyOverrideReason": true, "PolicyPublished": true, "PolicyRecord": true, "Record": true, "Report": true, "ReportMetadata": true, "ReportRecord": true, "Result": true, "ResultPolicy": true, "RetiredFilter": true, "RetiredSort": true, "Reverse": true, "Route": true, "Row": true, "Ruleset": true, "SMTPAuth": true, "SPFAuthResult": true, "SPFCheckResult": true, "SPFRecord": true, "SRV": true, "SRVConfCheckResult": true, "STSMX": true, "Selector": true, "Sort": true, "SubjectPass": true, "Summary": true, "SuppressAddress": true, "TLSCheckResult": true, "TLSPublicKey": true, "TLSRPT": true, "TLSRPTCheckResult": true, "TLSRPTDateRange": true, "TLSRPTRecord": true, "TLSRPTSummary": true, "TLSRPTSuppressAddress": true, "TLSReportRecord": true, "TLSResult": true, "Transport": true, "TransportDirect": true, "TransportSMTP": true, "TransportSocks": true, "URI": true, "WebForward": true, "WebHandler": true, "WebInternal": true, "WebRedirect": true, "WebStatic": true, "WebserverConfig": true }; - api.stringsTypes = { "Align": true, "CSRFToken": true, "DMARCPolicy": true, "IP": true, "Localpart": true, "Mode": true, "RUA": true }; + // AuthResult is the result of a login attempt. + let AuthResult; + (function (AuthResult) { + AuthResult["AuthSuccess"] = "ok"; + AuthResult["AuthBadUser"] = "baduser"; + AuthResult["AuthBadPassword"] = "badpassword"; + AuthResult["AuthBadCredentials"] = "badcreds"; + AuthResult["AuthBadChannelBinding"] = "badchanbind"; + AuthResult["AuthBadProtocol"] = "badprotocol"; + AuthResult["AuthLoginDisabled"] = "logindisabled"; + AuthResult["AuthError"] = "error"; + AuthResult["AuthAborted"] = "aborted"; + })(AuthResult = api.AuthResult || (api.AuthResult = {})); + api.structTypes = { "Account": true, "Address": true, "AddressAlias": true, "Alias": true, "AliasAddress": true, "AuthResults": true, "AutoconfCheckResult": true, "AutodiscoverCheckResult": true, "AutodiscoverSRV": true, "AutomaticJunkFlags": true, "Canonicalization": true, "CheckResult": true, "ClientConfigs": true, "ClientConfigsEntry": true, "ConfigDomain": true, "DANECheckResult": true, "DKIM": true, "DKIMAuthResult": true, "DKIMCheckResult": true, "DKIMRecord": true, "DMARC": true, "DMARCCheckResult": true, "DMARCRecord": true, "DMARCSummary": true, "DNSSECResult": true, "DateRange": true, "Destination": true, "Directive": true, "Domain": true, "DomainFeedback": true, "Dynamic": true, "Evaluation": true, "EvaluationStat": true, "Extension": true, "FailureDetails": true, "Filter": true, "HoldRule": true, "Hook": true, "HookFilter": true, "HookResult": true, "HookRetired": true, "HookRetiredFilter": true, "HookRetiredSort": true, "HookSort": true, "IPDomain": true, "IPRevCheckResult": true, "Identifiers": true, "IncomingWebhook": true, "JunkFilter": true, "LoginAttempt": true, "MTASTS": true, "MTASTSCheckResult": true, "MTASTSRecord": true, "MX": true, "MXCheckResult": true, "Modifier": true, "Msg": true, "MsgResult": true, "MsgRetired": true, "OutgoingWebhook": true, "Pair": true, "Policy": true, "PolicyEvaluated": true, "PolicyOverrideReason": true, "PolicyPublished": true, "PolicyRecord": true, "Record": true, "Report": true, "ReportMetadata": true, "ReportRecord": true, "Result": true, "ResultPolicy": true, "RetiredFilter": true, "RetiredSort": true, "Reverse": true, "Route": true, "Row": true, "Ruleset": true, "SMTPAuth": true, "SPFAuthResult": true, "SPFCheckResult": true, "SPFRecord": true, "SRV": true, "SRVConfCheckResult": true, "STSMX": true, "Selector": true, "Sort": true, "SubjectPass": true, "Summary": true, "SuppressAddress": true, "TLSCheckResult": true, "TLSPublicKey": true, "TLSRPT": true, "TLSRPTCheckResult": true, "TLSRPTDateRange": true, "TLSRPTRecord": true, "TLSRPTSummary": true, "TLSRPTSuppressAddress": true, "TLSReportRecord": true, "TLSResult": true, "Transport": true, "TransportDirect": true, "TransportSMTP": true, "TransportSocks": true, "URI": true, "WebForward": true, "WebHandler": true, "WebInternal": true, "WebRedirect": true, "WebStatic": true, "WebserverConfig": true }; + api.stringsTypes = { "Align": true, "AuthResult": true, "CSRFToken": true, "DMARCPolicy": true, "IP": true, "Localpart": true, "Mode": true, "RUA": true }; api.intsTypes = {}; api.types = { "CheckResult": { "Name": "CheckResult", "Docs": "", "Fields": [{ "Name": "Domain", "Docs": "", "Typewords": ["string"] }, { "Name": "DNSSEC", "Docs": "", "Typewords": ["DNSSECResult"] }, { "Name": "IPRev", "Docs": "", "Typewords": ["IPRevCheckResult"] }, { "Name": "MX", "Docs": "", "Typewords": ["MXCheckResult"] }, { "Name": "TLS", "Docs": "", "Typewords": ["TLSCheckResult"] }, { "Name": "DANE", "Docs": "", "Typewords": ["DANECheckResult"] }, { "Name": "SPF", "Docs": "", "Typewords": ["SPFCheckResult"] }, { "Name": "DKIM", "Docs": "", "Typewords": ["DKIMCheckResult"] }, { "Name": "DMARC", "Docs": "", "Typewords": ["DMARCCheckResult"] }, { "Name": "HostTLSRPT", "Docs": "", "Typewords": ["TLSRPTCheckResult"] }, { "Name": "DomainTLSRPT", "Docs": "", "Typewords": ["TLSRPTCheckResult"] }, { "Name": "MTASTS", "Docs": "", "Typewords": ["MTASTSCheckResult"] }, { "Name": "SRVConf", "Docs": "", "Typewords": ["SRVConfCheckResult"] }, { "Name": "Autoconf", "Docs": "", "Typewords": ["AutoconfCheckResult"] }, { "Name": "Autodiscover", "Docs": "", "Typewords": ["AutodiscoverCheckResult"] }] }, @@ -364,6 +377,7 @@ var api; "TLSRPTSuppressAddress": { "Name": "TLSRPTSuppressAddress", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Inserted", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "ReportingAddress", "Docs": "", "Typewords": ["string"] }, { "Name": "Until", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "Comment", "Docs": "", "Typewords": ["string"] }] }, "Dynamic": { "Name": "Dynamic", "Docs": "", "Fields": [{ "Name": "Domains", "Docs": "", "Typewords": ["{}", "ConfigDomain"] }, { "Name": "Accounts", "Docs": "", "Typewords": ["{}", "Account"] }, { "Name": "WebDomainRedirects", "Docs": "", "Typewords": ["{}", "string"] }, { "Name": "WebHandlers", "Docs": "", "Typewords": ["[]", "WebHandler"] }, { "Name": "Routes", "Docs": "", "Typewords": ["[]", "Route"] }, { "Name": "MonitorDNSBLs", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "MonitorDNSBLZones", "Docs": "", "Typewords": ["[]", "Domain"] }] }, "TLSPublicKey": { "Name": "TLSPublicKey", "Docs": "", "Fields": [{ "Name": "Fingerprint", "Docs": "", "Typewords": ["string"] }, { "Name": "Created", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "Type", "Docs": "", "Typewords": ["string"] }, { "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "NoIMAPPreauth", "Docs": "", "Typewords": ["bool"] }, { "Name": "CertDER", "Docs": "", "Typewords": ["nullable", "string"] }, { "Name": "Account", "Docs": "", "Typewords": ["string"] }, { "Name": "LoginAddress", "Docs": "", "Typewords": ["string"] }] }, + "LoginAttempt": { "Name": "LoginAttempt", "Docs": "", "Fields": [{ "Name": "Key", "Docs": "", "Typewords": ["nullable", "string"] }, { "Name": "Last", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "First", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "Count", "Docs": "", "Typewords": ["int64"] }, { "Name": "AccountName", "Docs": "", "Typewords": ["string"] }, { "Name": "LoginAddress", "Docs": "", "Typewords": ["string"] }, { "Name": "RemoteIP", "Docs": "", "Typewords": ["string"] }, { "Name": "LocalIP", "Docs": "", "Typewords": ["string"] }, { "Name": "TLS", "Docs": "", "Typewords": ["string"] }, { "Name": "TLSPubKeyFingerprint", "Docs": "", "Typewords": ["string"] }, { "Name": "Protocol", "Docs": "", "Typewords": ["string"] }, { "Name": "UserAgent", "Docs": "", "Typewords": ["string"] }, { "Name": "AuthMech", "Docs": "", "Typewords": ["string"] }, { "Name": "Result", "Docs": "", "Typewords": ["AuthResult"] }] }, "CSRFToken": { "Name": "CSRFToken", "Docs": "", "Values": null }, "DMARCPolicy": { "Name": "DMARCPolicy", "Docs": "", "Values": [{ "Name": "PolicyEmpty", "Value": "", "Docs": "" }, { "Name": "PolicyNone", "Value": "none", "Docs": "" }, { "Name": "PolicyQuarantine", "Value": "quarantine", "Docs": "" }, { "Name": "PolicyReject", "Value": "reject", "Docs": "" }] }, "Align": { "Name": "Align", "Docs": "", "Values": [{ "Name": "AlignStrict", "Value": "s", "Docs": "" }, { "Name": "AlignRelaxed", "Value": "r", "Docs": "" }] }, @@ -371,6 +385,7 @@ var api; "Mode": { "Name": "Mode", "Docs": "", "Values": [{ "Name": "ModeEnforce", "Value": "enforce", "Docs": "" }, { "Name": "ModeTesting", "Value": "testing", "Docs": "" }, { "Name": "ModeNone", "Value": "none", "Docs": "" }] }, "Localpart": { "Name": "Localpart", "Docs": "", "Values": null }, "IP": { "Name": "IP", "Docs": "", "Values": [] }, + "AuthResult": { "Name": "AuthResult", "Docs": "", "Values": [{ "Name": "AuthSuccess", "Value": "ok", "Docs": "" }, { "Name": "AuthBadUser", "Value": "baduser", "Docs": "" }, { "Name": "AuthBadPassword", "Value": "badpassword", "Docs": "" }, { "Name": "AuthBadCredentials", "Value": "badcreds", "Docs": "" }, { "Name": "AuthBadChannelBinding", "Value": "badchanbind", "Docs": "" }, { "Name": "AuthBadProtocol", "Value": "badprotocol", "Docs": "" }, { "Name": "AuthLoginDisabled", "Value": "logindisabled", "Docs": "" }, { "Name": "AuthError", "Value": "error", "Docs": "" }, { "Name": "AuthAborted", "Value": "aborted", "Docs": "" }] }, }; api.parser = { CheckResult: (v) => api.parse("CheckResult", v), @@ -483,6 +498,7 @@ var api; TLSRPTSuppressAddress: (v) => api.parse("TLSRPTSuppressAddress", v), Dynamic: (v) => api.parse("Dynamic", v), TLSPublicKey: (v) => api.parse("TLSPublicKey", v), + LoginAttempt: (v) => api.parse("LoginAttempt", v), CSRFToken: (v) => api.parse("CSRFToken", v), DMARCPolicy: (v) => api.parse("DMARCPolicy", v), Align: (v) => api.parse("Align", v), @@ -490,6 +506,7 @@ var api; Mode: (v) => api.parse("Mode", v), Localpart: (v) => api.parse("Localpart", v), IP: (v) => api.parse("IP", v), + AuthResult: (v) => api.parse("AuthResult", v), }; // Admin exports web API functions for the admin web interface. All its methods are // exported under api/. Function calls require valid HTTP Authentication @@ -1318,6 +1335,13 @@ var api; const params = [accountOpt]; return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); } + async LoginAttempts(accountName, limit) { + const fn = "LoginAttempts"; + const paramTypes = [["string"], ["int32"]]; + const returnTypes = [["[]", "LoginAttempt"]]; + const params = [accountName, limit]; + return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); + } } api.Client = Client; api.defaultBaseURL = (function () { @@ -2005,7 +2029,7 @@ const loglevels = async () => { const box = (color, ...l) => [ dom.div(style({ display: 'inline-block', - padding: '.25em .5em', + padding: '.125em .25em', backgroundColor: color, borderRadius: '3px', margin: '.5ex 0', @@ -2019,9 +2043,10 @@ const inlineBox = (color, ...l) => dom.span(style({ borderRadius: '3px', }), l); const accounts = async () => { - const [[accounts, accountsDisabled], domains] = await Promise.all([ + const [[accounts, accountsDisabled], domains, loginAttempts] = await Promise.all([ client.Accounts(), client.Domains(), + client.LoginAttempts("", 10), ]); let fieldset; let localpart; @@ -2029,18 +2054,31 @@ const accounts = async () => { let account; let accountModified = false; return dom.div(crumbs(crumblink('Mox Admin', '#'), 'Accounts'), dom.h2('Accounts'), (accounts || []).length === 0 ? dom.p('No accounts') : - dom.ul((accounts || []).map(s => dom.li(dom.a(attr.href('#accounts/' + s), s), accountsDisabled?.includes(s) ? ' (disabled)' : ''))), dom.br(), dom.h2('Add account'), dom.form(async function submit(e) { + dom.ul((accounts || []).map(s => dom.li(dom.a(attr.href('#accounts/l/' + s), s), accountsDisabled?.includes(s) ? ' (disabled)' : ''))), dom.br(), dom.h2('Add account'), dom.form(async function submit(e) { e.preventDefault(); e.stopPropagation(); await check(fieldset, client.AccountAdd(account.value, localpart.value + '@' + domain.value)); - window.location.hash = '#accounts/' + account.value; + window.location.hash = '#accounts/l/' + account.value; }, fieldset = dom.fieldset(dom.p('Start with the initial email address for the account. The localpart is the account name too by default, but the account name can be changed.'), dom.label(style({ display: 'inline-block' }), dom.span('Localpart', attr.title('The part before the "@" of an email address. More addresses, also at different domains, can be added after the account has been created.')), dom.br(), localpart = dom.input(attr.required(''), function keyup() { if (!accountModified) { account.value = localpart.value; } })), '@', dom.label(style({ display: 'inline-block' }), dom.span('Domain', attr.title('The domain of the email address, after the "@".')), dom.br(), domain = dom.select(attr.required(''), (domains || []).map(d => dom.option(domainName(d.Domain))))), ' ', dom.label(style({ display: 'inline-block' }), dom.span('Account name', attr.title('An account has a password, and email address(es) (possibly at different domains). Its messages and the message index database are are stored in the file system in a directory with the name of the account. An account name is not an email address. Use a name like a unix user name, or the localpart (the part before the "@") of the initial address.')), dom.br(), account = dom.input(attr.required(''), function change() { accountModified = true; - })), ' ', dom.submitbutton('Add account', attr.title('The account will be added and the config reloaded.'))))); + })), ' ', dom.submitbutton('Add account', attr.title('The account will be added and the config reloaded.')))), dom.br(), dom.h2('Recent login attempts', attr.title('Login attempts are stored for 30 days. At most 10000 failed login attempts are stored per account to prevent unlimited growth of the database.')), renderLoginAttempts(true, loginAttempts || []), dom.br(), loginAttempts && loginAttempts.length >= 10 ? dom.p('See ', dom.a(attr.href('#accounts/loginattempts'), 'all login attempts'), '.') : []); +}; +const loginattempts = async () => { + const loginAttempts = await client.LoginAttempts("", 0); + return dom.div(crumbs(crumblink('Mox Admin', '#'), crumblink('Accounts', '#accounts'), 'Login attempts'), dom.h2('Login attempts'), dom.p('Login attempts are stored for 30 days. At most 10000 failed login attempts are stored per account to prevent unlimited growth of the database.'), renderLoginAttempts(true, loginAttempts || [])); +}; +const accountloginattempts = async (accountName) => { + const loginAttempts = await client.LoginAttempts(accountName, 0); + return dom.div(crumbs(crumblink('Mox Admin', '#'), crumblink('Accounts', '#accounts'), ['(admin)', '-'].includes(accountName) ? accountName : crumblink(accountName, '#accounts/l/' + accountName), 'Login attempts'), dom.h2('Login attempts'), dom.p('Login attempts are stored for 30 days. At most 10000 failed login attempts are stored per account to prevent unlimited growth of the database.'), renderLoginAttempts(false, loginAttempts || [])); +}; +const renderLoginAttempts = (accountLinks, loginAttempts) => { + // todo: pagination and search + const nowSecs = new Date().getTime() / 1000; + return dom.table(dom.thead(dom.tr(dom.th('Time'), dom.th('Result'), dom.th('Count'), dom.th('Account'), dom.th('Address'), dom.th('Protocol'), dom.th('Mechanism'), dom.th('User Agent'), dom.th('Remote IP'), dom.th('Local IP'), dom.th('TLS'), dom.th('TLS pubkey fingerprint'), dom.th('First seen'))), dom.tbody(loginAttempts.length ? [] : dom.tr(dom.td(attr.colspan('13'), 'No login attempts in past 30 days.')), loginAttempts.map(la => dom.tr(dom.td(age(la.Last, false, nowSecs)), dom.td(la.Result === 'ok' ? la.Result : box(red, la.Result)), dom.td('' + la.Count), dom.td(accountLinks ? dom.a(attr.href('#accounts/l/' + la.AccountName + '/loginattempts'), la.AccountName) : la.AccountName), dom.td(la.LoginAddress), dom.td(la.Protocol), dom.td(la.AuthMech), dom.td(la.UserAgent), dom.td(la.RemoteIP), dom.td(la.LocalIP), dom.td(la.TLS), dom.td(la.TLSPubKeyFingerprint), dom.td(age(la.First, false, nowSecs)))))); }; const formatQuotaSize = (v) => { if (v === 0) { @@ -2119,11 +2157,12 @@ const RoutesEditor = (kind, transports, routes, save) => { return render(); }; const account = async (name) => { - const [[config, diskUsage], domains, transports, tlspubkeys] = await Promise.all([ + const [[config, diskUsage], domains, transports, tlspubkeys, loginAttempts] = await Promise.all([ client.Account(name), client.Domains(), client.Transports(), client.TLSPublicKeys(name), + client.LoginAttempts(name, 10), ]); // todo: show suppression list, and buttons to add/remove entries. let form; @@ -2244,7 +2283,7 @@ const account = async (name) => { close(); window.location.reload(); // todo: update account and rerender. }, fieldset = dom.fieldset(dom.label(dom.div('Message to user'), loginDisabled = dom.input(attr.required(''), style({ width: '100%' })), dom.p(style({ fontStyle: 'italic' }), 'Will be shown to user on login attempts. Single line, no special and maximum 256 characters since message is used in IMAP/SMTP.')), dom.div(dom.submitbutton('Disable login'))))); - })), dom.br(), dom.clickbutton('Remove account', async function click(e) { + })), dom.br(), dom.h2('Recent login attempts', attr.title('Login attempts are stored for 30 days. At most 10000 failed login attempts are stored per account to prevent unlimited growth of the database.')), renderLoginAttempts(false, loginAttempts || []), dom.br(), loginAttempts && loginAttempts.length >= 10 ? dom.p('See ', dom.a(attr.href('#accounts/l/' + name + '/loginattempts'), 'all login attempts'), ' for this account.') : [], dom.br(), dom.clickbutton('Remove account', async function click(e) { e.preventDefault(); if (!window.confirm('Are you sure you want to remove this account? All account data, including messages will be removed.')) { return; @@ -2389,7 +2428,7 @@ const domain = async (d) => { window.location.reload(); // todo: reload only dkim section }, fieldset = dom.fieldset(dom.div(style({ display: 'flex', gap: '1em' }), dom.div(dom.label(style({ display: 'block', marginBottom: '1ex' }), 'Selector', attr.title('Used in the DKIM-Signature header, and used to form a DNS record under ._domainkey..'), dom.div(selector = dom.input(attr.required(''), attr.value(defaultSelector())))), dom.label(style({ display: 'block', marginBottom: '1ex' }), 'Algorithm', attr.title('For signing messages. RSA is common at the time of writing, not all mail servers recognize ed25519 signature.'), dom.div(algorithm = dom.select(dom.option('rsa'), dom.option('ed25519')))), dom.label(style({ display: 'block', marginBottom: '1ex' }), 'Hash', attr.title("Used in signing messages. Don't use sha1 unless you understand the consequences."), dom.div(hash = dom.select(dom.option('sha256')))), dom.label(style({ display: 'block', marginBottom: '1ex' }), 'Canonicalization - header', attr.title('Canonicalization processes the message headers before signing. Relaxed allows more whitespace changes, making it more likely for DKIM signatures to validate after transit through servers that make whitespace modifications. Simple is more strict.'), dom.div(canonHeader = dom.select(dom.option('relaxed'), dom.option('simple')))), dom.label(style({ display: 'block', marginBottom: '1ex' }), 'Canonicalization - body', attr.title('Like canonicalization for headers, but for the bodies.'), dom.div(canonBody = dom.select(dom.option('relaxed'), dom.option('simple')))), dom.label(style({ display: 'block', marginBottom: '1ex' }), 'Signature lifetime', attr.title('How long a signature remains valid. Should be as long as a message may take to be delivered. The signature must be valid at the time a message is being delivered to the final destination.'), dom.div(lifetime = dom.input(attr.value('3d'), attr.required('')))), dom.label(style({ display: 'block', marginBottom: '1ex' }), 'Seal headers', attr.title("DKIM-signatures cover headers. If headers are not sealed, additional message headers can be added with the same key without invalidating the signature. This may confuse software about which headers are trustworthy. Sealing is the safer option."), dom.div(seal = dom.input(attr.type('checkbox'), attr.checked(''))))), dom.div(dom.label(style({ display: 'block', marginBottom: '1ex' }), 'Headers (optional)', attr.title('Headers to sign. If left empty, a set of standard headers are signed. The (standard set of) headers are most easily edited after creating the selector/key.'), dom.div(headers = dom.textarea(attr.rows('15')))))), dom.div(dom.submitbutton('Add'))))); }; - return dom.div(crumbs(crumblink('Mox Admin', '#'), 'Domain ' + domainString(dnsdomain)), domainConfig.Disabled ? dom.p(box(yellow, 'Warning: Domain is disabled. Incoming/outgoing messages involving this domain are rejected and ACME for new TLS certificates is disabled.')) : [], dom.ul(dom.li(dom.a('Required DNS records', attr.href('#domains/' + d + '/dnsrecords'))), dom.li(dom.a('Check current actual DNS records and domain configuration', attr.href('#domains/' + d + '/dnscheck')))), dom.br(), dom.h2('Client configuration'), dom.p('If autoconfig/autodiscover does not work with an email client, use the settings below for this domain. Authenticate with email address and password. ', dom.span('Explicitly configure', attr.title('To prevent authentication mechanism downgrade attempts that may result in clients sending plain text passwords to a MitM.')), ' the first supported authentication mechanism: SCRAM-SHA-256-PLUS, SCRAM-SHA-1-PLUS, SCRAM-SHA-256, SCRAM-SHA-1, CRAM-MD5.'), dom.table(dom.thead(dom.tr(dom.th('Protocol'), dom.th('Host'), dom.th('Port'), dom.th('Listener'), dom.th('Note'))), dom.tbody((clientConfigs.Entries || []).map(e => dom.tr(dom.td(e.Protocol), dom.td(domainString(e.Host)), dom.td('' + e.Port), dom.td('' + e.Listener), dom.td('' + e.Note))))), dom.br(), dom.h2('DMARC aggregate reports summary'), renderDMARCSummaries(dmarcSummaries || []), dom.br(), dom.h2('TLS reports summary'), renderTLSRPTSummaries(tlsrptSummaries || []), dom.br(), dom.h2('Addresses'), dom.table(dom.thead(dom.tr(dom.th('Address'), dom.th('Account'), dom.th('Action'))), dom.tbody(Object.entries(localpartAccounts).map(t => dom.tr(dom.td(prewrap(t[0]) || '(catchall)'), dom.td(dom.a(t[1], attr.href('#accounts/' + t[1]))), dom.td(dom.clickbutton('Remove', async function click(e) { + return dom.div(crumbs(crumblink('Mox Admin', '#'), 'Domain ' + domainString(dnsdomain)), domainConfig.Disabled ? dom.p(box(yellow, 'Warning: Domain is disabled. Incoming/outgoing messages involving this domain are rejected and ACME for new TLS certificates is disabled.')) : [], dom.ul(dom.li(dom.a('Required DNS records', attr.href('#domains/' + d + '/dnsrecords'))), dom.li(dom.a('Check current actual DNS records and domain configuration', attr.href('#domains/' + d + '/dnscheck')))), dom.br(), dom.h2('Client configuration'), dom.p('If autoconfig/autodiscover does not work with an email client, use the settings below for this domain. Authenticate with email address and password. ', dom.span('Explicitly configure', attr.title('To prevent authentication mechanism downgrade attempts that may result in clients sending plain text passwords to a MitM.')), ' the first supported authentication mechanism: SCRAM-SHA-256-PLUS, SCRAM-SHA-1-PLUS, SCRAM-SHA-256, SCRAM-SHA-1, CRAM-MD5.'), dom.table(dom.thead(dom.tr(dom.th('Protocol'), dom.th('Host'), dom.th('Port'), dom.th('Listener'), dom.th('Note'))), dom.tbody((clientConfigs.Entries || []).map(e => dom.tr(dom.td(e.Protocol), dom.td(domainString(e.Host)), dom.td('' + e.Port), dom.td('' + e.Listener), dom.td('' + e.Note))))), dom.br(), dom.h2('DMARC aggregate reports summary'), renderDMARCSummaries(dmarcSummaries || []), dom.br(), dom.h2('TLS reports summary'), renderTLSRPTSummaries(tlsrptSummaries || []), dom.br(), dom.h2('Addresses'), dom.table(dom.thead(dom.tr(dom.th('Address'), dom.th('Account'), dom.th('Action'))), dom.tbody(Object.entries(localpartAccounts).map(t => dom.tr(dom.td(prewrap(t[0]) || '(catchall)'), dom.td(dom.a(t[1], attr.href('#accounts/l/' + t[1]))), dom.td(dom.clickbutton('Remove', async function click(e) { e.preventDefault(); if (!window.confirm('Are you sure you want to remove this address? If it is a member of an alias, it will be removed from the alias.')) { return; @@ -2621,7 +2660,7 @@ const domainAlias = async (d, aliasLocalpart) => { check(aliasFieldset, client.AliasUpdate(aliasLocalpart, d, postPublic.checked, listMembers.checked, allowMsgFrom.checked)); }, aliasFieldset = dom.fieldset(style({ display: 'flex', flexDirection: 'column', gap: '.5ex' }), dom.label(postPublic = dom.input(attr.type('checkbox'), alias.PostPublic ? attr.checked('') : []), ' Public, anyone is allowed to send to the alias, instead of only members of the alias', attr.title('Based on address in message From header, which is assumed to be DMARC-like verified. If this setting is disabled and a non-member sends a message to the alias, the message is rejected.')), dom.label(listMembers = dom.input(attr.type('checkbox'), alias.ListMembers ? attr.checked('') : []), ' Members can list other members'), dom.label(allowMsgFrom = dom.input(attr.type('checkbox'), alias.AllowMsgFrom ? attr.checked('') : []), ' Allow messages to use the alias address in the message From header'), dom.div(style({ marginTop: '1ex' }), dom.submitbutton('Save')))), dom.br(), dom.h2('Members'), dom.p('Members receive messages sent to the alias. If a member address is in the message From header, the member will not receive the message.'), dom.table(dom.thead(dom.tr(dom.th('Address'), dom.th('Account'), dom.th())), dom.tbody((alias.Addresses || []).map((address, index) => { const pa = (alias.ParsedAddresses || [])[index]; - return dom.tr(dom.td(prewrap(address)), dom.td(dom.a(pa.AccountName, attr.href('#accounts/' + pa.AccountName))), dom.td(dom.clickbutton('Remove', async function click(e) { + return dom.tr(dom.td(prewrap(address)), dom.td(dom.a(pa.AccountName, attr.href('#accounts/l/' + pa.AccountName))), dom.td(dom.clickbutton('Remove', async function click(e) { await check(e.target, client.AliasAddressesRemove(aliasLocalpart, d, [address])); window.location.reload(); // todo: reload less }))); @@ -4106,8 +4145,14 @@ const init = async () => { else if (h === 'accounts') { root = await accounts(); } - else if (t[0] === 'accounts' && t.length === 2) { - root = await account(t[1]); + else if (h === 'accounts/loginattempts') { + root = await loginattempts(); + } + else if (t[0] === 'accounts' && t.length === 3 && t[1] === 'l') { + root = await account(t[2]); + } + else if (t[0] === 'accounts' && t.length === 4 && t[1] === 'l' && t[3] === 'loginattempts') { + root = await accountloginattempts(t[2]); } else if (t[0] === 'domains' && t.length === 2) { root = await domain(t[1]); diff --git a/webadmin/admin.ts b/webadmin/admin.ts index 4879fa1..f3dc360 100644 --- a/webadmin/admin.ts +++ b/webadmin/admin.ts @@ -561,7 +561,7 @@ const box = (color: string, ...l: ElemArg[]) => [ dom.div( style({ display: 'inline-block', - padding: '.25em .5em', + padding: '.125em .25em', backgroundColor: color, borderRadius: '3px', margin: '.5ex 0', @@ -582,9 +582,10 @@ const inlineBox = (color: string, ...l: ElemArg[]) => ) const accounts = async () => { - const [[accounts, accountsDisabled], domains] = await Promise.all([ + const [[accounts, accountsDisabled], domains, loginAttempts] = await Promise.all([ client.Accounts(), client.Domains(), + client.LoginAttempts("", 10), ]) let fieldset: HTMLFieldSetElement @@ -601,7 +602,7 @@ const accounts = async () => { dom.h2('Accounts'), (accounts || []).length === 0 ? dom.p('No accounts') : dom.ul( - (accounts || []).map(s => dom.li(dom.a(attr.href('#accounts/'+s), s), accountsDisabled?.includes(s) ? ' (disabled)' : '')), + (accounts || []).map(s => dom.li(dom.a(attr.href('#accounts/l/'+s), s), accountsDisabled?.includes(s) ? ' (disabled)' : '')), ), dom.br(), dom.h2('Add account'), @@ -610,7 +611,7 @@ const accounts = async () => { e.preventDefault() e.stopPropagation() await check(fieldset, client.AccountAdd(account.value, localpart.value+'@'+domain.value)) - window.location.hash = '#accounts/'+account.value + window.location.hash = '#accounts/l/'+account.value }, fieldset=dom.fieldset( dom.p('Start with the initial email address for the account. The localpart is the account name too by default, but the account name can be changed.'), @@ -643,7 +644,88 @@ const accounts = async () => { ' ', dom.submitbutton('Add account', attr.title('The account will be added and the config reloaded.')), ) - ) + ), + dom.br(), + dom.h2('Recent login attempts', attr.title('Login attempts are stored for 30 days. At most 10000 failed login attempts are stored per account to prevent unlimited growth of the database.')), + renderLoginAttempts(true, loginAttempts || []), + dom.br(), + loginAttempts && loginAttempts.length >= 10 ? dom.p('See ', dom.a(attr.href('#accounts/loginattempts'), 'all login attempts'), '.') : [], + ) +} + +const loginattempts = async () => { + const loginAttempts = await client.LoginAttempts("", 0) + + return dom.div( + crumbs( + crumblink('Mox Admin', '#'), + crumblink('Accounts', '#accounts'), + 'Login attempts', + ), + dom.h2('Login attempts'), + dom.p('Login attempts are stored for 30 days. At most 10000 failed login attempts are stored per account to prevent unlimited growth of the database.'), + renderLoginAttempts(true, loginAttempts || []) + ) +} + +const accountloginattempts = async (accountName: string) => { + const loginAttempts = await client.LoginAttempts(accountName, 0) + + return dom.div( + crumbs( + crumblink('Mox Admin', '#'), + crumblink('Accounts', '#accounts'), + ['(admin)', '-'].includes(accountName) ? accountName : crumblink(accountName, '#accounts/l/'+accountName), + 'Login attempts', + ), + dom.h2('Login attempts'), + dom.p('Login attempts are stored for 30 days. At most 10000 failed login attempts are stored per account to prevent unlimited growth of the database.'), + renderLoginAttempts(false, loginAttempts || []) + ) +} + +const renderLoginAttempts = (accountLinks: boolean, loginAttempts: api.LoginAttempt[]) => { + // todo: pagination and search + + const nowSecs = new Date().getTime()/1000 + return dom.table( + dom.thead( + dom.tr( + dom.th('Time'), + dom.th('Result'), + dom.th('Count'), + dom.th('Account'), + dom.th('Address'), + dom.th('Protocol'), + dom.th('Mechanism'), + dom.th('User Agent'), + dom.th('Remote IP'), + dom.th('Local IP'), + dom.th('TLS'), + dom.th('TLS pubkey fingerprint'), + dom.th('First seen'), + ), + ), + dom.tbody( + loginAttempts.length ? [] : dom.tr(dom.td(attr.colspan('13'), 'No login attempts in past 30 days.')), + loginAttempts.map(la => + dom.tr( + dom.td(age(la.Last, false, nowSecs)), + dom.td(la.Result === 'ok' ? la.Result : box(red, la.Result)), + dom.td(''+la.Count), + dom.td(accountLinks ? dom.a(attr.href('#accounts/l/'+la.AccountName+'/loginattempts'), la.AccountName) : la.AccountName), + dom.td(la.LoginAddress), + dom.td(la.Protocol), + dom.td(la.AuthMech), + dom.td(la.UserAgent), + dom.td(la.RemoteIP), + dom.td(la.LocalIP), + dom.td(la.TLS), + dom.td(la.TLSPubKeyFingerprint), + dom.td(age(la.First, false, nowSecs)), + ), + ), + ), ) } @@ -766,11 +848,12 @@ const RoutesEditor = (kind: string, transports: { [key: string]: api.Transport } } const account = async (name: string) => { - const [[config, diskUsage], domains, transports, tlspubkeys] = await Promise.all([ + const [[config, diskUsage], domains, transports, tlspubkeys, loginAttempts] = await Promise.all([ client.Account(name), client.Domains(), client.Transports(), client.TLSPublicKeys(name), + client.LoginAttempts(name, 10), ]) // todo: show suppression list, and buttons to add/remove entries. @@ -1071,6 +1154,11 @@ const account = async (name: string) => { }), ), dom.br(), + dom.h2('Recent login attempts', attr.title('Login attempts are stored for 30 days. At most 10000 failed login attempts are stored per account to prevent unlimited growth of the database.')), + renderLoginAttempts(false, loginAttempts || []), + dom.br(), + loginAttempts && loginAttempts.length >= 10 ? dom.p('See ', dom.a(attr.href('#accounts/l/'+name+'/loginattempts'), 'all login attempts'), ' for this account.') : [], + dom.br(), dom.clickbutton('Remove account', async function click(e: MouseEvent) { e.preventDefault() if (!window.confirm('Are you sure you want to remove this account? All account data, including messages will be removed.')) { @@ -1347,7 +1435,7 @@ const domain = async (d: string) => { Object.entries(localpartAccounts).map(t => dom.tr( dom.td(prewrap(t[0]) || '(catchall)'), - dom.td(dom.a(t[1], attr.href('#accounts/'+t[1]))), + dom.td(dom.a(t[1], attr.href('#accounts/l/'+t[1]))), dom.td( dom.clickbutton('Remove', async function click(e: MouseEvent) { e.preventDefault() @@ -1938,7 +2026,7 @@ const domainAlias = async (d: string, aliasLocalpart: string) => { const pa = (alias.ParsedAddresses || [])[index] return dom.tr( dom.td(prewrap(address)), - dom.td(dom.a(pa.AccountName, attr.href('#accounts/'+pa.AccountName))), + dom.td(dom.a(pa.AccountName, attr.href('#accounts/l/'+pa.AccountName))), dom.td( dom.clickbutton('Remove', async function click(e: MouseEvent) { await check(e.target! as HTMLButtonElement, client.AliasAddressesRemove(aliasLocalpart, d, [address])) @@ -5227,8 +5315,12 @@ const init = async () => { root = await loglevels() } else if (h === 'accounts') { root = await accounts() - } else if (t[0] === 'accounts' && t.length === 2) { - root = await account(t[1]) + } else if (h === 'accounts/loginattempts') { + root = await loginattempts() + } else if (t[0] === 'accounts' && t.length === 3 && t[1] === 'l') { + root = await account(t[2]) + } else if (t[0] === 'accounts' && t.length === 4 && t[1] === 'l' && t[3] === 'loginattempts') { + root = await accountloginattempts(t[2]) } else if (t[0] === 'domains' && t.length === 2) { root = await domain(t[1]) } else if (t[0] === 'domains' && t.length === 4 && t[2] === 'alias') { diff --git a/webadmin/admin_test.go b/webadmin/admin_test.go index 8de371b..031ee2c 100644 --- a/webadmin/admin_test.go +++ b/webadmin/admin_test.go @@ -87,6 +87,12 @@ func TestAdminAuth(t *testing.T) { mox.ConfigStaticPath = filepath.FromSlash("../testdata/webadmin/mox.conf") mox.ConfigDynamicPath = filepath.Join(filepath.Dir(mox.ConfigStaticPath), "domains.conf") mox.MustLoadConfig(true, false) + err := store.Init(ctxbg) + tcheck(t, err, "store init") + defer func() { + err := store.Close() + tcheck(t, err, "store close") + }() adminpwhash, err := bcrypt.GenerateFromPassword([]byte("moxtest123"), bcrypt.DefaultCost) tcheck(t, err, "generate bcrypt hash") diff --git a/webadmin/api.json b/webadmin/api.json index 2f4983c..80d7577 100644 --- a/webadmin/api.json +++ b/webadmin/api.json @@ -2110,6 +2110,33 @@ ] } ] + }, + { + "Name": "LoginAttempts", + "Docs": "", + "Params": [ + { + "Name": "accountName", + "Typewords": [ + "string" + ] + }, + { + "Name": "limit", + "Typewords": [ + "int32" + ] + } + ], + "Returns": [ + { + "Name": "r0", + "Typewords": [ + "[]", + "LoginAttempt" + ] + } + ] } ], "Sections": [], @@ -7395,6 +7422,111 @@ ] } ] + }, + { + "Name": "LoginAttempt", + "Docs": "LoginAttempt is a successful or failed login attempt, stored for auditing\npurposes.\n\nAt most 10000 failed attempts are stored per account, to prevent unbounded\ngrowth of the database by third parties.", + "Fields": [ + { + "Name": "Key", + "Docs": "Hash of all fields after \"Count\" below. We store a single entry per key, updating its Last and Count fields.", + "Typewords": [ + "[]", + "uint8" + ] + }, + { + "Name": "Last", + "Docs": "Last has an index for efficient removal of entries after 30 days.", + "Typewords": [ + "timestamp" + ] + }, + { + "Name": "First", + "Docs": "", + "Typewords": [ + "timestamp" + ] + }, + { + "Name": "Count", + "Docs": "Number of login attempts for the combination of fields below.", + "Typewords": [ + "int64" + ] + }, + { + "Name": "AccountName", + "Docs": "Admin logins use \"(admin)\". If no account is known, \"-\" is used. AccountName has an index for efficiently removing failed login attempts at the end of the list when there are too many, and for efficiently removing all records for an account.", + "Typewords": [ + "string" + ] + }, + { + "Name": "LoginAddress", + "Docs": "Empty for attempts to login in as admin.", + "Typewords": [ + "string" + ] + }, + { + "Name": "RemoteIP", + "Docs": "", + "Typewords": [ + "string" + ] + }, + { + "Name": "LocalIP", + "Docs": "", + "Typewords": [ + "string" + ] + }, + { + "Name": "TLS", + "Docs": "Empty if no TLS, otherwise contains version, algorithm, properties, etc.", + "Typewords": [ + "string" + ] + }, + { + "Name": "TLSPubKeyFingerprint", + "Docs": "", + "Typewords": [ + "string" + ] + }, + { + "Name": "Protocol", + "Docs": "\"submission\", \"imap\", \"webmail\", \"webaccount\", \"webadmin\"", + "Typewords": [ + "string" + ] + }, + { + "Name": "UserAgent", + "Docs": "From HTTP header, or IMAP ID command.", + "Typewords": [ + "string" + ] + }, + { + "Name": "AuthMech", + "Docs": "\"plain\", \"login\", \"cram-md5\", \"scram-sha-256-plus\", \"(unrecognized)\", etc", + "Typewords": [ + "string" + ] + }, + { + "Name": "Result", + "Docs": "", + "Typewords": [ + "AuthResult" + ] + } + ] } ], "Ints": [], @@ -7481,6 +7613,57 @@ "Name": "IP", "Docs": "An IP is a single IP address, a slice of bytes.\nFunctions in this package accept either 4-byte (IPv4)\nor 16-byte (IPv6) slices as input.\n\nNote that in this documentation, referring to an\nIP address as an IPv4 address or an IPv6 address\nis a semantic property of the address, not just the\nlength of the byte slice: a 16-byte slice can still\nbe an IPv4 address.", "Values": [] + }, + { + "Name": "AuthResult", + "Docs": "AuthResult is the result of a login attempt.", + "Values": [ + { + "Name": "AuthSuccess", + "Value": "ok", + "Docs": "" + }, + { + "Name": "AuthBadUser", + "Value": "baduser", + "Docs": "" + }, + { + "Name": "AuthBadPassword", + "Value": "badpassword", + "Docs": "" + }, + { + "Name": "AuthBadCredentials", + "Value": "badcreds", + "Docs": "" + }, + { + "Name": "AuthBadChannelBinding", + "Value": "badchanbind", + "Docs": "" + }, + { + "Name": "AuthBadProtocol", + "Value": "badprotocol", + "Docs": "" + }, + { + "Name": "AuthLoginDisabled", + "Value": "logindisabled", + "Docs": "" + }, + { + "Name": "AuthError", + "Value": "error", + "Docs": "" + }, + { + "Name": "AuthAborted", + "Value": "aborted", + "Docs": "" + } + ] } ], "SherpaVersion": 0, diff --git a/webadmin/api.ts b/webadmin/api.ts index b88afbb..ccb4f3e 100644 --- a/webadmin/api.ts +++ b/webadmin/api.ts @@ -1068,6 +1068,28 @@ export interface TLSPublicKey { LoginAddress: string // Must belong to account. } +// LoginAttempt is a successful or failed login attempt, stored for auditing +// purposes. +// +// At most 10000 failed attempts are stored per account, to prevent unbounded +// growth of the database by third parties. +export interface LoginAttempt { + Key?: string | null // Hash of all fields after "Count" below. We store a single entry per key, updating its Last and Count fields. + Last: Date // Last has an index for efficient removal of entries after 30 days. + First: Date + Count: number // Number of login attempts for the combination of fields below. + AccountName: string // Admin logins use "(admin)". If no account is known, "-" is used. AccountName has an index for efficiently removing failed login attempts at the end of the list when there are too many, and for efficiently removing all records for an account. + LoginAddress: string // Empty for attempts to login in as admin. + RemoteIP: string + LocalIP: string + TLS: string // Empty if no TLS, otherwise contains version, algorithm, properties, etc. + TLSPubKeyFingerprint: string + Protocol: string // "submission", "imap", "webmail", "webaccount", "webadmin" + UserAgent: string // From HTTP header, or IMAP ID command. + AuthMech: string // "plain", "login", "cram-md5", "scram-sha-256-plus", "(unrecognized)", etc + Result: AuthResult +} + export type CSRFToken = string // Policy as used in DMARC DNS record for "p=" or "sp=". @@ -1112,8 +1134,21 @@ export type Localpart = string // be an IPv4 address. export type IP = string -export const structTypes: {[typename: string]: boolean} = {"Account":true,"Address":true,"AddressAlias":true,"Alias":true,"AliasAddress":true,"AuthResults":true,"AutoconfCheckResult":true,"AutodiscoverCheckResult":true,"AutodiscoverSRV":true,"AutomaticJunkFlags":true,"Canonicalization":true,"CheckResult":true,"ClientConfigs":true,"ClientConfigsEntry":true,"ConfigDomain":true,"DANECheckResult":true,"DKIM":true,"DKIMAuthResult":true,"DKIMCheckResult":true,"DKIMRecord":true,"DMARC":true,"DMARCCheckResult":true,"DMARCRecord":true,"DMARCSummary":true,"DNSSECResult":true,"DateRange":true,"Destination":true,"Directive":true,"Domain":true,"DomainFeedback":true,"Dynamic":true,"Evaluation":true,"EvaluationStat":true,"Extension":true,"FailureDetails":true,"Filter":true,"HoldRule":true,"Hook":true,"HookFilter":true,"HookResult":true,"HookRetired":true,"HookRetiredFilter":true,"HookRetiredSort":true,"HookSort":true,"IPDomain":true,"IPRevCheckResult":true,"Identifiers":true,"IncomingWebhook":true,"JunkFilter":true,"MTASTS":true,"MTASTSCheckResult":true,"MTASTSRecord":true,"MX":true,"MXCheckResult":true,"Modifier":true,"Msg":true,"MsgResult":true,"MsgRetired":true,"OutgoingWebhook":true,"Pair":true,"Policy":true,"PolicyEvaluated":true,"PolicyOverrideReason":true,"PolicyPublished":true,"PolicyRecord":true,"Record":true,"Report":true,"ReportMetadata":true,"ReportRecord":true,"Result":true,"ResultPolicy":true,"RetiredFilter":true,"RetiredSort":true,"Reverse":true,"Route":true,"Row":true,"Ruleset":true,"SMTPAuth":true,"SPFAuthResult":true,"SPFCheckResult":true,"SPFRecord":true,"SRV":true,"SRVConfCheckResult":true,"STSMX":true,"Selector":true,"Sort":true,"SubjectPass":true,"Summary":true,"SuppressAddress":true,"TLSCheckResult":true,"TLSPublicKey":true,"TLSRPT":true,"TLSRPTCheckResult":true,"TLSRPTDateRange":true,"TLSRPTRecord":true,"TLSRPTSummary":true,"TLSRPTSuppressAddress":true,"TLSReportRecord":true,"TLSResult":true,"Transport":true,"TransportDirect":true,"TransportSMTP":true,"TransportSocks":true,"URI":true,"WebForward":true,"WebHandler":true,"WebInternal":true,"WebRedirect":true,"WebStatic":true,"WebserverConfig":true} -export const stringsTypes: {[typename: string]: boolean} = {"Align":true,"CSRFToken":true,"DMARCPolicy":true,"IP":true,"Localpart":true,"Mode":true,"RUA":true} +// AuthResult is the result of a login attempt. +export enum AuthResult { + AuthSuccess = "ok", + AuthBadUser = "baduser", + AuthBadPassword = "badpassword", + AuthBadCredentials = "badcreds", + AuthBadChannelBinding = "badchanbind", + AuthBadProtocol = "badprotocol", + AuthLoginDisabled = "logindisabled", + AuthError = "error", + AuthAborted = "aborted", +} + +export const structTypes: {[typename: string]: boolean} = {"Account":true,"Address":true,"AddressAlias":true,"Alias":true,"AliasAddress":true,"AuthResults":true,"AutoconfCheckResult":true,"AutodiscoverCheckResult":true,"AutodiscoverSRV":true,"AutomaticJunkFlags":true,"Canonicalization":true,"CheckResult":true,"ClientConfigs":true,"ClientConfigsEntry":true,"ConfigDomain":true,"DANECheckResult":true,"DKIM":true,"DKIMAuthResult":true,"DKIMCheckResult":true,"DKIMRecord":true,"DMARC":true,"DMARCCheckResult":true,"DMARCRecord":true,"DMARCSummary":true,"DNSSECResult":true,"DateRange":true,"Destination":true,"Directive":true,"Domain":true,"DomainFeedback":true,"Dynamic":true,"Evaluation":true,"EvaluationStat":true,"Extension":true,"FailureDetails":true,"Filter":true,"HoldRule":true,"Hook":true,"HookFilter":true,"HookResult":true,"HookRetired":true,"HookRetiredFilter":true,"HookRetiredSort":true,"HookSort":true,"IPDomain":true,"IPRevCheckResult":true,"Identifiers":true,"IncomingWebhook":true,"JunkFilter":true,"LoginAttempt":true,"MTASTS":true,"MTASTSCheckResult":true,"MTASTSRecord":true,"MX":true,"MXCheckResult":true,"Modifier":true,"Msg":true,"MsgResult":true,"MsgRetired":true,"OutgoingWebhook":true,"Pair":true,"Policy":true,"PolicyEvaluated":true,"PolicyOverrideReason":true,"PolicyPublished":true,"PolicyRecord":true,"Record":true,"Report":true,"ReportMetadata":true,"ReportRecord":true,"Result":true,"ResultPolicy":true,"RetiredFilter":true,"RetiredSort":true,"Reverse":true,"Route":true,"Row":true,"Ruleset":true,"SMTPAuth":true,"SPFAuthResult":true,"SPFCheckResult":true,"SPFRecord":true,"SRV":true,"SRVConfCheckResult":true,"STSMX":true,"Selector":true,"Sort":true,"SubjectPass":true,"Summary":true,"SuppressAddress":true,"TLSCheckResult":true,"TLSPublicKey":true,"TLSRPT":true,"TLSRPTCheckResult":true,"TLSRPTDateRange":true,"TLSRPTRecord":true,"TLSRPTSummary":true,"TLSRPTSuppressAddress":true,"TLSReportRecord":true,"TLSResult":true,"Transport":true,"TransportDirect":true,"TransportSMTP":true,"TransportSocks":true,"URI":true,"WebForward":true,"WebHandler":true,"WebInternal":true,"WebRedirect":true,"WebStatic":true,"WebserverConfig":true} +export const stringsTypes: {[typename: string]: boolean} = {"Align":true,"AuthResult":true,"CSRFToken":true,"DMARCPolicy":true,"IP":true,"Localpart":true,"Mode":true,"RUA":true} export const intsTypes: {[typename: string]: boolean} = {} export const types: TypenameMap = { "CheckResult": {"Name":"CheckResult","Docs":"","Fields":[{"Name":"Domain","Docs":"","Typewords":["string"]},{"Name":"DNSSEC","Docs":"","Typewords":["DNSSECResult"]},{"Name":"IPRev","Docs":"","Typewords":["IPRevCheckResult"]},{"Name":"MX","Docs":"","Typewords":["MXCheckResult"]},{"Name":"TLS","Docs":"","Typewords":["TLSCheckResult"]},{"Name":"DANE","Docs":"","Typewords":["DANECheckResult"]},{"Name":"SPF","Docs":"","Typewords":["SPFCheckResult"]},{"Name":"DKIM","Docs":"","Typewords":["DKIMCheckResult"]},{"Name":"DMARC","Docs":"","Typewords":["DMARCCheckResult"]},{"Name":"HostTLSRPT","Docs":"","Typewords":["TLSRPTCheckResult"]},{"Name":"DomainTLSRPT","Docs":"","Typewords":["TLSRPTCheckResult"]},{"Name":"MTASTS","Docs":"","Typewords":["MTASTSCheckResult"]},{"Name":"SRVConf","Docs":"","Typewords":["SRVConfCheckResult"]},{"Name":"Autoconf","Docs":"","Typewords":["AutoconfCheckResult"]},{"Name":"Autodiscover","Docs":"","Typewords":["AutodiscoverCheckResult"]}]}, @@ -1226,6 +1261,7 @@ export const types: TypenameMap = { "TLSRPTSuppressAddress": {"Name":"TLSRPTSuppressAddress","Docs":"","Fields":[{"Name":"ID","Docs":"","Typewords":["int64"]},{"Name":"Inserted","Docs":"","Typewords":["timestamp"]},{"Name":"ReportingAddress","Docs":"","Typewords":["string"]},{"Name":"Until","Docs":"","Typewords":["timestamp"]},{"Name":"Comment","Docs":"","Typewords":["string"]}]}, "Dynamic": {"Name":"Dynamic","Docs":"","Fields":[{"Name":"Domains","Docs":"","Typewords":["{}","ConfigDomain"]},{"Name":"Accounts","Docs":"","Typewords":["{}","Account"]},{"Name":"WebDomainRedirects","Docs":"","Typewords":["{}","string"]},{"Name":"WebHandlers","Docs":"","Typewords":["[]","WebHandler"]},{"Name":"Routes","Docs":"","Typewords":["[]","Route"]},{"Name":"MonitorDNSBLs","Docs":"","Typewords":["[]","string"]},{"Name":"MonitorDNSBLZones","Docs":"","Typewords":["[]","Domain"]}]}, "TLSPublicKey": {"Name":"TLSPublicKey","Docs":"","Fields":[{"Name":"Fingerprint","Docs":"","Typewords":["string"]},{"Name":"Created","Docs":"","Typewords":["timestamp"]},{"Name":"Type","Docs":"","Typewords":["string"]},{"Name":"Name","Docs":"","Typewords":["string"]},{"Name":"NoIMAPPreauth","Docs":"","Typewords":["bool"]},{"Name":"CertDER","Docs":"","Typewords":["nullable","string"]},{"Name":"Account","Docs":"","Typewords":["string"]},{"Name":"LoginAddress","Docs":"","Typewords":["string"]}]}, + "LoginAttempt": {"Name":"LoginAttempt","Docs":"","Fields":[{"Name":"Key","Docs":"","Typewords":["nullable","string"]},{"Name":"Last","Docs":"","Typewords":["timestamp"]},{"Name":"First","Docs":"","Typewords":["timestamp"]},{"Name":"Count","Docs":"","Typewords":["int64"]},{"Name":"AccountName","Docs":"","Typewords":["string"]},{"Name":"LoginAddress","Docs":"","Typewords":["string"]},{"Name":"RemoteIP","Docs":"","Typewords":["string"]},{"Name":"LocalIP","Docs":"","Typewords":["string"]},{"Name":"TLS","Docs":"","Typewords":["string"]},{"Name":"TLSPubKeyFingerprint","Docs":"","Typewords":["string"]},{"Name":"Protocol","Docs":"","Typewords":["string"]},{"Name":"UserAgent","Docs":"","Typewords":["string"]},{"Name":"AuthMech","Docs":"","Typewords":["string"]},{"Name":"Result","Docs":"","Typewords":["AuthResult"]}]}, "CSRFToken": {"Name":"CSRFToken","Docs":"","Values":null}, "DMARCPolicy": {"Name":"DMARCPolicy","Docs":"","Values":[{"Name":"PolicyEmpty","Value":"","Docs":""},{"Name":"PolicyNone","Value":"none","Docs":""},{"Name":"PolicyQuarantine","Value":"quarantine","Docs":""},{"Name":"PolicyReject","Value":"reject","Docs":""}]}, "Align": {"Name":"Align","Docs":"","Values":[{"Name":"AlignStrict","Value":"s","Docs":""},{"Name":"AlignRelaxed","Value":"r","Docs":""}]}, @@ -1233,6 +1269,7 @@ export const types: TypenameMap = { "Mode": {"Name":"Mode","Docs":"","Values":[{"Name":"ModeEnforce","Value":"enforce","Docs":""},{"Name":"ModeTesting","Value":"testing","Docs":""},{"Name":"ModeNone","Value":"none","Docs":""}]}, "Localpart": {"Name":"Localpart","Docs":"","Values":null}, "IP": {"Name":"IP","Docs":"","Values":[]}, + "AuthResult": {"Name":"AuthResult","Docs":"","Values":[{"Name":"AuthSuccess","Value":"ok","Docs":""},{"Name":"AuthBadUser","Value":"baduser","Docs":""},{"Name":"AuthBadPassword","Value":"badpassword","Docs":""},{"Name":"AuthBadCredentials","Value":"badcreds","Docs":""},{"Name":"AuthBadChannelBinding","Value":"badchanbind","Docs":""},{"Name":"AuthBadProtocol","Value":"badprotocol","Docs":""},{"Name":"AuthLoginDisabled","Value":"logindisabled","Docs":""},{"Name":"AuthError","Value":"error","Docs":""},{"Name":"AuthAborted","Value":"aborted","Docs":""}]}, } export const parser = { @@ -1346,6 +1383,7 @@ export const parser = { TLSRPTSuppressAddress: (v: any) => parse("TLSRPTSuppressAddress", v) as TLSRPTSuppressAddress, Dynamic: (v: any) => parse("Dynamic", v) as Dynamic, TLSPublicKey: (v: any) => parse("TLSPublicKey", v) as TLSPublicKey, + LoginAttempt: (v: any) => parse("LoginAttempt", v) as LoginAttempt, CSRFToken: (v: any) => parse("CSRFToken", v) as CSRFToken, DMARCPolicy: (v: any) => parse("DMARCPolicy", v) as DMARCPolicy, Align: (v: any) => parse("Align", v) as Align, @@ -1353,6 +1391,7 @@ export const parser = { Mode: (v: any) => parse("Mode", v) as Mode, Localpart: (v: any) => parse("Localpart", v) as Localpart, IP: (v: any) => parse("IP", v) as IP, + AuthResult: (v: any) => parse("AuthResult", v) as AuthResult, } // Admin exports web API functions for the admin web interface. All its methods are @@ -2281,6 +2320,14 @@ export class Client { const params: any[] = [accountOpt] return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as TLSPublicKey[] | null } + + async LoginAttempts(accountName: string, limit: number): Promise { + const fn: string = "LoginAttempts" + const paramTypes: string[][] = [["string"],["int32"]] + const returnTypes: string[][] = [["[]","LoginAttempt"]] + const params: any[] = [accountName, limit] + return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as LoginAttempt[] | null + } } export const defaultBaseURL = (function() { diff --git a/webapisrv/server.go b/webapisrv/server.go index 627b460..edf7e86 100644 --- a/webapisrv/server.go +++ b/webapisrv/server.go @@ -18,6 +18,7 @@ import ( "log/slog" "mime" "mime/multipart" + "net" "net/http" "net/textproto" "reflect" @@ -424,23 +425,24 @@ func (s server) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } - authResult := "error" + la := loginAttempt(r, "webapi", "httpbasic") + la.LoginAddress = email defer func() { + store.LoginAttemptAdd(context.Background(), log, la) metricDuration.WithLabelValues(fn).Observe(float64(time.Since(t0)) / float64(time.Second)) - metrics.AuthenticationInc("webapi", "httpbasic", authResult) }() var err error - acc, err = store.OpenEmailAuth(log, email, password, true) + acc, la.AccountName, err = store.OpenEmailAuth(log, email, password, true) if err != nil { mox.LimiterFailedAuth.Add(remoteIP, t0, 1) if errors.Is(err, mox.ErrDomainNotFound) || errors.Is(err, mox.ErrAddressNotFound) || errors.Is(err, store.ErrUnknownCredentials) || errors.Is(err, store.ErrLoginDisabled) { log.Debug("bad http basic authentication credentials") metricResults.WithLabelValues(fn, "badauth").Inc() - authResult = "badcreds" + la.Result = store.AuthBadCredentials msg := "use http basic auth with email address as username" if errors.Is(err, store.ErrLoginDisabled) { - authResult = "logindisabled" + la.Result = store.AuthLoginDisabled msg = "login is disabled for this account" } w.Header().Set("WWW-Authenticate", "Basic realm=webapi") @@ -450,7 +452,8 @@ func (s server) ServeHTTP(w http.ResponseWriter, r *http.Request) { writeError(webapi.Error{Code: "server", Message: "error verifying credentials"}) return } - authResult = "ok" + la.AccountName = acc.Name + la.Result = store.AuthSuccess mox.LimiterFailedAuth.Reset(remoteIP, t0) ct := r.Header.Get("Content-Type") @@ -526,6 +529,24 @@ func (s server) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } +// loginAttempt initializes a store.LoginAttempt, for adding to the store after +// filling in the results and other details. +func loginAttempt(r *http.Request, protocol, authMech string) store.LoginAttempt { + remoteIP, _, _ := net.SplitHostPort(r.RemoteAddr) + if remoteIP == "" { + remoteIP = r.RemoteAddr + } + + return store.LoginAttempt{ + RemoteIP: remoteIP, + TLS: store.LoginAttemptTLS(r.TLS), + Protocol: protocol, + AuthMech: authMech, + UserAgent: r.UserAgent(), + Result: store.AuthError, // Replaced by caller. + } +} + func xcheckf(err error, format string, args ...any) { if err != nil { msg := fmt.Sprintf(format, args...) diff --git a/webapisrv/server_test.go b/webapisrv/server_test.go index e85ae80..1548ae8 100644 --- a/webapisrv/server_test.go +++ b/webapisrv/server_test.go @@ -61,8 +61,14 @@ func TestServer(t *testing.T) { mox.Context = ctxbg mox.ConfigStaticPath = filepath.FromSlash("../testdata/webapisrv/mox.conf") mox.MustLoadConfig(true, false) + err := store.Init(ctxbg) + tcheckf(t, err, "store init") + defer func() { + err := store.Close() + tcheckf(t, err, "store close") + }() defer store.Switchboard()() - err := queue.Init() + err = queue.Init() tcheckf(t, err, "queue init") defer queue.Shutdown() diff --git a/webauth/accounts.go b/webauth/accounts.go index 03e123b..6a790cd 100644 --- a/webauth/accounts.go +++ b/webauth/accounts.go @@ -15,19 +15,19 @@ var Accounts SessionAuth = accountSessionAuth{} type accountSessionAuth struct{} func (accountSessionAuth) login(ctx context.Context, log mlog.Log, username, password string) (valid, disabled bool, accName string, rerr error) { - acc, err := store.OpenEmailAuth(log, username, password, true) + acc, accName, err := store.OpenEmailAuth(log, username, password, true) if err != nil && errors.Is(err, store.ErrUnknownCredentials) { - return false, false, "", nil + return false, false, accName, nil } else if err != nil && errors.Is(err, store.ErrLoginDisabled) { - return false, true, "", err // Returning error, for its message. + return false, true, accName, err // Returning error, for its message. } else if err != nil { - return false, false, "", err + return false, false, accName, err } defer func() { err := acc.Close() log.Check(err, "closing account") }() - return true, false, acc.Name, nil + return true, false, accName, nil } func (accountSessionAuth) add(ctx context.Context, log mlog.Log, accountName string, loginAddress string) (sessionToken store.SessionToken, csrfToken store.CSRFToken, rerr error) { diff --git a/webauth/admin.go b/webauth/admin.go index 0f28c50..8237a65 100644 --- a/webauth/admin.go +++ b/webauth/admin.go @@ -58,7 +58,7 @@ func (a *adminSessionAuth) login(ctx context.Context, log mlog.Log, username, pa return false, false, "", nil } - return true, false, "", nil + return true, false, "(admin)", nil } func (a *adminSessionAuth) add(ctx context.Context, log mlog.Log, accountName string, loginAddress string) (sessionToken store.SessionToken, csrfToken store.CSRFToken, rerr error) { diff --git a/webauth/webauth.go b/webauth/webauth.go index c1d217f..9728718 100644 --- a/webauth/webauth.go +++ b/webauth/webauth.go @@ -77,6 +77,23 @@ type SessionAuth interface { remove(ctx context.Context, log mlog.Log, accountName string, sessionToken store.SessionToken) error } +// loginAttempt initializes a loginAttempt, for adding to the store after filling in the results and other details. +func loginAttempt(r *http.Request, protocol, authMech string) store.LoginAttempt { + remoteIP, _, _ := net.SplitHostPort(r.RemoteAddr) + if remoteIP == "" { + remoteIP = r.RemoteAddr + } + + return store.LoginAttempt{ + RemoteIP: remoteIP, + TLS: store.LoginAttemptTLS(r.TLS), + Protocol: protocol, + AuthMech: authMech, + UserAgent: r.UserAgent(), + Result: store.AuthError, // Replaced by caller. + } +} + // Check authentication for a request based on session token in cookie and matching // csrf in case requireCSRF is set (from header, unless formCSRF is set). Also // performs rate limiting. @@ -143,9 +160,9 @@ func Check(ctx context.Context, log mlog.Log, sessionAuth SessionAuth, kind stri return } - authResult := "badcreds" + la := loginAttempt(r, kind, "websession") defer func() { - metrics.AuthenticationInc(kind, "websession", authResult) + store.LoginAttemptAdd(context.Background(), log, la) }() // Cookie values are of the form: token SP accountname. @@ -165,16 +182,19 @@ func Check(ctx context.Context, log mlog.Log, sessionAuth SessionAuth, kind stri respondAuthError("user:badAuth", "malformed session account name") return "", "", "", false } + la.AccountName = accountName loginAddress, err = sessionAuth.use(ctx, log, accountName, sessionToken, csrfToken) if err != nil { + la.Result = store.AuthBadCredentials time.Sleep(BadAuthDelay) respondAuthError("user:badAuth", err.Error()) return "", "", "", false } + la.LoginAddress = loginAddress mox.LimiterFailedAuth.Reset(ip, start) - authResult = "ok" + la.Result = store.AuthSuccess // Add to HTTP logging that this is an authenticated request. if lw, ok := w.(interface{ AddAttr(a slog.Attr) }); ok { @@ -247,26 +267,29 @@ func Login(ctx context.Context, log mlog.Log, sessionAuth SessionAuth, kind, coo } valid, disabled, accountName, err := sessionAuth.login(ctx, log, username, password) - var authResult string + la := loginAttempt(r, kind, "weblogin") + la.LoginAddress = username + la.AccountName = accountName defer func() { - metrics.AuthenticationInc(kind, "weblogin", authResult) + store.LoginAttemptAdd(context.Background(), log, la) }() if disabled { - authResult = "logindisabled" + la.Result = store.AuthLoginDisabled return "", &sherpa.Error{Code: "user:loginFailed", Message: err.Error()} } else if err != nil { - authResult = "error" + la.Result = store.AuthError return "", fmt.Errorf("evaluating login attempt: %v", err) } else if !valid { time.Sleep(BadAuthDelay) - authResult = "badcreds" + la.Result = store.AuthBadCredentials return "", &sherpa.Error{Code: "user:loginFailed", Message: "invalid credentials"} } - authResult = "ok" + la.Result = store.AuthSuccess mox.LimiterFailedAuth.Reset(ip, start) sessionToken, csrfToken, err := sessionAuth.add(ctx, log, accountName, username) if err != nil { + la.Result = store.AuthError log.Errorx("adding session after login", err) return "", fmt.Errorf("adding session: %v", err) } diff --git a/webmail/api_test.go b/webmail/api_test.go index 63d9c3b..b31986a 100644 --- a/webmail/api_test.go +++ b/webmail/api_test.go @@ -57,9 +57,15 @@ func TestAPI(t *testing.T) { mox.ConfigDynamicPath = filepath.FromSlash("../testdata/webmail/domains.conf") mox.MustLoadConfig(true, false) defer store.Switchboard()() + err := store.Init(ctxbg) + tcheck(t, err, "store init") + defer func() { + err := store.Close() + tcheck(t, err, "store close") + }() log := mlog.New("webmail", nil) - err := mtastsdb.Init(false) + err = mtastsdb.Init(false) tcheck(t, err, "mtastsdb init") acc, err := store.OpenAccount(log, "mjl", false) tcheck(t, err, "open account") @@ -113,7 +119,7 @@ func TestAPI(t *testing.T) { x := recover() expErr := len(expErrCodes) > 0 if (x != nil) != expErr { - t.Fatalf("got %v, expected codes %v, for username %q, password %q", x, expErrCodes, username, password) + panic(fmt.Sprintf("got %v, expected codes %v, for username %q, password %q", x, expErrCodes, username, password)) } if x == nil { return diff --git a/webmail/view_test.go b/webmail/view_test.go index bdeb520..bafc115 100644 --- a/webmail/view_test.go +++ b/webmail/view_test.go @@ -30,6 +30,12 @@ func TestView(t *testing.T) { mox.ConfigStaticPath = filepath.FromSlash("../testdata/webmail/mox.conf") mox.MustLoadConfig(true, false) defer store.Switchboard()() + err := store.Init(ctxbg) + tcheck(t, err, "store init") + defer func() { + err := store.Close() + tcheck(t, err, "store close") + }() log := mlog.New("webmail", nil) acc, err := store.OpenAccount(log, "mjl", false) diff --git a/webmail/webmail_test.go b/webmail/webmail_test.go index 5211f95..fcd07b8 100644 --- a/webmail/webmail_test.go +++ b/webmail/webmail_test.go @@ -307,6 +307,12 @@ func TestWebmail(t *testing.T) { mox.ConfigStaticPath = filepath.FromSlash("../testdata/webmail/mox.conf") mox.MustLoadConfig(true, false) defer store.Switchboard()() + err := store.Init(ctxbg) + tcheck(t, err, "store init") + defer func() { + err := store.Close() + tcheck(t, err, "store close") + }() log := mlog.New("webmail", nil) acc, err := store.OpenAccount(pkglog, "mjl", false)