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 confirmation and consumes nothing, so link
27// previews and prefetchers cannot use it; POST /-/login/<nonce> consumes the
28// link and sets the cookie. The cookie holds a signed token; every request
29// that carries a valid one runs as the owner, every other as anonymous.
30//
31// State changes are POST only and pass one gate (ServeHTTP): an Origin equal to
32// the origin of the public URL, and a valid session, except the login POST,
33// which needs the link instead. The routes read the cookie and never the
34// Authorization header; the API does the opposite, it reads the header and no cookie.
35
36// CookieName is the session cookie. The __Host- prefix makes browsers accept
37// it only with Secure, Path=/ and no Domain, and refuse it from a sibling
38// subdomain.
39const CookieName = "__Host-ermite"
40
41// loginPrefix is the path of login links, up to the nonce.
42const loginPrefix = "/-/login/"
43
44type signedInKey struct{}
45
46// SignedIn reports whether the request carries a valid owner session. The
47// page is then private: it was rendered for the owner and must not be cached
48// by anyone else. Handlers of POST routes can rely on it: the gate refused
49// the request otherwise.
50func SignedIn(ctx context.Context) bool {
51 _, ok := ctx.Value(signedInKey{}).(struct{})
52 return ok
53}
54
55// sessionCache is the Cache-Control of a page rendered for the owner.
56const sessionCache = "private, no-store"
57
58// cacheHeaders sets Cache-Control: public for a visitor, and for the owner
59// the private variant with Vary: Cookie.
60//
61// A response that already carries no-store is kept as it is: withSession sets
62// it when it clears an invalid cookie, and a later public Cache-Control must
63// not undo it.
64func cacheHeaders(ctx context.Context, h http.Header, public, private string) {
65 if SignedIn(ctx) {
66 h.Set("Cache-Control", private)
67 varyCookie(h)
68 return
69 }
70 if h.Get("Cache-Control") == noStore {
71 return
72 }
73 h.Set("Cache-Control", public)
74}
75
76// noStore is the Cache-Control of an answer no cache may keep.
77const noStore = "no-store"
78
79// varyCookie adds Vary: Cookie unless the header already says so.
80func varyCookie(h http.Header) {
81 for _, v := range h.Values("Vary") {
82 for f := range strings.SplitSeq(v, ",") {
83 if t := strings.TrimSpace(f); t == "*" || strings.EqualFold(t, "Cookie") {
84 return
85 }
86 }
87 }
88 h.Add("Vary", "Cookie")
89}
90
91// logPath is the path of a request as the log may show it: a login link is
92// a secret. The decoded path is given, so the check is wide on purpose: a
93// path that only reaches the link after cleaning ("/x/../-/login/…",
94// "//-/login/…") or that holds the prefix further in is cut too.
95func logPath(p string) string {
96 if strings.HasPrefix(path.Clean(p), loginPrefix) || strings.Contains(p, loginPrefix) {
97 return loginPrefix + "…"
98 }
99 return p
100}
101
102// withSession resolves the session of the request: the cookie, when valid,
103// makes the request the owner's. A cookie that is not valid is cleared. A
104// request without a cookie is untouched.
105func (s *Server) withSession(w http.ResponseWriter, r *http.Request) *http.Request {
106 if s.cfg.Sessions == nil || strings.HasPrefix(r.URL.EscapedPath(), StaticPrefix) {
107 return r
108 }
109 c, err := r.Cookie(CookieName)
110 if err != nil {
111 return r
112 }
113 ctx := r.Context()
114 owner, ok, err := s.cfg.Sessions.Verify(ctx, c.Value)
115 switch {
116 case err != nil:
117 // Fail closed, and keep the cookie: the failure may be passing.
118 log := zerowrap.FromCtx(ctx)
119 log.Error().Err(err).Msg("check web session")
120 return r
121 case !ok:
122 // The answer depends on a cookie that is gone now: no cache keeps it
123 // for another visitor, and none serves it for a request with a cookie.
124 http.SetCookie(w, s.cookie("", -1))
125 h := w.Header()
126 h.Set("Cache-Control", noStore)
127 varyCookie(h)
128 return r
129 }
130 ctx = domain.ContextWithPrincipal(ctx, owner)
131 return r.WithContext(context.WithValue(ctx, signedInKey{}, struct{}{}))
132}
133
134// principal is the caller of the request, the one every use case call carries:
135// the owner when the request has a valid session (withSession), anonymous
136// otherwise.
137func principal(ctx context.Context) domain.Principal {
138 p, ok := domain.PrincipalFromContext(ctx)
139 if !ok {
140 return domain.Anonymous()
141 }
142 return p
143}
144
145// sessionStillValid checks again the cookie a long-lived request started
146// with. It fails closed: a check that cannot be made ends the session of the
147// stream.
148func (s *Server) sessionStillValid(ctx context.Context, r *http.Request) bool {
149 c, err := r.Cookie(CookieName)
150 if err != nil {
151 return false
152 }
153 _, ok, err := s.cfg.Sessions.Verify(ctx, c.Value)
154 return err == nil && ok
155}
156
157// cookie builds the session cookie. maxAge < 0 deletes it.
158func (s *Server) cookie(token string, maxAge int) *http.Cookie {
159 // SameSite=Strict, as ADR 0018 says: the cookie is not sent when another
160 // site links to a page (that first page shows as signed out, the next
161 // navigation on the site is signed in), and not on any cross-site request,
162 // on top of the Origin check of the POST gate.
163 return &http.Cookie{
164 Name: CookieName, Value: token, Path: "/", MaxAge: maxAge,
165 Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode,
166 }
167}
168
169// gate refuses a state change that does not come from a page of this site
170// with a session. It answers 403 and returns false. GET and HEAD pass: they
171// never change anything.
172func (s *Server) gate(w http.ResponseWriter, r *http.Request) bool {
173 if r.Method == http.MethodGet || r.Method == http.MethodHead {
174 return true
175 }
176 // The Origin is compared with the configured public URL, never with the
177 // Host header, which the client chooses. A missing header, "null" and any
178 // other origin are refused.
179 if r.Header.Get("Origin") != s.origin {
180 denyForbidden(w)
181 return false
182 }
183 if !SignedIn(r.Context()) && !s.anonymousPost(r) {
184 denyForbidden(w)
185 return false
186 }
187 return true
188}
189
190// anonymousPost tells whether a POST without a session may pass the gate: the
191// requests that start a session. They are the confirmation of a login link and,
192// when passkeys are on, the two endpoints of a passkey sign-in; each is matched
193// exactly.
194func (s *Server) anonymousPost(r *http.Request) bool {
195 if r.Method != http.MethodPost {
196 return false
197 }
198 return isLoginPath(r.URL) || (s.passkeysOn() && isPasskeyLoginPath(r.URL.EscapedPath()))
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", []Crumb{{Label: "sign in", Current: true}})
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 := ports.ErrLinkRejected
279 if validNonce(nonce) {
280 token, lifetime, err = s.cfg.Sessions.Login(ctx, nonce)
281 }
282 switch {
283 case errors.Is(err, ports.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, []Crumb{{Label: crumb, Current: true}})
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}