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