| 1 | package slides |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "database/sql" |
| 7 | "errors" |
| 8 | "fmt" |
| 9 | "net/http" |
| 10 | |
| 11 | "amadan.net/oficina/home/connect" |
| 12 | "amadan.net/oficina/home/connect/linkstore" |
| 13 | "amadan.net/oficina/home/handoff" |
| 14 | "amadan.net/rastrillo/rastrillo" |
| 15 | "amadan.net/rastrillo/rastrillo/csrf" |
| 16 | "amadan.net/rastrillo/rastrillo/migrate" |
| 17 | "amadan.net/rastrillo/rastrillo/sessions" |
| 18 | "gorm.io/gorm" |
| 19 | ) |
| 20 | |
| 21 | var homeSchema = migrate.Merge(migrate.MustFromFS(handoff.Migrations, "home_handoff"), migrate.MustFromFS(linkstore.Migrations, linkstore.SchemaNamespace)) |
| 22 | var errHomeIdentity = errors.New("slides: Home identity refused") |
| 23 | |
| 24 | func neutralSession(s sessions.Session) connect.Session { |
| 25 | return connect.Session{Subject: s.Subject, Method: s.Method, AuthTime: s.AuthTime, At: s.At} |
| 26 | } |
| 27 | |
| 28 | func (a *app) initHome(c *connect.DecodedCredentials) error { |
| 29 | if c == nil { |
| 30 | return nil |
| 31 | } |
| 32 | sess, err := sessions.New(sessions.Config{DB: a.db.Writer(), Origin: a.cfg.Origin, Logger: a.logger}) |
| 33 | if err != nil { |
| 34 | return err |
| 35 | } |
| 36 | a.homeSessions = sess |
| 37 | read := func(r *http.Request) (*connect.Session, error) { |
| 38 | s, ok := sess.From(r) |
| 39 | if !ok { |
| 40 | return nil, nil |
| 41 | } |
| 42 | n := neutralSession(s) |
| 43 | return &n, nil |
| 44 | } |
| 45 | b, err := connect.New(connect.Config{DB: a.db.Writer(), Origin: a.cfg.Origin, Issuer: c.Issuer, ConsumerID: c.ConsumerID, Namespace: "oficina/slides", SessionCookie: sess.CookieName(), Keys: c.Keys, DEK: c.DEK, ReadSession: read, |
| 46 | MintHomeSession: func(w http.ResponseWriter, r *http.Request, s connect.Session) error { |
| 47 | return sess.SignIn(w, r, sessions.Session{Subject: s.Subject, Method: s.Method, AuthTime: s.AuthTime}) |
| 48 | }, |
| 49 | LookupLocalSession: func(ctx context.Context, s connect.Session) (connect.Person, error) { |
| 50 | if s.Method != "magiclink" && s.Method != "keymail" { |
| 51 | return connect.Person{}, errHomeIdentity |
| 52 | } |
| 53 | var u User |
| 54 | if err := a.db.G.WithContext(ctx).Where("email = ?", normalizeAddress(s.Subject)).Take(&u).Error; err != nil { |
| 55 | if errors.Is(err, gorm.ErrRecordNotFound) { |
| 56 | return connect.Person{}, sql.ErrNoRows |
| 57 | } |
| 58 | return connect.Person{}, err |
| 59 | } |
| 60 | if !a.allowed.allows(u.Email) { |
| 61 | return connect.Person{}, sql.ErrNoRows |
| 62 | } |
| 63 | return connect.Person{ID: u.ID, Label: u.Email}, nil |
| 64 | }, LookupPerson: func(ctx context.Context, q linkstore.Queryer, id int64) (string, error) { |
| 65 | var email string |
| 66 | if err := q.QueryRowContext(ctx, "SELECT email FROM users WHERE id = ?", id).Scan(&email); err != nil { |
| 67 | return "", err |
| 68 | } |
| 69 | if !a.allowed.allows(email) { |
| 70 | return "", sql.ErrNoRows |
| 71 | } |
| 72 | return email, nil |
| 73 | }, Render: a.renderHome}) |
| 74 | if err != nil { |
| 75 | return err |
| 76 | } |
| 77 | a.home = b |
| 78 | a.directory = c.Directory |
| 79 | a.homeRoutes, err = connect.NewRoutes(b, connect.RoutesConfig{Namespace: "oficina/slides", Origin: a.cfg.Origin, Issuer: c.Issuer, DEK: c.DEK, SessionCookie: sess.CookieName(), ReadSession: read, SignOut: sess.SignOut, LocalSignin: a.signin, Protect: csrf.Protect(a.cfg.Origin), Render: a.renderHome, FormPolicy: connect.FormPolicy(c.Issuer)}) |
| 80 | return err |
| 81 | } |
| 82 | |
| 83 | // homeLinkView is the account-connection pages' data: connect's page |
| 84 | // under this app's own bar. |
| 85 | type homeLinkView struct { |
| 86 | shell |
| 87 | connect.PageData |
| 88 | } |
| 89 | |
| 90 | // renderHome renders connect's account-connection pages, and the |
| 91 | // signed-out page Sign out lands on, in this app's layout and bar. |
| 92 | // |
| 93 | // Referrer-Policy is strict-origin, not no-referrer (shared chrome spec |
| 94 | // §9.5): these pages show the account menu once somebody is signed in, |
| 95 | // and its Sign out chains to id.*/signout, which admits the hop by the |
| 96 | // Referer's origin. strict-origin still sends no path or query. |
| 97 | func (a *app) renderHome(w http.ResponseWriter, r *http.Request, state, label string, status int) { |
| 98 | w.Header().Set("Cache-Control", "no-store") |
| 99 | w.Header().Set("Referrer-Policy", "strict-origin") |
| 100 | if state == "signedout" { |
| 101 | // The same signed-out page standalone mode shows (auth.go), so |
| 102 | // Sign out lands on one page whichever way the person signed in. |
| 103 | a.renderSignedOut(w, r, status) |
| 104 | return |
| 105 | } |
| 106 | page := connect.Page(state, label, func(key string) string { return rastrillo.T(r, "slides.home."+key) }) |
| 107 | var buf bytes.Buffer |
| 108 | if err := pages["home_link"].ExecuteTemplate(&buf, "layout", homeLinkView{shell: a.publicShell(r, titled(page.Title)), PageData: page}); err != nil { |
| 109 | a.logger.Error("slides: render Home account page", "err", err) |
| 110 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) |
| 111 | return |
| 112 | } |
| 113 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 114 | w.WriteHeader(status) |
| 115 | buf.WriteTo(w) |
| 116 | } |
| 117 | func (a *app) homeUser(r *http.Request, s sessions.Session) (User, error) { |
| 118 | if a.home == nil || s.Method != connect.HomeMethod { |
| 119 | return User{}, errHomeIdentity |
| 120 | } |
| 121 | person, err := a.home.Resolve(r.Context(), s.Subject) |
| 122 | if err != nil { |
| 123 | return User{}, err |
| 124 | } |
| 125 | var u User |
| 126 | if err := a.db.G.WithContext(r.Context()).Where("id = ?", person.ID).Take(&u).Error; err != nil { |
| 127 | if errors.Is(err, gorm.ErrRecordNotFound) { |
| 128 | return User{}, linkstore.ErrRefused |
| 129 | } |
| 130 | return User{}, fmt.Errorf("%w: %v", linkstore.ErrStorage, err) |
| 131 | } |
| 132 | if !a.allowed.allows(u.Email) { |
| 133 | return User{}, linkstore.ErrRefused |
| 134 | } |
| 135 | return u, nil |
| 136 | } |
| 137 | |
| 138 | func (a *app) revokeHome(w http.ResponseWriter, r *http.Request, subject string) { |
| 139 | var u User |
| 140 | if a.home != nil && a.db.G.WithContext(r.Context()).Raw("SELECT u.* FROM users u JOIN home_identity_links l ON l.person_id=u.id WHERE l.issuer=? AND l.subject=?", a.cfg.Home.Issuer, subject).Scan(&u).Error == nil && u.ID != 0 && !a.allowed.allows(u.Email) { |
| 141 | a.offboard(w, r, u.Email) |
| 142 | return |
| 143 | } |
| 144 | if err := a.db.G.WithContext(r.Context()).Exec("DELETE FROM sessions WHERE method=? AND subject=?", connect.HomeMethod, subject).Error; err != nil { |
| 145 | a.logger.Error("slides: revoke Home session", "err", err) |
| 146 | } |
| 147 | if a.homeSessions != nil { |
| 148 | a.homeSessions.SignOut(w, r) |
| 149 | } |
| 150 | if r.Method == http.MethodGet || r.Method == http.MethodHead { |
| 151 | http.Redirect(w, r, "/signin?err=removed", http.StatusSeeOther) |
| 152 | } else { |
| 153 | w.WriteHeader(http.StatusForbidden) |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | type exportSessionKey struct{} |
| 158 | |
| 159 | func (a *app) exportPrincipal(r *http.Request) *http.Request { |
| 160 | s, ok := sessions.Current(r) |
| 161 | if !ok || s.Method != connect.HomeMethod { |
| 162 | return r |
| 163 | } |
| 164 | u, err := a.homeUser(r, s) |
| 165 | if err != nil { |
| 166 | return r |
| 167 | } |
| 168 | original := s |
| 169 | s.Subject = u.Email |
| 170 | return sessions.WithSession(r.WithContext(context.WithValue(r.Context(), exportSessionKey{}, original)), s) |
| 171 | } |
| 172 | func restoreExportPrincipal(r *http.Request) *http.Request { |
| 173 | if s, ok := r.Context().Value(exportSessionKey{}).(sessions.Session); ok { |
| 174 | return sessions.WithSession(r, s) |
| 175 | } |
| 176 | return r |
| 177 | } |
| 178 | |