| 1 | package docs |
| 2 | |
| 3 | import ( |
| 4 | "database/sql" |
| 5 | "net/http" |
| 6 | "net/url" |
| 7 | "strings" |
| 8 | "time" |
| 9 | |
| 10 | "amadan.net/oficina/home/handoff" |
| 11 | "amadan.net/oficina/home/handoff/seal" |
| 12 | "amadan.net/rastrillo/rastrillo" |
| 13 | rauth "amadan.net/rastrillo/rastrillo/auth" |
| 14 | "amadan.net/rastrillo/rastrillo/csrf" |
| 15 | "amadan.net/rastrillo/rastrillo/sessions" |
| 16 | "github.com/go-chi/chi/v5" |
| 17 | ) |
| 18 | |
| 19 | type homeRoutes struct { |
| 20 | bridge *homeBridge |
| 21 | navigation *localLinkNavigation |
| 22 | handlers *handlers |
| 23 | formPolicy string |
| 24 | issuer string |
| 25 | } |
| 26 | |
| 27 | func newHomeRoutes(settings *homeSettings, origin string, db *sql.DB, auth *rauth.Auth, h *handlers) (*homeRoutes, error) { |
| 28 | b, err := newHomeBridge(settings, origin, db, auth, h.render, h.store) |
| 29 | if err != nil { |
| 30 | return nil, err |
| 31 | } |
| 32 | sealer, err := seal.New(settings.dek, "oficina/docs") |
| 33 | if err != nil { |
| 34 | return nil, errHomeConfig |
| 35 | } |
| 36 | navigation := newLocalLinkNavigation(sealer, b.sessions, origin) |
| 37 | if navigation == nil { |
| 38 | return nil, errHomeConfig |
| 39 | } |
| 40 | b.returnPath = navigation.returnPath |
| 41 | return &homeRoutes{bridge: b, navigation: navigation, handlers: h, formPolicy: homeFormPolicy(settings.issuer), issuer: settings.issuer}, nil |
| 42 | } |
| 43 | |
| 44 | func cookieCount(r *http.Request, name string) int { |
| 45 | n := 0 |
| 46 | for _, c := range r.Cookies() { |
| 47 | if c.Name == name { |
| 48 | n++ |
| 49 | } |
| 50 | } |
| 51 | return n |
| 52 | } |
| 53 | |
| 54 | // Only the assertion callback crosses origins. Its shared consumer validates |
| 55 | // the exact issuer without consulting the browser's local session. |
| 56 | func homeCSRF(origin string, home *homeRoutes) func(http.Handler) http.Handler { |
| 57 | protect := csrf.Protect(origin) |
| 58 | if home == nil { |
| 59 | return protect |
| 60 | } |
| 61 | return func(next http.Handler) http.Handler { |
| 62 | checked := protect(next) |
| 63 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 64 | w.Header().Set("Content-Security-Policy", home.formPolicy) |
| 65 | if r.Host != strings.TrimPrefix(origin, "https://") { |
| 66 | http.NotFound(w, r) |
| 67 | return |
| 68 | } |
| 69 | if r.Method == http.MethodPost && r.URL.Path == handoff.CallbackPath { |
| 70 | next.ServeHTTP(w, r) |
| 71 | return |
| 72 | } |
| 73 | if cookieCount(r, home.bridge.sessionCookie) > 1 { |
| 74 | home.bridge.fail(w, r, http.StatusBadRequest) |
| 75 | return |
| 76 | } |
| 77 | checked.ServeHTTP(w, r) |
| 78 | }) |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | // The local plugin resolves the first pending cookie, so reject ambiguity |
| 83 | // before it can verify a gesture or rotate the existing session. |
| 84 | func localAuthGuard(home *homeRoutes, next http.HandlerFunc) http.HandlerFunc { |
| 85 | if home == nil { |
| 86 | return next |
| 87 | } |
| 88 | return func(w http.ResponseWriter, r *http.Request) { |
| 89 | if cookieCount(r, home.bridge.sessionCookie) > 1 || cookieCount(r, "__Host-rastrillo_pending") > 1 { |
| 90 | home.bridge.fail(w, r, http.StatusBadRequest) |
| 91 | return |
| 92 | } |
| 93 | next(w, r) |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | func (h *homeRoutes) mountPublic(r chi.Router) { |
| 98 | r.Get("/signin", h.signin) |
| 99 | r.Get("/signed-out", func(w http.ResponseWriter, r *http.Request) { |
| 100 | h.handlers.render.homeLink(w, r, "signedout", "", http.StatusOK) |
| 101 | }) |
| 102 | r.Get("/signin/local", h.handlers.signin) |
| 103 | r.Get(localLinkCompletePath, h.navigation.Complete) |
| 104 | r.Get(handoff.StartPath, h.bridge.Start) |
| 105 | r.Post(handoff.CallbackPath, h.bridge.Callback) |
| 106 | r.Get(handoff.FinishPath, h.bridge.Finish) |
| 107 | } |
| 108 | |
| 109 | func (h *homeRoutes) signin(w http.ResponseWriter, r *http.Request) { |
| 110 | q, err := url.ParseQuery(r.URL.RawQuery) |
| 111 | if err != nil { |
| 112 | h.bridge.fail(w, r, http.StatusBadRequest) |
| 113 | return |
| 114 | } |
| 115 | // These are outcomes from the existing local email flow. Keep its form |
| 116 | // available even when the browser already has a Home session. |
| 117 | if q.Has("sent") || q.Has("err") || q.Has("force") { |
| 118 | h.handlers.signin(w, r) |
| 119 | return |
| 120 | } |
| 121 | // Already signed in to Docs: straight back, with no form and no new |
| 122 | // Home exchange (spec §9.2). A person with no session goes on to Home |
| 123 | // as before. |
| 124 | // Admitted, not merely holding a session: a refused identity must be |
| 125 | // able to reach sign-in, or the guard and this shortcut redirect to |
| 126 | // each other forever (h.handlers.admitted). |
| 127 | if _, ok := h.bridge.sessions.From(r); ok && h.handlers.admitted(r) { |
| 128 | alreadySignedIn(w, r) |
| 129 | return |
| 130 | } |
| 131 | path := "/" |
| 132 | if values, ok := q["return_to"]; ok && len(values) == 1 && handoff.SafePath(values[0]) { |
| 133 | path = values[0] |
| 134 | } |
| 135 | if !h.navigation.rememberReturn(w, path) { |
| 136 | h.bridge.fail(w, r, http.StatusServiceUnavailable) |
| 137 | return |
| 138 | } |
| 139 | w.Header().Set("Cache-Control", "no-store") |
| 140 | http.Redirect(w, r, handoff.StartPath+"?"+url.Values{"return_to": {path}}.Encode(), http.StatusSeeOther) |
| 141 | } |
| 142 | |
| 143 | func (h *homeRoutes) mountAccount(r chi.Router) { |
| 144 | r.Get("/account/home", func(w http.ResponseWriter, r *http.Request) { |
| 145 | s, ok := sessions.Current(r) |
| 146 | if _, fresh := freshLocalAuthentication(s, time.Now()); !ok || !fresh { |
| 147 | h.navigation.Begin(w, r) |
| 148 | return |
| 149 | } |
| 150 | h.bridge.LinkPage(w, r) |
| 151 | }) |
| 152 | r.Post("/account/home", func(w http.ResponseWriter, r *http.Request) { |
| 153 | s, ok := sessions.Current(r) |
| 154 | if _, fresh := freshLocalAuthentication(s, time.Now()); !ok || !fresh { |
| 155 | w.Header().Set("Cache-Control", "no-store") |
| 156 | http.Redirect(w, r, "/account/home", http.StatusSeeOther) |
| 157 | return |
| 158 | } |
| 159 | h.bridge.LinkStart(w, r) |
| 160 | }) |
| 161 | r.Get("/account/home/confirm", h.bridge.ConfirmPage) |
| 162 | r.Post("/account/home/confirm", func(w http.ResponseWriter, r *http.Request) { |
| 163 | localNavigationCookie(w, homeReturnCookie, "", -1) |
| 164 | h.bridge.ConfirmPost(w, r) |
| 165 | }) |
| 166 | } |
| 167 | |
| 168 | func (h *handlers) homeLinkLabel(r *http.Request) string { |
| 169 | if !h.homeEnabled { |
| 170 | return "" |
| 171 | } |
| 172 | if id, ok := currentIdentity(r); ok && id.Session.Method == homeAssertionMethod { |
| 173 | return "" |
| 174 | } |
| 175 | return rastrillo.T(r, "docs.home.menu_action") |
| 176 | } |
| 177 | |
| 178 | // signout ends the Docs session and clears the hand-off cookies, then hands |
| 179 | // the same POST on to {issuer}/signout, which ends the id.* session too and |
| 180 | // returns the browser to this origin's /signed-out (spec shared-chrome §9.5; |
| 181 | // connect.Routes.Signout does the same for every other app). Ending only the |
| 182 | // Docs session left "Sign in" on the signed-out page passing silently |
| 183 | // through the live 30-day id.* session. |
| 184 | // |
| 185 | // - 307, not 303, so the hop stays a POST. id.* ignores a GET sign-out, |
| 186 | // because any link or <img> can trigger one. |
| 187 | // - The browser sends "Origin: null" on the redirected POST, so id.* admits |
| 188 | // it by Referer. strict-origin-when-cross-origin makes the Referer |
| 189 | // exactly this origin, which id.* checks against HOME_CONSUMERS. |
| 190 | // - homeFormPolicy already names the issuer in form-action, which browsers |
| 191 | // enforce on the redirect target of a form submission. |
| 192 | // |
| 193 | // /signed-out itself still never starts another exchange. |
| 194 | func (h *homeRoutes) signout(w http.ResponseWriter, r *http.Request) { |
| 195 | h.bridge.sessions.SignOut(w, r) |
| 196 | for _, name := range []string{homeInitCookie, homeProofCookie, localLinkNavigationCookie, homeReturnCookie, "__Host-oficina-assertion-pending", "__Host-oficina-assertion-inbox"} { |
| 197 | localNavigationCookie(w, name, "", -1) |
| 198 | } |
| 199 | w.Header().Set("Cache-Control", "no-store") |
| 200 | w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") |
| 201 | http.Redirect(w, r, h.issuer+"/signout", http.StatusTemporaryRedirect) |
| 202 | } |
| 203 | |