5 cryptorand "crypto/rand"
14 "github.com/mjl-/bstore"
16 "github.com/mjl-/mox/metrics"
17 "github.com/mjl-/mox/mlog"
18 "github.com/mjl-/mox/mox-"
21const sessionsPerAccount = 100 // We remove the oldest when 100th is added.
22const sessionLifetime = 24 * time.Hour // Extended automatically by use.
23const sessionWriteDelay = 5 * time.Minute // Per account, for coalescing writes.
25var sessions = struct {
28 // For each account, we keep all sessions (with fixed maximum number) in memory. If
29 // the map for an account is nil, it is initialized from the database on first use.
30 accounts map[string]map[SessionToken]LoginSession
32 // We flush sessions with extended expiration timestamp to disk with a delay, to
33 // coalesce potentially many changes. The delay is short enough that we don't have
34 // to care about flushing to disk on shutdown.
35 pendingFlushes map[string]map[SessionToken]struct{}
37 accounts: map[string]map[SessionToken]LoginSession{},
38 pendingFlushes: map[string]map[SessionToken]struct{}{},
41// Ensure sessions for account are initialized from database. If the sessions were
42// initialized from the database, or when alwaysOpenAccount is true, an open
43// account is returned (assuming no error occurred).
45// must be called with sessions lock held.
46func ensureAccountSessions(ctx context.Context, log mlog.Log, accountName string, alwaysOpenAccount bool) (*Account, error) {
48 accSessions := sessions.accounts[accountName]
49 if accSessions == nil {
51 acc, err = OpenAccount(log, accountName)
56 // We still hold the lock, not great...
58 accSessions = map[SessionToken]LoginSession{}
59 err = bstore.QueryDB[LoginSession](ctx, acc.DB).ForEach(func(ls LoginSession) error {
60 // We keep strings around for easy comparison.
61 ls.sessionToken = SessionToken(base64.RawURLEncoding.EncodeToString(ls.SessionTokenBinary[:]))
62 ls.csrfToken = CSRFToken(base64.RawURLEncoding.EncodeToString(ls.CSRFTokenBinary[:]))
64 accSessions[ls.sessionToken] = ls
71 sessions.accounts[accountName] = accSessions
73 if acc == nil && alwaysOpenAccount {
74 return OpenAccount(log, accountName)
79// SessionUse checks if a session is valid. If csrfToken is the empty string, no
80// CSRF check is done. Otherwise it must be the csrf token associated with the
82func SessionUse(ctx context.Context, log mlog.Log, accountName string, sessionToken SessionToken, csrfToken CSRFToken) (LoginSession, error) {
84 defer sessions.Unlock()
86 acc, err := ensureAccountSessions(ctx, log, accountName, false)
88 return LoginSession{}, err
89 } else if acc != nil {
90 if err := acc.Close(); err != nil {
91 return LoginSession{}, fmt.Errorf("closing account: %w", err)
95 return sessionUse(ctx, log, accountName, sessionToken, csrfToken)
98// must be called with sessions lock held.
99func sessionUse(ctx context.Context, log mlog.Log, accountName string, sessionToken SessionToken, csrfToken CSRFToken) (LoginSession, error) {
101 ls, ok := sessions.accounts[accountName][sessionToken]
103 return LoginSession{}, fmt.Errorf("unknown session token")
104 } else if time.Until(ls.Expires) < 0 {
105 return LoginSession{}, fmt.Errorf("session expired")
106 } else if csrfToken != "" && csrfToken != ls.csrfToken {
107 return LoginSession{}, fmt.Errorf("mismatch between csrf and session tokens")
111 ls.Expires = time.Now().Add(sessionLifetime)
112 sessions.accounts[accountName][sessionToken] = ls
114 // If we haven't scheduled a flush to database yet, schedule one now.
115 if sessions.pendingFlushes[accountName] == nil {
116 sessions.pendingFlushes[accountName] = map[SessionToken]struct{}{}
118 pkglog := mlog.New("store", nil)
123 pkglog.Error("recover from panic", slog.Any("panic", x))
125 metrics.PanicInc(metrics.Store)
129 time.Sleep(sessionWriteDelay)
130 sessionsDelayedFlush(pkglog, accountName)
133 sessions.pendingFlushes[accountName][ls.sessionToken] = struct{}{}
138// wait, then flush all changed sessions for an account.
139func sessionsDelayedFlush(log mlog.Log, accountName string) {
141 defer sessions.Unlock()
143 sessionTokens := sessions.pendingFlushes[accountName]
144 delete(sessions.pendingFlushes, accountName)
146 _, ok := sessions.accounts[accountName]
148 // Account may have been removed. Nothing to flush.
152 acc, err := OpenAccount(log, accountName)
153 if err != nil && errors.Is(err, ErrAccountUnknown) {
154 // Account may have been removed. Nothing to flush.
155 log.Infox("flushing sessions for account", err, slog.String("account", accountName))
159 log.Errorx("open account for flushing changed session tokens", err, slog.String("account", accountName))
164 log.Check(err, "closing account")
167 err = acc.DB.Write(mox.Context, func(tx *bstore.Tx) error {
168 for sessionToken := range sessionTokens {
169 ls, ok := sessions.accounts[accountName][sessionToken]
171 return fmt.Errorf("unknown session token to flush")
173 if err := tx.Update(&ls); err != nil {
179 log.Check(err, "flushing changed sessions for account", slog.String("account", accountName))
182// SessionAddTokens adds a prepared or pre-existing LoginSession to the database and
183// cache. Can be used to restore a session token that was used to reset a password.
184func SessionAddToken(ctx context.Context, log mlog.Log, ls *LoginSession) error {
186 defer sessions.Unlock()
188 acc, err := ensureAccountSessions(ctx, log, ls.AccountName, true)
194 log.Check(err, "closing account after adding session token")
197 return sessionAddToken(ctx, log, acc, ls)
200// caller must hold sessions lock.
201func sessionAddToken(ctx context.Context, log mlog.Log, acc *Account, ls *LoginSession) error {
204 err := acc.DB.Write(ctx, func(tx *bstore.Tx) error {
205 // Remove sessions if we have too many, starting with expired sessions, and
206 // removing the oldest if needed.
207 if len(sessions.accounts[ls.AccountName]) >= sessionsPerAccount {
208 var oldest LoginSession
209 for _, ols := range sessions.accounts[ls.AccountName] {
210 if time.Until(ols.Expires) < 0 {
211 if err := tx.Delete(&ols); err != nil {
214 delete(sessions.accounts[ls.AccountName], ols.sessionToken)
217 if oldest.ID == 0 || ols.Expires.Before(oldest.Expires) {
221 if len(sessions.accounts[ls.AccountName]) >= sessionsPerAccount {
222 if err := tx.Delete(&oldest); err != nil {
225 delete(sessions.accounts[ls.AccountName], oldest.sessionToken)
229 if err := tx.Insert(ls); err != nil {
230 return fmt.Errorf("insert: %v", err)
237 sessions.accounts[ls.AccountName][ls.sessionToken] = *ls
241// SessionAdd creates a new session token, with csrf token, and adds it to the
242// database and in-memory session cache. If there are too many sessions, the oldest
244func SessionAdd(ctx context.Context, log mlog.Log, accountName, loginAddress string) (session SessionToken, csrf CSRFToken, rerr error) {
245 // Prepare new LoginSession.
246 ls := LoginSession{0, time.Time{}, time.Now().Add(sessionLifetime), [16]byte{}, [16]byte{}, accountName, loginAddress, "", ""}
247 if _, err := cryptorand.Read(ls.SessionTokenBinary[:]); err != nil {
250 if _, err := cryptorand.Read(ls.CSRFTokenBinary[:]); err != nil {
253 ls.sessionToken = SessionToken(base64.RawURLEncoding.EncodeToString(ls.SessionTokenBinary[:]))
254 ls.csrfToken = CSRFToken(base64.RawURLEncoding.EncodeToString(ls.CSRFTokenBinary[:]))
257 defer sessions.Unlock()
259 acc, err := ensureAccountSessions(ctx, log, accountName, true)
265 log.Check(err, "closing account")
268 if err := sessionAddToken(ctx, log, acc, &ls); err != nil {
272 return ls.sessionToken, ls.csrfToken, nil
275// SessionRemove removes a session from the database and in-memory cache. Future
276// operations using the session token will fail.
277func SessionRemove(ctx context.Context, log mlog.Log, accountName string, sessionToken SessionToken) error {
279 defer sessions.Unlock()
281 acc, err := ensureAccountSessions(ctx, log, accountName, true)
287 ls, ok := sessions.accounts[accountName][sessionToken]
289 return fmt.Errorf("unknown session token")
292 if err := acc.DB.Delete(ctx, &ls); err != nil {
296 delete(sessions.accounts[accountName], sessionToken)
297 pf := sessions.pendingFlushes[accountName]
299 delete(pf, sessionToken)
305// sessionRemoveAll removes all session tokens for an account. Useful after a password reset.
306func sessionRemoveAll(ctx context.Context, log mlog.Log, tx *bstore.Tx, accountName string) error {
308 defer sessions.Unlock()
310 if _, err := bstore.QueryTx[LoginSession](tx).Delete(); err != nil {
314 sessions.accounts[accountName] = map[SessionToken]LoginSession{}
315 if sessions.pendingFlushes[accountName] != nil {
316 sessions.pendingFlushes[accountName] = map[SessionToken]struct{}{}