1package store
2
3import (
4 "context"
5 "crypto/sha256"
6 "crypto/tls"
7 "fmt"
8 "log/slog"
9 "runtime/debug"
10 "time"
11
12 "github.com/mjl-/bstore"
13
14 "github.com/mjl-/mox/metrics"
15 "github.com/mjl-/mox/mlog"
16 "github.com/mjl-/mox/moxio"
17)
18
19var loginAttemptsMaxPerAccount = 10 * 1000 // Lower during tests.
20
21// LoginAttempt is a successful or failed login attempt, stored for auditing
22// purposes.
23//
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.
29 Key []byte
30
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.
35
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
39 // for an account.
40 AccountName string `bstore:"index AccountName+Last"`
41
42 LoginAddress string // Empty for attempts to login in as admin.
43 RemoteIP string
44 LocalIP string
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
50 Result AuthResult
51
52 log mlog.Log // For passing the logger to the goroutine that writes and logs.
53}
54
55func (a LoginAttempt) calculateKey() []byte {
56 h := sha256.New()
57 l := []string{
58 a.AccountName,
59 a.LoginAddress,
60 a.RemoteIP,
61 a.LocalIP,
62 a.TLS,
63 a.TLSPubKeyFingerprint,
64 a.Protocol,
65 a.UserAgent,
66 a.AuthMech,
67 string(a.Result),
68 }
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.
71 for _, s := range l {
72 h.Write([]byte(s))
73 }
74 return h.Sum(nil)
75}
76
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.
81
82 // Number of LoginAttempt records for login failures. For preventing unbounded
83 // growth of logs.
84 RecordsFailed int
85}
86
87// AuthResult is the result of a login attempt.
88type AuthResult string
89
90const (
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"
100)
101
102var writeLoginAttempt chan LoginAttempt
103var writeLoginAttemptStop chan chan struct{}
104
105func startLoginAttemptWriter() {
106 writeLoginAttempt = make(chan LoginAttempt, 100)
107 writeLoginAttemptStop = make(chan chan struct{})
108
109 process := func(la *LoginAttempt) {
110 var l []LoginAttempt
111 if la != nil {
112 l = []LoginAttempt{*la}
113 }
114 // Gather all that we can write now.
115 All:
116 for {
117 select {
118 case xla := <-writeLoginAttempt:
119 l = append(l, xla)
120 default:
121 break All
122 }
123 }
124
125 if len(l) > 0 {
126 loginAttemptWrite(l...)
127 }
128 }
129
130 go func() {
131 defer func() {
132 x := recover()
133 if x == nil {
134 return
135 }
136
137 mlog.New("store", nil).Error("unhandled panic in LoginAttemptAdd", slog.Any("err", x))
138 debug.PrintStack()
139 metrics.PanicInc(metrics.Store)
140 }()
141
142 for {
143 select {
144 case stopc := <-writeLoginAttemptStop:
145 process(nil)
146 stopc <- struct{}{}
147 return
148
149 case la := <-writeLoginAttempt:
150 process(&la)
151 }
152 }
153 }()
154}
155
156// LoginAttemptAdd logs a login attempt (with result), and upserts it in the
157// database and possibly cleans up old entries in the database.
158//
159// Use account name "(admin)" for admin logins.
160//
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))
165
166 a.log = log
167 // Send login attempt to writer. Only blocks if there are lots of login attempts.
168 writeLoginAttempt <- a
169}
170
171func loginAttemptWrite(l ...LoginAttempt) {
172 // Log on the way out, for "count" fetched from database.
173 defer func() {
174 for _, a := range l {
175 if a.AuthMech == "websession" {
176 // Prevent superfluous logging.
177 continue
178 }
179
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),
192 )
193 }
194 }()
195
196 for i := range l {
197 if l[i].AccountName == "" {
198 l[i].AccountName = "-"
199 }
200 l[i].Key = l[i].calculateKey()
201 }
202
203 err := AuthDB.Write(context.Background(), func(tx *bstore.Tx) error {
204 for i := range l {
205 err := loginAttemptWriteTx(tx, &l[i])
206 l[i].log.Check(err, "adding login attempt")
207 }
208 return nil
209 })
210 l[0].log.Check(err, "storing login attempt")
211}
212
213func loginAttemptWriteTx(tx *bstore.Tx, a *LoginAttempt) error {
214 xa := LoginAttempt{Key: a.Key}
215 var insert bool
216 if err := tx.Get(&xa); err == bstore.ErrAbsent {
217 a.First = time.Time{}
218 a.Count = 1
219 insert = true
220 if err := tx.Insert(a); err != nil {
221 return fmt.Errorf("inserting login attempt: %v", err)
222 }
223 } else if err != nil {
224 return fmt.Errorf("get loginattempt: %v", err)
225 } else {
226 log := a.log
227 last := a.Last
228 *a = xa
229 a.log = log
230 a.Last = last
231 if a.Last.IsZero() {
232 a.Last = time.Now()
233 }
234 a.Count++
235 if err := tx.Update(a); err != nil {
236 return fmt.Errorf("updating login attempt: %v", err)
237 }
238 }
239
240 // Update state with its RecordsFailed.
241 origstate := LoginAttemptState{AccountName: a.AccountName}
242 var newstate bool
243 if err := tx.Get(&origstate); err == bstore.ErrAbsent {
244 newstate = true
245 } else if err != nil {
246 return fmt.Errorf("get login attempt state: %v", err)
247 }
248 state := origstate
249 if insert && a.Result != AuthSuccess {
250 state.RecordsFailed++
251 }
252
253 if state.RecordsFailed > loginAttemptsMaxPerAccount {
254 q := bstore.QueryTx[LoginAttempt](tx)
255 q.FilterNonzero(LoginAttempt{AccountName: a.AccountName})
256 q.FilterNotEqual("Result", AuthSuccess)
257 q.SortAsc("Last")
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)
261 } else {
262 state.RecordsFailed -= n
263 }
264 }
265
266 if state == origstate {
267 return nil
268 }
269 if newstate {
270 if err := tx.Insert(&state); err != nil {
271 return fmt.Errorf("inserting login attempt state: %v", err)
272 }
273 return nil
274 }
275 if err := tx.Update(&state); err != nil {
276 return fmt.Errorf("updating login attempt state: %v", err)
277 }
278 return nil
279}
280
281// LoginAttemptCleanup removes any LoginAttempt entries older than 30 days, for
282// all accounts.
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))
288 q.Gather(&removed)
289 _, err := q.Delete()
290 if err != nil {
291 return fmt.Errorf("deleting old login attempts: %v", err)
292 }
293
294 deleted := map[string]int{}
295 for _, r := range removed {
296 if r.Result != AuthSuccess {
297 deleted[r.AccountName]++
298 }
299 }
300
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)
305 }
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)
309 }
310 }
311
312 return nil
313 })
314}
315
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})
321 _, err := q.Delete()
322 return err
323}
324
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) {
330 var l []LoginAttempt
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})
335 }
336 q.SortDesc("Last")
337 if limit > 0 {
338 q.Limit(limit)
339 }
340 var err error
341 l, err = q.List()
342 return err
343 })
344 return l, err
345}
346
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 {
350 if state == nil {
351 return ""
352 }
353
354 version, ciphersuite := moxio.TLSInfo(*state)
355 return fmt.Sprintf("version=%s ciphersuite=%s sni=%s resumed=%v alpn=%s",
356 version,
357 ciphersuite,
358 state.ServerName,
359 state.DidResume,
360 state.NegotiatedProtocol)
361}
362