mox/store/loginattempt.go
Mechiel Lukkien 1277d78cb1
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.
2025-02-06 14:16:13 +01:00

362 lines
10 KiB
Go

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