Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Add browser auth, contributor access, and gated CI

6c84d387e5f608360e30484a1a5b1cff41076850 · Keenan Nemetz · 16d ago · parent c36d5073

Add browser auth, contributor access, and gated CI

Browser sign-in now uses passkeys. Enrollment links are one-time, sessions are hashed in users.db, and issue/comment/approval writes use the session account. HTTPS public_url is required for WebAuthn.

Add the contributor role between read and maintain. Contributors can discuss and open change requests, and may push only refs/heads/contrib/<account-id>/. Contributor CI is held per commit until a maintainer allows the exact tip from the repository page.

Rename the write role to maintain. write remains accepted as the historical spelling, and access.Write remains as a Go alias so existing callers and ACL rows keep working.

Update the web, CLI, queue schema, and guides for the new flows, and cover the role, namespace, auth, and CI-gating paths in tests.

server/web/auth_handlers.go +361

@@ -0,0 +1,361 @@
1 package web
2
3 import (
4 "net/http"
5 "net/url"
6 "strings"
7 "time"
8
9 "github.com/go-webauthn/webauthn/protocol"
10 "github.com/go-webauthn/webauthn/webauthn"
11
12 "github.com/rodent-software/vole/forge/identity"
13 "github.com/rodent-software/vole/forge/session"
14 )
15
16 // LoginPage renders the passkey form. When public_url is not HTTPS the page
17 // explains the missing configuration instead of pretending login works.
18 func (w *Web) LoginPage(rw http.ResponseWriter, r *http.Request) {
19 next := safeNext(r.URL.Query().Get("next"))
20 w.render(rw, "login", struct {
21 Page
22 Next string
23 Enabled bool
24 Reason string
25 }{
26 Page: w.page(r, "Log in"),
27 Next: next,
28 Enabled: w.enabled(),
29 Reason: authReason(w.Auth),
30 }, 0)
31 }
32
33 // InvitePage shows whose account an enrollment link belongs to. The link is
34 // validated here but not consumed; consuming happens only after the authenticator
35 // has produced a credential.
36 func (w *Web) InvitePage(rw http.ResponseWriter, r *http.Request) {
37 token := r.PathValue("token")
38 acct, err := w.Ident.InviteAccount(token)
39 if err != nil {
40 w.render(rw, "invite", struct {
41 Page
42 Token string
43 Account *identity.Account
44 Enabled bool
45 Reason string
46 }{
47 Page: w.page(r, "Enrollment link"),
48 Enabled: w.enabled(),
49 Reason: authReason(w.Auth),
50 }, http.StatusNotFound)
51 return
52 }
53 w.render(rw, "invite", struct {
54 Page
55 Token string
56 Account *identity.Account
57 Enabled bool
58 Reason string
59 }{
60 Page: w.page(r, "Join "+acct.Name),
61 Token: token,
62 Account: acct,
63 Enabled: w.enabled(),
64 Reason: authReason(w.Auth),
65 }, 0)
66 }
67
68 func authReason(a *Auth) string {
69 if a == nil {
70 return "browser login is not configured"
71 }
72 return a.Disabled
73 }
74
75 func safeNext(next string) string {
76 if next == "" || strings.ContainsAny(next, "\\\r\n\t") {
77 return "/"
78 }
79 u, err := url.Parse(next)
80 if err != nil || u.IsAbs() || u.Host != "" || !strings.HasPrefix(u.Path, "/") {
81 return "/"
82 }
83 return next
84 }
85
86 func (w *Web) LoginBegin(rw http.ResponseWriter, r *http.Request) {
87 if !w.enabled() {
88 writeJSONError(rw, http.StatusServiceUnavailable, authReason(w.Auth))
89 return
90 }
91 var body struct {
92 Username string `json:"username"`
93 }
94 if err := decodeJSONBody(r, &body); err != nil {
95 writeJSONError(rw, http.StatusBadRequest, err.Error())
96 return
97 }
98 acct, err := w.Ident.Find(strings.TrimSpace(body.Username))
99 if err != nil || !acct.Active() {
100 writeJSONError(rw, http.StatusUnauthorized, "no passkey is available for that account")
101 return
102 }
103 user, err := w.waUser(acct)
104 if err != nil || len(user.creds) == 0 {
105 writeJSONError(rw, http.StatusUnauthorized, "no passkey is available for that account")
106 return
107 }
108 assertion, data, err := w.Auth.WA.BeginLogin(user)
109 if err != nil {
110 writeJSONError(rw, http.StatusBadRequest, "cannot begin login: "+err.Error())
111 return
112 }
113 if err := w.beginCeremony(rw, ceremony{Kind: "login", Data: *data, AccountID: acct.ID}, assertion); err != nil {
114 writeJSONError(rw, http.StatusInternalServerError, "cannot store the ceremony")
115 }
116 }
117
118 func (w *Web) LoginFinish(rw http.ResponseWriter, r *http.Request) {
119 if !w.enabled() {
120 writeJSONError(rw, http.StatusServiceUnavailable, authReason(w.Auth))
121 return
122 }
123 c, ok := w.takeCeremony(rw, r)
124 if !ok {
125 return
126 }
127 if c.Kind != "login" {
128 writeJSONError(rw, http.StatusBadRequest, "wrong ceremony")
129 return
130 }
131 acct, err := w.Ident.Find(c.AccountID)
132 if err != nil || !acct.Active() {
133 writeJSONError(rw, http.StatusUnauthorized, "login failed")
134 return
135 }
136 user, err := w.waUser(acct)
137 if err != nil {
138 writeJSONError(rw, http.StatusUnauthorized, "login failed")
139 return
140 }
141 cred, err := w.Auth.WA.FinishLogin(user, c.Data, r)
142 if err != nil {
143 writeJSONError(rw, http.StatusUnauthorized, "login failed")
144 return
145 }
146 if err := w.Ident.UpdateCredential(cred.ID, cred.Authenticator.SignCount, time.Now().UTC()); err != nil {
147 writeJSONError(rw, http.StatusInternalServerError, "cannot update the passkey")
148 return
149 }
150 sess, err := w.Sessions.Create(acct.ID, cred.ID, r.UserAgent())
151 if err != nil {
152 writeJSONError(rw, http.StatusInternalServerError, "cannot create a session")
153 return
154 }
155 w.setSessionCookie(rw, sess)
156 writeJSON(rw, http.StatusOK, map[string]any{"ok": true, "redirect": "/"})
157 }
158
159 func (w *Web) InviteBegin(rw http.ResponseWriter, r *http.Request) {
160 if !w.enabled() {
161 writeJSONError(rw, http.StatusServiceUnavailable, authReason(w.Auth))
162 return
163 }
164 var body struct {
165 Token string `json:"token"`
166 }
167 if err := decodeJSONBody(r, &body); err != nil {
168 writeJSONError(rw, http.StatusBadRequest, err.Error())
169 return
170 }
171 acct, err := w.Ident.InviteAccount(strings.TrimSpace(body.Token))
172 if err != nil {
173 writeJSONError(rw, http.StatusBadRequest, "this enrollment link is no longer valid")
174 return
175 }
176 user, err := w.waUser(acct)
177 if err != nil {
178 writeJSONError(rw, http.StatusInternalServerError, "cannot read the account")
179 return
180 }
181 creation, data, err := w.Auth.WA.BeginRegistration(user,
182 webauthn.WithExclusions(webauthn.Credentials(user.WebAuthnCredentials()).CredentialDescriptors()),
183 webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred))
184 if err != nil {
185 writeJSONError(rw, http.StatusBadRequest, "cannot begin enrollment: "+err.Error())
186 return
187 }
188 if err := w.beginCeremony(rw, ceremony{
189 Kind: "invite", Data: *data, AccountID: acct.ID,
190 InviteToken: strings.TrimSpace(body.Token),
191 }, creation); err != nil {
192 writeJSONError(rw, http.StatusInternalServerError, "cannot store the ceremony")
193 }
194 }
195
196 func (w *Web) InviteFinish(rw http.ResponseWriter, r *http.Request) {
197 if !w.enabled() {
198 writeJSONError(rw, http.StatusServiceUnavailable, authReason(w.Auth))
199 return
200 }
201 c, ok := w.takeCeremony(rw, r)
202 if !ok {
203 return
204 }
205 if c.Kind != "invite" {
206 writeJSONError(rw, http.StatusBadRequest, "wrong ceremony")
207 return
208 }
209 acct, err := w.Ident.InviteAccount(c.InviteToken)
210 if err != nil || acct.ID != c.AccountID {
211 writeJSONError(rw, http.StatusBadRequest, "this enrollment link is no longer valid")
212 return
213 }
214 user, err := w.waUser(acct)
215 if err != nil {
216 writeJSONError(rw, http.StatusInternalServerError, "cannot read the account")
217 return
218 }
219 cred, err := w.Auth.WA.FinishRegistration(user, c.Data, r)
220 if err != nil {
221 writeJSONError(rw, http.StatusBadRequest, "enrollment failed: "+err.Error())
222 return
223 }
224 acct, err = w.Ident.AcceptInvite(c.InviteToken, fromWebAuthn(cred, "first passkey"), acct.Name)
225 if err != nil {
226 writeJSONError(rw, http.StatusBadRequest, "this enrollment link is no longer valid")
227 return
228 }
229 sess, err := w.Sessions.Create(acct.ID, cred.ID, r.UserAgent())
230 if err != nil {
231 writeJSONError(rw, http.StatusInternalServerError, "cannot create a session")
232 return
233 }
234 w.setSessionCookie(rw, sess)
235 writeJSON(rw, http.StatusOK, map[string]any{"ok": true, "redirect": "/"})
236 }
237
238 func (w *Web) Logout(rw http.ResponseWriter, r *http.Request) {
239 if !w.checkCSRF(r) {
240 http.Error(rw, "bad request", http.StatusForbidden)
241 return
242 }
243 if c, err := r.Cookie(sessionCookieName); err == nil && c.Value != "" && w.Sessions != nil {
244 _ = w.Sessions.Revoke(c.Value)
245 }
246 w.clearCookie(rw, sessionCookieName)
247 http.Redirect(rw, r, "/", http.StatusSeeOther)
248 }
249
250 func (w *Web) AccountPage(rw http.ResponseWriter, r *http.Request) {
251 pr, ok := w.requireHTMLUser(rw, r)
252 if !ok {
253 return
254 }
255 creds, err := w.Ident.Credentials(pr.account.ID)
256 if err != nil {
257 http.Error(rw, "cannot read passkeys", http.StatusInternalServerError)
258 return
259 }
260 var sessions []session.Session
261 if w.Sessions != nil {
262 sessions, _ = w.Sessions.List(pr.account.ID)
263 }
264 w.render(rw, "account", struct {
265 Page
266 Account *identity.Account
267 Credentials []identity.Credential
268 Sessions []session.Session
269 Enabled bool
270 Reason string
271 }{
272 Page: w.page(r, "Account"),
273 Account: pr.account,
274 Credentials: creds,
275 Sessions: sessions,
276 Enabled: w.enabled(),
277 Reason: authReason(w.Auth),
278 }, 0)
279 }
280
281 func (w *Web) AddPasskeyBegin(rw http.ResponseWriter, r *http.Request) {
282 pr, ok := w.requireAPIUser(rw, r)
283 if !ok {
284 return
285 }
286 if !w.checkCSRF(r) {
287 writeJSONError(rw, http.StatusForbidden, "bad request")
288 return
289 }
290 if !w.enabled() {
291 writeJSONError(rw, http.StatusServiceUnavailable, authReason(w.Auth))
292 return
293 }
294 var body struct {
295 Label string `json:"label"`
296 }
297 if err := decodeJSONBody(r, &body); err != nil {
298 writeJSONError(rw, http.StatusBadRequest, err.Error())
299 return
300 }
301 user, err := w.waUser(pr.account)
302 if err != nil {
303 writeJSONError(rw, http.StatusInternalServerError, "cannot read the account")
304 return
305 }
306 creation, data, err := w.Auth.WA.BeginRegistration(user,
307 webauthn.WithExclusions(webauthn.Credentials(user.WebAuthnCredentials()).CredentialDescriptors()),
308 webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred))
309 if err != nil {
310 writeJSONError(rw, http.StatusBadRequest, "cannot begin registration: "+err.Error())
311 return
312 }
313 if err := w.beginCeremony(rw, ceremony{
314 Kind: "passkey", Data: *data, AccountID: pr.account.ID,
315 Label: strings.TrimSpace(body.Label),
316 }, creation); err != nil {
317 writeJSONError(rw, http.StatusInternalServerError, "cannot store the ceremony")
318 }
319 }
320
321 func (w *Web) AddPasskeyFinish(rw http.ResponseWriter, r *http.Request) {
322 pr, ok := w.requireAPIUser(rw, r)
323 if !ok {
324 return
325 }
326 if !w.checkCSRF(r) {
327 writeJSONError(rw, http.StatusForbidden, "bad request")
328 return
329 }
330 if !w.enabled() {
331 writeJSONError(rw, http.StatusServiceUnavailable, authReason(w.Auth))
332 return
333 }
334 c, ok := w.takeCeremony(rw, r)
335 if !ok {
336 return
337 }
338 if c.Kind != "passkey" || c.AccountID != pr.account.ID {
339 writeJSONError(rw, http.StatusBadRequest, "wrong ceremony")
340 return
341 }
342 user, err := w.waUser(pr.account)
343 if err != nil {
344 writeJSONError(rw, http.StatusInternalServerError, "cannot read the account")
345 return
346 }
347 cred, err := w.Auth.WA.FinishRegistration(user, c.Data, r)
348 if err != nil {
349 writeJSONError(rw, http.StatusBadRequest, "registration failed: "+err.Error())
350 return
351 }
352 label := c.Label
353 if label == "" {
354 label = "passkey"
355 }
356 if err := w.Ident.AddCredential(pr.account.ID, fromWebAuthn(cred, label), pr.account.Name); err != nil {
357 writeJSONError(rw, http.StatusBadRequest, err.Error())
358 return
359 }
360 writeJSON(rw, http.StatusOK, map[string]any{"ok": true})
361 }