| 1 | package docs |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "database/sql" |
| 6 | "errors" |
| 7 | "github.com/carlosframework/docs/internal/docs/principalstore" |
| 8 | "net/http" |
| 9 | "time" |
| 10 | |
| 11 | "amadan.net/rastrillo/rastrillo" |
| 12 | "amadan.net/rastrillo/rastrillo/sessions" |
| 13 | "gorm.io/gorm" |
| 14 | ) |
| 15 | |
| 16 | const homeAssertionMethod = "home-assertion" |
| 17 | |
| 18 | var ( |
| 19 | errIdentityRefused = errors.New("docs: identity refused") |
| 20 | errIdentityStorage = errors.New("docs: identity storage unavailable") |
| 21 | ) |
| 22 | |
| 23 | // requestIdentity keeps the verified session separate from the local person |
| 24 | // and address that own documents and hold grants. |
| 25 | type requestIdentity struct { |
| 26 | Actor Actor |
| 27 | HomeSubject string |
| 28 | Session sessions.Session |
| 29 | } |
| 30 | type identityContextKey struct{} |
| 31 | |
| 32 | func currentIdentity(r *http.Request) (requestIdentity, bool) { |
| 33 | id, ok := r.Context().Value(identityContextKey{}).(requestIdentity) |
| 34 | return id, ok |
| 35 | } |
| 36 | |
| 37 | type identityResolver struct { |
| 38 | team *teamRuntime |
| 39 | store *Store |
| 40 | issuer string |
| 41 | // Return zero for an unknown link. Errors must not contain identity values. |
| 42 | linkedPerson func(context.Context, string, string) (int64, error) |
| 43 | } |
| 44 | |
| 45 | func (v identityResolver) resolve(r *http.Request) (requestIdentity, error) { |
| 46 | session, ok := sessions.Current(r) |
| 47 | if !ok || session.Subject == "" || v.store == nil { |
| 48 | return requestIdentity{}, errIdentityRefused |
| 49 | } |
| 50 | if v.store.team != nil && v.team == nil { |
| 51 | return requestIdentity{}, errIdentityRefused |
| 52 | } |
| 53 | if session.Method == homeAssertionMethod { |
| 54 | if v.team != nil { |
| 55 | p, err := v.team.principals.Resolve(r.Context(), v.team.bindings.Issuer, session.Subject) |
| 56 | if errors.Is(err, principalstore.ErrRefused) { |
| 57 | return requestIdentity{}, errIdentityRefused |
| 58 | } |
| 59 | if err != nil { |
| 60 | return requestIdentity{}, errIdentityStorage |
| 61 | } |
| 62 | return requestIdentity{Actor: Actor{PersonID: p.ID, VerifiedAddress: p.VerifiedAddress}, Session: session, HomeSubject: session.Subject}, nil |
| 63 | } |
| 64 | if v.issuer == "" || v.linkedPerson == nil { |
| 65 | return requestIdentity{}, errIdentityRefused |
| 66 | } |
| 67 | person, err := v.linkedPerson(r.Context(), v.issuer, session.Subject) |
| 68 | if errors.Is(err, errIdentityRefused) { |
| 69 | return requestIdentity{}, errIdentityRefused |
| 70 | } |
| 71 | if err != nil { |
| 72 | return requestIdentity{}, errIdentityStorage |
| 73 | } |
| 74 | if person <= 0 { |
| 75 | return requestIdentity{}, errIdentityRefused |
| 76 | } |
| 77 | var p Person |
| 78 | if err = v.store.db.WithContext(r.Context()).First(&p, person).Error; err != nil { |
| 79 | if errors.Is(err, gorm.ErrRecordNotFound) { |
| 80 | return requestIdentity{}, errIdentityRefused |
| 81 | } |
| 82 | return requestIdentity{}, errIdentityStorage |
| 83 | } |
| 84 | address := subjectOf(p.Email) |
| 85 | if address == "" { |
| 86 | return requestIdentity{}, errIdentityRefused |
| 87 | } |
| 88 | return requestIdentity{Actor: localActor(p.ID, address), Session: session}, nil |
| 89 | } |
| 90 | if v.team != nil && session.Method != "magiclink" && session.Method != "keymail" { |
| 91 | return requestIdentity{}, errIdentityRefused |
| 92 | } |
| 93 | // The local authentication plugin verified this address. Preserve its |
| 94 | // existing get-or-create behavior, including opt-in sample seeding. |
| 95 | person, err := v.store.EnsurePerson(r.Context(), session.Subject) |
| 96 | if err != nil { |
| 97 | return requestIdentity{}, errIdentityStorage |
| 98 | } |
| 99 | id := requestIdentity{Actor: localActor(person, session.Subject), Session: session} |
| 100 | if v.team != nil { |
| 101 | p, err := v.team.principals.Person(r.Context(), person) |
| 102 | if err != nil { |
| 103 | return requestIdentity{}, errIdentityStorage |
| 104 | } |
| 105 | if p.Kind != principalstore.Local { |
| 106 | return requestIdentity{}, errIdentityRefused |
| 107 | } |
| 108 | id.Actor = Actor{PersonID: p.ID, VerifiedAddress: p.VerifiedAddress} |
| 109 | err = v.team.db.QueryRowContext(r.Context(), "SELECT subject FROM docs_identity_links WHERE issuer=? AND person_id=?", v.team.bindings.Issuer, p.ID).Scan(&id.HomeSubject) |
| 110 | if err != nil && !errors.Is(err, sql.ErrNoRows) { |
| 111 | return requestIdentity{}, errIdentityStorage |
| 112 | } |
| 113 | } |
| 114 | return id, nil |
| 115 | } |
| 116 | |
| 117 | // middleware belongs after RequireSession and before admission. A verified |
| 118 | // Home subject with no local link must never reach an app handler. |
| 119 | func (v identityResolver) middleware(signinPath string) func(http.Handler) http.Handler { |
| 120 | return func(next http.Handler) http.Handler { |
| 121 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 122 | id, err := v.resolve(r) |
| 123 | if err != nil { |
| 124 | if errors.Is(err, errIdentityStorage) { |
| 125 | http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusServiceUnavailable) |
| 126 | } else { |
| 127 | refuseIdentity(w, r, signinPath) |
| 128 | } |
| 129 | return |
| 130 | } |
| 131 | next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), identityContextKey{}, id))) |
| 132 | }) |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | func refuseIdentity(w http.ResponseWriter, r *http.Request, signinPath string) { |
| 137 | if r.Method == http.MethodGet || r.Method == http.MethodHead { |
| 138 | http.Redirect(w, r, signinPath, http.StatusSeeOther) |
| 139 | return |
| 140 | } |
| 141 | http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusForbidden) |
| 142 | } |
| 143 | |
| 144 | // requestActor supports standalone routes without the suite middleware too. |
| 145 | // The fallback resolver still refuses every Home session without a link. |
| 146 | func requestActor(r *http.Request, store *Store) (Actor, error) { |
| 147 | if id, ok := currentIdentity(r); ok { |
| 148 | return id.Actor, nil |
| 149 | } |
| 150 | if store.team != nil { |
| 151 | return Actor{}, errIdentityRefused |
| 152 | } |
| 153 | id, err := (identityResolver{store: store}).resolve(r) |
| 154 | return id.Actor, err |
| 155 | } |
| 156 | |
| 157 | // freshLocalAuthentication accepts only an independent local verification. |
| 158 | // Rastrillo stores zero AuthTime for completed magic links; only that method |
| 159 | // can use the persisted session creation time as verification evidence. |
| 160 | func freshLocalAuthentication(s sessions.Session, now time.Time) (time.Time, bool) { |
| 161 | if s.Subject == "" || (s.Method != "magiclink" && s.Method != "keymail") { |
| 162 | return time.Time{}, false |
| 163 | } |
| 164 | at := s.AuthTime |
| 165 | if at.IsZero() && s.Method == "magiclink" { |
| 166 | at = s.At |
| 167 | } |
| 168 | if at.IsZero() || at.After(now) || now.Sub(at) > 5*time.Minute { |
| 169 | return time.Time{}, false |
| 170 | } |
| 171 | return at, true |
| 172 | } |
| 173 | |