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