1package webauth
2
3import (
4 "context"
5 cryptorand "crypto/rand"
6 "encoding/base64"
7 "fmt"
8 "os"
9 "strings"
10 "sync"
11 "time"
12
13 "golang.org/x/crypto/bcrypt"
14 "golang.org/x/text/secure/precis"
15
16 "github.com/mjl-/mox/mlog"
17 "github.com/mjl-/mox/mox-"
18 "github.com/mjl-/mox/store"
19)
20
21// Admin is for admin logins, with authentication by password, and sessions only
22// stored in memory only, with lifetime 12 hour after last use, with a maximum of
23// 10 active sessions.
24var Admin SessionAuth = &adminSessionAuth{
25 sessions: map[store.SessionToken]adminSession{},
26}
27
28// Good chance of fitting one working day.
29const adminSessionLifetime = 12 * time.Hour
30
31type adminSession struct {
32 sessionToken store.SessionToken
33 csrfToken store.CSRFToken
34 expires time.Time
35}
36
37type adminSessionAuth struct {
38 sync.Mutex
39 sessions map[store.SessionToken]adminSession
40}
41
42func (a *adminSessionAuth) login(ctx context.Context, log mlog.Log, username, password string) (bool, string, error) {
43 a.Lock()
44 defer a.Unlock()
45
46 p := mox.ConfigDirPath(mox.Conf.Static.AdminPasswordFile)
47 buf, err := os.ReadFile(p)
48 if err != nil {
49 return false, "", fmt.Errorf("reading password file: %v", err)
50 }
51 passwordhash := strings.TrimSpace(string(buf))
52 // Transform with precis, if valid. ../rfc/8265:679
53 pw, err := precis.OpaqueString.String(password)
54 if err == nil {
55 password = pw
56 }
57 if err := bcrypt.CompareHashAndPassword([]byte(passwordhash), []byte(password)); err != nil {
58 return false, "", nil
59 }
60
61 return true, "", nil
62}
63
64func (a *adminSessionAuth) add(ctx context.Context, log mlog.Log, accountName string, loginAddress string) (sessionToken store.SessionToken, csrfToken store.CSRFToken, rerr error) {
65 a.Lock()
66 defer a.Unlock()
67
68 // Cleanup expired sessions.
69 for st, s := range a.sessions {
70 if time.Until(s.expires) < 0 {
71 delete(a.sessions, st)
72 }
73 }
74
75 // Ensure we have at most 10 sessions.
76 if len(a.sessions) > 10 {
77 var oldest *store.SessionToken
78 for _, s := range a.sessions {
79 if oldest == nil || s.expires.Before(a.sessions[*oldest].expires) {
80 oldest = &s.sessionToken
81 }
82 }
83 delete(a.sessions, *oldest)
84 }
85
86 // Generate new tokens.
87 var sessionData, csrfData [16]byte
88 if _, err := cryptorand.Read(sessionData[:]); err != nil {
89 return "", "", err
90 }
91 if _, err := cryptorand.Read(csrfData[:]); err != nil {
92 return "", "", err
93 }
94 sessionToken = store.SessionToken(base64.RawURLEncoding.EncodeToString(sessionData[:]))
95 csrfToken = store.CSRFToken(base64.RawURLEncoding.EncodeToString(csrfData[:]))
96
97 // Register session.
98 a.sessions[sessionToken] = adminSession{sessionToken, csrfToken, time.Now().Add(adminSessionLifetime)}
99 return sessionToken, csrfToken, nil
100}
101
102func (a *adminSessionAuth) use(ctx context.Context, log mlog.Log, accountName string, sessionToken store.SessionToken, csrfToken store.CSRFToken) (loginAddress string, rerr error) {
103 a.Lock()
104 defer a.Unlock()
105
106 s, ok := a.sessions[sessionToken]
107 if !ok {
108 return "", fmt.Errorf("unknown session (due to server restart or 10 new admin sessions)")
109 } else if time.Until(s.expires) < 0 {
110 return "", fmt.Errorf("session expired (after 12 hours inactivity)")
111 } else if csrfToken != "" && csrfToken != s.csrfToken {
112 return "", fmt.Errorf("mismatch between csrf and session tokens")
113 }
114 s.expires = time.Now().Add(adminSessionLifetime)
115 a.sessions[sessionToken] = s
116 return "", nil
117}
118
119func (a *adminSessionAuth) remove(ctx context.Context, log mlog.Log, accountName string, sessionToken store.SessionToken) error {
120 a.Lock()
121 defer a.Unlock()
122
123 if _, ok := a.sessions[sessionToken]; !ok {
124 return fmt.Errorf("unknown session")
125 }
126 delete(a.sessions, sessionToken)
127 return nil
128}
129