Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/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.

Blame · server/web/auth_handlers.go

Line attribution on main · 648 line(s)

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 }