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