mirror of
https://github.com/mjl-/mox.git
synced 2025-07-12 17:44:35 +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:
@ -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.
|
||||
|
Reference in New Issue
Block a user