summaryrefslogtreecommitdiff
path: root/internal/ui
diff options
context:
space:
mode:
authorGravatar Florian RĂ¼chel <florian.ruechel.github@inexplicity.de> 2023-11-06 04:27:35 +1030
committerGravatar GitHub <noreply@github.com> 2023-11-05 18:57:35 +0100
commit62ef8ed57aab9f2b05a64b153d231ae4f42769f4 (patch)
treeacc33ab1fd02113f8fc93751e593dc67ff504a84 /internal/ui
parent62188b49f072ea3c2bf30a8ed42f8b9303840191 (diff)
downloadv2-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.go20
-rw-r--r--internal/ui/middleware.go6
-rw-r--r--internal/ui/session/session.go5
-rw-r--r--internal/ui/settings_show.go8
-rw-r--r--internal/ui/static/css/common.css6
-rw-r--r--internal/ui/static/js/webauthn.js196
-rw-r--r--internal/ui/static/static.go3
-rw-r--r--internal/ui/ui.go10
-rw-r--r--internal/ui/view/view.go3
-rw-r--r--internal/ui/webauthn.go395
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)
+}