vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 1h ago
4b8c9e1d
| 1 | package web |
| 2 | |
| 3 | import ( |
| 4 | "crypto/rand" |
| 5 | "fmt" |
| 6 | "net/http" |
| 7 | "net/url" |
| 8 | "strings" |
| 9 | "time" |
| 10 | |
| 11 | "github.com/go-webauthn/webauthn/protocol" |
| 12 | "github.com/go-webauthn/webauthn/webauthn" |
| 13 | |
| 14 | "rodent.software/vole/forge/access" |
| 15 | "rodent.software/vole/forge/identity" |
| 16 | "rodent.software/vole/forge/session" |
| 17 | ) |
| 18 | |
| 19 | // LoginPage renders the passkey form. When public_url is not HTTPS the page |
| 20 | // explains the missing configuration instead of pretending login works. |
| 21 | func (w *Web) LoginPage(rw http.ResponseWriter, r *http.Request) { |
| 22 | w.noStore(rw) |
| 23 | next := safeNext(r.URL.Query().Get("next")) |
| 24 | w.render(rw, "login", struct { |
| 25 | Page |
| 26 | Next string |
| 27 | Enabled bool |
| 28 | Registration bool |
| 29 | Reason string |
| 30 | }{ |
| 31 | Page: w.page(r, "Sign in"), |
| 32 | Next: next, |
| 33 | Enabled: w.enabled(), |
| 34 | Registration: w.registrationEnabled(), |
| 35 | Reason: authReason(w.Auth), |
| 36 | }, 0) |
| 37 | } |
| 38 | |
| 39 | // InvitePage is the enrollment page. The token is in the URL fragment, which the |
| 40 | // browser never sends anywhere: it is read by the page's script and posted to |
| 41 | // InviteBegin, so the link itself does not put a one-time credential into this |
| 42 | // instance's logs, a proxy's access log, or the Referer of anything the page |
| 43 | // loads. The page therefore renders the same thing whether or not the token is |
| 44 | // valid; the answer comes from InviteBegin, which is where the ceremony starts. |
| 45 | func (w *Web) InvitePage(rw http.ResponseWriter, r *http.Request) { |
| 46 | w.noStore(rw) |
| 47 | w.render(rw, "invite", struct { |
| 48 | Page |
| 49 | Enabled bool |
| 50 | Reason string |
| 51 | }{ |
| 52 | Page: w.page(r, "Enrollment link"), |
| 53 | Enabled: w.enabled(), |
| 54 | Reason: authReason(w.Auth), |
| 55 | }, 0) |
| 56 | } |
| 57 | |
| 58 | // LegacyInvitePage moves a link in the old /invite/<token> shape into the fragment |
| 59 | // and sends the browser to the page. The token in the request path is the reason |
| 60 | // the shape changed; this keeps links that were already sent working, and the |
| 61 | // instance's own logs redact the path either way. |
| 62 | func (w *Web) LegacyInvitePage(rw http.ResponseWriter, r *http.Request) { |
| 63 | w.noStore(rw) |
| 64 | token := r.PathValue("token") |
| 65 | http.Redirect(rw, r, "/-/invite#"+url.PathEscape(token), http.StatusSeeOther) |
| 66 | } |
| 67 | |
| 68 | func authReason(a *Auth) string { |
| 69 | if a == nil { |
| 70 | return "browser sign-in 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 | w.noStore(rw) |
| 88 | if !w.enabled() { |
| 89 | writeJSONError(rw, http.StatusServiceUnavailable, authReason(w.Auth)) |
| 90 | return |
| 91 | } |
| 92 | if !w.limit(rw, r, w.limits.beginAddress, "login:"+w.clientAddress(r)) { |
| 93 | return |
| 94 | } |
| 95 | var body struct { |
| 96 | Username string `json:"username"` |
| 97 | } |
| 98 | if err := decodeJSONBody(r, &body); err != nil { |
| 99 | writeJSONError(rw, http.StatusBadRequest, err.Error()) |
| 100 | return |
| 101 | } |
| 102 | name := strings.TrimSpace(body.Username) |
| 103 | if name == "" { |
| 104 | // Conditional UI and a browser without a username field start here. The |
| 105 | // account is resolved from the credential when the ceremony finishes. |
| 106 | w.beginDiscoverableLogin(rw) |
| 107 | return |
| 108 | } |
| 109 | // A name that could not be an account is answered with the same decoy as an |
| 110 | // unknown account, but it must not reach the per-account limiter or the |
| 111 | // ceremony store at whatever length the client chose. |
| 112 | if err := identity.ValidateName(name); err != nil { |
| 113 | if !w.limit(rw, r, w.limits.beginAccount, "login:invalid") { |
| 114 | return |
| 115 | } |
| 116 | w.beginDecoyLogin(rw, "unknown") |
| 117 | return |
| 118 | } |
| 119 | if !w.limit(rw, r, w.limits.beginAccount, "login:"+name) { |
| 120 | return |
| 121 | } |
| 122 | acct, err := w.Ident.Find(name) |
| 123 | if err != nil || !acct.Active() { |
| 124 | w.beginDecoyLogin(rw, name) |
| 125 | return |
| 126 | } |
| 127 | user, err := w.waUser(acct) |
| 128 | if err != nil || len(user.creds) == 0 { |
| 129 | w.beginDecoyLogin(rw, name) |
| 130 | return |
| 131 | } |
| 132 | w.beginLoginCeremony(rw, user, acct.Name) |
| 133 | } |
| 134 | |
| 135 | // beginDecoyLogin answers a sign-in for an account that does not exist, is |
| 136 | // retired, or has no passkey with the same shape a real one gets: an ordinary |
| 137 | // assertion request naming one credential that will not be found. WebAuthn itself |
| 138 | // cannot tell "no such account" from "no matching credential", and neither may |
| 139 | // this endpoint, or it becomes a way to ask whether somebody has an account here. |
| 140 | func (w *Web) beginDecoyLogin(rw http.ResponseWriter, name string) { |
| 141 | decoy := make([]byte, 32) |
| 142 | if _, err := rand.Read(decoy); err != nil { |
| 143 | writeJSONError(rw, http.StatusInternalServerError, "cannot begin sign-in") |
| 144 | return |
| 145 | } |
| 146 | user := &waUser{ |
| 147 | account: &identity.Account{Name: name, Status: "active"}, |
| 148 | creds: []webauthn.Credential{{ID: decoy}}, |
| 149 | } |
| 150 | w.beginLoginCeremony(rw, user, name) |
| 151 | } |
| 152 | |
| 153 | func (w *Web) beginLoginCeremony(rw http.ResponseWriter, user *waUser, name string) { |
| 154 | assertion, data, err := w.Auth.WA.BeginLogin(user) |
| 155 | if err != nil { |
| 156 | writeJSONError(rw, http.StatusBadRequest, "cannot begin sign-in") |
| 157 | return |
| 158 | } |
| 159 | if err := w.beginCeremony(rw, ceremony{Kind: "login", Data: *data, Name: name}, assertion); err != nil { |
| 160 | w.Logger("auth: cannot store a login ceremony: %v", err) |
| 161 | writeJSONError(rw, http.StatusServiceUnavailable, |
| 162 | "too many sign-in attempts in progress; try again in a few minutes") |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | // beginDiscoverableLogin starts the usernameless flow: the assertion carries no |
| 167 | // allowCredentials, and the authenticator returns the user handle with the |
| 168 | // assertion. The account is only known when LoginFinish resolves the credential. |
| 169 | func (w *Web) beginDiscoverableLogin(rw http.ResponseWriter) { |
| 170 | assertion, data, err := w.Auth.WA.BeginDiscoverableLogin() |
| 171 | if err != nil { |
| 172 | writeJSONError(rw, http.StatusBadRequest, "cannot begin sign-in") |
| 173 | return |
| 174 | } |
| 175 | if err := w.beginCeremony(rw, ceremony{Kind: "login", Data: *data}, assertion); err != nil { |
| 176 | w.Logger("auth: cannot store a sign-in ceremony: %v", err) |
| 177 | writeJSONError(rw, http.StatusServiceUnavailable, |
| 178 | "too many sign-in attempts in progress; try again in a few minutes") |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | func (w *Web) LoginFinish(rw http.ResponseWriter, r *http.Request) { |
| 183 | w.noStore(rw) |
| 184 | if !w.enabled() { |
| 185 | writeJSONError(rw, http.StatusServiceUnavailable, authReason(w.Auth)) |
| 186 | return |
| 187 | } |
| 188 | if !w.limit(rw, r, w.limits.finish, "login:"+w.clientAddress(r)) { |
| 189 | return |
| 190 | } |
| 191 | c, ok := w.takeCeremony(rw, r) |
| 192 | if !ok { |
| 193 | return |
| 194 | } |
| 195 | if c.Kind != "login" { |
| 196 | writeJSONError(rw, http.StatusBadRequest, "wrong ceremony") |
| 197 | return |
| 198 | } |
| 199 | var ( |
| 200 | acct *identity.Account |
| 201 | cred *webauthn.Credential |
| 202 | err error |
| 203 | ) |
| 204 | if c.Name == "" { |
| 205 | cred, err = w.Auth.WA.FinishDiscoverableLogin(func(rawID, userHandle []byte) (webauthn.User, error) { |
| 206 | _, a, err := w.Ident.FindCredential(rawID) |
| 207 | if err != nil { |
| 208 | return nil, err |
| 209 | } |
| 210 | // The credential id already names the account. The user handle comes |
| 211 | // from the authenticator and must agree with it; a mismatch is not a |
| 212 | // sign-in for either account. |
| 213 | if string(userHandle) != a.Name { |
| 214 | return nil, identity.ErrCredentialNotFound |
| 215 | } |
| 216 | acct = a |
| 217 | return w.waUser(a) |
| 218 | }, c.Data, r) |
| 219 | } else { |
| 220 | acct, err = w.Ident.Find(c.Name) |
| 221 | if err != nil || !acct.Active() { |
| 222 | writeJSONError(rw, http.StatusUnauthorized, "sign-in failed") |
| 223 | return |
| 224 | } |
| 225 | user, uerr := w.waUser(acct) |
| 226 | if uerr != nil { |
| 227 | writeJSONError(rw, http.StatusUnauthorized, "sign-in failed") |
| 228 | return |
| 229 | } |
| 230 | cred, err = w.Auth.WA.FinishLogin(user, c.Data, r) |
| 231 | } |
| 232 | if err != nil || acct == nil || cred == nil { |
| 233 | writeJSONError(rw, http.StatusUnauthorized, "sign-in failed") |
| 234 | return |
| 235 | } |
| 236 | // A passkey's signature counter only goes up. If it did not, two copies of the |
| 237 | // key exist and one of them may not be the account holder's, so the sign-in is |
| 238 | // refused rather than answered with a session either copy could have asked for. |
| 239 | // The ceremony has already proved possession, which is why this is an audited |
| 240 | // event rather than a silent 401. |
| 241 | if cred.Authenticator.CloneWarning { |
| 242 | w.audit("auth.clone-warning", acct.Name, acct.Name, |
| 243 | "the passkey's signature counter did not advance; sign-in refused") |
| 244 | writeJSONError(rw, http.StatusUnauthorized, |
| 245 | "this passkey looks copied: its signature counter did not advance. Ask an instance admin to review it.") |
| 246 | return |
| 247 | } |
| 248 | if err := w.Ident.UpdateCredential(cred.ID, cred.Authenticator.SignCount, time.Now().UTC()); err != nil { |
| 249 | writeJSONError(rw, http.StatusInternalServerError, "cannot update the passkey") |
| 250 | return |
| 251 | } |
| 252 | sess, err := w.Sessions.Create(acct.Name, cred.ID, r.UserAgent()) |
| 253 | if err != nil { |
| 254 | writeJSONError(rw, http.StatusInternalServerError, "cannot create a session") |
| 255 | return |
| 256 | } |
| 257 | w.setSessionCookie(rw, sess) |
| 258 | writeJSON(rw, http.StatusOK, map[string]any{"ok": true, "redirect": "/"}) |
| 259 | } |
| 260 | |
| 261 | func (w *Web) InviteBegin(rw http.ResponseWriter, r *http.Request) { |
| 262 | w.noStore(rw) |
| 263 | if !w.enabled() { |
| 264 | writeJSONError(rw, http.StatusServiceUnavailable, authReason(w.Auth)) |
| 265 | return |
| 266 | } |
| 267 | if !w.limit(rw, r, w.limits.beginAddress, "invite:"+w.clientAddress(r)) { |
| 268 | return |
| 269 | } |
| 270 | var body struct { |
| 271 | Token string `json:"token"` |
| 272 | } |
| 273 | if err := decodeJSONBody(r, &body); err != nil { |
| 274 | writeJSONError(rw, http.StatusBadRequest, err.Error()) |
| 275 | return |
| 276 | } |
| 277 | acct, err := w.Ident.InviteAccount(strings.TrimSpace(body.Token)) |
| 278 | if err != nil { |
| 279 | writeJSONError(rw, http.StatusBadRequest, "this enrollment link is no longer valid") |
| 280 | return |
| 281 | } |
| 282 | user, err := w.waUser(acct) |
| 283 | if err != nil { |
| 284 | writeJSONError(rw, http.StatusInternalServerError, "cannot read the account") |
| 285 | return |
| 286 | } |
| 287 | creation, data, err := w.Auth.WA.BeginRegistration(user, |
| 288 | webauthn.WithExclusions(webauthn.Credentials(user.WebAuthnCredentials()).CredentialDescriptors()), |
| 289 | webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired)) |
| 290 | if err != nil { |
| 291 | writeJSONError(rw, http.StatusBadRequest, "cannot begin enrollment: "+err.Error()) |
| 292 | return |
| 293 | } |
| 294 | if err := w.beginCeremony(rw, ceremony{ |
| 295 | Kind: "invite", Data: *data, Name: acct.Name, |
| 296 | InviteToken: strings.TrimSpace(body.Token), |
| 297 | }, creation); err != nil { |
| 298 | writeJSONError(rw, http.StatusInternalServerError, "cannot store the ceremony") |
| 299 | } |
| 300 | } |
| 301 | |
| 302 | func (w *Web) InviteFinish(rw http.ResponseWriter, r *http.Request) { |
| 303 | w.noStore(rw) |
| 304 | if !w.enabled() { |
| 305 | writeJSONError(rw, http.StatusServiceUnavailable, authReason(w.Auth)) |
| 306 | return |
| 307 | } |
| 308 | if !w.limit(rw, r, w.limits.finish, "invite:"+w.clientAddress(r)) { |
| 309 | return |
| 310 | } |
| 311 | c, ok := w.takeCeremony(rw, r) |
| 312 | if !ok { |
| 313 | return |
| 314 | } |
| 315 | if c.Kind != "invite" { |
| 316 | writeJSONError(rw, http.StatusBadRequest, "wrong ceremony") |
| 317 | return |
| 318 | } |
| 319 | acct, err := w.Ident.InviteAccount(c.InviteToken) |
| 320 | if err != nil || acct.Name != c.Name { |
| 321 | writeJSONError(rw, http.StatusBadRequest, "this enrollment link is no longer valid") |
| 322 | return |
| 323 | } |
| 324 | user, err := w.waUser(acct) |
| 325 | if err != nil { |
| 326 | writeJSONError(rw, http.StatusInternalServerError, "cannot read the account") |
| 327 | return |
| 328 | } |
| 329 | cred, err := w.Auth.WA.FinishRegistration(user, c.Data, r) |
| 330 | if err != nil { |
| 331 | writeJSONError(rw, http.StatusBadRequest, "enrollment failed: "+err.Error()) |
| 332 | return |
| 333 | } |
| 334 | acct, err = w.Ident.AcceptInvite(c.InviteToken, fromWebAuthn(cred, "first passkey"), acct.Name) |
| 335 | if err != nil { |
| 336 | writeJSONError(rw, http.StatusBadRequest, "this enrollment link is no longer valid") |
| 337 | return |
| 338 | } |
| 339 | sess, err := w.Sessions.Create(acct.Name, cred.ID, r.UserAgent()) |
| 340 | if err != nil { |
| 341 | writeJSONError(rw, http.StatusInternalServerError, "cannot create a session") |
| 342 | return |
| 343 | } |
| 344 | w.setSessionCookie(rw, sess) |
| 345 | writeJSON(rw, http.StatusOK, map[string]any{"ok": true, "redirect": "/"}) |
| 346 | } |
| 347 | |
| 348 | // RegisterPage is only reachable when the operator explicitly enabled open |
| 349 | // registration. There is no half-open state where a page loads but the POST is |
| 350 | // refused for a different reason. |
| 351 | func (w *Web) RegisterPage(rw http.ResponseWriter, r *http.Request) { |
| 352 | w.noStore(rw) |
| 353 | if !w.registrationEnabled() { |
| 354 | w.notFound(rw, r) |
| 355 | return |
| 356 | } |
| 357 | w.render(rw, "register", struct { |
| 358 | Page |
| 359 | Reason string |
| 360 | }{ |
| 361 | Page: w.page(r, "Create an account"), |
| 362 | Reason: authReason(w.Auth), |
| 363 | }, 0) |
| 364 | } |
| 365 | |
| 366 | func (w *Web) registrationRole() (access.Role, error) { |
| 367 | role, err := access.ParseRole(w.Cfg.OpenRegistrationRole) |
| 368 | if err != nil { |
| 369 | return access.None, fmt.Errorf("open_registration_role: %w", err) |
| 370 | } |
| 371 | if role.AtLeast(access.Admin) { |
| 372 | return access.None, fmt.Errorf("open_registration_role cannot be admin") |
| 373 | } |
| 374 | return role, nil |
| 375 | } |
| 376 | |
| 377 | func (w *Web) RegisterBegin(rw http.ResponseWriter, r *http.Request) { |
| 378 | w.noStore(rw) |
| 379 | if !w.limit(rw, r, w.limits.register, "register:"+w.clientAddress(r)) { |
| 380 | return |
| 381 | } |
| 382 | if !w.registrationEnabled() { |
| 383 | writeJSONError(rw, http.StatusServiceUnavailable, "open registration is disabled") |
| 384 | return |
| 385 | } |
| 386 | if _, err := w.registrationRole(); err != nil { |
| 387 | writeJSONError(rw, http.StatusServiceUnavailable, err.Error()) |
| 388 | return |
| 389 | } |
| 390 | var body struct { |
| 391 | Username string `json:"username"` |
| 392 | } |
| 393 | if err := decodeJSONBody(r, &body); err != nil { |
| 394 | writeJSONError(rw, http.StatusBadRequest, err.Error()) |
| 395 | return |
| 396 | } |
| 397 | name := strings.TrimSpace(body.Username) |
| 398 | if err := identity.ValidateName(name); err != nil { |
| 399 | writeJSONError(rw, http.StatusBadRequest, err.Error()) |
| 400 | return |
| 401 | } |
| 402 | if _, err := w.Ident.Find(name); err == nil { |
| 403 | writeJSONError(rw, http.StatusConflict, "that account name is already taken") |
| 404 | return |
| 405 | } |
| 406 | // The name is the user handle, fixed now and written only when the ceremony |
| 407 | // succeeds. An abandoned registration therefore reserves no account. |
| 408 | user := &waUser{account: &identity.Account{Name: name, Status: "active"}} |
| 409 | creation, data, err := w.Auth.WA.BeginRegistration(user, |
| 410 | webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired)) |
| 411 | if err != nil { |
| 412 | writeJSONError(rw, http.StatusBadRequest, "cannot begin registration: "+err.Error()) |
| 413 | return |
| 414 | } |
| 415 | if err := w.beginCeremony(rw, ceremony{ |
| 416 | Kind: "register", Data: *data, Name: name, |
| 417 | }, creation); err != nil { |
| 418 | writeJSONError(rw, http.StatusInternalServerError, "cannot store the ceremony") |
| 419 | } |
| 420 | } |
| 421 | |
| 422 | func (w *Web) RegisterFinish(rw http.ResponseWriter, r *http.Request) { |
| 423 | w.noStore(rw) |
| 424 | if !w.limit(rw, r, w.limits.register, "register:"+w.clientAddress(r)) { |
| 425 | return |
| 426 | } |
| 427 | if !w.registrationEnabled() { |
| 428 | writeJSONError(rw, http.StatusServiceUnavailable, "open registration is disabled") |
| 429 | return |
| 430 | } |
| 431 | c, ok := w.takeCeremony(rw, r) |
| 432 | if !ok { |
| 433 | return |
| 434 | } |
| 435 | if c.Kind != "register" { |
| 436 | writeJSONError(rw, http.StatusBadRequest, "wrong ceremony") |
| 437 | return |
| 438 | } |
| 439 | role, err := w.registrationRole() |
| 440 | if err != nil { |
| 441 | writeJSONError(rw, http.StatusInternalServerError, err.Error()) |
| 442 | return |
| 443 | } |
| 444 | user := &waUser{account: &identity.Account{Name: c.Name, Status: "active"}} |
| 445 | cred, err := w.Auth.WA.FinishRegistration(user, c.Data, r) |
| 446 | if err != nil { |
| 447 | writeJSONError(rw, http.StatusBadRequest, "registration failed: "+err.Error()) |
| 448 | return |
| 449 | } |
| 450 | acct, err := w.Ident.CreatePasskeyWithRole(c.Name, role, fromWebAuthn(cred, "first passkey"), "self-registration") |
| 451 | if err != nil { |
| 452 | writeJSONError(rw, http.StatusConflict, "cannot create the account: "+err.Error()) |
| 453 | return |
| 454 | } |
| 455 | sess, err := w.Sessions.Create(acct.Name, cred.ID, r.UserAgent()) |
| 456 | if err != nil { |
| 457 | writeJSONError(rw, http.StatusInternalServerError, "cannot create a session") |
| 458 | return |
| 459 | } |
| 460 | w.audit("web.register", acct.Name, acct.Name, "registered") |
| 461 | w.setSessionCookie(rw, sess) |
| 462 | writeJSON(rw, http.StatusOK, map[string]any{"ok": true, "redirect": "/"}) |
| 463 | } |
| 464 | |
| 465 | func (w *Web) Logout(rw http.ResponseWriter, r *http.Request) { |
| 466 | // Signing out carries only a CSRF field, so the body is bounded before |
| 467 | // anything parses it. A client with no session has nothing to sign out of and |
| 468 | // gets the same answer as a successful one. |
| 469 | if pr := w.principal(r); pr != nil { |
| 470 | r.Body = http.MaxBytesReader(rw, r.Body, maxTinyForm) |
| 471 | if err := r.ParseForm(); err != nil { |
| 472 | http.Error(rw, "cannot read the form", http.StatusBadRequest) |
| 473 | return |
| 474 | } |
| 475 | if !w.csrfOK(r) { |
| 476 | http.Error(rw, "bad request", http.StatusForbidden) |
| 477 | return |
| 478 | } |
| 479 | } |
| 480 | if c, err := r.Cookie(sessionCookieName); err == nil && c.Value != "" && w.Sessions != nil { |
| 481 | _ = w.Sessions.Revoke(c.Value) |
| 482 | } |
| 483 | if c, err := r.Cookie(devSessionCookieName); err == nil && c.Value != "" && w.Sessions != nil { |
| 484 | _ = w.Sessions.Revoke(c.Value) |
| 485 | } |
| 486 | w.clearCookie(rw, sessionCookieName) |
| 487 | if w.devAccount != nil { |
| 488 | // Signing out must stick even though loopback would otherwise sign us back |
| 489 | // in. /dev/login clears this opt-out. |
| 490 | w.clearCookieFor(rw, devSessionCookieName, false) |
| 491 | http.SetCookie(rw, &http.Cookie{ |
| 492 | Name: devOffCookieName, Value: "1", Path: "/", HttpOnly: true, |
| 493 | SameSite: http.SameSiteLaxMode, MaxAge: 3600, |
| 494 | }) |
| 495 | } |
| 496 | http.Redirect(rw, r, "/", http.StatusSeeOther) |
| 497 | } |
| 498 | |
| 499 | func (w *Web) AccountPage(rw http.ResponseWriter, r *http.Request) { |
| 500 | w.noStore(rw) |
| 501 | pr, ok := w.requireHTMLUser(rw, r) |
| 502 | if !ok { |
| 503 | return |
| 504 | } |
| 505 | creds, err := w.Ident.Credentials(pr.account.Name) |
| 506 | if err != nil { |
| 507 | w.serverError(rw, r, "cannot read passkeys: "+err.Error()) |
| 508 | return |
| 509 | } |
| 510 | var sessions []session.Session |
| 511 | if w.Sessions != nil { |
| 512 | sessions, _ = w.Sessions.List(pr.account.Name) |
| 513 | } |
| 514 | w.render(rw, "account", struct { |
| 515 | Page |
| 516 | Account *identity.Account |
| 517 | Credentials []identity.Credential |
| 518 | Sessions []session.Session |
| 519 | Enabled bool |
| 520 | Reason string |
| 521 | }{ |
| 522 | Page: w.page(r, "Account"), |
| 523 | Account: pr.account, |
| 524 | Credentials: creds, |
| 525 | Sessions: sessions, |
| 526 | Enabled: w.enabled(), |
| 527 | Reason: authReason(w.Auth), |
| 528 | }, 0) |
| 529 | } |
| 530 | |
| 531 | func (w *Web) AddPasskeyBegin(rw http.ResponseWriter, r *http.Request) { |
| 532 | w.noStore(rw) |
| 533 | if !w.limit(rw, r, w.limits.finish, "passkey:"+w.clientAddress(r)) { |
| 534 | return |
| 535 | } |
| 536 | pr, ok := w.requireAPIUser(rw, r) |
| 537 | if !ok { |
| 538 | return |
| 539 | } |
| 540 | if !w.checkCSRF(r) { |
| 541 | writeJSONError(rw, http.StatusForbidden, "bad request") |
| 542 | return |
| 543 | } |
| 544 | if !w.enabled() { |
| 545 | writeJSONError(rw, http.StatusServiceUnavailable, authReason(w.Auth)) |
| 546 | return |
| 547 | } |
| 548 | var body struct { |
| 549 | Label string `json:"label"` |
| 550 | } |
| 551 | if err := decodeJSONBody(r, &body); err != nil { |
| 552 | writeJSONError(rw, http.StatusBadRequest, err.Error()) |
| 553 | return |
| 554 | } |
| 555 | label := strings.TrimSpace(body.Label) |
| 556 | if err := identity.ValidateCredentialLabel(label); err != nil { |
| 557 | writeJSONError(rw, http.StatusBadRequest, err.Error()) |
| 558 | return |
| 559 | } |
| 560 | user, err := w.waUser(pr.account) |
| 561 | if err != nil { |
| 562 | writeJSONError(rw, http.StatusInternalServerError, "cannot read the account") |
| 563 | return |
| 564 | } |
| 565 | creation, data, err := w.Auth.WA.BeginRegistration(user, |
| 566 | webauthn.WithExclusions(webauthn.Credentials(user.WebAuthnCredentials()).CredentialDescriptors()), |
| 567 | webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired)) |
| 568 | if err != nil { |
| 569 | writeJSONError(rw, http.StatusBadRequest, "cannot begin registration: "+err.Error()) |
| 570 | return |
| 571 | } |
| 572 | if err := w.beginCeremony(rw, ceremony{ |
| 573 | Kind: "passkey", Data: *data, Name: pr.account.Name, |
| 574 | Label: label, |
| 575 | }, creation); err != nil { |
| 576 | writeJSONError(rw, http.StatusInternalServerError, "cannot store the ceremony") |
| 577 | } |
| 578 | } |
| 579 | |
| 580 | func (w *Web) AddPasskeyFinish(rw http.ResponseWriter, r *http.Request) { |
| 581 | w.noStore(rw) |
| 582 | if !w.limit(rw, r, w.limits.finish, "passkey:"+w.clientAddress(r)) { |
| 583 | return |
| 584 | } |
| 585 | pr, ok := w.requireAPIUser(rw, r) |
| 586 | if !ok { |
| 587 | return |
| 588 | } |
| 589 | if !w.checkCSRF(r) { |
| 590 | writeJSONError(rw, http.StatusForbidden, "bad request") |
| 591 | return |
| 592 | } |
| 593 | if !w.enabled() { |
| 594 | writeJSONError(rw, http.StatusServiceUnavailable, authReason(w.Auth)) |
| 595 | return |
| 596 | } |
| 597 | c, ok := w.takeCeremony(rw, r) |
| 598 | if !ok { |
| 599 | return |
| 600 | } |
| 601 | if c.Kind != "passkey" || c.Name != pr.account.Name { |
| 602 | writeJSONError(rw, http.StatusBadRequest, "wrong ceremony") |
| 603 | return |
| 604 | } |
| 605 | user, err := w.waUser(pr.account) |
| 606 | if err != nil { |
| 607 | writeJSONError(rw, http.StatusInternalServerError, "cannot read the account") |
| 608 | return |
| 609 | } |
| 610 | cred, err := w.Auth.WA.FinishRegistration(user, c.Data, r) |
| 611 | if err != nil { |
| 612 | writeJSONError(rw, http.StatusBadRequest, "registration failed: "+err.Error()) |
| 613 | return |
| 614 | } |
| 615 | label := c.Label |
| 616 | if label == "" { |
| 617 | label = "passkey" |
| 618 | } |
| 619 | if err := w.Ident.AddCredential(pr.account.Name, fromWebAuthn(cred, label), pr.account.Name); err != nil { |
| 620 | writeJSONError(rw, http.StatusBadRequest, err.Error()) |
| 621 | return |
| 622 | } |
| 623 | writeJSON(rw, http.StatusOK, map[string]any{"ok": true}) |
| 624 | } |
| 625 | |
| 626 | // DevLogin signs a loopback browser in as the account named by dev_login. It is |
| 627 | // a development shortcut, not a second authentication path: the listener must be |
| 628 | // loopback, the account is resolved at startup, and a normal instance has no |
| 629 | // devAccount at all. |
| 630 | func (w *Web) DevLogin(rw http.ResponseWriter, r *http.Request) { |
| 631 | if w.devAccount == nil || !w.loopbackBrowser(r) { |
| 632 | w.notFound(rw, r) |
| 633 | return |
| 634 | } |
| 635 | if w.Sessions == nil { |
| 636 | w.serverError(rw, r, "sessions are unavailable") |
| 637 | return |
| 638 | } |
| 639 | // Visiting /dev/login is the explicit way back after a logout opted out. |
| 640 | w.clearCookieFor(rw, devOffCookieName, false) |
| 641 | sess := w.ensureDevSession() |
| 642 | if sess == nil { |
| 643 | w.serverError(rw, r, "cannot create a development session") |
| 644 | return |
| 645 | } |
| 646 | w.setDevSessionCookie(rw, sess) |
| 647 | http.Redirect(rw, r, "/", http.StatusSeeOther) |
| 648 | } |
| 649 |