1/*
2Package webauth handles authentication and session/csrf token management for
3the web interfaces (admin, account, mail).
4
5Authentication of web requests is through a session token in a cookie. For API
6requests, and other requests where the frontend can send custom headers, a
7header ("x-mox-csrf") with a CSRF token is also required and verified to belong
8to the session token. For other form POSTS, a field "csrf" is required. Session
9tokens and CSRF tokens are different randomly generated values. Session cookies
10are "httponly", samesite "strict", and with the path set to the root of the
11webadmin/webaccount/webmail. Cookies set over HTTPS are marked "secure".
12Cookies don't have an expiration, they can be extended indefinitely by using
13them.
14
15To login, a call to LoginPrep must first be made. It sets a random login token
16in a cookie, and returns it. The loginToken must be passed to the Login call,
17along with login credentials. If the loginToken is missing, the login attempt
18fails before checking any credentials. This should prevent third party websites
19from tricking a browser into logging in.
20
21Sessions are stored server-side, and their lifetime automatically extended each
22time they are used. This makes it easy to invalidate existing sessions after a
23password change, and keeps the frontend free from handling long-term vs
24short-term sessions.
25
26Sessions for the admin interface have a lifetime of 12 hours after last use,
27are only stored in memory (don't survive a server restart), and only 10
28sessions can exist at a time (the oldest session is dropped).
29
30Sessions for the account and mail interfaces have a lifetime of 24 hours after
31last use, are kept in memory and stored in the database (do survive a server
32restart), and only 100 sessions can exist per account (the oldest session is
33dropped).
34*/
35package webauth
36
37import (
38 "context"
39 "encoding/json"
40 "fmt"
41 "log/slog"
42 "net"
43 "net/http"
44 "strings"
45 "time"
46
47 "github.com/mjl-/sherpa"
48
49 "github.com/mjl-/mox/metrics"
50 "github.com/mjl-/mox/mlog"
51 "github.com/mjl-/mox/mox-"
52 "github.com/mjl-/mox/store"
53)
54
55// Delay before responding in case of bad authentication attempt.
56var BadAuthDelay = time.Second
57
58// SessionAuth handles login and session storage, used for both account and
59// admin authentication.
60type SessionAuth interface {
61 login(ctx context.Context, log mlog.Log, username, password string) (valid bool, accountName string, rerr error)
62
63 // Add a new session for account and login address.
64 add(ctx context.Context, log mlog.Log, accountName string, loginAddress string) (sessionToken store.SessionToken, csrfToken store.CSRFToken, rerr error)
65
66 // Use an existing session. If csrfToken is empty, no CSRF check must be done.
67 // Otherwise the CSRF token must be associated with the session token, as returned
68 // by add. If the token is not valid (e.g. expired, unknown, malformed), an error
69 // must be returned.
70 use(ctx context.Context, log mlog.Log, accountName string, sessionToken store.SessionToken, csrfToken store.CSRFToken) (loginAddress string, rerr error)
71
72 // Removes a session, invalidating any future use. Must return an error if the
73 // session is not valid.
74 remove(ctx context.Context, log mlog.Log, accountName string, sessionToken store.SessionToken) error
75}
76
77// Check authentication for a request based on session token in cookie and matching
78// csrf in case requireCSRF is set (from header, unless formCSRF is set). Also
79// performs rate limiting.
80//
81// If the returned boolean is true, the request is authenticated. If the returned
82// boolean is false, an HTTP error response has already been returned. If rate
83// limiting applies (after too many failed authentication attempts), an HTTP status
84// 429 is returned. Otherwise, for API requests an error object with either code
85// "user:noAuth" or "user:badAuth" is returned. Other unauthenticated requests
86// result in HTTP status 403.
87//
88// sessionAuth verifies login attempts and handles session management.
89//
90// kind is used for the cookie name (webadmin, webaccount, webmail), and for
91// logging/metrics.
92func Check(ctx context.Context, log mlog.Log, sessionAuth SessionAuth, kind string, isForwarded bool, w http.ResponseWriter, r *http.Request, isAPI, requireCSRF, postFormCSRF bool) (accountName string, sessionToken store.SessionToken, loginAddress string, ok bool) {
93 // Respond with an authentication error.
94 respondAuthError := func(code, msg string) {
95 if isAPI {
96 w.Header().Set("Content-Type", "application/json; charset=utf-8")
97 var result = struct {
98 Error sherpa.Error `json:"error"`
99 }{
100 sherpa.Error{Code: code, Message: msg},
101 }
102 json.NewEncoder(w).Encode(result)
103 } else {
104 http.Error(w, "403 - forbidden - "+msg, http.StatusForbidden)
105 }
106 }
107
108 // The frontends cannot inject custom headers for all requests, e.g. images loaded
109 // as resources. For those, we don't require the CSRF and rely on the session
110 // cookie with samesite=strict.
111 // todo future: possibly get a session-tied value to use in paths for resources, and verify server-side that it matches the session token.
112 var csrfValue string
113 if requireCSRF && postFormCSRF {
114 csrfValue = r.PostFormValue("csrf")
115 } else {
116 csrfValue = r.Header.Get("x-mox-csrf")
117 }
118 csrfToken := store.CSRFToken(csrfValue)
119 if requireCSRF && csrfToken == "" {
120 respondAuthError("user:noAuth", "missing required csrf header")
121 return "", "", "", false
122 }
123
124 // Cookies are named "webmailsession", "webaccountsession", "webadminsession".
125 cookie, _ := r.Cookie(kind + "session")
126 if cookie == nil {
127 respondAuthError("user:noAuth", "no session")
128 return "", "", "", false
129 }
130
131 ip := remoteIP(log, isForwarded, r)
132 if ip == nil {
133 respondAuthError("user:noAuth", "cannot find ip for rate limit check (missing x-forwarded-for header?)")
134 return "", "", "", false
135 }
136 start := time.Now()
137 if !mox.LimiterFailedAuth.Add(ip, start, 1) {
138 metrics.AuthenticationRatelimitedInc(kind)
139 http.Error(w, "429 - too many auth attempts", http.StatusTooManyRequests)
140 return
141 }
142
143 authResult := "badcreds"
144 defer func() {
145 metrics.AuthenticationInc(kind, "websession", authResult)
146 }()
147
148 // Cookie values are of the form: token SP accountname.
149 // For admin sessions, the accountname is empty (there is no login address either).
150 t := strings.SplitN(cookie.Value, " ", 2)
151 if len(t) != 2 {
152 time.Sleep(BadAuthDelay)
153 respondAuthError("user:badAuth", "malformed session")
154 return "", "", "", false
155 }
156 sessionToken = store.SessionToken(t[0])
157 accountName = t[1]
158
159 var err error
160 loginAddress, err = sessionAuth.use(ctx, log, accountName, sessionToken, csrfToken)
161 if err != nil {
162 time.Sleep(BadAuthDelay)
163 respondAuthError("user:badAuth", err.Error())
164 return "", "", "", false
165 }
166
167 mox.LimiterFailedAuth.Reset(ip, start)
168 authResult = "ok"
169
170 // Add to HTTP logging that this is an authenticated request.
171 if lw, ok := w.(interface{ AddAttr(a slog.Attr) }); ok {
172 lw.AddAttr(slog.String("authaccount", accountName))
173 }
174 return accountName, sessionToken, loginAddress, true
175}
176
177func remoteIP(log mlog.Log, isForwarded bool, r *http.Request) net.IP {
178 if isForwarded {
179 s := r.Header.Get("X-Forwarded-For")
180 ipstr := strings.TrimSpace(strings.Split(s, ",")[0])
181 return net.ParseIP(ipstr)
182 }
183
184 host, _, _ := net.SplitHostPort(r.RemoteAddr)
185 return net.ParseIP(host)
186}
187
188func isHTTPS(isForwarded bool, r *http.Request) bool {
189 if isForwarded {
190 return r.Header.Get("X-Forwarded-Proto") == "https"
191 }
192 return r.TLS != nil
193}
194
195// LoginPrep is an API call that returns a loginToken and also sets it as cookie
196// with the same value. The loginToken must be passed to a subsequent call to
197// Login, which will check that the loginToken and cookie are both present and
198// match before checking the actual login attempt. This would prevent a third party
199// site from triggering login attempts by the browser.
200func LoginPrep(ctx context.Context, log mlog.Log, kind, cookiePath string, isForwarded bool, w http.ResponseWriter, r *http.Request, token string) {
201 // todo future: we could sign the login token, and verify it on use, so subdomains cannot set it to known values.
202
203 http.SetCookie(w, &http.Cookie{
204 Name: kind + "login",
205 Value: token,
206 Path: cookiePath,
207 Secure: isHTTPS(isForwarded, r),
208 HttpOnly: true,
209 SameSite: http.SameSiteStrictMode,
210 MaxAge: 30, // Only for one login attempt.
211 })
212}
213
214// Login handles a login attempt, checking against the rate limiter, verifying the
215// credentials through sessionAuth, and setting a session token cookie on the HTTP
216// response and returning the associated CSRF token.
217//
218// In case of a user error, a *sherpa.Error is returned that sherpa handlers can
219// pass to panic. For bad credentials, the error code is "user:loginFailed".
220func Login(ctx context.Context, log mlog.Log, sessionAuth SessionAuth, kind, cookiePath string, isForwarded bool, w http.ResponseWriter, r *http.Request, loginToken, username, password string) (store.CSRFToken, error) {
221 loginCookie, _ := r.Cookie(kind + "login")
222 if loginCookie == nil || loginCookie.Value != loginToken {
223 return "", &sherpa.Error{Code: "user:error", Message: "missing login token"}
224 }
225
226 ip := remoteIP(log, isForwarded, r)
227 if ip == nil {
228 return "", fmt.Errorf("cannot find ip for rate limit check (missing x-forwarded-for header?)")
229 }
230 start := time.Now()
231 if !mox.LimiterFailedAuth.Add(ip, start, 1) {
232 metrics.AuthenticationRatelimitedInc(kind)
233 return "", &sherpa.Error{Code: "user:error", Message: "too many authentication attempts"}
234 }
235
236 valid, accountName, err := sessionAuth.login(ctx, log, username, password)
237 var authResult string
238 defer func() {
239 metrics.AuthenticationInc(kind, "weblogin", authResult)
240 }()
241 if err != nil {
242 authResult = "error"
243 return "", fmt.Errorf("evaluating login attempt: %v", err)
244 } else if !valid {
245 time.Sleep(BadAuthDelay)
246 authResult = "badcreds"
247 return "", &sherpa.Error{Code: "user:loginFailed", Message: "invalid credentials"}
248 }
249 authResult = "ok"
250 mox.LimiterFailedAuth.Reset(ip, start)
251
252 sessionToken, csrfToken, err := sessionAuth.add(ctx, log, accountName, username)
253 if err != nil {
254 log.Errorx("adding session after login", err)
255 return "", fmt.Errorf("adding session: %v", err)
256 }
257
258 // Add session cookie.
259 http.SetCookie(w, &http.Cookie{
260 Name: kind + "session",
261 Value: string(sessionToken) + " " + accountName,
262 Path: cookiePath,
263 Secure: isHTTPS(isForwarded, r),
264 HttpOnly: true,
265 SameSite: http.SameSiteStrictMode,
266 // We don't set a max-age. These makes cookies per-session. Browsers are rarely
267 // restarted nowadays, and they have "continue where you left off", keeping session
268 // cookies. Our sessions are only valid for max 1 day. Convenience can come from
269 // the browser remembering the password.
270 })
271 // Remove cookie used during login.
272 http.SetCookie(w, &http.Cookie{
273 Name: kind + "login",
274 Path: cookiePath,
275 Secure: isHTTPS(isForwarded, r),
276 HttpOnly: true,
277 SameSite: http.SameSiteStrictMode,
278 MaxAge: -1, // Delete cookie
279 })
280 return csrfToken, nil
281}
282
283// Logout removes the session token through sessionAuth, and clears the session
284// cookie through the HTTP response.
285func Logout(ctx context.Context, log mlog.Log, sessionAuth SessionAuth, kind, cookiePath string, isForwarded bool, w http.ResponseWriter, r *http.Request, accountName string, sessionToken store.SessionToken) error {
286 err := sessionAuth.remove(ctx, log, accountName, sessionToken)
287 if err != nil {
288 return fmt.Errorf("removing session: %w", err)
289 }
290
291 http.SetCookie(w, &http.Cookie{
292 Name: kind + "session",
293 Path: cookiePath,
294 Secure: isHTTPS(isForwarded, r),
295 HttpOnly: true,
296 SameSite: http.SameSiteStrictMode,
297 MaxAge: -1, // Delete cookie.
298 })
299 return nil
300}
301