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.
|
6c84d387
Keenan Nemetz
15d ago
|
1 | package web |
| 2 | ||
| 3 | import ( | |
|
edf9db6e
Keenan Nemetz
13d ago
|
4 | "crypto/rand" |
|
5b814e99
Keenan Nemetz
14d ago
|
5 | "fmt" |
|
6c84d387
Keenan Nemetz
15d ago
|
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 | ||
|
d37d301f
Keenan Nemetz
17h ago
|
14 | "rodent.software/vole/forge/access" |
| 15 | "rodent.software/vole/forge/identity" | |
| 16 | "rodent.software/vole/forge/session" | |
|
6c84d387
Keenan Nemetz
15d ago
|
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) { | |
|
edf9db6e
Keenan Nemetz
13d ago
|
22 | w.noStore(rw) |
|
6c84d387
Keenan Nemetz
15d ago
|
23 | next := safeNext(r.URL.Query().Get("next")) |
| 24 | w.render(rw, "login", struct { | |
| 25 | Page | |
|
f920a9f1
Keenan Nemetz
15d ago
|
26 | Next string |
| 27 | Enabled bool | |
| 28 | Registration bool | |
| 29 | Reason string | |
|
6c84d387
Keenan Nemetz
15d ago
|
30 | }{ |
|
ab326d3a
Keenan Nemetz
14d ago
|
31 | Page: w.page(r, "Sign in"), |
|
f920a9f1
Keenan Nemetz
15d ago
|
32 | Next: next, |
| 33 | Enabled: w.enabled(), | |
| 34 | Registration: w.registrationEnabled(), | |
| 35 | Reason: authReason(w.Auth), | |
|
6c84d387
Keenan Nemetz
15d ago
|
36 | }, 0) |
| 37 | } | |
| 38 | ||
|
edf9db6e
Keenan Nemetz
13d ago
|
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. | |
|
6c84d387
Keenan Nemetz
15d ago
|
45 | func (w *Web) InvitePage(rw http.ResponseWriter, r *http.Request) { |
|
edf9db6e
Keenan Nemetz
13d ago
|
46 | w.noStore(rw) |
|
6c84d387
Keenan Nemetz
15d ago
|
47 | w.render(rw, "invite", struct { |
| 48 | Page | |
| 49 | Enabled bool | |
| 50 | Reason string | |
| 51 | }{ | |
|
edf9db6e
Keenan Nemetz
13d ago
|
52 | Page: w.page(r, "Enrollment link"), |
|
6c84d387
Keenan Nemetz
15d ago
|
53 | Enabled: w.enabled(), |
| 54 | Reason: authReason(w.Auth), | |
| 55 | }, 0) | |
| 56 | } | |
| 57 | ||
|
edf9db6e
Keenan Nemetz
13d ago
|
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") | |
|
ed03b3d1
Keenan Nemetz
4h ago
|
65 | http.Redirect(rw, r, "/-/invite#"+url.PathEscape(token), http.StatusSeeOther) |
|
edf9db6e
Keenan Nemetz
13d ago
|
66 | } |
| 67 | ||
|
6c84d387
Keenan Nemetz
15d ago
|
68 | func authReason(a *Auth) string { |
| 69 | if a == nil { | |
|
ab326d3a
Keenan Nemetz
14d ago
|
70 | return "browser sign-in is not configured" |
|
6c84d387
Keenan Nemetz
15d ago
|
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) { | |
|
edf9db6e
Keenan Nemetz
13d ago
|
87 | w.noStore(rw) |
|
6c84d387
Keenan Nemetz
15d ago
|
88 | if !w.enabled() { |
| 89 | writeJSONError(rw, http.StatusServiceUnavailable, authReason(w.Auth)) | |
| 90 | return | |
| 91 | } | |
|
edf9db6e
Keenan Nemetz
13d ago
|
92 | if !w.limit(rw, r, w.limits.beginAddress, "login:"+w.clientAddress(r)) { |
| 93 | return | |
| 94 | } | |
|
6c84d387
Keenan Nemetz
15d ago
|
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 | } | |
|
edf9db6e
Keenan Nemetz
13d ago
|
102 | name := strings.TrimSpace(body.Username) |
|
ad79b7ab
Keenan Nemetz
3d ago
|
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 | } | |
|
25343521
Keenan Nemetz
3d ago
|
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 | } | |
|
edf9db6e
Keenan Nemetz
13d ago
|
119 | if !w.limit(rw, r, w.limits.beginAccount, "login:"+name) { |
| 120 | return | |
| 121 | } | |
| 122 | acct, err := w.Ident.Find(name) | |
|
6c84d387
Keenan Nemetz
15d ago
|
123 | if err != nil || !acct.Active() { |
|
edf9db6e
Keenan Nemetz
13d ago
|
124 | w.beginDecoyLogin(rw, name) |
|
6c84d387
Keenan Nemetz
15d ago
|
125 | return |
| 126 | } | |
| 127 | user, err := w.waUser(acct) | |
| 128 | if err != nil || len(user.creds) == 0 { | |
|
edf9db6e
Keenan Nemetz
13d ago
|
129 | w.beginDecoyLogin(rw, name) |
|
6c84d387
Keenan Nemetz
15d ago
|
130 | return |
| 131 | } | |
|
edf9db6e
Keenan Nemetz
13d ago
|
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) { | |
|
6c84d387
Keenan Nemetz
15d ago
|
154 | assertion, data, err := w.Auth.WA.BeginLogin(user) |
| 155 | if err != nil { | |
|
edf9db6e
Keenan Nemetz
13d ago
|
156 | writeJSONError(rw, http.StatusBadRequest, "cannot begin sign-in") |
|
6c84d387
Keenan Nemetz
15d ago
|
157 | return |
| 158 | } | |
|
edf9db6e
Keenan Nemetz
13d ago
|
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") | |
|
6c84d387
Keenan Nemetz
15d ago
|
163 | } |
| 164 | } | |
| 165 | ||
|
ad79b7ab
Keenan Nemetz
3d ago
|
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 | ||
|
6c84d387
Keenan Nemetz
15d ago
|
182 | func (w *Web) LoginFinish(rw http.ResponseWriter, r *http.Request) { |
|
edf9db6e
Keenan Nemetz
13d ago
|
183 | w.noStore(rw) |
|
6c84d387
Keenan Nemetz
15d ago
|
184 | if !w.enabled() { |
| 185 | writeJSONError(rw, http.StatusServiceUnavailable, authReason(w.Auth)) | |
| 186 | return | |
| 187 | } | |
|
edf9db6e
Keenan Nemetz
13d ago
|
188 | if !w.limit(rw, r, w.limits.finish, "login:"+w.clientAddress(r)) { |
| 189 | return | |
| 190 | } | |
|
6c84d387
Keenan Nemetz
15d ago
|
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 | } | |
|
ad79b7ab
Keenan Nemetz
3d ago
|
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) | |
|
6c84d387
Keenan Nemetz
15d ago
|
231 | } |
|
ad79b7ab
Keenan Nemetz
3d ago
|
232 | if err != nil || acct == nil || cred == nil { |
|
ab326d3a
Keenan Nemetz
14d ago
|
233 | writeJSONError(rw, http.StatusUnauthorized, "sign-in failed") |
|
6c84d387
Keenan Nemetz
15d ago
|
234 | return |
| 235 | } | |
|
edf9db6e
Keenan Nemetz
13d ago
|
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 | } | |
|
6c84d387
Keenan Nemetz
15d ago
|
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 | } | |
|
62576fa8
Keenan Nemetz
14d ago
|
252 | sess, err := w.Sessions.Create(acct.Name, cred.ID, r.UserAgent()) |
|
6c84d387
Keenan Nemetz
15d ago
|
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) { | |
|
edf9db6e
Keenan Nemetz
13d ago
|
262 | w.noStore(rw) |
|
6c84d387
Keenan Nemetz
15d ago
|
263 | if !w.enabled() { |
| 264 | writeJSONError(rw, http.StatusServiceUnavailable, authReason(w.Auth)) | |
| 265 | return | |
| 266 | } | |
|
edf9db6e
Keenan Nemetz
13d ago
|
267 | if !w.limit(rw, r, w.limits.beginAddress, "invite:"+w.clientAddress(r)) { |
| 268 | return | |
| 269 | } | |
|
6c84d387
Keenan Nemetz
15d ago
|
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()), | |
|
ad79b7ab
Keenan Nemetz
3d ago
|
289 | webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired)) |
|
6c84d387
Keenan Nemetz
15d ago
|
290 | if err != nil { |
| 291 | writeJSONError(rw, http.StatusBadRequest, "cannot begin enrollment: "+err.Error()) | |
| 292 | return | |
| 293 | } | |
| 294 | if err := w.beginCeremony(rw, ceremony{ | |
|
62576fa8
Keenan Nemetz
14d ago
|
295 | Kind: "invite", Data: *data, Name: acct.Name, |
|
6c84d387
Keenan Nemetz
15d ago
|
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) { | |
|
edf9db6e
Keenan Nemetz
13d ago
|
303 | w.noStore(rw) |
|
6c84d387
Keenan Nemetz
15d ago
|
304 | if !w.enabled() { |
| 305 | writeJSONError(rw, http.StatusServiceUnavailable, authReason(w.Auth)) | |
| 306 | return | |
| 307 | } | |
|
edf9db6e
Keenan Nemetz
13d ago
|
308 | if !w.limit(rw, r, w.limits.finish, "invite:"+w.clientAddress(r)) { |
| 309 | return | |
| 310 | } | |
|
6c84d387
Keenan Nemetz
15d ago
|
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) | |
|
62576fa8
Keenan Nemetz
14d ago
|
320 | if err != nil || acct.Name != c.Name { |
|
6c84d387
Keenan Nemetz
15d ago
|
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 | } | |
|
62576fa8
Keenan Nemetz
14d ago
|
339 | sess, err := w.Sessions.Create(acct.Name, cred.ID, r.UserAgent()) |
|
6c84d387
Keenan Nemetz
15d ago
|
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 | ||
|
f920a9f1
Keenan Nemetz
15d ago
|
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) { | |
|
edf9db6e
Keenan Nemetz
13d ago
|
352 | w.noStore(rw) |
|
f920a9f1
Keenan Nemetz
15d ago
|
353 | if !w.registrationEnabled() { |
|
e84a66f5
Keenan Nemetz
15d ago
|
354 | w.notFound(rw, r) |
|
f920a9f1
Keenan Nemetz
15d ago
|
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 | ||
|
5b814e99
Keenan Nemetz
14d ago
|
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 | ||
|
f920a9f1
Keenan Nemetz
15d ago
|
377 | func (w *Web) RegisterBegin(rw http.ResponseWriter, r *http.Request) { |
|
edf9db6e
Keenan Nemetz
13d ago
|
378 | w.noStore(rw) |
| 379 | if !w.limit(rw, r, w.limits.register, "register:"+w.clientAddress(r)) { | |
| 380 | return | |
| 381 | } | |
|
f920a9f1
Keenan Nemetz
15d ago
|
382 | if !w.registrationEnabled() { |
| 383 | writeJSONError(rw, http.StatusServiceUnavailable, "open registration is disabled") | |
| 384 | return | |
| 385 | } | |
|
5b814e99
Keenan Nemetz
14d ago
|
386 | if _, err := w.registrationRole(); err != nil { |
| 387 | writeJSONError(rw, http.StatusServiceUnavailable, err.Error()) | |
| 388 | return | |
| 389 | } | |
|
f920a9f1
Keenan Nemetz
15d ago
|
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 | } | |
|
62576fa8
Keenan Nemetz
14d ago
|
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"}} | |
|
f920a9f1
Keenan Nemetz
15d ago
|
409 | creation, data, err := w.Auth.WA.BeginRegistration(user, |
|
ad79b7ab
Keenan Nemetz
3d ago
|
410 | webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired)) |
|
f920a9f1
Keenan Nemetz
15d ago
|
411 | if err != nil { |
| 412 | writeJSONError(rw, http.StatusBadRequest, "cannot begin registration: "+err.Error()) | |
| 413 | return | |
| 414 | } | |
| 415 | if err := w.beginCeremony(rw, ceremony{ | |
|
62576fa8
Keenan Nemetz
14d ago
|
416 | Kind: "register", Data: *data, Name: name, |
|
f920a9f1
Keenan Nemetz
15d ago
|
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) { | |
|
edf9db6e
Keenan Nemetz
13d ago
|
423 | w.noStore(rw) |
| 424 | if !w.limit(rw, r, w.limits.register, "register:"+w.clientAddress(r)) { | |
| 425 | return | |
| 426 | } | |
|
f920a9f1
Keenan Nemetz
15d ago
|
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 | } | |
|
5b814e99
Keenan Nemetz
14d ago
|
439 | role, err := w.registrationRole() |
| 440 | if err != nil { | |
| 441 | writeJSONError(rw, http.StatusInternalServerError, err.Error()) | |
| 442 | return | |
| 443 | } | |
|
62576fa8
Keenan Nemetz
14d ago
|
444 | user := &waUser{account: &identity.Account{Name: c.Name, Status: "active"}} |
|
f920a9f1
Keenan Nemetz
15d ago
|
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 | } | |
|
5b814e99
Keenan Nemetz
14d ago
|
450 | acct, err := w.Ident.CreatePasskeyWithRole(c.Name, role, fromWebAuthn(cred, "first passkey"), "self-registration") |
|
f920a9f1
Keenan Nemetz
15d ago
|
451 | if err != nil { |
| 452 | writeJSONError(rw, http.StatusConflict, "cannot create the account: "+err.Error()) | |
| 453 | return | |
| 454 | } | |
|
62576fa8
Keenan Nemetz
14d ago
|
455 | sess, err := w.Sessions.Create(acct.Name, cred.ID, r.UserAgent()) |
|
f920a9f1
Keenan Nemetz
15d ago
|
456 | if err != nil { |
| 457 | writeJSONError(rw, http.StatusInternalServerError, "cannot create a session") | |
| 458 | return | |
| 459 | } | |
|
b1499c72
Keenan Nemetz
13d ago
|
460 | w.audit("web.register", acct.Name, acct.Name, "registered") |
|
f920a9f1
Keenan Nemetz
15d ago
|
461 | w.setSessionCookie(rw, sess) |
| 462 | writeJSON(rw, http.StatusOK, map[string]any{"ok": true, "redirect": "/"}) | |
| 463 | } | |
| 464 | ||
|
6c84d387
Keenan Nemetz
15d ago
|
465 | func (w *Web) Logout(rw http.ResponseWriter, r *http.Request) { |
|
edf9db6e
Keenan Nemetz
13d ago
|
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 | } | |
|
6c84d387
Keenan Nemetz
15d ago
|
479 | } |
| 480 | if c, err := r.Cookie(sessionCookieName); err == nil && c.Value != "" && w.Sessions != nil { | |
| 481 | _ = w.Sessions.Revoke(c.Value) | |
| 482 | } | |
|
f09ef39e
Keenan Nemetz
15d ago
|
483 | if c, err := r.Cookie(devSessionCookieName); err == nil && c.Value != "" && w.Sessions != nil { |
| 484 | _ = w.Sessions.Revoke(c.Value) | |
| 485 | } | |
|
6c84d387
Keenan Nemetz
15d ago
|
486 | w.clearCookie(rw, sessionCookieName) |
|
f09ef39e
Keenan Nemetz
15d ago
|
487 | if w.devAccount != nil { |
|
ab326d3a
Keenan Nemetz
14d ago
|
488 | // Signing out must stick even though loopback would otherwise sign us back |
|
f09ef39e
Keenan Nemetz
15d ago
|
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 | } | |
|
6c84d387
Keenan Nemetz
15d ago
|
496 | http.Redirect(rw, r, "/", http.StatusSeeOther) |
| 497 | } | |
| 498 | ||
| 499 | func (w *Web) AccountPage(rw http.ResponseWriter, r *http.Request) { | |
|
edf9db6e
Keenan Nemetz
13d ago
|
500 | w.noStore(rw) |
|
6c84d387
Keenan Nemetz
15d ago
|
501 | pr, ok := w.requireHTMLUser(rw, r) |
| 502 | if !ok { | |
| 503 | return | |
| 504 | } | |
|
62576fa8
Keenan Nemetz
14d ago
|
505 | creds, err := w.Ident.Credentials(pr.account.Name) |
|
6c84d387
Keenan Nemetz
15d ago
|
506 | if err != nil { |
|
e84a66f5
Keenan Nemetz
15d ago
|
507 | w.serverError(rw, r, "cannot read passkeys: "+err.Error()) |
|
6c84d387
Keenan Nemetz
15d ago
|
508 | return |
| 509 | } | |
| 510 | var sessions []session.Session | |
| 511 | if w.Sessions != nil { | |
|
62576fa8
Keenan Nemetz
14d ago
|
512 | sessions, _ = w.Sessions.List(pr.account.Name) |
|
6c84d387
Keenan Nemetz
15d ago
|
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) { | |
|
edf9db6e
Keenan Nemetz
13d ago
|
532 | w.noStore(rw) |
| 533 | if !w.limit(rw, r, w.limits.finish, "passkey:"+w.clientAddress(r)) { | |
| 534 | return | |
| 535 | } | |
|
6c84d387
Keenan Nemetz
15d ago
|
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 | } | |
|
25343521
Keenan Nemetz
3d ago
|
555 | label := strings.TrimSpace(body.Label) |
| 556 | if err := identity.ValidateCredentialLabel(label); err != nil { | |
| 557 | writeJSONError(rw, http.StatusBadRequest, err.Error()) | |
| 558 | return | |
| 559 | } | |
|
6c84d387
Keenan Nemetz
15d ago
|
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()), | |
|
ad79b7ab
Keenan Nemetz
3d ago
|
567 | webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired)) |
|
6c84d387
Keenan Nemetz
15d ago
|
568 | if err != nil { |
| 569 | writeJSONError(rw, http.StatusBadRequest, "cannot begin registration: "+err.Error()) | |
| 570 | return | |
| 571 | } | |
| 572 | if err := w.beginCeremony(rw, ceremony{ | |
|
62576fa8
Keenan Nemetz
14d ago
|
573 | Kind: "passkey", Data: *data, Name: pr.account.Name, |
|
25343521
Keenan Nemetz
3d ago
|
574 | Label: label, |
|
6c84d387
Keenan Nemetz
15d ago
|
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) { | |
|
edf9db6e
Keenan Nemetz
13d ago
|
581 | w.noStore(rw) |
| 582 | if !w.limit(rw, r, w.limits.finish, "passkey:"+w.clientAddress(r)) { | |
| 583 | return | |
| 584 | } | |
|
6c84d387
Keenan Nemetz
15d ago
|
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 | } | |
|
62576fa8
Keenan Nemetz
14d ago
|
601 | if c.Kind != "passkey" || c.Name != pr.account.Name { |
|
6c84d387
Keenan Nemetz
15d ago
|
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 | } | |
|
62576fa8
Keenan Nemetz
14d ago
|
619 | if err := w.Ident.AddCredential(pr.account.Name, fromWebAuthn(cred, label), pr.account.Name); err != nil { |
|
6c84d387
Keenan Nemetz
15d ago
|
620 | writeJSONError(rw, http.StatusBadRequest, err.Error()) |
| 621 | return | |
| 622 | } | |
| 623 | writeJSON(rw, http.StatusOK, map[string]any{"ok": true}) | |
| 624 | } | |
|
f09ef39e
Keenan Nemetz
15d ago
|
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) { | |
|
ed181212
Keenan Nemetz
13d ago
|
631 | if w.devAccount == nil || !w.loopbackBrowser(r) { |
|
e84a66f5
Keenan Nemetz
15d ago
|
632 | w.notFound(rw, r) |
|
f09ef39e
Keenan Nemetz
15d ago
|
633 | return |
| 634 | } | |
| 635 | if w.Sessions == nil { | |
|
e84a66f5
Keenan Nemetz
15d ago
|
636 | w.serverError(rw, r, "sessions are unavailable") |
|
f09ef39e
Keenan Nemetz
15d ago
|
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 { | |
|
e84a66f5
Keenan Nemetz
15d ago
|
643 | w.serverError(rw, r, "cannot create a development session") |
|
f09ef39e
Keenan Nemetz
15d ago
|
644 | return |
| 645 | } | |
| 646 | w.setDevSessionCookie(rw, sess) | |
| 647 | http.Redirect(rw, r, "/", http.StatusSeeOther) | |
| 648 | } |