| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 4 | "amadan.net/oficina/estilo.git/brand" |
| 5 | "amadan.net/oficina/estilo.git/chrome" |
| 6 | "amadan.net/oficina/estilo.git/sky" |
| 7 | "context" |
| 8 | "database/sql" |
| 9 | "errors" |
| 10 | "net/http" |
| 11 | "net/url" |
| 12 | "strings" |
| 13 | "time" |
| 14 | |
| 15 | "amadan.net/oficina/home/connect" |
| 16 | "amadan.net/oficina/home/connect/linkstore" |
| 17 | "amadan.net/rastrillo/rastrillo/csrf" |
| 18 | "amadan.net/rastrillo/rastrillo/sessions" |
| 19 | ) |
| 20 | |
| 21 | var errHome = errors.New("sheets: Home connection unavailable") |
| 22 | |
| 23 | // ConfigureHome runs after local auth is attached and before serving requests. |
| 24 | func (a *App) ConfigureHome(raw *connect.Credentials) error { |
| 25 | settings, err := connect.DecodeCredentials(raw, a.origin) |
| 26 | if err != nil { |
| 27 | return errHome |
| 28 | } |
| 29 | if settings == nil { |
| 30 | return nil |
| 31 | } |
| 32 | if a.db == nil || a.auth == nil { |
| 33 | return errHome |
| 34 | } |
| 35 | d, err := a.db.DB() |
| 36 | if err != nil { |
| 37 | return errHome |
| 38 | } |
| 39 | sess, err := sessions.New(sessions.Config{DB: d, Origin: a.origin, SigninPath: "/signin"}) |
| 40 | if err != nil || sess.CookieName() != a.auth.SessionCookie() { |
| 41 | return errHome |
| 42 | } |
| 43 | read := func(r *http.Request) (*connect.Session, error) { |
| 44 | s, ok := sess.From(r) |
| 45 | if !ok { |
| 46 | return nil, nil |
| 47 | } |
| 48 | return &connect.Session{Subject: s.Subject, Method: s.Method, AuthTime: s.AuthTime, At: s.At}, nil |
| 49 | } |
| 50 | render := func(w http.ResponseWriter, r *http.Request, state, label string, status int) { |
| 51 | w.Header().Set("Cache-Control", "no-store") |
| 52 | if state == "signedout" { |
| 53 | // The suite's signed-out page (spec §9.2), not connect's |
| 54 | // "Sign in with Home" one. |
| 55 | a.renderSignedOut(w, r, status) |
| 56 | return |
| 57 | } |
| 58 | p := connect.Page(state, label, func(k string) string { return homeCopy[k] }) |
| 59 | a.render(w, r, "home_link", titled(p.Title), status, p) |
| 60 | } |
| 61 | bridge, err := connect.New(connect.Config{DB: d, Origin: a.origin, Issuer: settings.Issuer, ConsumerID: settings.ConsumerID, Namespace: "oficina/sheets", Keys: settings.Keys, DEK: settings.DEK, SessionCookie: sess.CookieName(), ReadSession: read, |
| 62 | MintHomeSession: func(w http.ResponseWriter, r *http.Request, s connect.Session) error { |
| 63 | return sess.SignIn(w, r, sessions.Session{Subject: s.Subject, Method: s.Method, AuthTime: s.AuthTime}) |
| 64 | }, |
| 65 | LookupPerson: a.homePerson, |
| 66 | LookupLocalSession: func(ctx context.Context, s connect.Session) (connect.Person, error) { |
| 67 | var id int64 |
| 68 | address := strings.ToLower(strings.TrimSpace(s.Subject)) |
| 69 | if err := d.QueryRowContext(ctx, "SELECT id FROM members WHERE address=?", address).Scan(&id); err != nil { |
| 70 | return connect.Person{}, err |
| 71 | } |
| 72 | label, err := a.homePerson(ctx, d, id) |
| 73 | return connect.Person{ID: id, Label: label}, err |
| 74 | }, Render: render}) |
| 75 | if err != nil { |
| 76 | return errHome |
| 77 | } |
| 78 | routes, err := connect.NewRoutes(bridge, connect.RoutesConfig{LocalLandingPath: "/sheets", Namespace: "oficina/sheets", Origin: a.origin, Issuer: settings.Issuer, SessionCookie: sess.CookieName(), DEK: settings.DEK, ReadSession: read, SignOut: sess.SignOut, LocalSignin: a.handleSignin, Protect: csrf.Protect(a.origin), Render: render, FormPolicy: connect.FormPolicy(settings.Issuer)}) |
| 79 | if err != nil { |
| 80 | return errHome |
| 81 | } |
| 82 | a.home, a.homeRoutes, a.homeSessions = bridge, routes, sess |
| 83 | a.directory = settings.Directory |
| 84 | return nil |
| 85 | } |
| 86 | |
| 87 | // The same current admission rule as Admits, using the link transaction's q. |
| 88 | func (a *App) homePerson(ctx context.Context, q linkstore.Queryer, id int64) (string, error) { |
| 89 | var address string |
| 90 | if id <= 0 { |
| 91 | return "", sql.ErrNoRows |
| 92 | } |
| 93 | if err := q.QueryRowContext(ctx, "SELECT address FROM members WHERE id=?", id).Scan(&address); err != nil { |
| 94 | return "", err |
| 95 | } |
| 96 | if address == "" { |
| 97 | return "", sql.ErrNoRows |
| 98 | } |
| 99 | if a.admit != nil && a.admit(address) { |
| 100 | return address, nil |
| 101 | } |
| 102 | var count int |
| 103 | err := q.QueryRowContext(ctx, `SELECT COUNT(*) FROM grants JOIN sheets ON sheets.id=grants.sheet_id WHERE grants.member_id=? AND sheets.deleted_at IS NULL AND sheets.filling_since IS NULL`, id).Scan(&count) |
| 104 | if err != nil { |
| 105 | return "", err |
| 106 | } |
| 107 | if count == 0 { |
| 108 | return "", sql.ErrNoRows |
| 109 | } |
| 110 | return address, nil |
| 111 | } |
| 112 | |
| 113 | func (a *App) homeRequire(next http.Handler) http.Handler { |
| 114 | return a.homeSessions.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 115 | session, ok := a.homeSessions.From(r) |
| 116 | if ok && session.Method == connect.HomeMethod { |
| 117 | if _, err := a.home.ResolveSession(r); errors.Is(err, linkstore.ErrStorage) { |
| 118 | w.Header().Set("Cache-Control", "no-store") |
| 119 | http.Error(w, "Sign-in unavailable.", http.StatusServiceUnavailable) |
| 120 | return |
| 121 | } |
| 122 | } |
| 123 | address, admitted := a.identity(r) |
| 124 | if !ok || !admitted || !a.Admits(address) { |
| 125 | if ok { |
| 126 | a.homeSessions.SignOut(w, r) |
| 127 | } |
| 128 | w.Header().Set("Cache-Control", "no-store") |
| 129 | if r.Method == http.MethodGet || r.Method == http.MethodHead { |
| 130 | http.Redirect(w, r, "/signin?return_to="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther) |
| 131 | } else { |
| 132 | http.Error(w, "Sign in first.", http.StatusForbidden) |
| 133 | } |
| 134 | return |
| 135 | } |
| 136 | // Jobs use an already resolved local address so local and Home sessions have |
| 137 | // the same owner. The bridge always rereads the actual cookie-backed session. |
| 138 | session.Subject = address |
| 139 | next.ServeHTTP(w, sessions.WithSession(r, session)) |
| 140 | })) |
| 141 | } |
| 142 | |
| 143 | func (a *App) homeLocalComplete(w http.ResponseWriter, r *http.Request) { |
| 144 | s, ok := a.homeSessions.From(r) |
| 145 | if !ok { |
| 146 | a.homeRoutes.LocalComplete(w, r) |
| 147 | return |
| 148 | } |
| 149 | _, fresh := connect.FreshLocal(connect.Session{Subject: s.Subject, Method: s.Method, AuthTime: s.AuthTime, At: s.At}, time.Now()) |
| 150 | if !fresh || !a.Admits(s.Subject) { |
| 151 | http.Error(w, "Sign in first.", http.StatusForbidden) |
| 152 | return |
| 153 | } |
| 154 | // Only an independently verified local sign-in may create its local member. |
| 155 | // Home assertions and lookup hooks never pass through this completion handler. |
| 156 | if _, err := a.memberFor(s.Subject); err != nil { |
| 157 | http.Error(w, "Sign-in unavailable.", http.StatusServiceUnavailable) |
| 158 | return |
| 159 | } |
| 160 | a.homeRoutes.LocalComplete(w, r) |
| 161 | } |
| 162 | |
| 163 | // renderHomeOAuth is the page between the door and Keymail, in both |
| 164 | // modes (handleBegin), and it is the same card: the refresh stays |
| 165 | // Home's (head), the body is the door's notice. |
| 166 | func (a *App) renderHomeOAuth(w http.ResponseWriter, r *http.Request, destination string) { |
| 167 | p := connect.OAuthPage(destination) |
| 168 | a.render(w, r, "home_oauth", frame{parts: []string{p.Title}, signinPage: true}, http.StatusOK, homeOAuthView{ |
| 169 | Page: p, |
| 170 | Door: chrome.SignInNotice{Icon: brand.Path("sheets"), App: "Sheets", Sky: sky.For(r.Host), |
| 171 | Heading: p.Title, Body: "Taking you to Keymail to finish signing in.", |
| 172 | Link: &chrome.Link{Href: p.URL, Label: p.ActionLabel}}, |
| 173 | }) |
| 174 | } |
| 175 | |
| 176 | func (a *App) rejectDisabledHome(next http.Handler) http.Handler { |
| 177 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 178 | if s, ok := sessions.Current(r); ok && s.Method == connect.HomeMethod { |
| 179 | w.Header().Set("Cache-Control", "no-store") |
| 180 | http.Error(w, "Sign in first.", http.StatusForbidden) |
| 181 | return |
| 182 | } |
| 183 | next.ServeHTTP(w, r) |
| 184 | }) |
| 185 | } |
| 186 | |
| 187 | // homeOAuthView is the Keymail continuation page's data: Home's page (for |
| 188 | // its meta refresh) and the door card around the way on. |
| 189 | type homeOAuthView struct { |
| 190 | Page connect.OAuthData |
| 191 | Door chrome.SignInNotice |
| 192 | } |
| 193 | |