2Package webauth handles authentication and session/csrf token management for
3the web interfaces (admin, account, mail).
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
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.
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
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).
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
48 "golang.org/x/text/unicode/norm"
50 "github.com/mjl-/sherpa"
52 "github.com/mjl-/mox/metrics"
53 "github.com/mjl-/mox/mlog"
54 "github.com/mjl-/mox/mox-"
55 "github.com/mjl-/mox/store"
58// Delay before responding in case of bad authentication attempt.
59var BadAuthDelay = time.Second
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)
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)
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
75 use(ctx context.Context, log mlog.Log, accountName string, sessionToken store.SessionToken, csrfToken store.CSRFToken) (loginAddress string, rerr error)
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
82// loginAttempt initializes a loginAttempt, for adding to the store after filling in the results and other details.
83func loginAttempt(remoteIP string, r *http.Request, protocol, authMech string) store.LoginAttempt {
84 return store.LoginAttempt{
86 TLS: store.LoginAttemptTLS(r.TLS),
89 UserAgent: r.UserAgent(),
90 Result: store.AuthError, // Replaced by caller.
94// Check authentication for a request based on session token in cookie and matching
95// csrf in case requireCSRF is set (from header, unless formCSRF is set). Also
96// performs rate limiting.
98// If the returned boolean is true, the request is authenticated. If the returned
99// boolean is false, an HTTP error response has already been returned. If rate
100// limiting applies (after too many failed authentication attempts), an HTTP status
101// 429 is returned. Otherwise, for API requests an error object with either code
102// "user:noAuth" or "user:badAuth" is returned. Other unauthenticated requests
103// result in HTTP status 403.
105// sessionAuth verifies login attempts and handles session management.
107// kind is used for the cookie name (webadmin, webaccount, webmail), and for
109func 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) {
110 // Respond with an authentication error.
111 respondAuthError := func(code, msg string) {
113 w.Header().Set("Content-Type", "application/json; charset=utf-8")
114 var result = struct {
115 Error sherpa.Error `json:"error"`
117 sherpa.Error{Code: code, Message: msg},
119 err := json.NewEncoder(w).Encode(result)
120 log.Check(err, "writing error response")
122 http.Error(w, "403 - forbidden - "+msg, http.StatusForbidden)
126 // The frontends cannot inject custom headers for all requests, e.g. images loaded
127 // as resources. For those, we don't require the CSRF and rely on the session
128 // cookie with samesite=strict.
129 // todo future: possibly get a session-tied value to use in paths for resources, and verify server-side that it matches the session token.
131 if requireCSRF && postFormCSRF {
132 csrfValue = r.PostFormValue("csrf")
134 csrfValue = r.Header.Get("x-mox-csrf")
136 csrfToken := store.CSRFToken(csrfValue)
137 if requireCSRF && csrfToken == "" {
138 respondAuthError("user:noAuth", "missing required csrf header")
139 return "", "", "", false
142 // Cookies are named "webmailsession", "webaccountsession", "webadminsession".
143 cookie, _ := r.Cookie(kind + "session")
145 respondAuthError("user:noAuth", fmt.Sprintf("no session for %q web interface", strings.TrimPrefix(kind, "web")))
146 return "", "", "", false
149 ip := RemoteIP(log, isForwarded, r)
151 respondAuthError("user:noAuth", "cannot find ip for rate limit check (missing x-forwarded-for header?)")
152 return "", "", "", false
155 if !mox.LimiterFailedAuth.Add(ip, start, 1) {
156 metrics.AuthenticationRatelimitedInc(kind)
157 http.Error(w, "429 - too many auth attempts", http.StatusTooManyRequests)
161 la := loginAttempt(ip.String(), r, kind, "websession")
163 store.LoginAttemptAdd(context.Background(), log, la)
166 // Cookie values are of the form: token SP accountname.
167 // For admin sessions, the accountname is empty (there is no login address either).
168 t := strings.SplitN(cookie.Value, " ", 2)
170 time.Sleep(BadAuthDelay)
171 respondAuthError("user:badAuth", "malformed session")
172 return "", "", "", false
174 sessionToken = store.SessionToken(t[0])
177 accountName, err = url.QueryUnescape(t[1])
179 time.Sleep(BadAuthDelay)
180 respondAuthError("user:badAuth", "malformed session account name")
181 return "", "", "", false
183 la.AccountName = accountName
185 loginAddress, err = sessionAuth.use(ctx, log, accountName, sessionToken, csrfToken)
187 la.Result = store.AuthBadCredentials
188 time.Sleep(BadAuthDelay)
189 respondAuthError("user:badAuth", err.Error())
190 return "", "", "", false
192 la.LoginAddress = loginAddress
194 mox.LimiterFailedAuth.Reset(ip, start)
195 la.Result = store.AuthSuccess
197 // Add to HTTP logging that this is an authenticated request.
198 if lw, ok := w.(interface{ AddAttr(a slog.Attr) }); ok {
199 lw.AddAttr(slog.String("authaccount", accountName))
201 return accountName, sessionToken, loginAddress, true
204func RemoteIP(log mlog.Log, isForwarded bool, r *http.Request) net.IP {
206 s := r.Header.Get("X-Forwarded-For")
207 ipstr := strings.TrimSpace(strings.Split(s, ",")[0])
208 return net.ParseIP(ipstr)
211 host, _, _ := net.SplitHostPort(r.RemoteAddr)
212 return net.ParseIP(host)
215func isHTTPS(isForwarded bool, r *http.Request) bool {
217 return r.Header.Get("X-Forwarded-Proto") == "https"
222// LoginPrep is an API call that returns a loginToken and also sets it as cookie
223// with the same value. The loginToken must be passed to a subsequent call to
224// Login, which will check that the loginToken and cookie are both present and
225// match before checking the actual login attempt. This would prevent a third party
226// site from triggering login attempts by the browser.
227func LoginPrep(ctx context.Context, log mlog.Log, kind, cookiePath string, isForwarded bool, w http.ResponseWriter, r *http.Request, token string) {
228 // todo future: we could sign the login token, and verify it on use, so subdomains cannot set it to known values.
230 http.SetCookie(w, &http.Cookie{
231 Name: kind + "login",
234 Secure: isHTTPS(isForwarded, r),
236 SameSite: http.SameSiteStrictMode,
237 MaxAge: 30, // Only for one login attempt.
241// Login handles a login attempt, checking against the rate limiter, verifying the
242// credentials through sessionAuth, and setting a session token cookie on the HTTP
243// response and returning the associated CSRF token.
245// In case of a user error, a *sherpa.Error is returned that sherpa handlers can
246// pass to panic. For bad credentials, the error code is "user:loginFailed".
247func 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) {
248 loginCookie, _ := r.Cookie(kind + "login")
249 if loginCookie == nil || loginCookie.Value != loginToken {
250 msg := "missing login token cookie"
251 if isForwarded && loginCookie == nil {
252 msg += " (hint: reverse proxy must keep path, for login cookie)"
254 return "", &sherpa.Error{Code: "user:error", Message: msg}
257 ip := RemoteIP(log, isForwarded, r)
259 return "", fmt.Errorf("cannot find ip for rate limit check (missing x-forwarded-for header?)")
262 if !mox.LimiterFailedAuth.Add(ip, start, 1) {
263 metrics.AuthenticationRatelimitedInc(kind)
264 return "", &sherpa.Error{Code: "user:error", Message: "too many authentication attempts"}
267 username = norm.NFC.String(username)
268 valid, disabled, accountName, err := sessionAuth.login(ctx, log, username, password)
269 la := loginAttempt(ip.String(), r, kind, "weblogin")
270 la.LoginAddress = username
271 la.AccountName = accountName
273 store.LoginAttemptAdd(context.Background(), log, la)
276 la.Result = store.AuthLoginDisabled
277 return "", &sherpa.Error{Code: "user:loginFailed", Message: err.Error()}
278 } else if err != nil {
279 la.Result = store.AuthError
280 return "", fmt.Errorf("evaluating login attempt: %v", err)
282 time.Sleep(BadAuthDelay)
283 la.Result = store.AuthBadCredentials
284 return "", &sherpa.Error{Code: "user:loginFailed", Message: "invalid credentials"}
286 la.Result = store.AuthSuccess
287 mox.LimiterFailedAuth.Reset(ip, start)
289 sessionToken, csrfToken, err := sessionAuth.add(ctx, log, accountName, username)
291 la.Result = store.AuthError
292 log.Errorx("adding session after login", err)
293 return "", fmt.Errorf("adding session: %v", err)
296 // Add session cookie.
297 http.SetCookie(w, &http.Cookie{
298 Name: kind + "session",
299 // Cookies values are ascii only, so we keep the account name query escaped.
300 Value: string(sessionToken) + " " + url.QueryEscape(accountName),
302 Secure: isHTTPS(isForwarded, r),
304 SameSite: http.SameSiteStrictMode,
305 // We don't set a max-age. These makes cookies per-session. Browsers are rarely
306 // restarted nowadays, and they have "continue where you left off", keeping session
307 // cookies. Our sessions are only valid for max 1 day. Convenience can come from
308 // the browser remembering the password.
310 // Remove cookie used during login.
311 http.SetCookie(w, &http.Cookie{
312 Name: kind + "login",
314 Secure: isHTTPS(isForwarded, r),
316 SameSite: http.SameSiteStrictMode,
317 MaxAge: -1, // Delete cookie
319 return csrfToken, nil
322// Logout removes the session token through sessionAuth, and clears the session
323// cookie through the HTTP response.
324func 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 {
325 err := sessionAuth.remove(ctx, log, accountName, sessionToken)
327 return fmt.Errorf("removing session: %w", err)
330 http.SetCookie(w, &http.Cookie{
331 Name: kind + "session",
333 Secure: isHTTPS(isForwarded, r),
335 SameSite: http.SameSiteStrictMode,
336 MaxAge: -1, // Delete cookie.