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