1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "context"
7 "errors"
8 "net/http"
9 "net/url"
10 "path"
11 "strconv"
12 "strings"
13 "time"
14
15 "github.com/a-h/templ"
16 "github.com/bnema/zerowrap"
17
18 "bnema.dev/ermite/internal/clientlimit"
19)
20
21// The owner's session on the web (ADR 0018).
22//
23// A browser signs in by opening the one-time link `ermite web login` printed:
24// GET /-/login/<nonce> shows a confirmation and consumes nothing, so link
25// previews and prefetchers cannot use it; POST /-/login/<nonce> consumes the
26// link and sets the cookie. The cookie holds a signed token; every request
27// that carries a valid one runs as the owner, every other as anonymous.
28//
29// State changes are POST only and pass one gate (ServeHTTP): an Origin equal to
30// the origin of the public URL, and a valid session, except the login POST,
31// which needs the link instead. The routes read the cookie and never the
32// Authorization header; the API does the opposite.
33
34// CookieName is the session cookie. The __Host- prefix makes browsers accept
35// it only with Secure, Path=/ and no Domain, and refuse it from a sibling
36// subdomain.
37const CookieName = "__Host-ermite"
38
39// loginPrefix is the path of login links, up to the nonce.
40const loginPrefix = "/-/login/"
41
42// ErrLinkRejected is what SessionAuth.Login returns for a link that is
43// unknown, expired or already used.
44var ErrLinkRejected = errors.New("login link rejected")
45
46// SessionAuth is the session side of the use cases, as the web needs it.
47type SessionAuth interface {
48 // LoginLinkValid reports whether the link is pending, without using it.
49 LoginLinkValid(nonce string) bool
50 // Login uses the link and returns the session token and how long it
51 // lasts, or ErrLinkRejected.
52 Login(ctx context.Context, nonce string) (token string, lifetime time.Duration, err error)
53 // Verify checks a session token. When it is valid it returns the context
54 // to serve the request with, which carries the owner as the caller of the
55 // API; when it is not, ok is false and err nil. An error means the check
56 // could not be made (the request then runs as anonymous).
57 Verify(ctx context.Context, token string) (owner context.Context, ok bool, err error)
58}
59
60// FailureLimiter counts failed logins per client, with the failed
61// authentications of the other routes. *clientlimit.Limiter fits.
62type FailureLimiter interface {
63 Blocked(ctx context.Context, addr string) (bool, time.Duration)
64 Fail(ctx context.Context, addr string)
65}
66
67type signedInKey struct{}
68
69// SignedIn reports whether the request carries a valid owner session. The
70// page is then private: it was rendered for the owner and must not be cached
71// by anyone else. Handlers of POST routes can rely on it: the gate refused
72// the request otherwise.
73func SignedIn(ctx context.Context) bool {
74 _, ok := ctx.Value(signedInKey{}).(struct{})
75 return ok
76}
77
78// sessionCache is the Cache-Control of a page rendered for the owner.
79const sessionCache = "private, no-store"
80
81// cacheHeaders sets Cache-Control: public for a visitor, and for the owner
82// the private variant with Vary: Cookie.
83//
84// A response that already carries no-store is kept as it is: withSession sets
85// it when it clears an invalid cookie, and a later public Cache-Control must
86// not undo it.
87func cacheHeaders(ctx context.Context, h http.Header, public, private string) {
88 if SignedIn(ctx) {
89 h.Set("Cache-Control", private)
90 varyCookie(h)
91 return
92 }
93 if h.Get("Cache-Control") == noStore {
94 return
95 }
96 h.Set("Cache-Control", public)
97}
98
99// noStore is the Cache-Control of an answer no cache may keep.
100const noStore = "no-store"
101
102// varyCookie adds Vary: Cookie unless the header already says so.
103func varyCookie(h http.Header) {
104 for _, v := range h.Values("Vary") {
105 for f := range strings.SplitSeq(v, ",") {
106 if t := strings.TrimSpace(f); t == "*" || strings.EqualFold(t, "Cookie") {
107 return
108 }
109 }
110 }
111 h.Add("Vary", "Cookie")
112}
113
114// logPath is the path of a request as the log may show it: a login link is
115// a secret. The decoded path is given, so the check is wide on purpose: a
116// path that only reaches the link after cleaning ("/x/../-/login/…",
117// "//-/login/…") or that holds the prefix further in is cut too.
118func logPath(p string) string {
119 if strings.HasPrefix(path.Clean(p), loginPrefix) || strings.Contains(p, loginPrefix) {
120 return loginPrefix + "…"
121 }
122 return p
123}
124
125// withSession resolves the session of the request: the cookie, when valid,
126// makes the request the owner's. A cookie that is not valid is cleared. A
127// request without a cookie is untouched.
128func (s *Server) withSession(w http.ResponseWriter, r *http.Request) *http.Request {
129 if s.cfg.Sessions == nil || strings.HasPrefix(r.URL.EscapedPath(), StaticPrefix) {
130 return r
131 }
132 c, err := r.Cookie(CookieName)
133 if err != nil {
134 return r
135 }
136 ctx := r.Context()
137 owner, ok, err := s.cfg.Sessions.Verify(ctx, c.Value)
138 switch {
139 case err != nil:
140 // Fail closed, and keep the cookie: the failure may be passing.
141 log := zerowrap.FromCtx(ctx)
142 log.Error().Err(err).Msg("check web session")
143 return r
144 case !ok:
145 // The answer depends on a cookie that is gone now: no cache keeps it
146 // for another visitor, and none serves it for a request with a cookie.
147 http.SetCookie(w, s.cookie("", -1))
148 h := w.Header()
149 h.Set("Cache-Control", noStore)
150 varyCookie(h)
151 return r
152 }
153 return r.WithContext(context.WithValue(owner, signedInKey{}, struct{}{}))
154}
155
156// sessionStillValid checks again the cookie a long-lived request started
157// with. It fails closed: a check that cannot be made ends the session of the
158// stream.
159func (s *Server) sessionStillValid(ctx context.Context, r *http.Request) bool {
160 c, err := r.Cookie(CookieName)
161 if err != nil {
162 return false
163 }
164 _, ok, err := s.cfg.Sessions.Verify(ctx, c.Value)
165 return err == nil && ok
166}
167
168// cookie builds the session cookie. maxAge < 0 deletes it.
169func (s *Server) cookie(token string, maxAge int) *http.Cookie {
170 // SameSite=Strict, as ADR 0018 says: the cookie is not sent when another
171 // site links to a page (that first page shows as signed out, the next
172 // navigation on the site is signed in), and not on any cross-site request,
173 // on top of the Origin check of the POST gate.
174 return &http.Cookie{
175 Name: CookieName, Value: token, Path: "/", MaxAge: maxAge,
176 Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode,
177 }
178}
179
180// gate refuses a state change that does not come from a page of this site
181// with a session. It answers 403 and returns false. GET and HEAD pass: they
182// never change anything.
183func (s *Server) gate(w http.ResponseWriter, r *http.Request) bool {
184 if r.Method == http.MethodGet || r.Method == http.MethodHead {
185 return true
186 }
187 // The Origin is compared with the configured public URL, never with the
188 // Host header, which the client chooses. A missing header, "null" and any
189 // other origin are refused.
190 if r.Header.Get("Origin") != s.origin {
191 denyForbidden(w)
192 return false
193 }
194 if !SignedIn(r.Context()) && (r.Method != http.MethodPost || !isLoginPath(r.URL)) {
195 denyForbidden(w)
196 return false
197 }
198 return true
199}
200
201// isLoginPath tells whether u is exactly a login link: loginPrefix, a nonce of
202// the right shape and nothing after it. It looks at the escaped path, which is
203// what the router matches: the decoded path would let "/-%2Flogin%2Fx/..." pass
204// for a link while the router sends it to another route.
205func isLoginPath(u *url.URL) bool {
206 nonce, ok := strings.CutPrefix(u.EscapedPath(), loginPrefix)
207 return ok && validNonce(nonce)
208}
209
210func denyForbidden(w http.ResponseWriter) {
211 w.Header().Set("Cache-Control", "no-store")
212 http.Error(w, "forbidden", http.StatusForbidden)
213}
214
215// validNonce tells whether s has the shape of a login link secret, so that
216// anything else is refused before it reaches the store.
217func validNonce(s string) bool {
218 if len(s) != nonceLen {
219 return false
220 }
221 for _, c := range []byte(s) {
222 switch {
223 case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '_':
224 default:
225 return false
226 }
227 }
228 return true
229}
230
231// nonceLen is the length of the secret of a login link: 32 bytes in unpadded
232// base64url.
233const nonceLen = 43
234
235// secretPage writes a page of the login flow: never cached, and its address,
236// which holds the link, is never sent along: strict-origin sends only the
237// origin. no-referrer would also turn the Origin of the sign-in POST into
238// "null" in browsers, which the POST gate refuses.
239func (s *Server) secretPage(w http.ResponseWriter, r *http.Request, status int, c templ.Component) {
240 h := w.Header()
241 h.Set("Cache-Control", "no-store")
242 h.Set("Referrer-Policy", "strict-origin")
243 s.write(r.Context(), w, r, status, c)
244}
245
246// loginConfirm is GET /-/login/<nonce>: it shows the confirmation and does not
247// use the link.
248func (s *Server) loginConfirm(w http.ResponseWriter, r *http.Request) {
249 nonce := r.PathValue("nonce")
250 if !validNonce(nonce) || !s.cfg.Sessions.LoginLinkValid(nonce) {
251 s.secretPage(w, r, http.StatusNotFound, s.loginRejectedPage())
252 return
253 }
254 l := s.stateLayout("sign in", "sign in · confirm", []Crumb{{Label: "-"}, {Label: "sign in"}})
255 s.secretPage(w, r, http.StatusOK, Login(l, LoginPage{
256 Title: "sign in as the owner",
257 Lines: []string{"this browser will be signed in for 12 hours.", "only confirm if you ran `ermite web login` just now."},
258 Action: loginPrefix + nonce,
259 Button: "[sign in]",
260 }))
261}
262
263// loginPost is POST /-/login/<nonce>: it uses the link, sets the cookie and
264// goes home. A link that does not work answers the same page whatever the
265// reason, and counts as a failed authentication of the client.
266func (s *Server) loginPost(w http.ResponseWriter, r *http.Request) {
267 ctx := r.Context()
268 addr := clientlimit.ClientAddr(r, s.cfg.TrustedProxies)
269 if blocked, wait := s.cfg.Limiter.Blocked(ctx, addr); blocked {
270 w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1))
271 s.secretPage(w, r, http.StatusTooManyRequests, s.stateOnly("sign in", "429", "too many attempts",
272 "too many failed sign-in attempts from this address.", "try again later."))
273 return
274 }
275 nonce := r.PathValue("nonce")
276 var token string
277 var lifetime time.Duration
278 err := ErrLinkRejected
279 if validNonce(nonce) {
280 token, lifetime, err = s.cfg.Sessions.Login(ctx, nonce)
281 }
282 switch {
283 case errors.Is(err, ErrLinkRejected):
284 s.cfg.Limiter.Fail(ctx, addr)
285 s.secretPage(w, r, http.StatusNotFound, s.loginRejectedPage())
286 return
287 case err != nil:
288 id := requestID()
289 log := zerowrap.FromCtx(ctx)
290 log.Error().Err(err).Str("request_id", id).Msg("web login failed")
291 s.secretPage(w, r, http.StatusInternalServerError, s.errorPage(id))
292 return
293 }
294 h := w.Header()
295 h.Del("Set-Cookie") // a stale cookie cleared by withSession
296 http.SetCookie(w, s.cookie(token, int(lifetime.Seconds())))
297 h.Set("Cache-Control", "no-store")
298 http.Redirect(w, r, "/", http.StatusSeeOther)
299}
300
301// logout is POST /-/logout: it ends the session of this browser. The token
302// itself stays valid until it expires, as a stateless token must; `ermite web
303// logout --all` ends them all.
304func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
305 w.Header().Del("Set-Cookie")
306 http.SetCookie(w, s.cookie("", -1))
307 w.Header().Set("Cache-Control", "no-store")
308 http.Redirect(w, r, "/", http.StatusSeeOther)
309}
310
311// stateOnly is a state page with a title and two lines, for the login flow.
312func (s *Server) stateOnly(crumb, code, title string, lines ...string) templ.Component {
313 l := s.stateLayout(title, code+" · "+title, []Crumb{{Label: "-"}, {Label: crumb}})
314 return State(l, StatePage{Code: code, Title: title, Lines: lines, Back: []Link{{Label: "home", Href: "/"}}})
315}
316
317// loginRejectedPage is the answer for a link that is unknown, expired or used.
318func (s *Server) loginRejectedPage() templ.Component {
319 return s.stateOnly("sign in", "404", "login link not valid",
320 "this link has expired, was already used, or never existed.", "run `ermite web login` for a new one.")
321}