passkey existing
This commit is contained in:
@@ -301,6 +301,54 @@ func TestTokenCreationUsesDedicatedAccountPage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordUserCanBeginPasskeyRegistrationFromAccount(t *testing.T) {
|
||||
server := newAPITestServer(t)
|
||||
|
||||
login := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(login, jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{
|
||||
"email": "editor@example.com",
|
||||
"password": "very secure password",
|
||||
}))
|
||||
if login.Code != http.StatusOK || len(login.Result().Cookies()) == 0 {
|
||||
t.Fatalf("login response = %d %q, want session cookie", login.Code, login.Body.String())
|
||||
}
|
||||
session := cookieByName(t, login.Result().Cookies(), auth.SessionCookieName)
|
||||
|
||||
accountRequest := httptest.NewRequest(http.MethodGet, "/account", nil)
|
||||
accountRequest.AddCookie(session)
|
||||
account := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(account, accountRequest)
|
||||
if account.Code != http.StatusOK || !bytes.Contains(account.Body.Bytes(), []byte("data-passkey-add")) {
|
||||
t.Fatalf("account page response = %d %q, want add-passkey form", account.Code, account.Body.String())
|
||||
}
|
||||
|
||||
beginRequest := jsonRequest(http.MethodPost, "/api/auth/passkeys/me/register/begin", map[string]any{})
|
||||
beginRequest.AddCookie(session)
|
||||
begin := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(begin, beginRequest)
|
||||
if begin.Code != http.StatusOK {
|
||||
t.Fatalf("passkey begin response = %d %q", begin.Code, begin.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
ChallengeID string `json:"challengeId"`
|
||||
Options any `json:"options"`
|
||||
}
|
||||
if err := json.NewDecoder(begin.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode passkey begin response: %v", err)
|
||||
}
|
||||
if body.ChallengeID == "" || body.Options == nil {
|
||||
t.Fatalf("passkey begin body = %#v, want challenge and options", body)
|
||||
}
|
||||
|
||||
publicBegin := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(publicBegin, jsonRequest(http.MethodPost, "/api/auth/passkeys/register/begin", map[string]string{
|
||||
"email": "editor@example.com",
|
||||
}))
|
||||
if publicBegin.Code == http.StatusOK {
|
||||
t.Fatalf("public passkey begin response = %d %q, want failure for existing account", publicBegin.Code, publicBegin.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUserAccessAndPasswordResetHTTPFlow(t *testing.T) {
|
||||
server := newAPITestServer(t)
|
||||
sender := &testEmailSender{}
|
||||
|
||||
@@ -334,6 +334,42 @@ func (s *Server) handlePasskeyRegisterFinish(w http.ResponseWriter, r *http.Requ
|
||||
writeJSON(w, http.StatusOK, map[string]any{"user": publicUser(user)})
|
||||
}
|
||||
|
||||
func (s *Server) handleCurrentUserPasskeyRegisterBegin(w http.ResponseWriter, r *http.Request) {
|
||||
principal, _ := requirePrincipal(r)
|
||||
if !requireSessionPrincipal(w, principal) {
|
||||
return
|
||||
}
|
||||
options, challengeID, err := s.auth.BeginCurrentUserPasskeyRegistration(r.Context(), principal)
|
||||
if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"challengeId": challengeID, "options": options})
|
||||
}
|
||||
|
||||
func (s *Server) handleCurrentUserPasskeyRegisterFinish(w http.ResponseWriter, r *http.Request) {
|
||||
principal, _ := requirePrincipal(r)
|
||||
if !requireSessionPrincipal(w, principal) {
|
||||
return
|
||||
}
|
||||
challengeID := r.URL.Query().Get("challengeId")
|
||||
if challengeID == "" {
|
||||
var req passkeyFinishRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
challengeID = req.ChallengeID
|
||||
}
|
||||
if challengeID == "" {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "challengeId is required"})
|
||||
return
|
||||
}
|
||||
user, err := s.auth.FinishCurrentUserPasskeyRegistration(r.Context(), principal, challengeID, r)
|
||||
if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"user": publicUser(user)})
|
||||
}
|
||||
|
||||
func (s *Server) handlePasskeyLoginBegin(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.allowAuthAttempt(w, r) {
|
||||
return
|
||||
|
||||
@@ -174,7 +174,7 @@ func requiredScopeForPath(r *http.Request) (auth.Scope, bool) {
|
||||
case path == "/permissions" && method == http.MethodPost:
|
||||
return auth.ScopeDocsWrite, true
|
||||
case strings.HasPrefix(path, "/api/auth/"):
|
||||
if path == "/api/auth/me" || path == "/api/auth/logout" || path == "/api/auth/password/change" || path == "/api/auth/account" {
|
||||
if path == "/api/auth/me" || path == "/api/auth/logout" || path == "/api/auth/password/change" || path == "/api/auth/account" || strings.HasPrefix(path, "/api/auth/passkeys/me/") {
|
||||
return auth.ScopeDocsRead, true
|
||||
}
|
||||
return "", false
|
||||
|
||||
@@ -124,6 +124,8 @@ func New(deps Dependencies) (http.Handler, error) {
|
||||
router.Post("/api/auth/logout", server.handleLogout)
|
||||
router.Post("/api/auth/password/change", server.handleChangePassword)
|
||||
router.Delete("/api/auth/account", server.handleDeleteAccount)
|
||||
router.Post("/api/auth/passkeys/me/register/begin", server.handleCurrentUserPasskeyRegisterBegin)
|
||||
router.Post("/api/auth/passkeys/me/register/finish", server.handleCurrentUserPasskeyRegisterFinish)
|
||||
router.Post("/api/auth/passkeys/register/begin", server.handlePasskeyRegisterBegin)
|
||||
router.Post("/api/auth/passkeys/register/finish", server.handlePasskeyRegisterFinish)
|
||||
router.Post("/api/auth/passkeys/login/begin", server.handlePasskeyLoginBegin)
|
||||
|
||||
@@ -251,6 +251,26 @@
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("[data-passkey-add]")?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const support = await checkPasskeySupport();
|
||||
if (!support.supported) {
|
||||
setStatus("[data-passkey-add-status]", support.reason, true);
|
||||
return;
|
||||
}
|
||||
if (support.reason) {
|
||||
console.warn("Passkey support:", support.reason);
|
||||
}
|
||||
try {
|
||||
const begin = await postJSON("/api/auth/passkeys/me/register/begin", {});
|
||||
const credential = await navigator.credentials.create(normalizeCreateOptions(begin.options));
|
||||
await postJSON(`/api/auth/passkeys/me/register/finish?challengeId=${encodeURIComponent(begin.challengeId)}`, credentialToJSON(credential));
|
||||
setStatus("[data-passkey-add-status]", "Passkey added. You can use it the next time you sign in.");
|
||||
} catch (error) {
|
||||
setStatus("[data-passkey-add-status]", webAuthnErrorMessage(error), true);
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("[data-auth-logout]")?.addEventListener("click", async () => {
|
||||
try {
|
||||
await postJSON("/api/auth/logout", {});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const STATIC_CACHE = "md-hub-static-v7";
|
||||
const STATIC_CACHE = "md-hub-static-v8";
|
||||
const DB_NAME = "md-hub-cache";
|
||||
const DB_VERSION = 3;
|
||||
const STORE_PAGES = "pages";
|
||||
|
||||
@@ -66,6 +66,20 @@
|
||||
</div>
|
||||
|
||||
<aside class="account-sidebar">
|
||||
<section class="account-section">
|
||||
<header class="account-section__header">
|
||||
<div>
|
||||
<p class="account-section__kicker">Security</p>
|
||||
<h2>Passkeys</h2>
|
||||
</div>
|
||||
<p>Add a passkey to sign in with this device, a password manager, or a hardware security key.</p>
|
||||
</header>
|
||||
<form class="auth-form" data-passkey-add>
|
||||
<button type="submit">Add passkey</button>
|
||||
<p class="form-status" data-passkey-add-status></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="account-section">
|
||||
<header class="account-section__header">
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user