12 "github.com/mjl-/bstore"
14 "github.com/mjl-/mox/metrics"
15 "github.com/mjl-/mox/mlog"
16 "github.com/mjl-/mox/moxio"
19var loginAttemptsMaxPerAccount = 10 * 1000 // Lower during tests.
21// LoginAttempt is a successful or failed login attempt, stored for auditing
24// At most 10000 failed attempts are stored per account, to prevent unbounded
25// growth of the database by third parties.
26type LoginAttempt struct {
27 // Hash of all fields after "Count" below. We store a single entry per key,
28 // updating its Last and Count fields.
31 // Last has an index for efficient removal of entries after 30 days.
32 Last time.Time `bstore:"nonzero,default now,index"`
33 First time.Time `bstore:"nonzero,default now"`
34 Count int64 // Number of login attempts for the combination of fields below.
36 // Admin logins use "(admin)". If no account is known, "-" is used.
37 // AccountName has an index for efficiently removing failed login attempts at the
38 // end of the list when there are too many, and for efficiently removing all records
40 AccountName string `bstore:"index AccountName+Last"`
42 LoginAddress string // Empty for attempts to login in as admin.
45 TLS string // Empty if no TLS, otherwise contains version, algorithm, properties, etc.
46 TLSPubKeyFingerprint string
47 Protocol string // "submission", "imap", "webmail", "webaccount", "webadmin"
48 UserAgent string // From HTTP header, or IMAP ID command.
49 AuthMech string // "plain", "login", "cram-md5", "scram-sha-256-plus", "(unrecognized)", etc
52 log mlog.Log // For passing the logger to the goroutine that writes and logs.
55func (a LoginAttempt) calculateKey() []byte {
63 a.TLSPubKeyFingerprint,
69 // We don't add field separators. It allows us to add fields in the future that are
70 // empty by default without changing existing keys.
77// LoginAttemptState keeps track of the number of failed LoginAttempt records
78// per account. For efficiently removing records beyond 10000.
79type LoginAttemptState struct {
80 AccountName string // "-" is used when no account is present, for unknown addresses.
82 // Number of LoginAttempt records for login failures. For preventing unbounded
87// AuthResult is the result of a login attempt.
91 AuthSuccess AuthResult = "ok"
92 AuthBadUser AuthResult = "baduser"
93 AuthBadPassword AuthResult = "badpassword"
94 AuthBadCredentials AuthResult = "badcreds"
95 AuthBadChannelBinding AuthResult = "badchanbind"
96 AuthBadProtocol AuthResult = "badprotocol"
97 AuthLoginDisabled AuthResult = "logindisabled"
98 AuthError AuthResult = "error"
99 AuthAborted AuthResult = "aborted"
102var writeLoginAttempt chan LoginAttempt
103var writeLoginAttemptStop chan chan struct{}
105func startLoginAttemptWriter() {
106 writeLoginAttempt = make(chan LoginAttempt, 100)
107 writeLoginAttemptStop = make(chan chan struct{})
109 process := func(la *LoginAttempt) {
112 l = []LoginAttempt{*la}
114 // Gather all that we can write now.
118 case xla := <-writeLoginAttempt:
126 loginAttemptWrite(l...)
137 mlog.New("store", nil).Error("unhandled panic in LoginAttemptAdd", slog.Any("err", x))
139 metrics.PanicInc(metrics.Store)
144 case stopc := <-writeLoginAttemptStop:
149 case la := <-writeLoginAttempt:
156// LoginAttemptAdd logs a login attempt (with result), and upserts it in the
157// database and possibly cleans up old entries in the database.
159// Use account name "(admin)" for admin logins.
161// Writes are done in a background routine, unless we are shutting down or when
162// there are many pending writes.
163func LoginAttemptAdd(ctx context.Context, log mlog.Log, a LoginAttempt) {
164 metrics.AuthenticationInc(a.Protocol, a.AuthMech, string(a.Result))
167 // Send login attempt to writer. Only blocks if there are lots of login attempts.
168 writeLoginAttempt <- a
171func loginAttemptWrite(l ...LoginAttempt) {
172 // Log on the way out, for "count" fetched from database.
174 for _, a := range l {
175 if a.AuthMech == "websession" {
176 // Prevent superfluous logging.
180 a.log.Info("login attempt",
181 slog.String("address", a.LoginAddress),
182 slog.String("account", a.AccountName),
183 slog.String("protocol", a.Protocol),
184 slog.String("authmech", a.AuthMech),
185 slog.String("result", string(a.Result)),
186 slog.String("clientip", a.RemoteIP),
187 slog.String("localip", a.LocalIP),
188 slog.String("tls", a.TLS),
189 slog.String("useragent", a.UserAgent),
190 slog.String("tlspubkeyfp", a.TLSPubKeyFingerprint),
191 slog.Int64("count", a.Count),
197 if l[i].AccountName == "" {
198 l[i].AccountName = "-"
200 l[i].Key = l[i].calculateKey()
203 err := AuthDB.Write(context.Background(), func(tx *bstore.Tx) error {
205 err := loginAttemptWriteTx(tx, &l[i])
206 l[i].log.Check(err, "adding login attempt")
210 l[0].log.Check(err, "storing login attempt")
213func loginAttemptWriteTx(tx *bstore.Tx, a *LoginAttempt) error {
214 xa := LoginAttempt{Key: a.Key}
216 if err := tx.Get(&xa); err == bstore.ErrAbsent {
217 a.First = time.Time{}
220 if err := tx.Insert(a); err != nil {
221 return fmt.Errorf("inserting login attempt: %v", err)
223 } else if err != nil {
224 return fmt.Errorf("get loginattempt: %v", err)
235 if err := tx.Update(a); err != nil {
236 return fmt.Errorf("updating login attempt: %v", err)
240 // Update state with its RecordsFailed.
241 origstate := LoginAttemptState{AccountName: a.AccountName}
243 if err := tx.Get(&origstate); err == bstore.ErrAbsent {
245 } else if err != nil {
246 return fmt.Errorf("get login attempt state: %v", err)
249 if insert && a.Result != AuthSuccess {
250 state.RecordsFailed++
253 if state.RecordsFailed > loginAttemptsMaxPerAccount {
254 q := bstore.QueryTx[LoginAttempt](tx)
255 q.FilterNonzero(LoginAttempt{AccountName: a.AccountName})
256 q.FilterNotEqual("Result", AuthSuccess)
258 q.Limit(state.RecordsFailed - loginAttemptsMaxPerAccount)
259 if n, err := q.Delete(); err != nil {
260 return fmt.Errorf("deleting too many failed login attempts: %v", err)
262 state.RecordsFailed -= n
266 if state == origstate {
270 if err := tx.Insert(&state); err != nil {
271 return fmt.Errorf("inserting login attempt state: %v", err)
275 if err := tx.Update(&state); err != nil {
276 return fmt.Errorf("updating login attempt state: %v", err)
281// LoginAttemptCleanup removes any LoginAttempt entries older than 30 days, for
283func LoginAttemptCleanup(ctx context.Context) error {
284 return AuthDB.Write(ctx, func(tx *bstore.Tx) error {
285 var removed []LoginAttempt
286 q := bstore.QueryTx[LoginAttempt](tx)
287 q.FilterLess("Last", time.Now().Add(-30*24*time.Hour))
291 return fmt.Errorf("deleting old login attempts: %v", err)
294 deleted := map[string]int{}
295 for _, r := range removed {
296 if r.Result != AuthSuccess {
297 deleted[r.AccountName]++
301 for accName, n := range deleted {
302 state := LoginAttemptState{AccountName: accName}
303 if err := tx.Get(&state); err != nil {
304 return fmt.Errorf("get login attempt state for account %v: %v", accName, err)
306 state.RecordsFailed -= n
307 if err := tx.Update(&state); err != nil {
308 return fmt.Errorf("update login attempt state for account %v: %v", accName, err)
316// loginAttemptRemoveAccount removes all LoginAttempt records for an account
317// (value must be non-empty).
318func loginAttemptRemoveAccount(tx *bstore.Tx, accountName string) error {
319 q := bstore.QueryTx[LoginAttempt](tx)
320 q.FilterNonzero(LoginAttempt{AccountName: accountName})
325// LoginAttemptList returns LoginAttempt records for the accountName. If
326// accountName is empty, all records are returned. Use "(admin)" for admin
327// logins. Use "-" for login attempts for which no account was found.
328// If limit is greater than 0, at most limit records, most recent first, are returned.
329func LoginAttemptList(ctx context.Context, accountName string, limit int) ([]LoginAttempt, error) {
331 err := AuthDB.Read(ctx, func(tx *bstore.Tx) error {
332 q := bstore.QueryTx[LoginAttempt](tx)
333 if accountName != "" {
334 q.FilterNonzero(LoginAttempt{AccountName: accountName})
347// LoginAttemptTLS returns a string for use as LoginAttempt.TLS. Returns an empty
348// string if "c" is not a TLS connection.
349func LoginAttemptTLS(state *tls.ConnectionState) string {
354 version, ciphersuite := moxio.TLSInfo(*state)
355 return fmt.Sprintf("version=%s ciphersuite=%s sni=%s resumed=%v alpn=%s",
360 state.NegotiatedProtocol)