| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "strings" |
| 6 | |
| 7 | "amadan.net/oficina/estilo.git/brand" |
| 8 | "amadan.net/oficina/estilo.git/chrome" |
| 9 | "amadan.net/oficina/estilo.git/sky" |
| 10 | "amadan.net/rastrillo/rastrillo/auth" |
| 11 | "amadan.net/rastrillo/rastrillo/sessions" |
| 12 | ) |
| 13 | |
| 14 | // The sign-in pages are estilo's door (estilo-signin, estilo-signin-sent, |
| 15 | // estilo-signed-out) and so are their words: the form's heading, label, |
| 16 | // help line and "Sign in" submit, the sent state that replaces the form, |
| 17 | // and the signed-out page all come from chrome.English, so every app in |
| 18 | // the suite says the same thing. Only the error lines are this app's. |
| 19 | |
| 20 | // signinError maps auth's redirect vocabulary to what the person in |
| 21 | // front of the screen needs to do next. It SELECTS from a fixed set — |
| 22 | // there is no path from the URL into the page. |
| 23 | // |
| 24 | // The default case is the point of this function. On the sign-in page |
| 25 | // every outcome means one operational thing — we could not sign you in, |
| 26 | // ask for a new link — so an unrecognised code must say that too, not |
| 27 | // fall through to an app-wide "something went wrong". The codes that |
| 28 | // actually reach it are ?err=1 (an internal failure in Begin) and any |
| 29 | // future code auth adds. |
| 30 | // |
| 31 | // field is true for the one error that belongs in the address field's |
| 32 | // own slot rather than a callout above the form (§9.2). |
| 33 | // |
| 34 | // An earlier version of this comment claimed a second click on an |
| 35 | // expired link fell through to the default. It does not: auth redirects |
| 36 | // ?err=expired from all three of its not-found paths, and rastrillo's own |
| 37 | // auth_test.go asserts it for the second Verify. |
| 38 | func signinError(code string) (text string, field bool) { |
| 39 | switch code { |
| 40 | case "": |
| 41 | return "", false |
| 42 | case "rate": |
| 43 | return "Too many attempts. Wait a few minutes, then try again.", false |
| 44 | case "address": |
| 45 | // rastrillo's ?err=address is an address it could not parse. |
| 46 | // A refused (unadmitted) address is never told so at the form: |
| 47 | // handleBegin answers it exactly as it answers an admitted one. |
| 48 | return "That doesn’t look like an email address.", true |
| 49 | case "expired": |
| 50 | return "That link has expired or was already used. Enter your email to get a new one.", false |
| 51 | case "keymail": |
| 52 | return "Your keymail sign-in couldn’t be completed. Enter your email to get a link instead.", false |
| 53 | default: |
| 54 | return "We couldn’t sign you in. Enter your email to get a new link.", false |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | // signinView is the sign-in page's own payload: the form, the sent |
| 59 | // state that replaces it, or the confirm step an emailed link opens on. |
| 60 | type signinView struct { |
| 61 | Form chrome.SignIn |
| 62 | Sent *chrome.SignInNotice |
| 63 | Confirm *signinConfirm |
| 64 | } |
| 65 | |
| 66 | // signinFormPath is where the local sign-in form lives. With Home |
| 67 | // configured, bare /signin starts the Home exchange and the local form is |
| 68 | // /signin/local; standalone, /signin is the form. |
| 69 | func (a *App) signinFormPath() string { |
| 70 | if a.home != nil { |
| 71 | return "/signin/local" |
| 72 | } |
| 73 | return "/signin" |
| 74 | } |
| 75 | |
| 76 | // handleSignin serves the sign-in page. |
| 77 | // |
| 78 | // Standalone, somebody already signed in never sees the form (§9.2's |
| 79 | // last row): /signin answers 303 to a safe return_to or the list. With |
| 80 | // Home configured this handler is the LOCAL form behind /signin/local, |
| 81 | // which a person with a Home session reaches on purpose to link an |
| 82 | // existing account, so it never skips there; connect's own /signin does |
| 83 | // the skip in that mode. |
| 84 | func (a *App) handleSignin(w http.ResponseWriter, r *http.Request) { |
| 85 | q := r.URL.Query() |
| 86 | if a.home == nil && q.Get("sent") == "" && q.Get("err") == "" && q.Get("force") == "" { |
| 87 | if _, ok := a.identity(r); ok { |
| 88 | http.Redirect(w, r, signedInLanding(r), http.StatusSeeOther) |
| 89 | return |
| 90 | } |
| 91 | } |
| 92 | if q.Get("sent") != "" { |
| 93 | w.Header().Set("Cache-Control", "no-store") |
| 94 | // rastrillo's Begin redirects to ?sent=1 with no address, so the |
| 95 | // page cannot echo it (Docs made the same call). |
| 96 | sent := chrome.SignInNotice{Icon: brand.Path("sheets"), App: "Sheets", Sky: sky.For(r.Host)}.Sent(a.signinFormPath()) |
| 97 | a.render(w, r, "signin", frame{parts: []string{sent.Page()}, signinPage: true}, http.StatusOK, signinView{Sent: &sent}) |
| 98 | return |
| 99 | } |
| 100 | text, field := signinError(q.Get("err")) |
| 101 | a.renderDoor(w, r, http.StatusOK, text, field) |
| 102 | } |
| 103 | |
| 104 | // renderDoor draws the sign-in form with text as its error, in the |
| 105 | // address field's own slot when field is set. handleSignin passes the |
| 106 | // query's error; App.refused passes the refusal with a 403. |
| 107 | func (a *App) renderDoor(w http.ResponseWriter, r *http.Request, status int, text string, field bool) { |
| 108 | form := a.door(r) |
| 109 | form.Action = "/signin" |
| 110 | if field { |
| 111 | form.AddressError = text |
| 112 | } else { |
| 113 | form.Error = text |
| 114 | } |
| 115 | // Force is the affordance auth asks for. When a keymail sign-in |
| 116 | // fails, auth redirects to ?force=1&err=keymail specifically so this |
| 117 | // page can offer the plain-email path: Begin reads a non-empty |
| 118 | // "force" and sends a magic link instead of bouncing back to keymail. |
| 119 | // Without carrying it, a keymail user whose OAuth fails is redirected |
| 120 | // to keymail again on every retry, with no way out. |
| 121 | if r.URL.Query().Get("force") != "" { |
| 122 | form.Hidden = []chrome.Hidden{{Name: "force", Value: "1"}} |
| 123 | } |
| 124 | a.render(w, r, "signin", frame{parts: []string{form.Page()}, signinPage: true}, status, signinView{Form: form}) |
| 125 | } |
| 126 | |
| 127 | // door is the sign-in door's chrome for this instance: Sheets' mark and |
| 128 | // name on its sky. |
| 129 | func (a *App) door(r *http.Request) chrome.SignIn { |
| 130 | return chrome.SignIn{ |
| 131 | Icon: brand.Path("sheets"), |
| 132 | App: "Sheets", |
| 133 | Sky: sky.For(r.Host), |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | // renderConfirm is auth's RenderConfirm: the page an emailed link opens |
| 138 | // on. GET /auth/verify spends nothing, so a mail-security scanner that |
| 139 | // fetches every link in an inbound message cannot spend this one; the |
| 140 | // door's single Sign in button POSTs the token back to d.Action, and |
| 141 | // that POST signs in. |
| 142 | // |
| 143 | // It is the door with the address field taken away — the same mark, |
| 144 | // sky, heading and Sign in label — so the person who clicked the link |
| 145 | // sees Sheets, not rastrillo's plain English fallback. auth has already |
| 146 | // set no-store, Referrer-Policy: strict-origin (the URL is a live |
| 147 | // credential) and the framing refusals; render adds nothing that |
| 148 | // replaces them. d.Token is untrusted query input and reaches the page |
| 149 | // only through html/template. |
| 150 | func (a *App) renderConfirm(w http.ResponseWriter, r *http.Request, d auth.ConfirmPageData) { |
| 151 | c := signinConfirm{Door: a.door(r), Action: d.Action, Token: d.Token} |
| 152 | c.Door.Lede = "Select Sign in to finish signing in." |
| 153 | a.render(w, r, "signin", frame{parts: []string{c.Door.Page()}, signinPage: true}, http.StatusOK, signinView{Confirm: &c}) |
| 154 | } |
| 155 | |
| 156 | // signinConfirm is the confirm step's door: the chrome, and the form |
| 157 | // that posts the emailed token back. |
| 158 | type signinConfirm struct { |
| 159 | Door chrome.SignIn |
| 160 | Action, Token string |
| 161 | } |
| 162 | |
| 163 | // signedInLanding is where an already-signed-in /signin goes: the |
| 164 | // request's return_to when rastrillo's SafeReturn accepts it and it is |
| 165 | // not itself a sign-in page (which would loop), otherwise the list. |
| 166 | func signedInLanding(r *http.Request) string { |
| 167 | target := sessions.SafeReturn(r, "/sheets") |
| 168 | if target == "/" || strings.HasPrefix(target, "/signin") || strings.HasPrefix(target, "/auth/") || strings.HasPrefix(target, "/signed-out") { |
| 169 | return "/sheets" |
| 170 | } |
| 171 | return target |
| 172 | } |
| 173 | |
| 174 | // renderSignedOut is where Sign out lands: /signed-out, in both modes. |
| 175 | // With Home configured connect's route renders it through ConfigureHome's |
| 176 | // callback; standalone, signOutToPage sends Sign out here. |
| 177 | func (a *App) renderSignedOut(w http.ResponseWriter, r *http.Request, status int) { |
| 178 | w.Header().Set("Cache-Control", "no-store") |
| 179 | out := chrome.SignInNotice{Icon: brand.Path("sheets"), App: "Sheets", Sky: sky.For(r.Host)}.SignedOut("/signin") |
| 180 | a.render(w, r, "signed_out", frame{parts: []string{out.Page()}, signinPage: true}, status, out) |
| 181 | } |
| 182 | |
| 183 | func (a *App) handleSignedOut(w http.ResponseWriter, r *http.Request) { |
| 184 | a.renderSignedOut(w, r, http.StatusOK) |
| 185 | } |
| 186 | |
| 187 | // signOutToPage is rastrillo's Sign out, landing on /signed-out instead of |
| 188 | // the sign-in form. auth.Signout ends the session and 303s to its |
| 189 | // SigninPath; this rewrites only that one Location, so everything else |
| 190 | // auth answers (its cross-origin refusal) is untouched. |
| 191 | func (a *App) signOutToPage(w http.ResponseWriter, r *http.Request) { |
| 192 | a.auth.Signout(&landing{ResponseWriter: w}, r) |
| 193 | } |
| 194 | |
| 195 | type landing struct{ http.ResponseWriter } |
| 196 | |
| 197 | func (l *landing) WriteHeader(code int) { |
| 198 | if code == http.StatusSeeOther && l.Header().Get("Location") == "/signin" { |
| 199 | l.Header().Set("Location", "/signed-out") |
| 200 | } |
| 201 | l.ResponseWriter.WriteHeader(code) |
| 202 | } |
| 203 | |