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 | } | ||