Compare commits
2 Commits
0adb980cf7
...
b6d2ded141
| Author | SHA1 | Date | |
|---|---|---|---|
| b6d2ded141 | |||
| 4655008154 |
@@ -190,6 +190,57 @@ func (s *Service) FinishPasskeyRegistration(ctx context.Context, challengeID str
|
|||||||
return s.repo.GetUserByID(ctx, user.ID)
|
return s.repo.GetUserByID(ctx, user.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) BeginCurrentUserPasskeyRegistration(ctx context.Context, principal Principal) (any, string, error) {
|
||||||
|
if principal.UserID == "" {
|
||||||
|
return nil, "", fmt.Errorf("authentication required")
|
||||||
|
}
|
||||||
|
user, err := s.repo.GetUserByID(ctx, principal.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if user.Disabled {
|
||||||
|
return nil, "", fmt.Errorf("account disabled")
|
||||||
|
}
|
||||||
|
creation, session, err := s.webauthn.BeginRegistration(user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
challengeID, err := s.repo.SaveChallenge(ctx, user.ID, "webauthn_registration", *session, challengeTTL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return creation, challengeID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) FinishCurrentUserPasskeyRegistration(ctx context.Context, principal Principal, challengeID string, r *http.Request) (User, error) {
|
||||||
|
if principal.UserID == "" {
|
||||||
|
return User{}, fmt.Errorf("authentication required")
|
||||||
|
}
|
||||||
|
session, userID, err := s.repo.ConsumeChallenge(ctx, challengeID, "webauthn_registration")
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
if userID != principal.UserID {
|
||||||
|
return User{}, fmt.Errorf("challenge does not belong to the current user")
|
||||||
|
}
|
||||||
|
user, err := s.repo.GetUserByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
if user.Disabled {
|
||||||
|
return User{}, fmt.Errorf("account disabled")
|
||||||
|
}
|
||||||
|
credential, err := s.webauthn.FinishRegistration(user, session, r)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
if err := s.repo.SaveCredential(ctx, user.ID, *credential); err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
s.repo.Audit(ctx, user.ID, "auth.passkey.add", "user", user.ID, clientIP(r), r.UserAgent(), nil)
|
||||||
|
return s.repo.GetUserByID(ctx, user.ID)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) BeginPasskeyLogin(ctx context.Context, email string) (any, string, error) {
|
func (s *Service) BeginPasskeyLogin(ctx context.Context, email string) (any, string, error) {
|
||||||
user, err := s.repo.GetUserByEmail(ctx, normalizeEmail(email))
|
user, err := s.repo.GetUserByEmail(ctx, normalizeEmail(email))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -315,6 +315,21 @@ func TestPublicRegistrationCannotAttachCredentialsToExistingUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCurrentUserCanBeginPasskeyRegistrationForExistingAccount(t *testing.T) {
|
||||||
|
service := setupAuthTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
user := setupInitialAdmin(t, service, false)
|
||||||
|
principal := principalFromUser(user, "session", "sess:test", "", nil, time.Now().Add(time.Hour))
|
||||||
|
options, challengeID, err := service.BeginCurrentUserPasskeyRegistration(ctx, principal)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BeginCurrentUserPasskeyRegistration() error = %v", err)
|
||||||
|
}
|
||||||
|
if options == nil || challengeID == "" {
|
||||||
|
t.Fatalf("options = %#v, challengeID = %q; want registration options and challenge", options, challengeID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestInitialSetupCanDisablePublicRegistration(t *testing.T) {
|
func TestInitialSetupCanDisablePublicRegistration(t *testing.T) {
|
||||||
service := setupAuthTestService(t)
|
service := setupAuthTestService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -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) {
|
func TestAdminUserAccessAndPasswordResetHTTPFlow(t *testing.T) {
|
||||||
server := newAPITestServer(t)
|
server := newAPITestServer(t)
|
||||||
sender := &testEmailSender{}
|
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)})
|
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) {
|
func (s *Server) handlePasskeyLoginBegin(w http.ResponseWriter, r *http.Request) {
|
||||||
if !s.allowAuthAttempt(w, r) {
|
if !s.allowAuthAttempt(w, r) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ func requiredScopeForPath(r *http.Request) (auth.Scope, bool) {
|
|||||||
case path == "/permissions" && method == http.MethodPost:
|
case path == "/permissions" && method == http.MethodPost:
|
||||||
return auth.ScopeDocsWrite, true
|
return auth.ScopeDocsWrite, true
|
||||||
case strings.HasPrefix(path, "/api/auth/"):
|
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 auth.ScopeDocsRead, true
|
||||||
}
|
}
|
||||||
return "", false
|
return "", false
|
||||||
|
|||||||
@@ -124,6 +124,8 @@ func New(deps Dependencies) (http.Handler, error) {
|
|||||||
router.Post("/api/auth/logout", server.handleLogout)
|
router.Post("/api/auth/logout", server.handleLogout)
|
||||||
router.Post("/api/auth/password/change", server.handleChangePassword)
|
router.Post("/api/auth/password/change", server.handleChangePassword)
|
||||||
router.Delete("/api/auth/account", server.handleDeleteAccount)
|
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/begin", server.handlePasskeyRegisterBegin)
|
||||||
router.Post("/api/auth/passkeys/register/finish", server.handlePasskeyRegisterFinish)
|
router.Post("/api/auth/passkeys/register/finish", server.handlePasskeyRegisterFinish)
|
||||||
router.Post("/api/auth/passkeys/login/begin", server.handlePasskeyLoginBegin)
|
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 () => {
|
document.querySelector("[data-auth-logout]")?.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
await postJSON("/api/auth/logout", {});
|
await postJSON("/api/auth/logout", {});
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -18,6 +18,7 @@
|
|||||||
var codeMirrorContainer = document.querySelector("[data-codemirror-mount]");
|
var codeMirrorContainer = document.querySelector("[data-codemirror-mount]");
|
||||||
var codeMirrorReadyPromise = null;
|
var codeMirrorReadyPromise = null;
|
||||||
var codeMirrorModules = null;
|
var codeMirrorModules = null;
|
||||||
|
var documentCompletionOptionsPromise = null;
|
||||||
|
|
||||||
if (!form || !textarea || statusNodes.length === 0) {
|
if (!form || !textarea || statusNodes.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -507,6 +508,105 @@
|
|||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeCompletionPath(doc) {
|
||||||
|
var docPath = doc && doc.path ? String(doc.path) : "";
|
||||||
|
return docPath.replace(/\.md$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDocumentCompletionOptions(documents) {
|
||||||
|
var seen = new Set();
|
||||||
|
|
||||||
|
return (documents || [])
|
||||||
|
.map(function (doc) {
|
||||||
|
var label = normalizeCompletionPath(doc);
|
||||||
|
if (!label || seen.has(label) || label === path.replace(/\.md$/, "")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
seen.add(label);
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: label,
|
||||||
|
detail: doc.title && doc.title !== label ? doc.title : doc.path,
|
||||||
|
type: "file",
|
||||||
|
apply: function (view, completion, from, to) {
|
||||||
|
var insert = completion.label;
|
||||||
|
var after = view.state.doc.sliceString(to, Math.min(view.state.doc.length, to + 2));
|
||||||
|
if (after !== "]]") {
|
||||||
|
insert += "]]";
|
||||||
|
}
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: from, to: to, insert: insert },
|
||||||
|
selection: { anchor: from + insert.length },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort(function (a, b) {
|
||||||
|
return a.label.localeCompare(b.label);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchDocumentCompletionOptions() {
|
||||||
|
return fetch("/api/documents", { credentials: "same-origin" })
|
||||||
|
.then(function (response) {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Could not load documents");
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(function (data) {
|
||||||
|
var documents = data.documents || [];
|
||||||
|
if (window.MDHubCache && window.MDHubCache.cacheDocuments) {
|
||||||
|
window.MDHubCache.cacheDocuments(documents).catch(function () {});
|
||||||
|
}
|
||||||
|
return buildDocumentCompletionOptions(documents);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadDocumentCompletionOptions() {
|
||||||
|
if (documentCompletionOptionsPromise) {
|
||||||
|
return documentCompletionOptionsPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
documentCompletionOptionsPromise = Promise.resolve()
|
||||||
|
.then(function () {
|
||||||
|
if (window.MDHubCache && window.MDHubCache.getCachedDocuments) {
|
||||||
|
return window.MDHubCache.getCachedDocuments().then(function (documents) {
|
||||||
|
if (documents && documents.length > 0) {
|
||||||
|
return buildDocumentCompletionOptions(documents);
|
||||||
|
}
|
||||||
|
return fetchDocumentCompletionOptions();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return fetchDocumentCompletionOptions();
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
return documentCompletionOptionsPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wikiLinkCompletions(context) {
|
||||||
|
var before = context.matchBefore(/\[\[([^\]\n]*)$/);
|
||||||
|
if (!before) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return loadDocumentCompletionOptions().then(function (options) {
|
||||||
|
if (!options.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
from: before.from + 2,
|
||||||
|
to: before.to,
|
||||||
|
options: options,
|
||||||
|
validFor: /^[^\]\n]*$/,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function createCodeMirrorEditor(mount, initialValue, options) {
|
function createCodeMirrorEditor(mount, initialValue, options) {
|
||||||
var cm = codeMirrorModules.cm;
|
var cm = codeMirrorModules.cm;
|
||||||
var markdownModule = codeMirrorModules.markdown;
|
var markdownModule = codeMirrorModules.markdown;
|
||||||
@@ -521,6 +621,10 @@
|
|||||||
cm.EditorView.lineWrapping,
|
cm.EditorView.lineWrapping,
|
||||||
codeMirrorTheme(),
|
codeMirrorTheme(),
|
||||||
codeMirrorHighlighting(),
|
codeMirrorHighlighting(),
|
||||||
|
cm.autocompletion({
|
||||||
|
override: [wikiLinkCompletions],
|
||||||
|
activateOnTyping: true,
|
||||||
|
}),
|
||||||
cm.EditorView.updateListener.of(function (update) {
|
cm.EditorView.updateListener.of(function (update) {
|
||||||
if (update.docChanged) {
|
if (update.docChanged) {
|
||||||
onChange(update.state.doc.toString());
|
onChange(update.state.doc.toString());
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const STATIC_CACHE = "md-hub-static-v6";
|
const STATIC_CACHE = "md-hub-static-v8";
|
||||||
const DB_NAME = "md-hub-cache";
|
const DB_NAME = "md-hub-cache";
|
||||||
const DB_VERSION = 3;
|
const DB_VERSION = 3;
|
||||||
const STORE_PAGES = "pages";
|
const STORE_PAGES = "pages";
|
||||||
|
|||||||
@@ -66,6 +66,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside class="account-sidebar">
|
<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">
|
<section class="account-section">
|
||||||
<header class="account-section__header">
|
<header class="account-section__header">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user