oficina / home Public

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

Plain git — no account needed to clone.

Download

Download this file

1package connect
2
3import (
4 "crypto/sha256"
5 "errors"
6 "net/http"
7 "net/url"
8 "strings"
9
10 "amadan.net/oficina/home/handoff"
11 "amadan.net/oficina/home/handoff/seal"
12)
13
14const LocalCompletePath = localLinkCompletePath
15
16type RoutesConfig struct {
17 LocalLandingPath string
18 Namespace, Origin, Issuer, SessionCookie string
19 FormPolicy string
20 DEK []byte
21 ReadSession func(*http.Request) (*Session, error)
22 SignOut func(http.ResponseWriter, *http.Request)
23 LocalSignin http.HandlerFunc
24 Protect func(http.Handler) http.Handler
25 Render func(http.ResponseWriter, *http.Request, string, string, int)
26}
27
28type Routes struct {
29 bridge *Bridge
30 navigation *localLinkNavigation
31 cfg RoutesConfig
32}
33
34func NewRoutes(b *Bridge, c RoutesConfig) (*Routes, error) {
35 if (c.LocalLandingPath != "" && !handoff.SafePath(c.LocalLandingPath)) || b == nil || c.ReadSession == nil || c.SignOut == nil || c.LocalSignin == nil || c.Protect == nil || c.Render == nil || c.Origin != b.origin || c.Issuer != b.issuer || c.SessionCookie != b.sessionCookie || c.Namespace != b.namespace || sha256.Sum256(c.DEK) != b.keyHash {
36 return nil, errors.New("home connect: invalid routes")
37 }
38 s, err := seal.New(c.DEK, c.Namespace)
39 if err != nil {
40 return nil, err
41 }
42 n := newLocalLinkNavigation(s, c)
43 if n == nil {
44 return nil, errors.New("home connect: invalid navigation")
45 }
46 n.now = b.now
47 b.returnPath = n.returnPath
48 return &Routes{bridge: b, navigation: n, cfg: c}, nil
49}
50
51func cookieCount(r *http.Request, name string) int {
52 n := 0
53 for _, c := range r.Cookies() {
54 if c.Name == name {
55 n++
56 }
57 }
58 return n
59}
60
61// Only the signed assertion callback bypasses the application's CSRF guard.
62func (h *Routes) Middleware(next http.Handler) http.Handler {
63 checked := h.cfg.Protect(next)
64 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
65 policy := h.cfg.FormPolicy
66 if policy == "" {
67 policy = FormPolicy(h.cfg.Issuer)
68 }
69 w.Header().Set("Content-Security-Policy", policy)
70 if r.Host != strings.TrimPrefix(h.cfg.Origin, "https://") {
71 http.NotFound(w, r)
72 return
73 }
74 if r.Method == http.MethodPost && r.URL.Path == handoff.CallbackPath {
75 next.ServeHTTP(w, r)
76 return
77 }
78 if cookieCount(r, h.cfg.SessionCookie) > 1 {
79 h.bridge.fail(w, r, http.StatusBadRequest)
80 return
81 }
82 checked.ServeHTTP(w, r)
83 })
84}
85
86func FormPolicy(issuer string) string {
87 return "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self' " + issuer
88}
89
90func (h *Routes) LocalGuard(next http.HandlerFunc) http.HandlerFunc {
91 return func(w http.ResponseWriter, r *http.Request) {
92 if cookieCount(r, h.cfg.SessionCookie) > 1 || cookieCount(r, "__Host-rastrillo_pending") > 1 {
93 h.bridge.fail(w, r, http.StatusBadRequest)
94 return
95 }
96 next(w, r)
97 }
98}
99
100func (h *Routes) Signin(w http.ResponseWriter, r *http.Request) {
101 q, err := url.ParseQuery(r.URL.RawQuery)
102 if err != nil {
103 h.bridge.fail(w, r, http.StatusBadRequest)
104 return
105 }
106 if q.Has("sent") || q.Has("err") || q.Has("force") {
107 h.cfg.LocalSignin(w, r)
108 return
109 }
110 path := "/"
111 if values, ok := q["return_to"]; ok && len(values) == 1 && handoff.SafePath(values[0]) {
112 path = values[0]
113 }
114 // Already signed in: go where the person was headed, with no form and
115 // no second exchange (spec shared-chrome §9.4). SafePath refuses
116 // /signin and /auth/*, so this cannot loop.
117 if h.signedIn(r) {
118 w.Header().Set("Cache-Control", "no-store")
119 http.Redirect(w, r, path, http.StatusSeeOther)
120 return
121 }
122 if !h.navigation.rememberReturn(w, path) {
123 h.bridge.fail(w, r, http.StatusServiceUnavailable)
124 return
125 }
126 w.Header().Set("Cache-Control", "no-store")
127 http.Redirect(w, r, handoff.StartPath+"?"+url.Values{"return_to": {path}}.Encode(), http.StatusSeeOther)
128}
129
130// signedIn reports a live, signed-in app session: exactly one session
131// cookie, which ReadSession resolves to a person who signed in, through
132// Home (HomeMethod) or a local link (magiclink, keymail). An ambiguous
133// pair or a revoked cookie is not one, and neither is any other method:
134// Calls' anonymous per-meeting guests ("guest") are sessions but not
135// sign-ins, and sending one to the root instead of the exchange ends on a
136// 404 (the #74 review, finding 3). An unknown method falls back to the
137// exchange, which is what /signin did before this check existed.
138func (h *Routes) signedIn(r *http.Request) bool {
139 if cookieCount(r, h.cfg.SessionCookie) != 1 {
140 return false
141 }
142 s, err := h.cfg.ReadSession(r)
143 if err != nil || s == nil || s.Subject == "" {
144 return false
145 }
146 switch s.Method {
147 case HomeMethod, "magiclink", "keymail":
148 return true
149 }
150 return false
151}
152
153func (h *Routes) SignedOut(w http.ResponseWriter, r *http.Request) {
154 h.cfg.Render(w, r, "signedout", "", http.StatusOK)
155}
156func (h *Routes) LocalComplete(w http.ResponseWriter, r *http.Request) { h.navigation.Complete(w, r) }
157func (h *Routes) fresh(r *http.Request) bool {
158 if cookieCount(r, h.cfg.SessionCookie) != 1 {
159 return false
160 }
161 s, err := h.cfg.ReadSession(r)
162 if err != nil || s == nil {
163 return false
164 }
165 _, ok := FreshLocal(*s, h.bridge.now())
166 return ok
167}
168func (h *Routes) Account(w http.ResponseWriter, r *http.Request) {
169 if !h.fresh(r) {
170 h.navigation.Begin(w, r)
171 return
172 }
173 h.bridge.LinkPage(w, r)
174}
175func (h *Routes) LinkStart(w http.ResponseWriter, r *http.Request) {
176 if !h.fresh(r) {
177 w.Header().Set("Cache-Control", "no-store")
178 http.Redirect(w, r, "/account/home", http.StatusSeeOther)
179 return
180 }
181 h.bridge.LinkStart(w, r)
182}
183func (h *Routes) ConfirmPost(w http.ResponseWriter, r *http.Request) {
184 localNavigationCookie(w, homeReturnCookie, "", -1)
185 h.bridge.ConfirmPost(w, r)
186}
187
188// Signout ends the app session and clears the hand-off cookies. With Home
189// configured it then hands the same POST on to {Issuer}/signout, which ends
190// the id.* session and returns the browser to this origin's /signed-out (spec
191// shared-chrome §9.5). Ending only the app session left "Sign in" on the
192// signed-out page passing silently through the live 30-day id.* session.
193//
194// - 307, not 303, so the hop stays a POST. A GET sign-out is one any link
195// or <img> can trigger; id.* ignores GET.
196// - The browser sends "Origin: null" on the redirected POST, so id.* admits
197// it by Referer. This response sets the policy that makes Referer exactly
198// this origin. A page that itself sends Referrer-Policy: no-referrer still
199// strips it (no redirect can restore it), and its sign-out lands on id.*'s
200// "Finish signing out" page: recoverable, but a page with the account menu
201// should use strict-origin instead.
202// - FormPolicy already names the issuer in form-action, which browsers
203// enforce on the redirect target of a form submission. An app that
204// supplies its own policy must keep the issuer there.
205//
206// Without an issuer (never the case for Routes built by NewRoutes) it ends
207// here, as before.
208func (h *Routes) Signout(w http.ResponseWriter, r *http.Request) {
209 h.cfg.SignOut(w, r)
210 for _, name := range []string{homeInitCookie, homeProofCookie, localLinkNavigationCookie, homeReturnCookie, "__Host-oficina-assertion-pending", "__Host-oficina-assertion-inbox"} {
211 localNavigationCookie(w, name, "", -1)
212 }
213 w.Header().Set("Cache-Control", "no-store")
214 if h.cfg.Issuer == "" {
215 http.Redirect(w, r, "/signed-out", http.StatusSeeOther)
216 return
217 }
218 w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
219 http.Redirect(w, r, h.cfg.Issuer+"/signout", http.StatusTemporaryRedirect)
220}
221