oficina / memoria Public

Clone
git clone https://amadan.net/oficina/memoria

Plain git — no account needed to clone.

Download

Download this file

1package memoria
2
3// Return to the page you came from (shared chrome spec §9.4).
4//
5// requireSession used to answer a signed-out page request with rastrillo's
6// bare /signin, dropping the path: a link to a document, opened signed
7// out, landed on the index after sign-in. Now a signed-out GET is sent to
8// /signin?return_to=<the page>, and the page is carried to wherever the
9// magic link lands.
10//
11// It cannot ride the emailed link: rastrillo's auth lands a verified link
12// on one fixed SignedInPath and never reads return_to. So GET /signin
13// remembers it in a short-lived cookie, and the landing reads it back:
14//
15// - standalone, SignedInPath is /auth/landed (landed, below);
16// - with Home, a local link lands on connect's /auth/local/complete,
17// which goes to "/" (or /account/home mid-link); landedAfter swaps
18// that "/" for the remembered page. With Home and no local detour,
19// connect carries return_to itself through the Home exchange.
20//
21// The cookie is SEALED (seal.PurposeReturn), bound to this origin, and
22// carries its own issue and expiry times, checked on read — a browser's
23// Max-Age alone is a suggestion (Astra on Slides #14, finding 3). Its
24// value is only ever a same-origin path that handoff.SafePath admits (no
25// scheme, no host, no "//", no control characters, not /signin or
26// /auth/*), checked when written and again when read, so it can never
27// send anyone off-site or into a sign-in loop.
28
29import (
30 "bytes"
31 "encoding/base64"
32 "encoding/json"
33 "math"
34 "net/http"
35 "net/url"
36 "strings"
37 "time"
38
39 "amadan.net/oficina/estilo.git/chrome"
40 "amadan.net/oficina/estilo.git/sky"
41 "amadan.net/oficina/home/handoff"
42
43 "amadan.net/oficina/memoria/internal/memoria/seal"
44)
45
46// landedPath is rastrillo's SignedInPath in standalone mode: where a
47// verified magic link lands, which then goes on to the remembered page.
48const landedPath = "/auth/landed"
49
50// returnCookieAge is how long a remembered page lasts: a link opened after
51// ten minutes, or in another browser, lands on the index. Intended.
52const returnCookieAge int64 = 600
53
54const returnCookieLimit = 4096
55
56type returnIntent struct {
57 Path string `json:"path"`
58 Origin string `json:"origin"`
59 Issued int64 `json:"issued"`
60 Expires int64 `json:"expires"`
61}
62
63func (a *app) secure() bool { return strings.HasPrefix(a.origin, "https://") }
64
65// returnCookieName is host-only, and __Host- prefixed wherever the origin
66// is https.
67func (a *app) returnCookieName() string {
68 if a.secure() {
69 return "__Host-memoria_return"
70 }
71 return "memoria_return"
72}
73
74func (a *app) now() time.Time {
75 if a.clock != nil {
76 return a.clock()
77 }
78 return time.Now()
79}
80
81// safeReturn is the request's return_to, when there is exactly one and it
82// is a same-origin path handoff.SafePath admits.
83func safeReturn(q url.Values) (string, bool) {
84 values, ok := q["return_to"]
85 if !ok || len(values) != 1 || !handoff.SafePath(values[0]) {
86 return "", false
87 }
88 return values[0], true
89}
90
91func (a *app) rememberReturn(w http.ResponseWriter, path string) {
92 if !handoff.SafePath(path) || a.store == nil || a.store.sealer == nil {
93 return
94 }
95 now := a.now().Unix()
96 if now <= 0 || now > math.MaxInt64-returnCookieAge {
97 return
98 }
99 raw, err := json.Marshal(returnIntent{Path: path, Origin: a.origin, Issued: now, Expires: now + returnCookieAge})
100 if err != nil {
101 return
102 }
103 sealed, err := a.store.sealer.Seal(seal.PurposeReturn, raw)
104 if err != nil {
105 return
106 }
107 value := base64.RawURLEncoding.EncodeToString(sealed)
108 if len(value) > returnCookieLimit {
109 return
110 }
111 http.SetCookie(w, &http.Cookie{
112 Name: a.returnCookieName(), Value: value, Path: "/",
113 MaxAge: int(returnCookieAge), HttpOnly: true, Secure: a.secure(),
114 SameSite: http.SameSiteLaxMode,
115 })
116}
117
118func (a *app) forgetReturn(w http.ResponseWriter) {
119 http.SetCookie(w, &http.Cookie{
120 Name: a.returnCookieName(), Value: "", Path: "/",
121 MaxAge: -1, HttpOnly: true, Secure: a.secure(),
122 SameSite: http.SameSiteLaxMode,
123 })
124}
125
126// rememberedReturn reads the remembered page back, validated again: the
127// cookie is a request header like any other. Exactly one cookie, strict
128// base64, a seal that opens under this purpose, canonical JSON (nothing
129// added, nothing duplicated), this origin, and a live, bounded window.
130func (a *app) rememberedReturn(r *http.Request) (string, bool) {
131 if a.store == nil || a.store.sealer == nil {
132 return "", false
133 }
134 count := 0
135 value := ""
136 for _, c := range r.Cookies() {
137 if c.Name == a.returnCookieName() {
138 count++
139 value = c.Value
140 }
141 }
142 if count != 1 || value == "" || len(value) > returnCookieLimit {
143 return "", false
144 }
145 sealed, err := base64.RawURLEncoding.Strict().DecodeString(value)
146 if err != nil || base64.RawURLEncoding.EncodeToString(sealed) != value {
147 return "", false
148 }
149 raw, err := a.store.sealer.Open(seal.PurposeReturn, sealed)
150 if err != nil || len(raw) > returnCookieLimit {
151 return "", false
152 }
153 var intent returnIntent
154 if json.Unmarshal(raw, &intent) != nil {
155 return "", false
156 }
157 canonical, _ := json.Marshal(intent)
158 now := a.now().Unix()
159 if !bytes.Equal(raw, canonical) || intent.Origin != a.origin || intent.Issued <= 0 || intent.Issued > now ||
160 intent.Expires <= now || intent.Expires <= intent.Issued || intent.Expires-intent.Issued > returnCookieAge ||
161 !handoff.SafePath(intent.Path) {
162 return "", false
163 }
164 return intent.Path, true
165}
166
167// landed serves GET /auth/landed: the remembered return_to, or the index.
168// The cookie is spent either way.
169func (a *app) landed(w http.ResponseWriter, r *http.Request) {
170 dest := "/"
171 if ret, ok := a.rememberedReturn(r); ok {
172 dest = ret
173 }
174 a.forgetReturn(w)
175 w.Header().Set("Cache-Control", "no-store")
176 http.Redirect(w, r, dest, http.StatusSeeOther)
177}
178
179// landedAfter wraps connect's local-link completion (Home mode): where it
180// would land on the root, it lands on the remembered page instead. Any
181// other destination — /account/home in the middle of linking an account —
182// is connect's decision and is left alone. The cookie is spent either way.
183func (a *app) landedAfter(complete http.HandlerFunc) http.HandlerFunc {
184 return func(w http.ResponseWriter, r *http.Request) {
185 ret, ok := a.rememberedReturn(r)
186 a.forgetReturn(w)
187 rw := &rewriteLocation{ResponseWriter: w, from: "/"}
188 if ok {
189 rw.to = ret
190 }
191 complete(rw, r)
192 }
193}
194
195// rewriteLocation swaps one Location for another at the last moment a
196// header is still settable. to == "" leaves it alone.
197type rewriteLocation struct {
198 http.ResponseWriter
199 from, to string
200}
201
202func (rw *rewriteLocation) WriteHeader(status int) {
203 if rw.to != "" && rw.Header().Get("Location") == rw.from {
204 rw.Header().Set("Location", rw.to)
205 }
206 rw.ResponseWriter.WriteHeader(status)
207}
208
209func (rw *rewriteLocation) Write(b []byte) (int, error) {
210 return rw.ResponseWriter.Write(b)
211}
212
213// signedOutAfter wraps rastrillo's Signout (standalone), which ends the
214// session and redirects to the sign-in form: a page that, with no word
215// about what just happened, read like an error (polish audit §B5). It
216// goes to /signed-out instead, which says so. (With Home, connect's
217// Signout chains to id.* and id.* returns the browser to /signed-out.)
218func signedOutAfter(signout http.HandlerFunc) http.HandlerFunc {
219 return func(w http.ResponseWriter, r *http.Request) {
220 signout(&rewriteLocation{ResponseWriter: w, from: signinPath, to: "/signed-out"}, r)
221 }
222}
223
224// signedOut serves GET /signed-out: the door's signed-out page ("You’re
225// signed out", and a primary Sign in), in the door's words. The same page
226// standalone and with Home, which is never named.
227func (a *app) signedOut(w http.ResponseWriter, r *http.Request) {
228 w.Header().Set("Cache-Control", "no-store")
229 out := chrome.SignInNotice{Icon: assets.Path("static/brand/icon.svg"), App: appName, Sky: sky.For(r.Host)}.SignedOut(signinPath)
230 render(w, "signin", signinPage{
231 page: page{Frame: a.frameAs(r, viewer{}, frame{parts: []string{out.Page()}, signinPage: true}), ModelOn: a.modelOn()},
232 SignedOut: &out,
233 })
234}
235