diff options
author | 2023-11-06 04:27:35 +1030 | |
---|---|---|
committer | 2023-11-05 18:57:35 +0100 | |
commit | 62ef8ed57aab9f2b05a64b153d231ae4f42769f4 (patch) | |
tree | acc33ab1fd02113f8fc93751e593dc67ff504a84 /internal/ui | |
parent | 62188b49f072ea3c2bf30a8ed42f8b9303840191 (diff) | |
download | v2-62ef8ed57aab9f2b05a64b153d231ae4f42769f4.tar.gz v2-62ef8ed57aab9f2b05a64b153d231ae4f42769f4.tar.zst v2-62ef8ed57aab9f2b05a64b153d231ae4f42769f4.zip |
Add WebAuthn / Passkey integration
This is a rebase of #1618 in which @dave-atx added WebAuthn support.
Closes #1618
Diffstat (limited to '')
-rw-r--r-- | internal/ui/form/webauthn.go | 20 | ||||
-rw-r--r-- | internal/ui/middleware.go | 6 | ||||
-rw-r--r-- | internal/ui/session/session.go | 5 | ||||
-rw-r--r-- | internal/ui/settings_show.go | 8 | ||||
-rw-r--r-- | internal/ui/static/css/common.css | 6 | ||||
-rw-r--r-- | internal/ui/static/js/webauthn.js | 196 | ||||
-rw-r--r-- | internal/ui/static/static.go | 3 | ||||
-rw-r--r-- | internal/ui/ui.go | 10 | ||||
-rw-r--r-- | internal/ui/view/view.go | 3 | ||||
-rw-r--r-- | internal/ui/webauthn.go | 395 |
10 files changed, 649 insertions, 3 deletions
diff --git a/internal/ui/form/webauthn.go b/internal/ui/form/webauthn.go new file mode 100644 index 00000000..3f6a57db --- /dev/null +++ b/internal/ui/form/webauthn.go @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package form // import "miniflux.app/v2/internal/ui/form" + +import ( + "net/http" +) + +// WebauthnForm represents a credential rename form in the UI +type WebauthnForm struct { + Name string +} + +// NewWebauthnForm returns a new WebnauthnForm. +func NewWebauthnForm(r *http.Request) *WebauthnForm { + return &WebauthnForm{ + Name: r.FormValue("name"), + } +} diff --git a/internal/ui/middleware.go b/internal/ui/middleware.go index 7cfa5b34..d9682532 100644 --- a/internal/ui/middleware.go +++ b/internal/ui/middleware.go @@ -120,7 +120,7 @@ func (m *middleware) handleAppSession(next http.Handler) http.Handler { ctx = context.WithValue(ctx, request.UserThemeContextKey, session.Data.Theme) ctx = context.WithValue(ctx, request.PocketRequestTokenContextKey, session.Data.PocketRequestToken) ctx = context.WithValue(ctx, request.LastForceRefreshContextKey, session.Data.LastForceRefresh) - + ctx = context.WithValue(ctx, request.WebAuthnDataContextKey, session.Data.WebAuthnSessionData) next.ServeHTTP(w, r.WithContext(ctx)) }) } @@ -159,7 +159,9 @@ func (m *middleware) isPublicRoute(r *http.Request) bool { "sharedEntry", "healthcheck", "offline", - "proxy": + "proxy", + "webauthnLoginBegin", + "webauthnLoginFinish": return true default: return false diff --git a/internal/ui/session/session.go b/internal/ui/session/session.go index c47a1828..ef43d6c8 100644 --- a/internal/ui/session/session.go +++ b/internal/ui/session/session.go @@ -6,6 +6,7 @@ package session // import "miniflux.app/v2/internal/ui/session" import ( "time" + "miniflux.app/v2/internal/model" "miniflux.app/v2/internal/storage" ) @@ -72,3 +73,7 @@ func (s *Session) SetTheme(theme string) { func (s *Session) SetPocketRequestToken(requestToken string) { s.store.UpdateAppSessionField(s.sessionID, "pocket_request_token", requestToken) } + +func (s *Session) SetWebAuthnSessionData(sessionData *model.WebAuthnSession) { + s.store.UpdateAppSessionObjectField(s.sessionID, "webauthn_session_data", sessionData) +} diff --git a/internal/ui/settings_show.go b/internal/ui/settings_show.go index edbf0345..96714271 100644 --- a/internal/ui/settings_show.go +++ b/internal/ui/settings_show.go @@ -52,6 +52,12 @@ func (h *handler) showSettingsPage(w http.ResponseWriter, r *http.Request) { return } + creds, err := h.store.WebAuthnCredentialsByUserID(user.ID) + if err != nil { + html.ServerError(w, r, err) + return + } + view.Set("form", settingsForm) view.Set("themes", model.Themes()) view.Set("languages", locale.AvailableLanguages()) @@ -62,6 +68,8 @@ func (h *handler) showSettingsPage(w http.ResponseWriter, r *http.Request) { view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID)) view.Set("default_home_pages", model.HomePages()) view.Set("categories_sorting_options", model.CategoriesSortingOptions()) + view.Set("countWebAuthnCerts", h.store.CountWebAuthnCredentialsByUserID(user.ID)) + view.Set("webAuthnCerts", creds) html.OK(w, r, view.Render("settings")) } diff --git a/internal/ui/static/css/common.css b/internal/ui/static/css/common.css index 4775b739..32a783b8 100644 --- a/internal/ui/static/css/common.css +++ b/internal/ui/static/css/common.css @@ -1112,4 +1112,8 @@ audio, video { .integration-form details .form-section { margin-top: 15px; -}
\ No newline at end of file +} + +.hidden { + display: none; +} diff --git a/internal/ui/static/js/webauthn.js b/internal/ui/static/js/webauthn.js new file mode 100644 index 00000000..465aa49e --- /dev/null +++ b/internal/ui/static/js/webauthn.js @@ -0,0 +1,196 @@ +function isWebAuthnSupported() { + return window.PublicKeyCredential; +} + +async function isConditionalLoginSupported() { + return isWebAuthnSupported() && + window.PublicKeyCredential.isConditionalMediationAvailable && + window.PublicKeyCredential.isConditionalMediationAvailable(); +} + +// URLBase64 to ArrayBuffer +function bufferDecode(value) { + return Uint8Array.from(atob(value.replace(/-/g, "+").replace(/_/g, "/")), c => c.charCodeAt(0)); +} + +// ArrayBuffer to URLBase64 +function bufferEncode(value) { + return btoa(String.fromCharCode.apply(null, new Uint8Array(value))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); +} + +function getCsrfToken() { + let element = document.querySelector("body[data-csrf-token]"); + if (element !== null) { + return element.dataset.csrfToken; + } + return ""; +} + +async function post(urlKey, username, data) { + var url = document.body.dataset[urlKey]; + if (username) { + url += "?username=" + username; + } + return fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Csrf-Token": getCsrfToken() + }, + body: JSON.stringify(data), + }); +} + +async function get(urlKey, username) { + var url = document.body.dataset[urlKey]; + if (username) { + url += "?username=" + username; + } + return fetch(url); +} + +function showError(error) { + console.log("webauthn error: " + error); + let alert = document.getElementById("webauthn-error"); + if (alert) { + alert.classList.remove("hidden"); + } +} + +async function register() { + let beginRegisterURL = "webauthnRegisterBeginUrl"; + let r = await get(beginRegisterURL); + let credOptions = await r.json(); + credOptions.publicKey.challenge = bufferDecode(credOptions.publicKey.challenge); + credOptions.publicKey.user.id = bufferDecode(credOptions.publicKey.user.id); + if(Object.hasOwn(credOptions.publicKey, 'excludeCredentials')) { + credOptions.publicKey.excludeCredentials.forEach((credential) => credential.id = bufferDecode(credential.id)); + } + let attestation = await navigator.credentials.create(credOptions); + let cred = { + id: attestation.id, + rawId: bufferEncode(attestation.rawId), + type: attestation.type, + response: { + attestationObject: bufferEncode(attestation.response.attestationObject), + clientDataJSON: bufferEncode(attestation.response.clientDataJSON), + }, + }; + let finishRegisterURL = "webauthnRegisterFinishUrl"; + let response = await post(finishRegisterURL, null, cred); + if (!response.ok) { + throw new Error("Login failed with HTTP status " + response.status); + } + console.log("registration successful"); + + let jsonData = await response.json(); + let redirect = jsonData.redirect; + window.location.href = redirect; +} + +async function login(username, conditional) { + let beginLoginURL = "webauthnLoginBeginUrl"; + let r = await get(beginLoginURL, username); + let credOptions = await r.json(); + credOptions.publicKey.challenge = bufferDecode(credOptions.publicKey.challenge); + if(Object.hasOwn(credOptions.publicKey, 'allowCredentials')) { + credOptions.publicKey.allowCredentials.forEach((credential) => credential.id = bufferDecode(credential.id)); + } + if (conditional) { + credOptions.signal = abortController.signal; + credOptions.mediation = "conditional"; + } + + var assertion; + try { + assertion = await navigator.credentials.get(credOptions); + } + catch (err) { + // swallow aborted conditional logins + if (err instanceof DOMException && err.name == "AbortError") { + return; + } + throw err; + } + + if (!assertion) { + return; + } + + let assertionResponse = { + id: assertion.id, + rawId: bufferEncode(assertion.rawId), + type: assertion.type, + response: { + authenticatorData: bufferEncode(assertion.response.authenticatorData), + clientDataJSON: bufferEncode(assertion.response.clientDataJSON), + signature: bufferEncode(assertion.response.signature), + userHandle: bufferEncode(assertion.response.userHandle), + }, + }; + + let finishLoginURL = "webauthnLoginFinishUrl"; + let response = await post(finishLoginURL, username, assertionResponse); + if (!response.ok) { + throw new Error("Login failed with HTTP status " + response.status); + } + window.location.reload(); +} + +async function conditionalLogin() { + if (await isConditionalLoginSupported()) { + login("", true); + } +} + +async function removeCreds(event) { + event.preventDefault(); + let removeCredsURL = "webauthnDeleteAllUrl"; + await post(removeCredsURL, null, {}); + window.location.reload(); +} + +let abortController = new AbortController(); +document.addEventListener("DOMContentLoaded", function () { + if (!isWebAuthnSupported()) { + return; + } + + let registerButton = document.getElementById("webauthn-register"); + if (registerButton != null) { + registerButton.disabled = false; + registerButton.addEventListener("click", (e) => { + e.preventDefault(); + register().catch((err) => showError(err)); + }); + } + + let removeCredsButton = document.getElementById("webauthn-delete"); + if (removeCredsButton != null) { + removeCredsButton.addEventListener("click", removeCreds); + } + + let loginButton = document.getElementById("webauthn-login"); + if (loginButton != null) { + loginButton.disabled = false; + let usernameField = document.getElementById("form-username"); + if (usernameField != null) { + usernameField.autocomplete += " webauthn"; + } + let passwordField = document.getElementById("form-password"); + if (passwordField != null) { + passwordField.autocomplete += " webauthn"; + } + + loginButton.addEventListener("click", (e) => { + e.preventDefault(); + abortController.abort(); + login(usernameField.value).catch(err => showError(err)); + }); + + conditionalLogin().catch(err => showError(err)); + } +}); diff --git a/internal/ui/static/static.go b/internal/ui/static/static.go index 5ea2a263..bcc40fce 100644 --- a/internal/ui/static/static.go +++ b/internal/ui/static/static.go @@ -123,6 +123,9 @@ func GenerateJavascriptBundles() error { "service-worker": { "js/service_worker.js", }, + "webauthn": { + "js/webauthn.js", + }, } var prefixes = map[string]string{ diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 64149658..f95d6ebf 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -151,6 +151,16 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) { uiRouter.HandleFunc("/logout", handler.logout).Name("logout").Methods(http.MethodGet) uiRouter.Handle("/", middleware.handleAuthProxy(http.HandlerFunc(handler.showLoginPage))).Name("login").Methods(http.MethodGet) + // WebAuthn flow + uiRouter.HandleFunc("/webauthn/register/begin", handler.beginRegistration).Name("webauthnRegisterBegin").Methods(http.MethodGet) + uiRouter.HandleFunc("/webauthn/register/finish", handler.finishRegistration).Name("webauthnRegisterFinish").Methods(http.MethodPost) + uiRouter.HandleFunc("/webauthn/login/begin", handler.beginLogin).Name("webauthnLoginBegin").Methods(http.MethodGet) + uiRouter.HandleFunc("/webauthn/login/finish", handler.finishLogin).Name("webauthnLoginFinish").Methods(http.MethodPost) + uiRouter.HandleFunc("/webauthn/deleteall", handler.deleteAllCredentials).Name("webauthnDeleteAll").Methods(http.MethodPost) + uiRouter.HandleFunc("/webauthn/{credentialHandle}/delete", handler.deleteCredential).Name("webauthnDelete").Methods(http.MethodPost) + uiRouter.HandleFunc("/webauthn/{credentialHandle}/rename", handler.renameCredential).Name("webauthnRename").Methods(http.MethodGet) + uiRouter.HandleFunc("/webauthn/{credentialHandle}/save", handler.saveCredential).Name("webauthnSave").Methods(http.MethodPost) + router.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") w.Write([]byte("User-agent: *\nDisallow: /")) diff --git a/internal/ui/view/view.go b/internal/ui/view/view.go index 1720cfc5..077340b5 100644 --- a/internal/ui/view/view.go +++ b/internal/ui/view/view.go @@ -6,6 +6,7 @@ package view // import "miniflux.app/v2/internal/ui/view" import ( "net/http" + "miniflux.app/v2/internal/config" "miniflux.app/v2/internal/http/request" "miniflux.app/v2/internal/template" "miniflux.app/v2/internal/ui/session" @@ -43,5 +44,7 @@ func New(tpl *template.Engine, r *http.Request, sess *session.Session) *View { b.params["theme_checksum"] = static.StylesheetBundleChecksums[theme] b.params["app_js_checksum"] = static.JavascriptBundleChecksums["app"] b.params["sw_js_checksum"] = static.JavascriptBundleChecksums["service-worker"] + b.params["webauthn_js_checksum"] = static.JavascriptBundleChecksums["webauthn"] + b.params["webAuthnEnabled"] = config.Opts.WebAuthn() return b } diff --git a/internal/ui/webauthn.go b/internal/ui/webauthn.go new file mode 100644 index 00000000..0071c74c --- /dev/null +++ b/internal/ui/webauthn.go @@ -0,0 +1,395 @@ +// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package ui // import "miniflux.app/v2/internal/ui" + +import ( + "bytes" + "encoding/hex" + "fmt" + "log/slog" + "net/http" + "net/url" + + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" + "miniflux.app/v2/internal/config" + "miniflux.app/v2/internal/crypto" + "miniflux.app/v2/internal/http/cookie" + "miniflux.app/v2/internal/http/request" + "miniflux.app/v2/internal/http/response/html" + "miniflux.app/v2/internal/http/response/json" + "miniflux.app/v2/internal/http/route" + "miniflux.app/v2/internal/model" + "miniflux.app/v2/internal/ui/form" + "miniflux.app/v2/internal/ui/session" + "miniflux.app/v2/internal/ui/view" +) + +type WebAuthnUser struct { + User *model.User + AuthnID []byte + Credentials []model.WebAuthnCredential +} + +func (u WebAuthnUser) WebAuthnID() []byte { + return u.AuthnID +} + +func (u WebAuthnUser) WebAuthnName() string { + return u.User.Username +} + +func (u WebAuthnUser) WebAuthnDisplayName() string { + return u.User.Username +} + +func (u WebAuthnUser) WebAuthnIcon() string { + return "" +} + +func (u WebAuthnUser) WebAuthnCredentials() []webauthn.Credential { + creds := make([]webauthn.Credential, len(u.Credentials)) + for i, cred := range u.Credentials { + creds[i] = cred.Credential + } + return creds +} + +func newWebAuthn(h *handler) (*webauthn.WebAuthn, error) { + url, err := url.Parse(config.Opts.BaseURL()) + if err != nil { + return nil, err + } + return webauthn.New(&webauthn.Config{ + RPDisplayName: "Miniflux", + RPID: url.Hostname(), + RPOrigin: config.Opts.RootURL(), + }) +} + +func (h *handler) beginRegistration(w http.ResponseWriter, r *http.Request) { + web, err := newWebAuthn(h) + if err != nil { + json.ServerError(w, r, err) + return + } + uid := request.UserID(r) + if uid == 0 { + json.Unauthorized(w, r) + return + } + user, err := h.store.UserByID(uid) + if err != nil { + json.ServerError(w, r, err) + return + } + var creds []model.WebAuthnCredential + + creds, err = h.store.WebAuthnCredentialsByUserID(user.ID) + if err != nil { + json.ServerError(w, r, err) + return + } + + credsDescriptors := make([]protocol.CredentialDescriptor, len(creds)) + for i, cred := range creds { + credsDescriptors[i] = cred.Credential.Descriptor() + } + + options, sessionData, err := web.BeginRegistration( + WebAuthnUser{ + user, + crypto.GenerateRandomBytes(32), + nil, + }, + webauthn.WithExclusions(credsDescriptors), + ) + + if err != nil { + json.ServerError(w, r, err) + return + } + s := session.New(h.store, request.SessionID(r)) + s.SetWebAuthnSessionData(&model.WebAuthnSession{SessionData: sessionData}) + json.OK(w, r, options) +} + +func (h *handler) finishRegistration(w http.ResponseWriter, r *http.Request) { + web, err := newWebAuthn(h) + if err != nil { + json.ServerError(w, r, err) + return + } + uid := request.UserID(r) + if uid == 0 { + json.Unauthorized(w, r) + return + } + user, err := h.store.UserByID(uid) + if err != nil { + json.ServerError(w, r, err) + return + } + sessionData := request.WebAuthnSessionData(r) + webAuthnUser := WebAuthnUser{user, sessionData.UserID, nil} + cred, err := web.FinishRegistration(webAuthnUser, *sessionData.SessionData, r) + if err != nil { + json.ServerError(w, r, err) + return + } + + err = h.store.AddWebAuthnCredential(uid, sessionData.UserID, cred) + if err != nil { + json.ServerError(w, r, err) + return + } + + handleEncoded := model.WebAuthnCredential{Handle: sessionData.UserID}.HandleEncoded() + redirect := route.Path(h.router, "webauthnRename", "credentialHandle", handleEncoded) + json.OK(w, r, map[string]string{"redirect": redirect}) +} + +func (h *handler) beginLogin(w http.ResponseWriter, r *http.Request) { + web, err := newWebAuthn(h) + if err != nil { + json.ServerError(w, r, err) + return + } + + var user *model.User + username := request.QueryStringParam(r, "username", "") + if username != "" { + user, err = h.store.UserByUsername(username) + if err != nil { + json.Unauthorized(w, r) + return + } + } + + var assertion *protocol.CredentialAssertion + var sessionData *webauthn.SessionData + if user != nil { + creds, err := h.store.WebAuthnCredentialsByUserID(user.ID) + if err != nil { + json.ServerError(w, r, err) + return + } + assertion, sessionData, err = web.BeginLogin(WebAuthnUser{user, nil, creds}) + if err != nil { + json.ServerError(w, r, err) + return + } + } else { + assertion, sessionData, err = web.BeginDiscoverableLogin() + if err != nil { + json.ServerError(w, r, err) + return + } + } + + s := session.New(h.store, request.SessionID(r)) + s.SetWebAuthnSessionData(&model.WebAuthnSession{SessionData: sessionData}) + json.OK(w, r, assertion) +} + +func (h *handler) finishLogin(w http.ResponseWriter, r *http.Request) { + web, err := newWebAuthn(h) + if err != nil { + json.ServerError(w, r, err) + return + } + + parsedResponse, err := protocol.ParseCredentialRequestResponseBody(r.Body) + if err != nil { + json.ServerError(w, r, err) + return + } + sessionData := request.WebAuthnSessionData(r) + + var user *model.User + username := request.QueryStringParam(r, "username", "") + if username != "" { + user, err = h.store.UserByUsername(username) + if err != nil { + json.Unauthorized(w, r) + return + } + } + + var cred *model.WebAuthnCredential + if user != nil { + creds, err := h.store.WebAuthnCredentialsByUserID(user.ID) + if err != nil { + json.ServerError(w, r, err) + return + } + sessionData.SessionData.UserID = parsedResponse.Response.UserHandle + credCredential, err := web.ValidateLogin(WebAuthnUser{user, parsedResponse.Response.UserHandle, creds}, *sessionData.SessionData, parsedResponse) + if err != nil { + json.Unauthorized(w, r) + return + } + + for _, credTest := range creds { + if bytes.Equal(credCredential.ID, credTest.Credential.ID) { + cred = &credTest + } + } + + if cred == nil { + json.ServerError(w, r, fmt.Errorf("no matching credential for %v", credCredential)) + return + } + } else { + userByHandle := func(rawID, userHandle []byte) (webauthn.User, error) { + var uid int64 + uid, cred, err = h.store.WebAuthnCredentialByHandle(userHandle) + if err != nil { + return nil, err + } + if uid == 0 { + return nil, fmt.Errorf("no user found for handle %x", userHandle) + } + user, err = h.store.UserByID(uid) + if err != nil { + return nil, err + } + if user == nil { + return nil, fmt.Errorf("no user found for handle %x", userHandle) + } + return WebAuthnUser{user, userHandle, []model.WebAuthnCredential{*cred}}, nil + } + + _, err = web.ValidateDiscoverableLogin(userByHandle, *sessionData.SessionData, parsedResponse) + if err != nil { + json.Unauthorized(w, r) + return + } + } + + sessionToken, _, err := h.store.CreateUserSessionFromUsername(user.Username, r.UserAgent(), request.ClientIP(r)) + if err != nil { + json.ServerError(w, r, err) + return + } + + h.store.WebAuthnSaveLogin(cred.Handle) + + slog.Info("User authenticated successfully with webauthn", + slog.Bool("authentication_successful", true), + slog.String("client_ip", request.ClientIP(r)), + slog.String("user_agent", r.UserAgent()), + slog.Int64("user_id", user.ID), + slog.String("username", user.Username), + ) + h.store.SetLastLogin(user.ID) + + sess := session.New(h.store, request.SessionID(r)) + sess.SetLanguage(user.Language) + sess.SetTheme(user.Theme) + + http.SetCookie(w, cookie.New( + cookie.CookieUserSessionID, + sessionToken, + config.Opts.HTTPS, + config.Opts.BasePath(), + )) + + json.NoContent(w, r) +} + +func (h *handler) renameCredential(w http.ResponseWriter, r *http.Request) { + sess := session.New(h.store, request.SessionID(r)) + view := view.New(h.tpl, r, sess) + + user, err := h.store.UserByID(request.UserID(r)) + if err != nil { + html.ServerError(w, r, err) + return + } + + credentialHandleEncoded := request.RouteStringParam(r, "credentialHandle") + credentialHandle, err := hex.DecodeString(credentialHandleEncoded) + if err != nil { + html.ServerError(w, r, err) + return + } + cred_uid, cred, err := h.store.WebAuthnCredentialByHandle(credentialHandle) + if err != nil { + html.ServerError(w, r, err) + return + } + + if cred_uid != user.ID { + html.Forbidden(w, r) + return + } + + webauthnForm := form.WebauthnForm{Name: cred.Name} + + view.Set("form", webauthnForm) + view.Set("cred", cred) + view.Set("menu", "settings") + view.Set("user", user) + view.Set("countUnread", h.store.CountUnreadEntries(user.ID)) + view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID)) + + html.OK(w, r, view.Render("webauthn_rename")) +} + +func (h *handler) saveCredential(w http.ResponseWriter, r *http.Request) { + _, err := h.store.UserByID(request.UserID(r)) + if err != nil { + html.ServerError(w, r, err) + return + } + + credentialHandleEncoded := request.RouteStringParam(r, "credentialHandle") + credentialHandle, err := hex.DecodeString(credentialHandleEncoded) + if err != nil { + html.ServerError(w, r, err) + return + } + + newName := r.FormValue("name") + err = h.store.WebAuthnUpdateName(credentialHandle, newName) + if err != nil { + html.ServerError(w, r, err) + return + } + + html.Redirect(w, r, route.Path(h.router, "settings")) +} + +func (h *handler) deleteCredential(w http.ResponseWriter, r *http.Request) { + uid := request.UserID(r) + if uid == 0 { + json.Unauthorized(w, r) + return + } + + credentialHandleEncoded := request.RouteStringParam(r, "credentialHandle") + credentialHandle, err := hex.DecodeString(credentialHandleEncoded) + if err != nil { + json.ServerError(w, r, err) + return + } + + err = h.store.DeleteCredentialByHandle(uid, []byte(credentialHandle)) + if err != nil { + json.ServerError(w, r, err) + return + } + + json.NoContent(w, r) +} + +func (h *handler) deleteAllCredentials(w http.ResponseWriter, r *http.Request) { + err := h.store.DeleteAllWebAuthnCredentialsByUserID(request.UserID(r)) + if err != nil { + json.ServerError(w, r, err) + return + } + json.NoContent(w, r) +} |