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