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.go +433

@@ -0,0 +1,433 @@
1 package web
2
3 // Browser authentication: passkey ceremonies, sessions and CSRF.
4 //
5 // The browser is a second credential for an account that already exists; it is
6 // not a second account. A passkey assertion resolves to the same account id an
7 // SSH key does, and every write still goes through that id.
8
9 import (
10 "context"
11 "crypto/rand"
12 "crypto/subtle"
13 "encoding/base64"
14 "encoding/json"
15 "errors"
16 "fmt"
17 "net/http"
18 "net/url"
19 "strings"
20 "sync"
21 "time"
22
23 "github.com/go-webauthn/webauthn/protocol"
24 "github.com/go-webauthn/webauthn/webauthn"
25
26 "github.com/rodent-software/vole/forge/access"
27 "github.com/rodent-software/vole/forge/config"
28 "github.com/rodent-software/vole/forge/identity"
29 "github.com/rodent-software/vole/forge/session"
30 )
31
32 const (
33 sessionCookieName = "__Host-vole_session"
34 ceremonyCookieName = "__Host-vole_ceremony"
35 ceremonyTTL = 5 * time.Minute
36 )
37
38 type Auth struct {
39 WA *webauthn.WebAuthn
40 RPID string
41 Origin string
42 Disabled string
43 }
44
45 func (a *Auth) Enabled() bool { return a != nil && a.WA != nil }
46
47 // newAuth enables passkeys only when the operator has told Vole the canonical
48 // HTTPS origin. WebAuthn is bound to that origin; guessing it from Host would
49 // make a misconfigured proxy an authentication bypass.
50 func newAuth(cfg *config.Config) (*Auth, error) {
51 if strings.TrimSpace(cfg.PublicURL) == "" {
52 return &Auth{Disabled: "set public_url to an https URL to enable browser login"}, nil
53 }
54 u, err := url.Parse(cfg.PublicURL)
55 if err != nil || u.Scheme != "https" || u.Host == "" {
56 return &Auth{Disabled: "browser login needs public_url to be an https URL"}, nil
57 }
58 rpID := strings.ToLower(u.Hostname())
59 if rpID == "" {
60 return &Auth{Disabled: "public_url has no hostname"}, nil
61 }
62 origin := u.Scheme + "://" + u.Host
63 wa, err := webauthn.New(&webauthn.Config{
64 RPID: rpID,
65 RPDisplayName: "Vole",
66 RPOrigins: []string{origin},
67 AttestationPreference: protocol.PreferNoAttestation,
68 AuthenticatorSelection: protocol.AuthenticatorSelection{
69 UserVerification: protocol.VerificationRequired,
70 ResidentKey: protocol.ResidentKeyRequirementPreferred,
71 },
72 })
73 if err != nil {
74 return nil, fmt.Errorf("webauthn: %w", err)
75 }
76 return &Auth{WA: wa, RPID: rpID, Origin: origin}, nil
77 }
78
79 type principalKey struct{}
80
81 type principal struct {
82 account *identity.Account
83 session *session.Session
84 }
85
86 func (w *Web) principal(r *http.Request) *principal {
87 p, _ := r.Context().Value(principalKey{}).(*principal)
88 return p
89 }
90
91 // Middleware resolves the session cookie once per request and sets the browser
92 // security headers every response carries. An invalid or expired cookie is
93 // cleared rather than left to be retried on every page.
94 func (w *Web) Middleware(next http.Handler) http.Handler {
95 return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
96 w.securityHeaders(rw)
97 if w.Sessions != nil {
98 if c, err := r.Cookie(sessionCookieName); err == nil && c.Value != "" {
99 sess, serr := w.Sessions.Lookup(c.Value)
100 if serr == nil {
101 if acct, aerr := w.Ident.Find(sess.AccountID); aerr == nil && acct.Active() {
102 r = r.WithContext(context.WithValue(r.Context(), principalKey{}, &principal{account: acct, session: sess}))
103 } else {
104 w.clearCookie(rw, sessionCookieName)
105 }
106 } else {
107 w.clearCookie(rw, sessionCookieName)
108 }
109 }
110 }
111 next.ServeHTTP(rw, r)
112 })
113 }
114
115 func (w *Web) securityHeaders(rw http.ResponseWriter) {
116 rw.Header().Set("X-Content-Type-Options", "nosniff")
117 rw.Header().Set("X-Frame-Options", "DENY")
118 rw.Header().Set("Referrer-Policy", "same-origin")
119 rw.Header().Set("Content-Security-Policy",
120 "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'")
121 if w.Auth != nil && w.Auth.Enabled() {
122 rw.Header().Set("Strict-Transport-Security", "max-age=31536000")
123 }
124 }
125
126 func (w *Web) enabled() bool { return w.Auth != nil && w.Auth.Enabled() }
127
128 func (w *Web) accountID(r *http.Request) string {
129 if p := w.principal(r); p != nil {
130 return p.account.ID
131 }
132 return ""
133 }
134
135 func (w *Web) canDiscuss(r *http.Request, acl *access.ACL) bool {
136 id := w.accountID(r)
137 return id != "" && acl.CanDiscuss(id)
138 }
139
140 func (w *Web) canApprove(r *http.Request, acl *access.ACL, authorID string) bool {
141 id := w.accountID(r)
142 return id != "" && id != authorID && acl.CanApprove(id)
143 }
144
145 func (w *Web) page(r *http.Request, title string) Page {
146 p := Page{Title: title}
147 if pr := w.principal(r); pr != nil {
148 p.LoggedIn = true
149 p.User = pr.account.Name
150 p.AccountID = pr.account.ID
151 p.CSRF = pr.session.CSRF
152 }
153 return p
154 }
155
156 func (w *Web) setSessionCookie(rw http.ResponseWriter, sess *session.Session) {
157 http.SetCookie(rw, &http.Cookie{
158 Name: sessionCookieName,
159 Value: sess.Token,
160 Path: "/",
161 HttpOnly: true,
162 Secure: true,
163 SameSite: http.SameSiteLaxMode,
164 Expires: sess.Expires,
165 MaxAge: int(time.Until(sess.Expires).Seconds()),
166 })
167 }
168
169 func (w *Web) clearCookie(rw http.ResponseWriter, name string) {
170 http.SetCookie(rw, &http.Cookie{
171 Name: name,
172 Value: "",
173 Path: "/",
174 HttpOnly: true,
175 Secure: true,
176 SameSite: http.SameSiteLaxMode,
177 MaxAge: -1,
178 })
179 }
180
181 func (w *Web) checkCSRF(r *http.Request) bool {
182 pr := w.principal(r)
183 if pr == nil || pr.session == nil || pr.session.CSRF == "" {
184 return false
185 }
186 got := r.Header.Get("X-Vole-CSRF")
187 if got == "" {
188 _ = r.ParseForm()
189 got = r.PostFormValue("csrf")
190 }
191 return subtle.ConstantTimeCompare([]byte(got), []byte(pr.session.CSRF)) == 1
192 }
193
194 func (w *Web) requireHTMLUser(rw http.ResponseWriter, r *http.Request) (*principal, bool) {
195 pr := w.principal(r)
196 if pr == nil {
197 next := url.QueryEscape(r.URL.RequestURI())
198 http.Redirect(rw, r, "/login?next="+next, http.StatusSeeOther)
199 return nil, false
200 }
201 return pr, true
202 }
203
204 func (w *Web) requireAPIUser(rw http.ResponseWriter, r *http.Request) (*principal, bool) {
205 pr := w.principal(r)
206 if pr == nil {
207 writeJSONError(rw, http.StatusUnauthorized, "log in first")
208 return nil, false
209 }
210 return pr, true
211 }
212
213 func writeJSON(rw http.ResponseWriter, status int, v any) {
214 rw.Header().Set("Content-Type", "application/json; charset=utf-8")
215 rw.WriteHeader(status)
216 _ = json.NewEncoder(rw).Encode(v)
217 }
218
219 func writeJSONError(rw http.ResponseWriter, status int, msg string) {
220 writeJSON(rw, status, map[string]string{"error": msg})
221 }
222
223 // ---------- ceremony state ----------
224
225 type ceremony struct {
226 Kind string // login | invite | passkey
227 Data webauthn.SessionData
228 AccountID string
229 InviteToken string
230 Label string
231 Expires time.Time
232 }
233
234 type ceremonyStore struct {
235 mu sync.Mutex
236 m map[string]ceremony
237 }
238
239 func newCeremonyStore() *ceremonyStore { return &ceremonyStore{m: map[string]ceremony{}} }
240
241 func (s *ceremonyStore) put(c ceremony) (string, error) {
242 id := make([]byte, 32)
243 if _, err := rand.Read(id); err != nil {
244 return "", err
245 }
246 key := base64.RawURLEncoding.EncodeToString(id)
247 c.Expires = time.Now().UTC().Add(ceremonyTTL)
248 s.mu.Lock()
249 defer s.mu.Unlock()
250 for k, v := range s.m {
251 if time.Now().UTC().After(v.Expires) {
252 delete(s.m, k)
253 }
254 }
255 s.m[key] = c
256 return key, nil
257 }
258
259 func (s *ceremonyStore) take(key string) (ceremony, bool) {
260 if key == "" {
261 return ceremony{}, false
262 }
263 s.mu.Lock()
264 defer s.mu.Unlock()
265 c, ok := s.m[key]
266 if !ok {
267 return ceremony{}, false
268 }
269 delete(s.m, key)
270 if time.Now().UTC().After(c.Expires) {
271 return ceremony{}, false
272 }
273 return c, true
274 }
275
276 func (w *Web) beginCeremony(rw http.ResponseWriter, c ceremony, v any) error {
277 id, err := w.Ceremonies.put(c)
278 if err != nil {
279 return err
280 }
281 http.SetCookie(rw, &http.Cookie{
282 Name: ceremonyCookieName,
283 Value: id,
284 Path: "/",
285 HttpOnly: true,
286 Secure: true,
287 SameSite: http.SameSiteLaxMode,
288 MaxAge: int(ceremonyTTL.Seconds()),
289 })
290 writeJSON(rw, http.StatusOK, v)
291 return nil
292 }
293
294 func (w *Web) takeCeremony(rw http.ResponseWriter, r *http.Request) (ceremony, bool) {
295 c, err := r.Cookie(ceremonyCookieName)
296 if err != nil {
297 writeJSONError(rw, http.StatusBadRequest, "the ceremony expired; try again")
298 return ceremony{}, false
299 }
300 w.clearCookie(rw, ceremonyCookieName)
301 state, ok := w.Ceremonies.take(c.Value)
302 if !ok {
303 writeJSONError(rw, http.StatusBadRequest, "the ceremony expired; try again")
304 return ceremony{}, false
305 }
306 return state, true
307 }
308
309 // ---------- WebAuthn user adapter ----------
310
311 type waUser struct {
312 account *identity.Account
313 creds []webauthn.Credential
314 }
315
316 func (u *waUser) WebAuthnID() []byte { return []byte(u.account.ID) }
317 func (u *waUser) WebAuthnName() string { return u.account.Name }
318 func (u *waUser) WebAuthnDisplayName() string { return u.account.Name }
319 func (u *waUser) WebAuthnCredentials() []webauthn.Credential { return u.creds }
320
321 func (w *Web) waUser(a *identity.Account) (*waUser, error) {
322 creds, err := w.Ident.Credentials(a.ID)
323 if err != nil {
324 return nil, err
325 }
326 out := make([]webauthn.Credential, 0, len(creds))
327 for _, c := range creds {
328 out = append(out, toWebAuthn(c))
329 }
330 return &waUser{account: a, creds: out}, nil
331 }
332
333 func toWebAuthn(c identity.Credential) webauthn.Credential {
334 transports := make([]protocol.AuthenticatorTransport, 0, len(c.Transport))
335 for _, t := range c.Transport {
336 transports = append(transports, protocol.AuthenticatorTransport(t))
337 }
338 return webauthn.Credential{
339 ID: c.ID,
340 PublicKey: c.PublicKey,
341 AttestationType: c.AttestationType,
342 Transport: transports,
343 Flags: webauthn.CredentialFlags{
344 UserPresent: c.Flags.UserPresent,
345 UserVerified: c.Flags.UserVerified,
346 BackupEligible: c.Flags.BackupEligible,
347 BackupState: c.Flags.BackupState,
348 },
349 Authenticator: webauthn.Authenticator{
350 AAGUID: c.Authenticator.AAGUID,
351 SignCount: c.Authenticator.SignCount,
352 CloneWarning: c.Authenticator.CloneWarning,
353 Attachment: protocol.AuthenticatorAttachment(c.Authenticator.Attachment),
354 },
355 }
356 }
357
358 func fromWebAuthn(c *webauthn.Credential, label string) identity.Credential {
359 transports := make([]string, 0, len(c.Transport))
360 for _, t := range c.Transport {
361 transports = append(transports, string(t))
362 }
363 return identity.Credential{
364 ID: c.ID,
365 PublicKey: c.PublicKey,
366 AttestationType: c.AttestationType,
367 Transport: transports,
368 Flags: identity.CredentialFlags{
369 UserPresent: c.Flags.UserPresent,
370 UserVerified: c.Flags.UserVerified,
371 BackupEligible: c.Flags.BackupEligible,
372 BackupState: c.Flags.BackupState,
373 },
374 Authenticator: identity.Authenticator{
375 AAGUID: c.Authenticator.AAGUID,
376 SignCount: c.Authenticator.SignCount,
377 CloneWarning: c.Authenticator.CloneWarning,
378 Attachment: string(c.Authenticator.Attachment),
379 },
380 Label: strings.TrimSpace(label),
381 }
382 }
383
384 // decodeJSONBody bounds every auth request before it reaches a parser.
385 func decodeJSONBody(r *http.Request, dst any) error {
386 defer r.Body.Close()
387 dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20))
388 if err := dec.Decode(dst); err != nil {
389 return errors.New("invalid request body")
390 }
391 return nil
392 }
393
394 const authJS = `(function(){
395 const csrf = (document.querySelector('meta[name="csrf"]') || {}).content || '';
396 function b64(s){s=s.replace(/-/g,'+').replace(/_/g,'/');while(s.length%4)s+='=';const bin=atob(s);const out=new Uint8Array(bin.length);for(let i=0;i<bin.length;i++)out[i]=bin.charCodeAt(i);return out}
397 function enc(buf){const b=new Uint8Array(buf);let s='';for(let i=0;i<b.length;i++)s+=String.fromCharCode(b[i]);return btoa(s).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'')}
398 function prepCreate(o){o.publicKey.challenge=b64(o.publicKey.challenge);o.publicKey.user.id=b64(o.publicKey.user.id);(o.publicKey.excludeCredentials||[]).forEach(c=>c.id=b64(c.id));return o.publicKey}
399 function prepGet(o){o.publicKey.challenge=b64(o.publicKey.challenge);(o.publicKey.allowCredentials||[]).forEach(c=>c.id=b64(c.id));return o.publicKey}
400 function resp(c){const r=c.response;const out={id:c.id,rawId:enc(c.rawId),type:c.type,clientExtensionResults:c.getClientExtensionResults()};if(r.attestationObject){out.response={clientDataJSON:enc(r.clientDataJSON),attestationObject:enc(r.attestationObject)};if(r.getTransports)out.response.transports=r.getTransports()}else{out.response={clientDataJSON:enc(r.clientDataJSON),authenticatorData:enc(r.authenticatorData),signature:enc(r.signature)};if(r.userHandle)out.response.userHandle=enc(r.userHandle)}if(c.authenticatorAttachment)out.authenticatorAttachment=c.authenticatorAttachment;return out}
401 async function post(url,body,withCSRF){const h={'Content-Type':'application/json'};if(withCSRF)h['X-Vole-CSRF']=csrf;const r=await fetch(url,{method:'POST',headers:h,body:JSON.stringify(body),credentials:'same-origin'});let d={};try{d=await r.json()}catch(e){}if(!r.ok)throw new Error(d.error||r.statusText);return d}
402 function fail(form,err){const e=form.querySelector('[data-auth-error]')||document.querySelector('[data-auth-error]');if(e)e.textContent=err.message||String(err);else alert(err.message||String(err))}
403 document.querySelectorAll('form[data-auth]').forEach(function(form){
404 form.addEventListener('submit',async function(ev){
405 ev.preventDefault();const btn=form.querySelector('button[type=submit]');if(btn)btn.disabled=true;
406 try{
407 if(form.dataset.auth==='login'){
408 const b=await post('/auth/login/begin',{username:form.username.value.trim()});
409 const c=await navigator.credentials.get({publicKey:prepGet(b)});
410 await post('/auth/login/finish',resp(c));
411 location.href=form.dataset.next||'/';
412 }else if(form.dataset.auth==='invite'){
413 const b=await post('/auth/invite/begin',{token:form.dataset.token});
414 const c=await navigator.credentials.create({publicKey:prepCreate(b)});
415 await post('/auth/invite/finish',resp(c));
416 location.href='/';
417 }else if(form.dataset.auth==='passkey'){
418 const b=await post('/account/passkeys/begin',{label:form.label.value.trim()},true);
419 const c=await navigator.credentials.create({publicKey:prepCreate(b)});
420 await post('/account/passkeys/finish',resp(c),true);
421 location.reload();
422 }
423 }catch(err){fail(form,err);if(btn)btn.disabled=false}
424 })
425 })
426 })();`
427
428 // AuthJS is the only script the CSP allows: same-origin, no eval, no CDN.
429 func (w *Web) AuthJS(rw http.ResponseWriter, r *http.Request) {
430 rw.Header().Set("Content-Type", "application/javascript; charset=utf-8")
431 rw.Header().Set("Cache-Control", "no-cache")
432 _, _ = rw.Write([]byte(authJS))
433 }