Go · 3760 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package middleware
4
5 import (
6 "context"
7 "net/http"
8 "net/url"
9 )
10
11 var currentUserKey = ctxKey{name: "current_user"}
12
13 // CurrentUser carries the loaded user identity in request context. Handlers
14 // pull this out via CurrentUserFromContext. Anonymous requests have ID == 0.
15 type CurrentUser struct {
16 ID int64
17 Username string
18 }
19
20 // IsAnonymous reports whether this is an unauthenticated request.
21 func (u CurrentUser) IsAnonymous() bool { return u.ID == 0 }
22
23 // UserLookup resolves a user_id into the data the auth middleware needs.
24 // epoch is the users.session_epoch column; the middleware compares it to
25 // the session's recorded epoch on every request so "log out everywhere"
26 // (which bumps the column) invalidates stale cookies on the next hit.
27 type UserLookup func(ctx context.Context, userID int64) (username string, epoch int32, err error)
28
29 // OptionalUser populates CurrentUser into context from the loaded session
30 // when present. Does not redirect or reject — pages that don't need a
31 // user (homepage, public repo views) just ignore an empty CurrentUser.
32 //
33 // When lookup returns successfully and the user's current session_epoch
34 // does NOT match the session's recorded epoch, the binding is skipped so
35 // the request looks anonymous. The stale cookie itself isn't actively
36 // cleared — RequireUser will redirect the next protected hit to /login,
37 // at which point a fresh session is minted with the current epoch.
38 //
39 // Pass nil to skip the lookup entirely (CurrentUser.Username will be
40 // empty and epoch checks are bypassed).
41 func OptionalUser(lookup UserLookup) func(http.Handler) http.Handler {
42 return func(next http.Handler) http.Handler {
43 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
44 ctx := r.Context()
45 s := SessionFromContext(ctx)
46 if s.UserID != 0 {
47 u := CurrentUser{ID: s.UserID}
48 bind := true
49 if lookup != nil {
50 if name, epoch, err := lookup(ctx, s.UserID); err == nil {
51 if epoch != s.Epoch {
52 bind = false
53 } else {
54 u.Username = name
55 }
56 }
57 }
58 if bind {
59 ctx = context.WithValue(ctx, currentUserKey, u)
60 }
61 }
62 next.ServeHTTP(w, r.WithContext(ctx))
63 })
64 }
65 }
66
67 // RequireUser is a stricter wrapper: redirects to /login (preserving the
68 // requested path in the `next` query param) when there is no user bound
69 // to the session. Pages that demand a user (settings, dashboard) compose
70 // this on top of OptionalUser.
71 func RequireUser(next http.Handler) http.Handler {
72 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
73 u := CurrentUserFromContext(r.Context())
74 if u.IsAnonymous() {
75 to := "/login"
76 if r.Method == http.MethodGet {
77 q := url.Values{"next": []string{r.URL.RequestURI()}}
78 to = "/login?" + q.Encode()
79 }
80 http.Redirect(w, r, to, http.StatusSeeOther)
81 return
82 }
83 next.ServeHTTP(w, r)
84 })
85 }
86
87 // CurrentUserFromContext returns the user bound to ctx by OptionalUser /
88 // RequireUser. Returns the zero value (anonymous) when no user is bound.
89 func CurrentUserFromContext(ctx context.Context) CurrentUser {
90 if u, ok := ctx.Value(currentUserKey).(CurrentUser); ok {
91 return u
92 }
93 return CurrentUser{}
94 }
95
96 // MaxBodySize returns middleware that caps r.Body at maxBytes via
97 // http.MaxBytesReader. Use this on auth POST endpoints (signup, login,
98 // password reset) so a misbehaving client can't ship a 10 MB password
99 // to weaponize the argon2 hashing path.
100 func MaxBodySize(maxBytes int64) func(http.Handler) http.Handler {
101 return func(next http.Handler) http.Handler {
102 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
103 r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
104 next.ServeHTTP(w, r)
105 })
106 }
107 }
108