oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1package docs
2
3import (
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
16const homeAssertionMethod = "home-assertion"
17
18var (
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.
25type requestIdentity struct {
26 Actor Actor
27 HomeSubject string
28 Session sessions.Session
29}
30type identityContextKey struct{}
31
32func currentIdentity(r *http.Request) (requestIdentity, bool) {
33 id, ok := r.Context().Value(identityContextKey{}).(requestIdentity)
34 return id, ok
35}
36
37type 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
45func (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.
119func (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
136func 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.
146func 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.
160func 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