| 1 | package calls |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "database/sql" |
| 7 | "errors" |
| 8 | "fmt" |
| 9 | "net/http" |
| 10 | "strconv" |
| 11 | |
| 12 | "amadan.net/oficina/home/connect" |
| 13 | "amadan.net/oficina/home/connect/linkstore" |
| 14 | "amadan.net/oficina/home/handoff" |
| 15 | "amadan.net/rastrillo/rastrillo/csrf" |
| 16 | "amadan.net/rastrillo/rastrillo/migrate" |
| 17 | "amadan.net/rastrillo/rastrillo/sessions" |
| 18 | ) |
| 19 | |
| 20 | var errHomeIdentity = errors.New("calls: Home identity refused") |
| 21 | |
| 22 | // errHomeRemoved is the definite subset of errHomeIdentity: the Home |
| 23 | // link is refused, the person row is gone, or the person is no longer a |
| 24 | // member. A database that could not be read is errHomeIdentity alone, |
| 25 | // and never ends a session. |
| 26 | var errHomeRemoved = fmt.Errorf("%w: removed", errHomeIdentity) |
| 27 | |
| 28 | type homeIntegration struct { |
| 29 | bridge *connect.Bridge |
| 30 | routes *connect.Routes |
| 31 | sessions *sessions.Sessions |
| 32 | } |
| 33 | |
| 34 | func (a *app) wireHome() error { |
| 35 | h := a.cfg.home |
| 36 | if h == nil { |
| 37 | return nil |
| 38 | } |
| 39 | if _, err := migrate.Apply(context.Background(), a.db, migrate.Merge(migrate.MustFromFS(handoff.Migrations, "oficina_handoff"), migrate.MustFromFS(linkstore.Migrations, linkstore.SchemaNamespace))); err != nil { |
| 40 | return errHomeIdentity |
| 41 | } |
| 42 | sess, err := sessions.New(sessions.Config{DB: a.db.Writer(), Origin: a.cfg.Origin, SigninPath: signinPath, Logger: a.logger}) |
| 43 | if err != nil || sess.CookieName() != a.auth.SessionCookie() { |
| 44 | return errHomeIdentity |
| 45 | } |
| 46 | read := func(r *http.Request) (*connect.Session, error) { |
| 47 | s, ok := sess.From(r) |
| 48 | if !ok { |
| 49 | return nil, nil |
| 50 | } |
| 51 | return &connect.Session{Subject: s.Subject, Method: s.Method, AuthTime: s.AuthTime, At: s.At}, nil |
| 52 | } |
| 53 | b, err := connect.New(connect.Config{DB: a.db.Writer(), Origin: a.cfg.Origin, Issuer: h.Issuer, ConsumerID: h.ConsumerID, Namespace: "oficina/calls", SessionCookie: sess.CookieName(), Keys: h.Keys, DEK: h.DEK, ReadSession: read, |
| 54 | MintHomeSession: func(w http.ResponseWriter, r *http.Request, s connect.Session) error { |
| 55 | return sess.SignIn(w, r, sessions.Session{Subject: s.Subject, Method: s.Method, AuthTime: s.AuthTime}) |
| 56 | }, |
| 57 | LookupLocalSession: func(ctx context.Context, s connect.Session) (connect.Person, error) { |
| 58 | if s.Method != "magiclink" && s.Method != "keymail" { |
| 59 | return connect.Person{}, errHomeIdentity |
| 60 | } |
| 61 | p, err := a.store.existingHomePerson(ctx, s.Subject) |
| 62 | if err != nil || !a.isMember(p) { |
| 63 | return connect.Person{}, errHomeIdentity |
| 64 | } |
| 65 | return connect.Person{ID: p.ID, Label: p.Email}, nil |
| 66 | }, |
| 67 | LookupPerson: func(ctx context.Context, q linkstore.Queryer, id int64) (string, error) { |
| 68 | p, err := a.store.homePersonByID(ctx, q, id) |
| 69 | if err != nil { |
| 70 | return "", err |
| 71 | } |
| 72 | if !a.isMember(p) { |
| 73 | return "", sql.ErrNoRows |
| 74 | } |
| 75 | return p.Email, nil |
| 76 | }, Render: a.renderHome}) |
| 77 | if err != nil { |
| 78 | return errHomeIdentity |
| 79 | } |
| 80 | routes, err := connect.NewRoutes(b, connect.RoutesConfig{LocalLandingPath: "/continue", Namespace: "oficina/calls", Origin: a.cfg.Origin, Issuer: h.Issuer, FormPolicy: homeFormPolicy(h.Issuer), SessionCookie: sess.CookieName(), DEK: h.DEK, ReadSession: read, SignOut: sess.SignOut, LocalSignin: a.localSignin, Protect: csrf.Protect(a.cfg.Origin), Render: a.renderHome}) |
| 81 | if err != nil { |
| 82 | return errHomeIdentity |
| 83 | } |
| 84 | a.home = &homeIntegration{b, routes, sess} |
| 85 | return nil |
| 86 | } |
| 87 | |
| 88 | func (a *app) personForSession(ctx context.Context, s sessions.Session) (Person, error) { |
| 89 | if s.Method == "guest" { |
| 90 | id, err := strconv.ParseInt(s.Subject, 10, 64) |
| 91 | if err != nil { |
| 92 | return Person{}, errHomeIdentity |
| 93 | } |
| 94 | p, ok, err := a.store.personByID(ctx, id) |
| 95 | if err != nil || !ok || !p.Anonymous { |
| 96 | return Person{}, errHomeIdentity |
| 97 | } |
| 98 | return p, nil |
| 99 | } |
| 100 | if s.Method == connect.HomeMethod { |
| 101 | if a.home == nil { |
| 102 | return Person{}, errHomeIdentity |
| 103 | } |
| 104 | linked, err := a.home.bridge.Resolve(ctx, s.Subject) |
| 105 | if errors.Is(err, linkstore.ErrRefused) { |
| 106 | return Person{}, errHomeRemoved |
| 107 | } |
| 108 | if err != nil { |
| 109 | return Person{}, errHomeIdentity |
| 110 | } |
| 111 | p, err := a.store.homePersonByID(ctx, a.db.Writer(), linked.ID) |
| 112 | if errors.Is(err, sql.ErrNoRows) { |
| 113 | return Person{}, errHomeRemoved |
| 114 | } |
| 115 | if err != nil { |
| 116 | return Person{}, errHomeIdentity |
| 117 | } |
| 118 | if !a.isMember(p) { |
| 119 | return Person{}, errHomeRemoved |
| 120 | } |
| 121 | return p, nil |
| 122 | } |
| 123 | if s.Method != "magiclink" && s.Method != "keymail" { |
| 124 | return Person{}, errHomeIdentity |
| 125 | } |
| 126 | return a.store.ensurePerson(ctx, s.Subject) |
| 127 | } |
| 128 | |
| 129 | // signin is GET /signin. A session that no longer resolves to a person |
| 130 | // who may be here is ended first and told so: with Home configured, |
| 131 | // connect's Signin skips straight to the destination for any live |
| 132 | // session cookie, and the destination would refuse it again — the loop |
| 133 | // Astra found in Docs (#65, finding 1) and Sheets (#39, finding 2). |
| 134 | func (a *app) signin(w http.ResponseWriter, r *http.Request) { |
| 135 | if id, ok := a.auth.SessionFrom(r); ok { |
| 136 | if _, err := a.lookupPerson(r, sessions.Session{Subject: id.Address, Method: string(id.Method)}); errors.Is(err, errHomeRemoved) { |
| 137 | a.endRemovedSession(w, r) |
| 138 | return |
| 139 | } |
| 140 | } |
| 141 | if a.home != nil { |
| 142 | if _, pending := a.readJoinCookie(r); !pending { |
| 143 | a.home.sessions.Middleware(http.HandlerFunc(a.home.routes.Signin)).ServeHTTP(w, r) |
| 144 | return |
| 145 | } |
| 146 | } |
| 147 | a.localSignin(w, r) |
| 148 | } |
| 149 | |
| 150 | // endRemovedSession ends this request's session and sends a page request |
| 151 | // to the sign-in form with the reason; anything else gets the 403 a |
| 152 | // fetch reads as signed out (room.js's readRefusal). |
| 153 | func (a *app) endRemovedSession(w http.ResponseWriter, r *http.Request) { |
| 154 | a.nativeAuth.sessions.SignOut(w, r) |
| 155 | if r.Method != http.MethodGet && r.Method != http.MethodHead { |
| 156 | plainError(w, http.StatusForbidden) |
| 157 | return |
| 158 | } |
| 159 | w.Header().Set("Cache-Control", "no-store") |
| 160 | http.Redirect(w, r, a.signinAgain(r, "err=removed"), http.StatusSeeOther) |
| 161 | } |
| 162 | |
| 163 | func (a *app) renderHome(w http.ResponseWriter, r *http.Request, state, label string, status int) { |
| 164 | home := connect.Page(state, label) |
| 165 | if state == "signedout" { |
| 166 | // Where Sign out lands: the Oficina door, in the door's words |
| 167 | // (connect's name the hub); its Sign in goes to connect's action. |
| 168 | a.renderSignedOut(w, r, home.Action, status) |
| 169 | return |
| 170 | } |
| 171 | var body bytes.Buffer |
| 172 | if err := pages["home"].ExecuteTemplate(&body, "layout", a.page(r, titled(home.Title), map[string]any{"Home": home})); err != nil { |
| 173 | a.ErrorPage(w, r, 500, "") |
| 174 | return |
| 175 | } |
| 176 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 177 | w.Header().Set("Cache-Control", "no-store") |
| 178 | w.WriteHeader(status) |
| 179 | _, _ = w.Write(body.Bytes()) |
| 180 | } |
| 181 | func (a *app) showHomeConnection(r *http.Request) bool { |
| 182 | s, ok := sessions.Current(r) |
| 183 | return a.home != nil && ok && (s.Method == "magiclink" || s.Method == "keymail") |
| 184 | } |
| 185 | |