| 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 |