keep track of login attempts, both successful and failures

and show them in the account and admin interfaces. this should help with
debugging, to find misconfigured clients, and potentially find attackers trying
to login.

we include details like login name, account name, protocol, authentication
mechanism, ip addresses, tls connection properties, user-agent. and of course
the result.

we group entries by their details. repeat connections don't cause new records
in the database, they just increase the count on the existing record.

we keep data for at most 30 days. and we keep at most 10k entries per account.
to prevent unbounded growth. for successful login attempts, we store them all
for 30d. if a bad user causes so many entries this becomes a problem, it will
be time to talk to the user...

there is no pagination/searching yet in the admin/account interfaces. so the
list may be long. we only show the 10 most recent login attempts by default.
the rest is only shown on a separate page.

there is no way yet to disable this. may come later, either as global setting
or per account.
This commit is contained in:
Mechiel Lukkien 2025-02-05 23:52:21 +01:00
parent d08e0d3882
commit 1277d78cb1
No known key found for this signature in database
34 changed files with 1676 additions and 206 deletions

View File

@ -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) 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)) log.Info("account removed", slog.String("account", account))
return nil return nil
} }

4
ctl.go
View File

@ -329,7 +329,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, cid int64, shutdown func()) {
*/ */
to := ctl.xread() 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") ctl.xcheck(err, "lookup destination address")
msgFile, err := store.CreateMessageTemp(log, "ctl-deliver") msgFile, err := store.CreateMessageTemp(log, "ctl-deliver")
@ -1155,7 +1155,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, cid int64, shutdown func()) {
if name != "" { if name != "" {
tlspubkey.Name = 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") ctl.xcheck(err, "open account for address")
defer func() { defer func() {
err := acc.Close() err := acc.Close()

View File

@ -196,6 +196,11 @@ type conn struct {
// ../rfc/5182:13 ../rfc/9051:4040 // ../rfc/5182:13 ../rfc/9051:4040
searchResult []store.UID 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 // Only set when connection has been authenticated. These can be set even when
// c.state is stateNotAuthenticated, for TLS client certificate authentication. In // c.state is stateNotAuthenticated, for TLS client certificate authentication. In
// that case, credentials aren't used until the authentication command with the // 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) defer mox.Connections.Unregister(nc)
if preauthAddress != "" { if preauthAddress != "" {
acc, _, err := store.OpenEmail(c.log, preauthAddress, false) acc, _, _, err := store.OpenEmail(c.log, preauthAddress, false)
if err != nil { if err != nil {
c.log.Debugx("open account for preauth address", err, slog.String("address", preauthAddress)) c.log.Debugx("open account for preauth address", err, slog.String("address", preauthAddress))
c.writelinef("* BYE open account for address: %s", err) 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()) 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 { for {
c.command() c.command()
c.xflush() // For flushing errors, or possibly commands that did not flush explicitly. 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) 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 // makeTLSConfig makes a new tls config that is bound to the connection for
// possible client certificate authentication. // possible client certificate authentication.
func (c *conn) makeTLSConfig() *tls.Config { 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") 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() { defer func() {
metrics.AuthenticationInc("imap", "tlsclientauth", authResult) // Get TLS connection state in goroutine because we are called while performing the
if authResult == "ok" { // 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()) mox.LimiterFailedAuth.Reset(c.remoteIP, time.Now())
} else { } else {
mox.LimiterFailedAuth.Add(c.remoteIP, time.Now(), 1) 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) shabuf := sha256.Sum256(cert.RawSubjectPublicKeyInfo)
fp := base64.RawURLEncoding.EncodeToString(shabuf[:]) fp := base64.RawURLEncoding.EncodeToString(shabuf[:])
c.loginAttempt.TLSPubKeyFingerprint = fp
pubKey, err := store.TLSPublicKeyGet(context.TODO(), fp) pubKey, err := store.TLSPublicKeyGet(context.TODO(), fp)
if err != nil { if err != nil {
if err == bstore.ErrAbsent { 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) 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 // 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 // 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 // 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. // then, where it will result in a more helpful error message.
checkLoginDisabled := !pubKey.NoIMAPPreauth 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 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. // 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) 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.xsanity(err, "close account")
} }
}() }()
c.loginAttempt.AccountName = acc.Name
if acc.Name != pubKey.Account { 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) 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.authFailed = 0
c.noPreauth = pubKey.NoIMAPPreauth c.noPreauth = pubKey.NoIMAPPreauth
c.account = acc c.account = acc
@ -1646,6 +1732,7 @@ func (c *conn) cmdID(tag, cmd string, p *parser) {
// Request syntax: ../rfc/2971:241 // Request syntax: ../rfc/2971:241
p.xspace() p.xspace()
var params map[string]string var params map[string]string
var values []string
if p.take("(") { if p.take("(") {
params = map[string]string{} params = map[string]string{}
for !p.take(")") { for !p.take(")") {
@ -1659,12 +1746,21 @@ func (c *conn) cmdID(tag, cmd string, p *parser) {
xsyntaxErrorf("duplicate key %q", k) xsyntaxErrorf("duplicate key %q", k)
} }
params[k] = v params[k] = v
values = append(values, fmt.Sprintf("%s=%q", k, v))
} }
} else { } else {
p.xnil() p.xnil()
} }
p.xempty() 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. // We just log the client id.
c.log.Info("client id", slog.Any("params", params)) 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. c.newLoginAttempt(true, "")
authResult := "error"
defer func() { defer func() {
metrics.AuthenticationInc("imap", authVariant, authResult) if c.loginAttempt.Result == store.AuthSuccess {
if authResult == "ok" {
mox.LimiterFailedAuth.Reset(c.remoteIP, time.Now()) mox.LimiterFailedAuth.Reset(c.remoteIP, time.Now())
} else if !missingDerivedSecrets { } else if !missingDerivedSecrets {
mox.LimiterFailedAuth.Add(c.remoteIP, time.Now(), 1) 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 // ../rfc/9051:1442 ../rfc/3501:1553
if line == "*" { if line == "*" {
authResult = "aborted" c.loginAttempt.Result = store.AuthAborted
xsyntaxErrorf("authenticate aborted by client") xsyntaxErrorf("authenticate aborted by client")
} }
buf, err := base64.StdEncoding.DecodeString(line) buf, err := base64.StdEncoding.DecodeString(line)
@ -1788,7 +1882,7 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) {
xreadContinuation := func() []byte { xreadContinuation := func() []byte {
line := c.readline(false) line := c.readline(false)
if line == "*" { if line == "*" {
authResult = "aborted" c.loginAttempt.Result = store.AuthAborted
xsyntaxErrorf("authenticate aborted by client") xsyntaxErrorf("authenticate aborted by client")
} }
buf, err := base64.StdEncoding.DecodeString(line) buf, err := base64.StdEncoding.DecodeString(line)
@ -1812,7 +1906,7 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) {
switch strings.ToUpper(authType) { switch strings.ToUpper(authType) {
case "PLAIN": case "PLAIN":
authVariant = "plain" c.loginAttempt.AuthMech = "plain"
if !c.noRequireSTARTTLS && !c.tls { if !c.noRequireSTARTTLS && !c.tls {
// ../rfc/9051:5194 // ../rfc/9051:5194
@ -1830,16 +1924,17 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) {
authz := string(plain[0]) authz := string(plain[0])
username = string(plain[1]) username = string(plain[1])
password := string(plain[2]) password := string(plain[2])
c.loginAttempt.LoginAddress = username
if authz != "" && authz != username { if authz != "" && authz != username {
xusercodeErrorf("AUTHORIZATIONFAILED", "cannot assume role") xusercodeErrorf("AUTHORIZATIONFAILED", "cannot assume role")
} }
var err error 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 err != nil {
if errors.Is(err, store.ErrUnknownCredentials) { if errors.Is(err, store.ErrUnknownCredentials) {
authResult = "badcreds" c.loginAttempt.Result = store.AuthBadCredentials
c.log.Info("authentication failed", slog.String("username", username)) c.log.Info("authentication failed", slog.String("username", username))
xusercodeErrorf("AUTHENTICATIONFAILED", "bad credentials") xusercodeErrorf("AUTHENTICATIONFAILED", "bad credentials")
} }
@ -1847,7 +1942,7 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) {
} }
case "CRAM-MD5": case "CRAM-MD5":
authVariant = strings.ToLower(authType) c.loginAttempt.AuthMech = strings.ToLower(authType)
// ../rfc/9051:1462 // ../rfc/9051:1462
p.xempty() p.xempty()
@ -1862,16 +1957,17 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) {
xsyntaxErrorf("malformed cram-md5 response") xsyntaxErrorf("malformed cram-md5 response")
} }
username = t[0] username = t[0]
c.loginAttempt.LoginAddress = username
c.log.Debug("cram-md5 auth", slog.String("address", username)) c.log.Debug("cram-md5 auth", slog.String("address", username))
var err error 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 err != nil {
if errors.Is(err, store.ErrUnknownCredentials) { 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)) c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP))
xusercodeErrorf("AUTHENTICATIONFAILED", "bad credentials") xusercodeErrorf("AUTHENTICATIONFAILED", "bad credentials")
} else if errors.Is(err, store.ErrLoginDisabled) { } else if errors.Is(err, store.ErrLoginDisabled) {
authResult = "logindisabled" c.loginAttempt.Result = store.AuthLoginDisabled
c.log.Info("account login disabled", slog.String("username", username)) c.log.Info("account login disabled", slog.String("username", username))
// No error code, we don't want to cause prompt for new password // No error code, we don't want to cause prompt for new password
// (AUTHENTICATIONFAILED) and don't want to trigger message suppression with ALERT. // (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. // No plaintext credentials, we can log these normally.
authVariant = strings.ToLower(authType) c.loginAttempt.AuthMech = strings.ToLower(authType)
var h func() hash.Hash var h func() hash.Hash
switch authVariant { switch c.loginAttempt.AuthMech {
case "scram-sha-1", "scram-sha-1-plus": case "scram-sha-1", "scram-sha-1-plus":
h = sha1.New h = sha1.New
case "scram-sha-256", "scram-sha-256-plus": 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 var cs *tls.ConnectionState
requireChannelBinding := strings.HasSuffix(authVariant, "-plus") requireChannelBinding := strings.HasSuffix(c.loginAttempt.AuthMech, "-plus")
if requireChannelBinding && !c.tls { if requireChannelBinding && !c.tls {
xuserErrorf("cannot use plus variant with tls channel binding without 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) xuserErrorf("scram protocol error: %s", err)
} }
username = ss.Authentication username = ss.Authentication
c.loginAttempt.LoginAddress = username
c.log.Debug("scram auth", slog.String("authentication", username)) c.log.Debug("scram auth", slog.String("authentication", username))
// We check for login being disabled when finishing. // 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 { if err != nil {
// todo: we could continue scram with a generated salt, deterministically generated // 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 // 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") xusercodeErrorf("AUTHENTICATIONFAILED", "bad credentials")
} }
xcheckf(err, "fetching credentials") xcheckf(err, "fetching credentials")
switch authVariant { switch c.loginAttempt.AuthMech {
case "scram-sha-1", "scram-sha-1-plus": case "scram-sha-1", "scram-sha-1-plus":
xscram = password.SCRAMSHA1 xscram = password.SCRAMSHA1
case "scram-sha-256", "scram-sha-256-plus": case "scram-sha-256", "scram-sha-256-plus":
@ -1995,15 +2092,15 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) {
if err != nil { if err != nil {
c.readline(false) // Should be "*" for cancellation. c.readline(false) // Should be "*" for cancellation.
if errors.Is(err, scram.ErrInvalidProof) { 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)) c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP))
xusercodeErrorf("AUTHENTICATIONFAILED", "bad credentials") xusercodeErrorf("AUTHENTICATIONFAILED", "bad credentials")
} else if errors.Is(err, scram.ErrChannelBindingsDontMatch) { } 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)) 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") xusercodeErrorf("AUTHENTICATIONFAILED", "channel bindings do not match, potential mitm")
} else if errors.Is(err, scram.ErrInvalidEncoding) { } 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)) c.log.Infox("bad scram protocol message", err, slog.String("username", username), slog.Any("remote", c.remoteIP))
xuserErrorf("bad scram protocol message: %s", err) xuserErrorf("bad scram protocol message: %s", err)
} }
@ -2015,11 +2112,12 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) {
xreadContinuation() xreadContinuation()
case "EXTERNAL": case "EXTERNAL":
authVariant = strings.ToLower(authType) c.loginAttempt.AuthMech = "external"
// ../rfc/4422:1618 // ../rfc/4422:1618
buf := xreadInitial() buf := xreadInitial()
username = string(buf) username = string(buf)
c.loginAttempt.LoginAddress = username
if !c.tls { if !c.tls {
xusercodeErrorf("AUTHENTICATIONFAILED", "tls required for tls client certificate authentication") xusercodeErrorf("AUTHENTICATIONFAILED", "tls required for tls client certificate authentication")
@ -2030,19 +2128,21 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) {
if username == "" { if username == "" {
username = c.username username = c.username
c.loginAttempt.LoginAddress = username
} }
var err error 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") xcheckf(err, "looking up username from tls client authentication")
default: default:
c.loginAttempt.AuthMech = "(unrecognized)"
xuserErrorf("method not supported") xuserErrorf("method not supported")
} }
if accConf, ok := account.Conf(); !ok { if accConf, ok := account.Conf(); !ok {
xserverErrorf("cannot get account config") xserverErrorf("cannot get account config")
} else if accConf.LoginDisabled != "" { } else if accConf.LoginDisabled != "" {
authResult = "logindisabled" c.loginAttempt.Result = store.AuthLoginDisabled
c.log.Info("account login disabled", slog.String("username", username)) c.log.Info("account login disabled", slog.String("username", username))
// No AUTHENTICATIONFAILED code, clients could prompt users for different password. // No AUTHENTICATIONFAILED code, clients could prompt users for different password.
xuserErrorf("%w: %s", store.ErrLoginDisabled, accConf.LoginDisabled) 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 c.account != nil {
if account != c.account { if account != c.account {
c.log.Debug("sasl authentication for different account than tls client authentication, aborting connection", 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("saslaccount", account.Name),
slog.String("tlsaccount", c.account.Name), slog.String("tlsaccount", c.account.Name),
slog.String("saslusername", username), 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") xusercodeErrorf("AUTHENTICATIONFAILED", "authentication failed, tls client certificate public key belongs to another account")
} else if username != c.username { } else if username != c.username {
c.log.Debug("sasl authentication for different username than tls client certificate authentication, switching to sasl 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("saslusername", username),
slog.String("tlsusername", c.username), slog.String("tlsusername", c.username),
slog.String("account", c.account.Name), slog.String("account", c.account.Name),
@ -2080,7 +2180,9 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) {
} }
c.setSlow(false) 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.authFailed = 0
c.state = stateAuthenticated c.state = stateAuthenticated
c.writeresultf("%s OK [CAPABILITY %s] authenticate done", tag, c.capabilities()) 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) { func (c *conn) cmdLogin(tag, cmd string, p *parser) {
// Command: ../rfc/9051:1597 ../rfc/3501:1663 // Command: ../rfc/9051:1597 ../rfc/3501:1663
authResult := "error" c.newLoginAttempt(true, "login")
defer func() { defer func() {
metrics.AuthenticationInc("imap", "login", authResult) if c.loginAttempt.Result == store.AuthSuccess {
if authResult == "ok" {
mox.LimiterFailedAuth.Reset(c.remoteIP, time.Now()) mox.LimiterFailedAuth.Reset(c.remoteIP, time.Now())
} else { } else {
mox.LimiterFailedAuth.Add(c.remoteIP, time.Now(), 1) 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 // Request syntax: ../rfc/9051:6667 ../rfc/3501:4804
p.xspace() p.xspace()
username := p.xastring() username := p.xastring()
c.loginAttempt.LoginAddress = username
p.xspace() p.xspace()
password := p.xastring() password := p.xastring()
p.xempty() 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 { if err != nil {
var code string var code string
if errors.Is(err, store.ErrUnknownCredentials) { if errors.Is(err, store.ErrUnknownCredentials) {
authResult = "badcreds" c.loginAttempt.Result = store.AuthBadCredentials
code = "AUTHENTICATIONFAILED" code = "AUTHENTICATIONFAILED"
c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP)) c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP))
} else if errors.Is(err, store.ErrLoginDisabled) { } else if errors.Is(err, store.ErrLoginDisabled) {
authResult = "logindisabled" c.loginAttempt.Result = store.AuthLoginDisabled
c.log.Info("account login disabled", slog.String("username", username)) c.log.Info("account login disabled", slog.String("username", username))
// There is no specific code for "account disabled" in IMAP. AUTHORIZATIONFAILED is // 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, // 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 { if c.comm == nil {
c.comm = store.RegisterComm(c.account) 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.authFailed = 0
c.setSlow(false) c.setSlow(false)
c.state = stateAuthenticated c.state = stateAuthenticated
authResult = "ok"
c.writeresultf("%s OK [CAPABILITY %s] login done", tag, c.capabilities()) c.writeresultf("%s OK [CAPABILITY %s] login done", tag, c.capabilities())
} }

View File

@ -482,7 +482,7 @@ func writeLocalConfig(log mlog.Log, dir, ip string) (rerr error) {
loadLoglevel(log, "info") loadLoglevel(log, "info")
// Set password on account. // 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") xcheck(err, "opening account to set password")
password := "moxmoxmox" password := "moxmoxmox"
err = a.SetPassword(log, password) err = a.SetPassword(log, password)

View File

@ -16,7 +16,7 @@ var (
"kind", // submission, imap, webmail, webapi, webaccount, webadmin (formerly httpaccount, httpadmin) "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. "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 // 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.
}, },
) )

View File

@ -957,7 +957,7 @@ and check the admin page for the needed DNS records.`)
fatalf("cannot find domain in new config") 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 { if err != nil {
fatalf("open account: %s", err) fatalf("open account: %s", err)
} }

View File

@ -72,7 +72,7 @@ func TestAliasSubmitMsgFromDenied(t *testing.T) {
defer ts.close() defer ts.close()
// Trying to open account by alias should result in proper error. // 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) { if err == nil || !errors.Is(err, store.ErrUnknownCredentials) {
t.Fatalf("opening alias, got err %v, expected store.ErrUnknownCredentials", err) t.Fatalf("opening alias, got err %v, expected store.ErrUnknownCredentials", err)
} }

View File

@ -401,6 +401,25 @@ func isClosed(err error) bool {
return errors.Is(err, errIO) || moxio.IsClosed(err) 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 // makeTLSConfig makes a new tls config that is bound to the connection for
// possible client certificate authentication in case of submission. // possible client certificate authentication in case of submission.
func (c *conn) makeTLSConfig() *tls.Config { 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") return fmt.Errorf("cannot authenticate with tls client certificate after previous authentication")
} }
authResult := "error" la := c.loginAttempt(false, "tlsclientauth")
defer func() { defer func() {
metrics.AuthenticationInc("submission", "tlsclientauth", authResult) // Get TLS connection state in goroutine because we are called while performing the
if authResult == "ok" { // 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()) mox.LimiterFailedAuth.Reset(c.remoteIP, time.Now())
} else { } else {
mox.LimiterFailedAuth.Add(c.remoteIP, time.Now(), 1) 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) shabuf := sha256.Sum256(cert.RawSubjectPublicKeyInfo)
fp := base64.RawURLEncoding.EncodeToString(shabuf[:]) fp := base64.RawURLEncoding.EncodeToString(shabuf[:])
la.TLSPubKeyFingerprint = fp
pubKey, err := store.TLSPublicKeyGet(context.TODO(), fp) pubKey, err := store.TLSPublicKeyGet(context.TODO(), fp)
if err != nil { if err != nil {
if err == bstore.ErrAbsent { if err == bstore.ErrAbsent {
authResult = "badcreds" la.Result = store.AuthBadCredentials
} }
return fmt.Errorf("looking up tls public key with fingerprint %s: %v", fp, err) 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 // 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 // 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 // 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. // then, where it will result in a more helpful error message.
checkLoginDisabled := !pubKey.NoIMAPPreauth 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 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) return fmt.Errorf("opening account for address %s for public key %s: %w", pubKey.LoginAddress, fp, err)
} }
defer func() { defer func() {
@ -507,16 +550,17 @@ func (c *conn) tlsClientAuthVerifyPeerCertParsed(cert *x509.Certificate) error {
c.log.Check(err, "close account") c.log.Check(err, "close account")
} }
}() }()
la.AccountName = acc.Name
if acc.Name != pubKey.Account { 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) 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.authFailed = 0
c.account = acc c.account = acc
acc = nil // Prevent cleanup by defer. acc = nil // Prevent cleanup by defer.
c.username = pubKey.LoginAddress c.username = pubKey.LoginAddress
c.authTLS = true c.authTLS = true
la.Result = store.AuthSuccess
c.log.Debug("tls client authenticated with client certificate", c.log.Debug("tls client authenticated with client certificate",
slog.String("fingerprint", fp), slog.String("fingerprint", fp),
slog.String("username", c.username), slog.String("username", c.username),
@ -1248,11 +1292,10 @@ func (c *conn) cmdAuth(p *parser) {
} }
}() }()
var authVariant string // Only known strings, used in metrics. la := c.loginAttempt(true, "")
authResult := "error"
defer func() { defer func() {
metrics.AuthenticationInc("submission", authVariant, authResult) store.LoginAttemptAdd(context.Background(), c.log, la)
if authResult == "ok" { if la.Result == store.AuthSuccess {
mox.LimiterFailedAuth.Reset(c.remoteIP, time.Now()) mox.LimiterFailedAuth.Reset(c.remoteIP, time.Now())
} else if !missingDerivedSecrets { } else if !missingDerivedSecrets {
mox.LimiterFailedAuth.Add(c.remoteIP, time.Now(), 1) mox.LimiterFailedAuth.Add(c.remoteIP, time.Now(), 1)
@ -1273,7 +1316,7 @@ func (c *conn) cmdAuth(p *parser) {
auth = c.readline() auth = c.readline()
if auth == "*" { if auth == "*" {
// ../rfc/4954:193 // ../rfc/4954:193
authResult = "aborted" la.Result = store.AuthAborted
xsmtpUserErrorf(smtp.C501BadParamSyntax, smtp.SeProto5Other0, "authentication aborted") xsmtpUserErrorf(smtp.C501BadParamSyntax, smtp.SeProto5Other0, "authentication aborted")
} }
} else { } else {
@ -1304,7 +1347,7 @@ func (c *conn) cmdAuth(p *parser) {
xreadContinuation := func() []byte { xreadContinuation := func() []byte {
line := c.readline() line := c.readline()
if line == "*" { if line == "*" {
authResult = "aborted" la.Result = store.AuthAborted
xsmtpUserErrorf(smtp.C501BadParamSyntax, smtp.SeProto5Other0, "authentication aborted") xsmtpUserErrorf(smtp.C501BadParamSyntax, smtp.SeProto5Other0, "authentication aborted")
} }
buf, err := base64.StdEncoding.DecodeString(line) buf, err := base64.StdEncoding.DecodeString(line)
@ -1329,7 +1372,7 @@ func (c *conn) cmdAuth(p *parser) {
switch mech { switch mech {
case "PLAIN": case "PLAIN":
authVariant = "plain" la.AuthMech = "plain"
// ../rfc/4954:343 // ../rfc/4954:343
// ../rfc/4954:326 // ../rfc/4954:326
@ -1347,18 +1390,19 @@ func (c *conn) cmdAuth(p *parser) {
} }
authz := norm.NFC.String(string(plain[0])) authz := norm.NFC.String(string(plain[0]))
username = norm.NFC.String(string(plain[1])) username = norm.NFC.String(string(plain[1]))
la.LoginAddress = username
password := string(plain[2]) password := string(plain[2])
if authz != "" && authz != username { if authz != "" && authz != username {
authResult = "badcreds" la.Result = store.AuthBadCredentials
xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "cannot assume other role") xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "cannot assume other role")
} }
var err error 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) { if err != nil && errors.Is(err, store.ErrUnknownCredentials) {
// ../rfc/4954:274 // ../rfc/4954:274
authResult = "badcreds" la.Result = store.AuthBadCredentials
c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP)) c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP))
xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "bad user/pass") xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "bad user/pass")
} }
@ -1369,7 +1413,7 @@ func (c *conn) cmdAuth(p *parser) {
// clients, see Internet-Draft (I-D): // clients, see Internet-Draft (I-D):
// https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00 // https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00
authVariant = "login" la.LoginAddress = "login"
// ../rfc/4954:343 // ../rfc/4954:343
// ../rfc/4954:326 // ../rfc/4954:326
@ -1386,6 +1430,7 @@ func (c *conn) cmdAuth(p *parser) {
encChal := base64.StdEncoding.EncodeToString([]byte("Username:")) encChal := base64.StdEncoding.EncodeToString([]byte("Username:"))
username = string(xreadInitial(encChal)) username = string(xreadInitial(encChal))
username = norm.NFC.String(username) username = norm.NFC.String(username)
la.LoginAddress = username
// Again, client should ignore the challenge, we send the same as the example in // Again, client should ignore the challenge, we send the same as the example in
// the I-D. // the I-D.
@ -1397,17 +1442,17 @@ func (c *conn) cmdAuth(p *parser) {
c.xtrace(mlog.LevelTrace) // Restore. c.xtrace(mlog.LevelTrace) // Restore.
var err error 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) { if err != nil && errors.Is(err, store.ErrUnknownCredentials) {
// ../rfc/4954:274 // ../rfc/4954:274
authResult = "badcreds" la.Result = store.AuthBadCredentials
c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP)) c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP))
xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "bad user/pass") xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "bad user/pass")
} }
xcheckf(err, "verifying credentials") xcheckf(err, "verifying credentials")
case "CRAM-MD5": case "CRAM-MD5":
authVariant = strings.ToLower(mech) la.AuthMech = strings.ToLower(mech)
p.xempty() p.xempty()
@ -1421,15 +1466,17 @@ func (c *conn) cmdAuth(p *parser) {
xsmtpUserErrorf(smtp.C501BadParamSyntax, smtp.SeProto5BadParams4, "malformed cram-md5 response") xsmtpUserErrorf(smtp.C501BadParamSyntax, smtp.SeProto5BadParams4, "malformed cram-md5 response")
} }
username = norm.NFC.String(t[0]) username = norm.NFC.String(t[0])
la.LoginAddress = username
c.log.Debug("cram-md5 auth", slog.String("username", username)) c.log.Debug("cram-md5 auth", slog.String("username", username))
var err error 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) { 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)) c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP))
xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "bad user/pass") xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "bad user/pass")
} }
xcheckf(err, "looking up address") xcheckf(err, "looking up address")
la.AccountName = account.Name
var ipadhash, opadhash hash.Hash var ipadhash, opadhash hash.Hash
account.WithRLock(func() { account.WithRLock(func() {
err := account.DB.Read(context.TODO(), func(tx *bstore.Tx) error { 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. // Passwords cannot be retrieved or replayed from the trace.
authVariant = strings.ToLower(mech) la.AuthMech = strings.ToLower(mech)
var h func() hash.Hash var h func() hash.Hash
switch authVariant { switch la.AuthMech {
case "scram-sha-1", "scram-sha-1-plus": case "scram-sha-1", "scram-sha-1-plus":
h = sha1.New h = sha1.New
case "scram-sha-256", "scram-sha-256-plus": case "scram-sha-256", "scram-sha-256-plus":
@ -1482,7 +1529,7 @@ func (c *conn) cmdAuth(p *parser) {
} }
var cs *tls.ConnectionState var cs *tls.ConnectionState
channelBindingRequired := strings.HasSuffix(authVariant, "-plus") channelBindingRequired := strings.HasSuffix(la.AuthMech, "-plus")
if channelBindingRequired && !c.tls { if channelBindingRequired && !c.tls {
// ../rfc/4954:630 // ../rfc/4954:630
xsmtpUserErrorf(smtp.C538EncReqForAuth, smtp.SePol7EncReqForAuth11, "scram plus mechanism requires tls connection") 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) xsmtpUserErrorf(smtp.C455BadParams, smtp.SePol7Other0, "scram protocol error: %s", err)
} }
username = norm.NFC.String(ss.Authentication) username = norm.NFC.String(ss.Authentication)
la.LoginAddress = username
c.log.Debug("scram auth", slog.String("authentication", 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 { if err != nil {
// todo: we could continue scram with a generated salt, deterministically generated // 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 // 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") xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "bad user/pass")
} }
xcheckf(err, "fetching credentials") xcheckf(err, "fetching credentials")
switch authVariant { switch la.AuthMech {
case "scram-sha-1", "scram-sha-1-plus": case "scram-sha-1", "scram-sha-1-plus":
xscram = password.SCRAMSHA1 xscram = password.SCRAMSHA1
case "scram-sha-256", "scram-sha-256-plus": case "scram-sha-256", "scram-sha-256-plus":
@ -1548,15 +1596,15 @@ func (c *conn) cmdAuth(p *parser) {
if err != nil { if err != nil {
c.readline() // Should be "*" for cancellation. c.readline() // Should be "*" for cancellation.
if errors.Is(err, scram.ErrInvalidProof) { 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)) c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP))
xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "bad credentials") xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "bad credentials")
} else if errors.Is(err, scram.ErrChannelBindingsDontMatch) { } 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)) 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") xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7MsgIntegrity7, "channel bindings do not match, potential mitm")
} else if errors.Is(err, scram.ErrInvalidEncoding) { } 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)) 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") xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7Other0, "bad scram protocol message")
} }
@ -1568,11 +1616,12 @@ func (c *conn) cmdAuth(p *parser) {
xreadContinuation() xreadContinuation()
case "EXTERNAL": case "EXTERNAL":
authVariant = strings.ToLower(mech) la.AuthMech = "external"
// ../rfc/4422:1618 // ../rfc/4422:1618
buf := xreadInitial("") buf := xreadInitial("")
username = string(buf) username = string(buf)
la.LoginAddress = username
if !c.tls { if !c.tls {
// ../rfc/4954:630 // ../rfc/4954:630
@ -1584,12 +1633,14 @@ func (c *conn) cmdAuth(p *parser) {
if username == "" { if username == "" {
username = c.username username = c.username
la.LoginAddress = username
} }
var err error 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") xcheckf(err, "looking up username from tls client authentication")
default: default:
la.AuthMech = "(unrecognized)"
// ../rfc/4954:176 // ../rfc/4954:176
xsmtpUserErrorf(smtp.C504ParamNotImpl, smtp.SeProto5BadParams4, "mechanism %s not supported", mech) 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 { if accConf, ok := account.Conf(); !ok {
xcheckf(errors.New("cannot find account"), "get account config") xcheckf(errors.New("cannot find account"), "get account config")
} else if accConf.LoginDisabled != "" { } else if accConf.LoginDisabled != "" {
authResult = "logindisabled" la.Result = store.AuthLoginDisabled
c.log.Info("account login disabled", slog.String("username", username)) c.log.Info("account login disabled", slog.String("username", username))
xsmtpUserErrorf(smtp.C525AccountDisabled, smtp.SePol7AccountDisabled13, "%w: %s", store.ErrLoginDisabled, accConf.LoginDisabled) 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 c.account != nil {
if account != c.account { if account != c.account {
c.log.Debug("sasl authentication for different account than tls client authentication, aborting connection", 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("saslaccount", account.Name),
slog.String("tlsaccount", c.account.Name), slog.String("tlsaccount", c.account.Name),
slog.String("saslusername", username), 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") xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "authentication failed, tls client certificate public key belongs to another account")
} else if username != c.username { } else if username != c.username {
c.log.Debug("sasl authentication for different username than tls client certificate authentication, switching to sasl 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("saslusername", username),
slog.String("tlsusername", c.username), slog.String("tlsusername", c.username),
slog.String("account", c.account.Name), slog.String("account", c.account.Name),
@ -1628,7 +1679,9 @@ func (c *conn) cmdAuth(p *parser) {
} }
c.username = username c.username = username
authResult = "ok" la.LoginAddress = c.username
la.AccountName = c.account.Name
la.Result = store.AuthSuccess
c.authSASL = true c.authSASL = true
c.authFailed = 0 c.authFailed = 0
c.setSlow(false) c.setSlow(false)

View File

@ -2222,48 +2222,50 @@ func manageAuthCache() {
// OpenEmailAuth opens an account given an email address and password. // OpenEmailAuth opens an account given an email address and password.
// //
// The email address may contain a catchall separator. // The email address may contain a catchall separator.
func OpenEmailAuth(log mlog.Log, email string, password string, checkLoginDisabled bool) (acc *Account, rerr error) { // For invalid credentials, a nil account is returned, but accName may be
password, err := precis.OpaqueString.String(password) // non-empty.
if err != nil { func OpenEmailAuth(log mlog.Log, email string, password string, checkLoginDisabled bool) (acc *Account, accName string, rerr error) {
return nil, ErrUnknownCredentials
}
// We check for LoginDisabled after verifying the password. Otherwise users can get // We check for LoginDisabled after verifying the password. Otherwise users can get
// messages about the account being disabled without knowing the password. // 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 { if rerr != nil {
return return
} }
defer func() { defer func() {
if rerr != nil && acc != nil { if rerr != nil {
err := acc.Close() err := acc.Close()
log.Check(err, "closing account after open auth failure") log.Check(err, "closing account after open auth failure")
acc = nil 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() pw, err := bstore.QueryDB[Password](context.TODO(), acc.DB).Get()
if err != nil { if err != nil {
if err == bstore.ErrAbsent { 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() authCache.Lock()
ok := len(password) >= 8 && authCache.success[authKey{email, pw.Hash}] == password ok := len(password) >= 8 && authCache.success[authKey{email, pw.Hash}] == password
authCache.Unlock() authCache.Unlock()
if !ok { if !ok {
if err := bcrypt.CompareHashAndPassword([]byte(pw.Hash), []byte(password)); err != nil { if err := bcrypt.CompareHashAndPassword([]byte(pw.Hash), []byte(password)); err != nil {
return acc, ErrUnknownCredentials return acc, accName, ErrUnknownCredentials
} }
} }
if checkLoginDisabled { if checkLoginDisabled {
conf, aok := acc.Conf() conf, aok := acc.Conf()
if !aok { 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 != "" { } 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() authCache.Lock()
@ -2275,22 +2277,24 @@ func OpenEmailAuth(log mlog.Log, email string, password string, checkLoginDisabl
// OpenEmail opens an account given an email address. // OpenEmail opens an account given an email address.
// //
// The email address may contain a catchall separator. // 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) addr, err := smtp.ParseAddress(email)
if err != nil { 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) 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)) { 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 { } 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) acc, err := OpenAccount(log, accountName, checkLoginDisabled)
if err != nil { 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 // 64 characters, must be power of 2 for MessagePath

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"os" "os"
"path/filepath" "path/filepath"
"reflect"
"regexp" "regexp"
"strings" "strings"
"testing" "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) { func TestMailbox(t *testing.T) {
log := mlog.New("store", nil) log := mlog.New("store", nil)
os.RemoveAll("../testdata/store/data") os.RemoveAll("../testdata/store/data")
@ -224,30 +232,30 @@ func TestMailbox(t *testing.T) {
// Run the auth tests twice for possible cache effects. // Run the auth tests twice for possible cache effects.
for i := 0; i < 2; i++ { 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 { if err != ErrUnknownCredentials {
t.Fatalf("got %v, expected ErrUnknownCredentials", err) t.Fatalf("got %v, expected ErrUnknownCredentials", err)
} }
} }
for i := 0; i < 2; i++ { 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") tcheck(t, err, "open for email with auth")
err = acc2.Close() err = acc2.Close()
tcheck(t, err, "close account") 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") tcheck(t, err, "open for email with auth")
err = acc2.Close() err = acc2.Close()
tcheck(t, err, "close account") tcheck(t, err, "close account")
_, err = OpenEmailAuth(log, "bogus@mox.example", "testtest", false) _, _, err = OpenEmailAuth(log, "bogus@mox.example", "testtest", false)
if err != ErrUnknownCredentials { if err != ErrUnknownCredentials {
t.Fatalf("got %v, expected ErrUnknownCredentials", err) 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 { if err != ErrUnknownCredentials {
t.Fatalf("got %v, expected ErrUnknownCredentials", err) t.Fatalf("got %v, expected ErrUnknownCredentials", err)
} }

78
store/init.go Normal file
View File

@ -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
}

361
store/loginattempt.go Normal file
View File

@ -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)
}

112
store/loginattempt_test.go Normal file
View File

@ -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)
}

View File

@ -9,16 +9,11 @@ import (
"crypto/x509" "crypto/x509"
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"os"
"path/filepath"
"strings" "strings"
"time" "time"
"github.com/mjl-/bstore" "github.com/mjl-/bstore"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/moxvar"
"github.com/mjl-/mox/smtp" "github.com/mjl-/mox/smtp"
) )
@ -43,34 +38,6 @@ type TLSPublicKey struct {
LoginAddress string `bstore:"nonzero"` // Must belong to account. 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 // ParseTLSPublicKeyCert parses a certificate, preparing a TLSPublicKey for
// insertion into the database. Caller must set fields that are not in the // insertion into the database. Caller must set fields that are not in the
// certificat, such as Account and LoginAddress. // certificat, such as Account and LoginAddress.

View File

@ -731,7 +731,7 @@ func (Account) TLSPublicKeyUpdate(ctx context.Context, pubKey store.TLSPublicKey
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo) reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
tpk := xtlspublickey(ctx, reqInfo.AccountName, pubKey.Fingerprint) tpk := xtlspublickey(ctx, reqInfo.AccountName, pubKey.Fingerprint)
log := pkglog.WithContext(ctx) 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 { if err == nil && acc.Name != reqInfo.AccountName {
err = store.ErrUnknownCredentials err = store.ErrUnknownCredentials
} }
@ -749,3 +749,10 @@ func (Account) TLSPublicKeyUpdate(ctx context.Context, pubKey store.TLSPublicKey
xcheckf(ctx, err, "updating tls public key") xcheckf(ctx, err, "updating tls public key")
return nil 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
}

View File

@ -255,8 +255,21 @@ var api;
// per-outgoing-message address used for sending. // per-outgoing-message address used for sending.
OutgoingEvent["EventUnrecognized"] = "unrecognized"; OutgoingEvent["EventUnrecognized"] = "unrecognized";
})(OutgoingEvent = api.OutgoingEvent || (api.OutgoingEvent = {})); })(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 }; // AuthResult is the result of a login attempt.
api.stringsTypes = { "CSRFToken": true, "Localpart": true, "OutgoingEvent": true }; 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.intsTypes = {};
api.types = { 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"] }] }, "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"] }] }, "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"] }] }, "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"] }] }, "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 }, "CSRFToken": { "Name": "CSRFToken", "Docs": "", "Values": null },
"Localpart": { "Name": "Localpart", "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": "" }] }, "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 = { api.parser = {
Account: (v) => api.parse("Account", v), Account: (v) => api.parse("Account", v),
@ -308,9 +323,11 @@ var api;
Structure: (v) => api.parse("Structure", v), Structure: (v) => api.parse("Structure", v),
IncomingMeta: (v) => api.parse("IncomingMeta", v), IncomingMeta: (v) => api.parse("IncomingMeta", v),
TLSPublicKey: (v) => api.parse("TLSPublicKey", v), TLSPublicKey: (v) => api.parse("TLSPublicKey", v),
LoginAttempt: (v) => api.parse("LoginAttempt", v),
CSRFToken: (v) => api.parse("CSRFToken", v), CSRFToken: (v) => api.parse("CSRFToken", v),
Localpart: (v) => api.parse("Localpart", v), Localpart: (v) => api.parse("Localpart", v),
OutgoingEvent: (v) => api.parse("OutgoingEvent", 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 // Account exports web API functions for the account web interface. All its
// methods are exported under api/. Function calls require valid HTTP // methods are exported under api/. Function calls require valid HTTP
@ -555,6 +572,13 @@ var api;
const params = [pubKey]; const params = [pubKey];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); 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.Client = Client;
api.defaultBaseURL = (function () { api.defaultBaseURL = (function () {
@ -1059,7 +1083,7 @@ const domainString = (d) => {
const box = (color, ...l) => [ const box = (color, ...l) => [
dom.div(style({ dom.div(style({
display: 'inline-block', display: 'inline-block',
padding: '.25em .5em', padding: '.125em .25em',
backgroundColor: color, backgroundColor: color,
borderRadius: '3px', borderRadius: '3px',
margin: '.5ex 0', margin: '.5ex 0',
@ -1122,9 +1146,10 @@ const formatQuotaSize = (v) => {
return '' + v; return '' + v;
}; };
const index = async () => { 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.Account(),
client.TLSPublicKeys(), client.TLSPublicKeys(),
client.LoginAttempts(10),
]); ]);
const tlspubkeys = tlspubkeys0 || []; const tlspubkeys = tlspubkeys0 || [];
let fullNameForm; 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.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() { 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))))); 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 = ''; 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) { })), ' ', 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(); e.preventDefault();
@ -1754,6 +1779,14 @@ openssl pkcs12 \\
})(); })();
return root; 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 destination = async (name) => {
const [acc] = await client.Account(); const [acc] = await client.Account();
let dest = (acc.Destinations || {})[name]; let dest = (acc.Destinations || {})[name];
@ -1885,6 +1918,9 @@ const init = async () => {
if (h === '') { if (h === '') {
root = await index(); root = await index();
} }
else if (t[0] === 'loginattempts' && t.length === 1) {
root = await loginattempts();
}
else if (t[0] === 'destinations' && t.length === 2) { else if (t[0] === 'destinations' && t.length === 2) {
root = await destination(t[1]); root = await destination(t[1]);
} }

View File

@ -229,7 +229,7 @@ const box = (color: string, ...l: ElemArg[]) => [
dom.div( dom.div(
style({ style({
display: 'inline-block', display: 'inline-block',
padding: '.25em .5em', padding: '.125em .25em',
backgroundColor: color, backgroundColor: color,
borderRadius: '3px', borderRadius: '3px',
margin: '.5ex 0', margin: '.5ex 0',
@ -298,9 +298,10 @@ const formatQuotaSize = (v: number) => {
} }
const index = async () => { 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.Account(),
client.TLSPublicKeys(), client.TLSPublicKeys(),
client.LoginAttempts(10),
]) ])
const tlspubkeys = tlspubkeys0 || [] const tlspubkeys = tlspubkeys0 || []
@ -821,6 +822,11 @@ const index = async () => {
), ),
dom.br(), 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'), dom.h2('Change password'),
passwordForm=dom.form( passwordForm=dom.form(
passwordFieldset=dom.fieldset( passwordFieldset=dom.fieldset(
@ -1628,6 +1634,62 @@ openssl pkcs12 \\
return root 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 destination = async (name: string) => {
const [acc] = await client.Account() const [acc] = await client.Account()
let dest = (acc.Destinations || {})[name] let dest = (acc.Destinations || {})[name]
@ -1881,6 +1943,8 @@ const init = async () => {
let root: HTMLElement let root: HTMLElement
if (h === '') { if (h === '') {
root = await index() root = await index()
} else if (t[0] === 'loginattempts' && t.length === 1) {
root = await loginattempts()
} else if (t[0] === 'destinations' && t.length === 2) { } else if (t[0] === 'destinations' && t.length === 2) {
root = await destination(t[1]) root = await destination(t[1])
} else { } else {

View File

@ -97,6 +97,12 @@ func TestAccount(t *testing.T) {
mox.ConfigStaticPath = filepath.FromSlash("../testdata/httpaccount/mox.conf") mox.ConfigStaticPath = filepath.FromSlash("../testdata/httpaccount/mox.conf")
mox.ConfigDynamicPath = filepath.Join(filepath.Dir(mox.ConfigStaticPath), "domains.conf") mox.ConfigDynamicPath = filepath.Join(filepath.Dir(mox.ConfigStaticPath), "domains.conf")
mox.MustLoadConfig(true, false) 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) log := mlog.New("webaccount", nil)
acc, err := store.OpenAccount(log, "mjl☺", false) acc, err := store.OpenAccount(log, "mjl☺", false)
tcheck(t, err, "open account") tcheck(t, err, "open account")
@ -511,13 +517,6 @@ func TestAccount(t *testing.T) {
tcheck(t, err, "encoding certificate as pem") tcheck(t, err, "encoding certificate as pem")
certPEM := b.String() 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) tpkl, err := api.TLSPublicKeys(ctx)
tcheck(t, err, "list tls public keys") tcheck(t, err, "list tls public keys")
tcompare(t, len(tpkl), 0) tcompare(t, len(tpkl), 0)

View File

@ -528,6 +528,27 @@
} }
], ],
"Returns": [] "Returns": []
},
{
"Name": "LoginAttempts",
"Docs": "",
"Params": [
{
"Name": "limit",
"Typewords": [
"int32"
]
}
],
"Returns": [
{
"Name": "r0",
"Typewords": [
"[]",
"LoginAttempt"
]
}
]
} }
], ],
"Sections": [], "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": [], "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." "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, "SherpaVersion": 0,

View File

@ -221,6 +221,28 @@ export interface TLSPublicKey {
LoginAddress: string // Must belong to account. 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 export type CSRFToken = string
// Localpart is a decoded local part of an email address, before the "@". // Localpart is a decoded local part of an email address, before the "@".
@ -255,8 +277,21 @@ export enum OutgoingEvent {
EventUnrecognized = "unrecognized", 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} // AuthResult is the result of a login attempt.
export const stringsTypes: {[typename: string]: boolean} = {"CSRFToken":true,"Localpart":true,"OutgoingEvent":true} 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 intsTypes: {[typename: string]: boolean} = {}
export const types: TypenameMap = { 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"]}]}, "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"]}]}, "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"]}]}, "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"]}]}, "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}, "CSRFToken": {"Name":"CSRFToken","Docs":"","Values":null},
"Localpart": {"Name":"Localpart","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":""}]}, "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 = { export const parser = {
@ -309,9 +346,11 @@ export const parser = {
Structure: (v: any) => parse("Structure", v) as Structure, Structure: (v: any) => parse("Structure", v) as Structure,
IncomingMeta: (v: any) => parse("IncomingMeta", v) as IncomingMeta, IncomingMeta: (v: any) => parse("IncomingMeta", v) as IncomingMeta,
TLSPublicKey: (v: any) => parse("TLSPublicKey", v) as TLSPublicKey, TLSPublicKey: (v: any) => parse("TLSPublicKey", v) as TLSPublicKey,
LoginAttempt: (v: any) => parse("LoginAttempt", v) as LoginAttempt,
CSRFToken: (v: any) => parse("CSRFToken", v) as CSRFToken, CSRFToken: (v: any) => parse("CSRFToken", v) as CSRFToken,
Localpart: (v: any) => parse("Localpart", v) as Localpart, Localpart: (v: any) => parse("Localpart", v) as Localpart,
OutgoingEvent: (v: any) => parse("OutgoingEvent", v) as OutgoingEvent, 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 // Account exports web API functions for the account web interface. All its
@ -586,6 +625,14 @@ export class Client {
const params: any[] = [pubKey] const params: any[] = [pubKey]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
} }
async LoginAttempts(limit: number): Promise<LoginAttempt[] | null> {
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() { export const defaultBaseURL = (function() {

View File

@ -2712,3 +2712,9 @@ func (Admin) AliasAddressesRemove(ctx context.Context, aliaslp string, domainNam
func (Admin) TLSPublicKeys(ctx context.Context, accountOpt string) ([]store.TLSPublicKey, error) { func (Admin) TLSPublicKeys(ctx context.Context, accountOpt string) ([]store.TLSPublicKey, error) {
return store.TLSPublicKeyList(ctx, accountOpt) 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
}

View File

@ -250,8 +250,21 @@ var api;
Mode["ModeTesting"] = "testing"; Mode["ModeTesting"] = "testing";
Mode["ModeNone"] = "none"; Mode["ModeNone"] = "none";
})(Mode = api.Mode || (api.Mode = {})); })(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 }; // AuthResult is the result of a login attempt.
api.stringsTypes = { "Align": true, "CSRFToken": true, "DMARCPolicy": true, "IP": true, "Localpart": true, "Mode": true, "RUA": true }; 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.intsTypes = {};
api.types = { 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"] }] }, "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"] }] }, "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"] }] }, "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"] }] }, "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 }, "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": "" }] }, "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": "" }] }, "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": "" }] }, "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 }, "Localpart": { "Name": "Localpart", "Docs": "", "Values": null },
"IP": { "Name": "IP", "Docs": "", "Values": [] }, "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 = { api.parser = {
CheckResult: (v) => api.parse("CheckResult", v), CheckResult: (v) => api.parse("CheckResult", v),
@ -483,6 +498,7 @@ var api;
TLSRPTSuppressAddress: (v) => api.parse("TLSRPTSuppressAddress", v), TLSRPTSuppressAddress: (v) => api.parse("TLSRPTSuppressAddress", v),
Dynamic: (v) => api.parse("Dynamic", v), Dynamic: (v) => api.parse("Dynamic", v),
TLSPublicKey: (v) => api.parse("TLSPublicKey", v), TLSPublicKey: (v) => api.parse("TLSPublicKey", v),
LoginAttempt: (v) => api.parse("LoginAttempt", v),
CSRFToken: (v) => api.parse("CSRFToken", v), CSRFToken: (v) => api.parse("CSRFToken", v),
DMARCPolicy: (v) => api.parse("DMARCPolicy", v), DMARCPolicy: (v) => api.parse("DMARCPolicy", v),
Align: (v) => api.parse("Align", v), Align: (v) => api.parse("Align", v),
@ -490,6 +506,7 @@ var api;
Mode: (v) => api.parse("Mode", v), Mode: (v) => api.parse("Mode", v),
Localpart: (v) => api.parse("Localpart", v), Localpart: (v) => api.parse("Localpart", v),
IP: (v) => api.parse("IP", 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 // Admin exports web API functions for the admin web interface. All its methods are
// exported under api/. Function calls require valid HTTP Authentication // exported under api/. Function calls require valid HTTP Authentication
@ -1318,6 +1335,13 @@ var api;
const params = [accountOpt]; const params = [accountOpt];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); 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.Client = Client;
api.defaultBaseURL = (function () { api.defaultBaseURL = (function () {
@ -2005,7 +2029,7 @@ const loglevels = async () => {
const box = (color, ...l) => [ const box = (color, ...l) => [
dom.div(style({ dom.div(style({
display: 'inline-block', display: 'inline-block',
padding: '.25em .5em', padding: '.125em .25em',
backgroundColor: color, backgroundColor: color,
borderRadius: '3px', borderRadius: '3px',
margin: '.5ex 0', margin: '.5ex 0',
@ -2019,9 +2043,10 @@ const inlineBox = (color, ...l) => dom.span(style({
borderRadius: '3px', borderRadius: '3px',
}), l); }), l);
const accounts = async () => { const accounts = async () => {
const [[accounts, accountsDisabled], domains] = await Promise.all([ const [[accounts, accountsDisabled], domains, loginAttempts] = await Promise.all([
client.Accounts(), client.Accounts(),
client.Domains(), client.Domains(),
client.LoginAttempts("", 10),
]); ]);
let fieldset; let fieldset;
let localpart; let localpart;
@ -2029,18 +2054,31 @@ const accounts = async () => {
let account; let account;
let accountModified = false; let accountModified = false;
return dom.div(crumbs(crumblink('Mox Admin', '#'), 'Accounts'), dom.h2('Accounts'), (accounts || []).length === 0 ? dom.p('No accounts') : 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.preventDefault();
e.stopPropagation(); e.stopPropagation();
await check(fieldset, client.AccountAdd(account.value, localpart.value + '@' + domain.value)); 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() { }, 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) { if (!accountModified) {
account.value = localpart.value; 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() { })), '@', 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; 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) => { const formatQuotaSize = (v) => {
if (v === 0) { if (v === 0) {
@ -2119,11 +2157,12 @@ const RoutesEditor = (kind, transports, routes, save) => {
return render(); return render();
}; };
const account = async (name) => { 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.Account(name),
client.Domains(), client.Domains(),
client.Transports(), client.Transports(),
client.TLSPublicKeys(name), client.TLSPublicKeys(name),
client.LoginAttempts(name, 10),
]); ]);
// todo: show suppression list, and buttons to add/remove entries. // todo: show suppression list, and buttons to add/remove entries.
let form; let form;
@ -2244,7 +2283,7 @@ const account = async (name) => {
close(); close();
window.location.reload(); // todo: update account and rerender. 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'))))); }, 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(); e.preventDefault();
if (!window.confirm('Are you sure you want to remove this account? All account data, including messages will be removed.')) { if (!window.confirm('Are you sure you want to remove this account? All account data, including messages will be removed.')) {
return; return;
@ -2389,7 +2428,7 @@ const domain = async (d) => {
window.location.reload(); // todo: reload only dkim section 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.<domain>.'), 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'))))); }, 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.<domain>.'), 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(); 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.')) { 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; return;
@ -2621,7 +2660,7 @@ const domainAlias = async (d, aliasLocalpart) => {
check(aliasFieldset, client.AliasUpdate(aliasLocalpart, d, postPublic.checked, listMembers.checked, allowMsgFrom.checked)); 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) => { }, 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]; 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])); await check(e.target, client.AliasAddressesRemove(aliasLocalpart, d, [address]));
window.location.reload(); // todo: reload less window.location.reload(); // todo: reload less
}))); })));
@ -4106,8 +4145,14 @@ const init = async () => {
else if (h === 'accounts') { else if (h === 'accounts') {
root = await accounts(); root = await accounts();
} }
else if (t[0] === 'accounts' && t.length === 2) { else if (h === 'accounts/loginattempts') {
root = await account(t[1]); 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) { else if (t[0] === 'domains' && t.length === 2) {
root = await domain(t[1]); root = await domain(t[1]);

View File

@ -561,7 +561,7 @@ const box = (color: string, ...l: ElemArg[]) => [
dom.div( dom.div(
style({ style({
display: 'inline-block', display: 'inline-block',
padding: '.25em .5em', padding: '.125em .25em',
backgroundColor: color, backgroundColor: color,
borderRadius: '3px', borderRadius: '3px',
margin: '.5ex 0', margin: '.5ex 0',
@ -582,9 +582,10 @@ const inlineBox = (color: string, ...l: ElemArg[]) =>
) )
const accounts = async () => { const accounts = async () => {
const [[accounts, accountsDisabled], domains] = await Promise.all([ const [[accounts, accountsDisabled], domains, loginAttempts] = await Promise.all([
client.Accounts(), client.Accounts(),
client.Domains(), client.Domains(),
client.LoginAttempts("", 10),
]) ])
let fieldset: HTMLFieldSetElement let fieldset: HTMLFieldSetElement
@ -601,7 +602,7 @@ const accounts = async () => {
dom.h2('Accounts'), dom.h2('Accounts'),
(accounts || []).length === 0 ? dom.p('No accounts') : (accounts || []).length === 0 ? dom.p('No accounts') :
dom.ul( 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.br(),
dom.h2('Add account'), dom.h2('Add account'),
@ -610,7 +611,7 @@ const accounts = async () => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
await check(fieldset, client.AccountAdd(account.value, localpart.value+'@'+domain.value)) 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( 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.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.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 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.Account(name),
client.Domains(), client.Domains(),
client.Transports(), client.Transports(),
client.TLSPublicKeys(name), client.TLSPublicKeys(name),
client.LoginAttempts(name, 10),
]) ])
// todo: show suppression list, and buttons to add/remove entries. // todo: show suppression list, and buttons to add/remove entries.
@ -1071,6 +1154,11 @@ const account = async (name: string) => {
}), }),
), ),
dom.br(), 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) { dom.clickbutton('Remove account', async function click(e: MouseEvent) {
e.preventDefault() e.preventDefault()
if (!window.confirm('Are you sure you want to remove this account? All account data, including messages will be removed.')) { 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 => Object.entries(localpartAccounts).map(t =>
dom.tr( dom.tr(
dom.td(prewrap(t[0]) || '(catchall)'), 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.td(
dom.clickbutton('Remove', async function click(e: MouseEvent) { dom.clickbutton('Remove', async function click(e: MouseEvent) {
e.preventDefault() e.preventDefault()
@ -1938,7 +2026,7 @@ const domainAlias = async (d: string, aliasLocalpart: string) => {
const pa = (alias.ParsedAddresses || [])[index] const pa = (alias.ParsedAddresses || [])[index]
return dom.tr( return dom.tr(
dom.td(prewrap(address)), 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.td(
dom.clickbutton('Remove', async function click(e: MouseEvent) { dom.clickbutton('Remove', async function click(e: MouseEvent) {
await check(e.target! as HTMLButtonElement, client.AliasAddressesRemove(aliasLocalpart, d, [address])) await check(e.target! as HTMLButtonElement, client.AliasAddressesRemove(aliasLocalpart, d, [address]))
@ -5227,8 +5315,12 @@ const init = async () => {
root = await loglevels() root = await loglevels()
} else if (h === 'accounts') { } else if (h === 'accounts') {
root = await accounts() root = await accounts()
} else if (t[0] === 'accounts' && t.length === 2) { } else if (h === 'accounts/loginattempts') {
root = await account(t[1]) 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) { } else if (t[0] === 'domains' && t.length === 2) {
root = await domain(t[1]) root = await domain(t[1])
} else if (t[0] === 'domains' && t.length === 4 && t[2] === 'alias') { } else if (t[0] === 'domains' && t.length === 4 && t[2] === 'alias') {

View File

@ -87,6 +87,12 @@ func TestAdminAuth(t *testing.T) {
mox.ConfigStaticPath = filepath.FromSlash("../testdata/webadmin/mox.conf") mox.ConfigStaticPath = filepath.FromSlash("../testdata/webadmin/mox.conf")
mox.ConfigDynamicPath = filepath.Join(filepath.Dir(mox.ConfigStaticPath), "domains.conf") mox.ConfigDynamicPath = filepath.Join(filepath.Dir(mox.ConfigStaticPath), "domains.conf")
mox.MustLoadConfig(true, false) 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) adminpwhash, err := bcrypt.GenerateFromPassword([]byte("moxtest123"), bcrypt.DefaultCost)
tcheck(t, err, "generate bcrypt hash") tcheck(t, err, "generate bcrypt hash")

View File

@ -2110,6 +2110,33 @@
] ]
} }
] ]
},
{
"Name": "LoginAttempts",
"Docs": "",
"Params": [
{
"Name": "accountName",
"Typewords": [
"string"
]
},
{
"Name": "limit",
"Typewords": [
"int32"
]
}
],
"Returns": [
{
"Name": "r0",
"Typewords": [
"[]",
"LoginAttempt"
]
}
]
} }
], ],
"Sections": [], "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": [], "Ints": [],
@ -7481,6 +7613,57 @@
"Name": "IP", "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.", "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": [] "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, "SherpaVersion": 0,

View File

@ -1068,6 +1068,28 @@ export interface TLSPublicKey {
LoginAddress: string // Must belong to account. 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 export type CSRFToken = string
// Policy as used in DMARC DNS record for "p=" or "sp=". // Policy as used in DMARC DNS record for "p=" or "sp=".
@ -1112,8 +1134,21 @@ export type Localpart = string
// be an IPv4 address. // be an IPv4 address.
export type IP = string 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} // AuthResult is the result of a login attempt.
export const stringsTypes: {[typename: string]: boolean} = {"Align":true,"CSRFToken":true,"DMARCPolicy":true,"IP":true,"Localpart":true,"Mode":true,"RUA":true} 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 intsTypes: {[typename: string]: boolean} = {}
export const types: TypenameMap = { 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"]}]}, "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"]}]}, "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"]}]}, "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"]}]}, "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}, "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":""}]}, "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":""}]}, "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":""}]}, "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}, "Localpart": {"Name":"Localpart","Docs":"","Values":null},
"IP": {"Name":"IP","Docs":"","Values":[]}, "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 = { export const parser = {
@ -1346,6 +1383,7 @@ export const parser = {
TLSRPTSuppressAddress: (v: any) => parse("TLSRPTSuppressAddress", v) as TLSRPTSuppressAddress, TLSRPTSuppressAddress: (v: any) => parse("TLSRPTSuppressAddress", v) as TLSRPTSuppressAddress,
Dynamic: (v: any) => parse("Dynamic", v) as Dynamic, Dynamic: (v: any) => parse("Dynamic", v) as Dynamic,
TLSPublicKey: (v: any) => parse("TLSPublicKey", v) as TLSPublicKey, TLSPublicKey: (v: any) => parse("TLSPublicKey", v) as TLSPublicKey,
LoginAttempt: (v: any) => parse("LoginAttempt", v) as LoginAttempt,
CSRFToken: (v: any) => parse("CSRFToken", v) as CSRFToken, CSRFToken: (v: any) => parse("CSRFToken", v) as CSRFToken,
DMARCPolicy: (v: any) => parse("DMARCPolicy", v) as DMARCPolicy, DMARCPolicy: (v: any) => parse("DMARCPolicy", v) as DMARCPolicy,
Align: (v: any) => parse("Align", v) as Align, Align: (v: any) => parse("Align", v) as Align,
@ -1353,6 +1391,7 @@ export const parser = {
Mode: (v: any) => parse("Mode", v) as Mode, Mode: (v: any) => parse("Mode", v) as Mode,
Localpart: (v: any) => parse("Localpart", v) as Localpart, Localpart: (v: any) => parse("Localpart", v) as Localpart,
IP: (v: any) => parse("IP", v) as IP, 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 // 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] const params: any[] = [accountOpt]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as TLSPublicKey[] | null return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as TLSPublicKey[] | null
} }
async LoginAttempts(accountName: string, limit: number): Promise<LoginAttempt[] | null> {
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() { export const defaultBaseURL = (function() {

View File

@ -18,6 +18,7 @@ import (
"log/slog" "log/slog"
"mime" "mime"
"mime/multipart" "mime/multipart"
"net"
"net/http" "net/http"
"net/textproto" "net/textproto"
"reflect" "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() { defer func() {
store.LoginAttemptAdd(context.Background(), log, la)
metricDuration.WithLabelValues(fn).Observe(float64(time.Since(t0)) / float64(time.Second)) metricDuration.WithLabelValues(fn).Observe(float64(time.Since(t0)) / float64(time.Second))
metrics.AuthenticationInc("webapi", "httpbasic", authResult)
}() }()
var err error var err error
acc, err = store.OpenEmailAuth(log, email, password, true) acc, la.AccountName, err = store.OpenEmailAuth(log, email, password, true)
if err != nil { if err != nil {
mox.LimiterFailedAuth.Add(remoteIP, t0, 1) 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) { 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") log.Debug("bad http basic authentication credentials")
metricResults.WithLabelValues(fn, "badauth").Inc() metricResults.WithLabelValues(fn, "badauth").Inc()
authResult = "badcreds" la.Result = store.AuthBadCredentials
msg := "use http basic auth with email address as username" msg := "use http basic auth with email address as username"
if errors.Is(err, store.ErrLoginDisabled) { if errors.Is(err, store.ErrLoginDisabled) {
authResult = "logindisabled" la.Result = store.AuthLoginDisabled
msg = "login is disabled for this account" msg = "login is disabled for this account"
} }
w.Header().Set("WWW-Authenticate", "Basic realm=webapi") 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"}) writeError(webapi.Error{Code: "server", Message: "error verifying credentials"})
return return
} }
authResult = "ok" la.AccountName = acc.Name
la.Result = store.AuthSuccess
mox.LimiterFailedAuth.Reset(remoteIP, t0) mox.LimiterFailedAuth.Reset(remoteIP, t0)
ct := r.Header.Get("Content-Type") 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) { func xcheckf(err error, format string, args ...any) {
if err != nil { if err != nil {
msg := fmt.Sprintf(format, args...) msg := fmt.Sprintf(format, args...)

View File

@ -61,8 +61,14 @@ func TestServer(t *testing.T) {
mox.Context = ctxbg mox.Context = ctxbg
mox.ConfigStaticPath = filepath.FromSlash("../testdata/webapisrv/mox.conf") mox.ConfigStaticPath = filepath.FromSlash("../testdata/webapisrv/mox.conf")
mox.MustLoadConfig(true, false) 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()() defer store.Switchboard()()
err := queue.Init() err = queue.Init()
tcheckf(t, err, "queue init") tcheckf(t, err, "queue init")
defer queue.Shutdown() defer queue.Shutdown()

View File

@ -15,19 +15,19 @@ var Accounts SessionAuth = accountSessionAuth{}
type accountSessionAuth struct{} type accountSessionAuth struct{}
func (accountSessionAuth) login(ctx context.Context, log mlog.Log, username, password string) (valid, disabled bool, accName string, rerr error) { 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) { 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) { } 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 { } else if err != nil {
return false, false, "", err return false, false, accName, err
} }
defer func() { defer func() {
err := acc.Close() err := acc.Close()
log.Check(err, "closing account") 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) { func (accountSessionAuth) add(ctx context.Context, log mlog.Log, accountName string, loginAddress string) (sessionToken store.SessionToken, csrfToken store.CSRFToken, rerr error) {

View File

@ -58,7 +58,7 @@ func (a *adminSessionAuth) login(ctx context.Context, log mlog.Log, username, pa
return false, false, "", nil 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) { func (a *adminSessionAuth) add(ctx context.Context, log mlog.Log, accountName string, loginAddress string) (sessionToken store.SessionToken, csrfToken store.CSRFToken, rerr error) {

View File

@ -77,6 +77,23 @@ type SessionAuth interface {
remove(ctx context.Context, log mlog.Log, accountName string, sessionToken store.SessionToken) error 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 // 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 // csrf in case requireCSRF is set (from header, unless formCSRF is set). Also
// performs rate limiting. // performs rate limiting.
@ -143,9 +160,9 @@ func Check(ctx context.Context, log mlog.Log, sessionAuth SessionAuth, kind stri
return return
} }
authResult := "badcreds" la := loginAttempt(r, kind, "websession")
defer func() { defer func() {
metrics.AuthenticationInc(kind, "websession", authResult) store.LoginAttemptAdd(context.Background(), log, la)
}() }()
// Cookie values are of the form: token SP accountname. // 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") respondAuthError("user:badAuth", "malformed session account name")
return "", "", "", false return "", "", "", false
} }
la.AccountName = accountName
loginAddress, err = sessionAuth.use(ctx, log, accountName, sessionToken, csrfToken) loginAddress, err = sessionAuth.use(ctx, log, accountName, sessionToken, csrfToken)
if err != nil { if err != nil {
la.Result = store.AuthBadCredentials
time.Sleep(BadAuthDelay) time.Sleep(BadAuthDelay)
respondAuthError("user:badAuth", err.Error()) respondAuthError("user:badAuth", err.Error())
return "", "", "", false return "", "", "", false
} }
la.LoginAddress = loginAddress
mox.LimiterFailedAuth.Reset(ip, start) mox.LimiterFailedAuth.Reset(ip, start)
authResult = "ok" la.Result = store.AuthSuccess
// Add to HTTP logging that this is an authenticated request. // Add to HTTP logging that this is an authenticated request.
if lw, ok := w.(interface{ AddAttr(a slog.Attr) }); ok { 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) 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() { defer func() {
metrics.AuthenticationInc(kind, "weblogin", authResult) store.LoginAttemptAdd(context.Background(), log, la)
}() }()
if disabled { if disabled {
authResult = "logindisabled" la.Result = store.AuthLoginDisabled
return "", &sherpa.Error{Code: "user:loginFailed", Message: err.Error()} return "", &sherpa.Error{Code: "user:loginFailed", Message: err.Error()}
} else if err != nil { } else if err != nil {
authResult = "error" la.Result = store.AuthError
return "", fmt.Errorf("evaluating login attempt: %v", err) return "", fmt.Errorf("evaluating login attempt: %v", err)
} else if !valid { } else if !valid {
time.Sleep(BadAuthDelay) time.Sleep(BadAuthDelay)
authResult = "badcreds" la.Result = store.AuthBadCredentials
return "", &sherpa.Error{Code: "user:loginFailed", Message: "invalid credentials"} return "", &sherpa.Error{Code: "user:loginFailed", Message: "invalid credentials"}
} }
authResult = "ok" la.Result = store.AuthSuccess
mox.LimiterFailedAuth.Reset(ip, start) mox.LimiterFailedAuth.Reset(ip, start)
sessionToken, csrfToken, err := sessionAuth.add(ctx, log, accountName, username) sessionToken, csrfToken, err := sessionAuth.add(ctx, log, accountName, username)
if err != nil { if err != nil {
la.Result = store.AuthError
log.Errorx("adding session after login", err) log.Errorx("adding session after login", err)
return "", fmt.Errorf("adding session: %v", err) return "", fmt.Errorf("adding session: %v", err)
} }

View File

@ -57,9 +57,15 @@ func TestAPI(t *testing.T) {
mox.ConfigDynamicPath = filepath.FromSlash("../testdata/webmail/domains.conf") mox.ConfigDynamicPath = filepath.FromSlash("../testdata/webmail/domains.conf")
mox.MustLoadConfig(true, false) mox.MustLoadConfig(true, false)
defer store.Switchboard()() 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) log := mlog.New("webmail", nil)
err := mtastsdb.Init(false) err = mtastsdb.Init(false)
tcheck(t, err, "mtastsdb init") tcheck(t, err, "mtastsdb init")
acc, err := store.OpenAccount(log, "mjl", false) acc, err := store.OpenAccount(log, "mjl", false)
tcheck(t, err, "open account") tcheck(t, err, "open account")
@ -113,7 +119,7 @@ func TestAPI(t *testing.T) {
x := recover() x := recover()
expErr := len(expErrCodes) > 0 expErr := len(expErrCodes) > 0
if (x != nil) != expErr { 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 { if x == nil {
return return

View File

@ -30,6 +30,12 @@ func TestView(t *testing.T) {
mox.ConfigStaticPath = filepath.FromSlash("../testdata/webmail/mox.conf") mox.ConfigStaticPath = filepath.FromSlash("../testdata/webmail/mox.conf")
mox.MustLoadConfig(true, false) mox.MustLoadConfig(true, false)
defer store.Switchboard()() 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) log := mlog.New("webmail", nil)
acc, err := store.OpenAccount(log, "mjl", false) acc, err := store.OpenAccount(log, "mjl", false)

View File

@ -307,6 +307,12 @@ func TestWebmail(t *testing.T) {
mox.ConfigStaticPath = filepath.FromSlash("../testdata/webmail/mox.conf") mox.ConfigStaticPath = filepath.FromSlash("../testdata/webmail/mox.conf")
mox.MustLoadConfig(true, false) mox.MustLoadConfig(true, false)
defer store.Switchboard()() 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) log := mlog.New("webmail", nil)
acc, err := store.OpenAccount(pkglog, "mjl", false) acc, err := store.OpenAccount(pkglog, "mjl", false)