// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. // SPDX-License-Identifier: Apache-2.0 package ui // import "miniflux.app/v2/internal/ui" import ( "encoding/hex" "errors" "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/request" "miniflux.app/v2/internal/http/response" "miniflux.app/v2/internal/model" "miniflux.app/v2/internal/ui/form" "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() (*webauthn.WebAuthn, error) { baseURL, err := url.Parse(config.Opts.BaseURL()) if err != nil { return nil, err } return webauthn.New(&webauthn.Config{ RPDisplayName: "Miniflux", RPID: baseURL.Hostname(), RPOrigins: []string{config.Opts.RootURL()}, }) } func (h *handler) beginRegistration(w http.ResponseWriter, r *http.Request) { web, err := newWebAuthn() if err != nil { response.JSONServerError(w, r, err) return } user, err := h.store.UserByID(request.UserID(r)) if err != nil { response.JSONServerError(w, r, err) return } credentials, err := h.store.WebAuthnCredentialsByUserID(user.ID) if err != nil { response.JSONServerError(w, r, err) return } credentialDescriptors := make([]protocol.CredentialDescriptor, len(credentials)) for i, credential := range credentials { credentialDescriptors[i] = credential.Credential.Descriptor() } options, sessionData, err := web.BeginRegistration( WebAuthnUser{ User: user, AuthnID: crypto.GenerateRandomBytes(32), }, webauthn.WithExclusions(credentialDescriptors), webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired), ) if err != nil { response.JSONServerError(w, r, err) return } request.WebSession(r).SetWebAuthn(sessionData) response.JSON(w, r, options) } func (h *handler) finishRegistration(w http.ResponseWriter, r *http.Request) { web, err := newWebAuthn() if err != nil { response.JSONServerError(w, r, err) return } userID := request.UserID(r) user, err := h.store.UserByID(userID) if err != nil { response.JSONServerError(w, r, err) return } sessionData := request.WebSession(r).ConsumeWebAuthnSession() if sessionData == nil { response.JSONBadRequest(w, r, errors.New("missing webauthn session data")) return } webAuthnUser := WebAuthnUser{User: user, AuthnID: sessionData.UserID} credential, err := web.FinishRegistration(webAuthnUser, *sessionData, r) if err != nil { response.JSONServerError(w, r, err) return } err = h.store.AddWebAuthnCredential(userID, sessionData.UserID, credential) if err != nil { response.JSONServerError(w, r, err) return } handleEncoded := model.WebAuthnCredential{Handle: sessionData.UserID}.HandleEncoded() redirect := h.routePath("/webauthn/%s/rename", handleEncoded) response.JSON(w, r, map[string]string{"redirect": redirect}) } func (h *handler) beginLogin(w http.ResponseWriter, r *http.Request) { web, err := newWebAuthn() if err != nil { response.JSONServerError(w, r, err) return } assertion, sessionData, err := web.BeginDiscoverableLogin() if err != nil { response.JSONServerError(w, r, err) return } request.WebSession(r).SetWebAuthn(sessionData) response.JSON(w, r, assertion) } func (h *handler) finishLogin(w http.ResponseWriter, r *http.Request) { web, err := newWebAuthn() if err != nil { response.JSONServerError(w, r, err) return } parsedResponse, err := protocol.ParseCredentialRequestResponseBody(r.Body) if err != nil { response.JSONServerError(w, r, err) return } slog.Debug("WebAuthn: parsed response flags", slog.Bool("user_present", parsedResponse.Response.AuthenticatorData.Flags.HasUserPresent()), slog.Bool("user_verified", parsedResponse.Response.AuthenticatorData.Flags.HasUserVerified()), slog.Bool("has_attested_credential_data", parsedResponse.Response.AuthenticatorData.Flags.HasAttestedCredentialData()), slog.Bool("has_backup_eligible", parsedResponse.Response.AuthenticatorData.Flags.HasBackupEligible()), slog.Bool("has_backup_state", parsedResponse.Response.AuthenticatorData.Flags.HasBackupState()), ) sessionData := request.WebSession(r).ConsumeWebAuthnSession() if sessionData == nil { response.JSONBadRequest(w, r, errors.New("missing webauthn session data")) return } var resolvedUser *model.User var resolvedCredential *model.WebAuthnCredential userByHandle := func(rawID, userHandle []byte) (webauthn.User, error) { userID, credential, err := h.store.WebAuthnCredentialByHandle(userHandle) if err != nil { return nil, err } if userID == 0 || credential == nil { return nil, fmt.Errorf("no user found for handle %x", userHandle) } loadedUser, err := h.store.UserByID(userID) if err != nil { return nil, err } if loadedUser == nil { return nil, fmt.Errorf("no user found for handle %x", userHandle) } // One-shot backfill for credentials registered before the // backup_eligible column was added: trust the assertion's BE // once, then persist it after successful validation. if !credential.BackupEligibleKnown { credential.Credential.Flags.BackupEligible = parsedResponse.Response.AuthenticatorData.Flags.HasBackupEligible() } resolvedUser = loadedUser resolvedCredential = credential return WebAuthnUser{ User: loadedUser, AuthnID: userHandle, Credentials: []model.WebAuthnCredential{*credential}, }, nil } validatedCredential, err := web.ValidateDiscoverableLogin(userByHandle, *sessionData, parsedResponse) if err != nil { slog.Warn("WebAuthn: ValidateDiscoverableLogin failed", slog.String("client_ip", request.ClientIP(r)), slog.String("user_agent", r.UserAgent()), slog.Any("error", err), ) response.JSONUnauthorized(w, r) return } user := resolvedUser matchingCredential := resolvedCredential if err := h.store.WebAuthnSaveLogin(matchingCredential.Handle, validatedCredential); err != nil { slog.Warn("WebAuthn: unable to persist credential state after login", slog.Int64("user_id", user.ID), slog.Any("error", err), ) } 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), ) if err := h.store.SetLastLogin(user.ID); err != nil { slog.Warn("Unable to update last login date", slog.Int64("user_id", user.ID), slog.Any("error", err), ) } if err := authenticateWebSession(w, r, h.store, user); err != nil { response.JSONServerError(w, r, err) return } response.NoContent(w, r) } func (h *handler) renameCredential(w http.ResponseWriter, r *http.Request) { view := view.New(h.tpl, r) user, err := h.store.UserByID(request.UserID(r)) if err != nil { response.HTMLServerError(w, r, err) return } credentialHandleEncoded := request.RouteStringParam(r, "credentialHandle") credentialHandle, err := hex.DecodeString(credentialHandleEncoded) if err != nil { response.HTMLServerError(w, r, err) return } credUserID, credential, err := h.store.WebAuthnCredentialByHandle(credentialHandle) if err != nil { response.HTMLServerError(w, r, err) return } if credUserID != user.ID { response.HTMLForbidden(w, r) return } view.Set("form", form.WebauthnForm{Name: credential.Name}) view.Set("cred", credential) view.Set("menu", "settings") view.Set("user", user) navMetadata, _ := h.store.GetNavMetadata(user.ID) view.Set("countUnread", navMetadata.CountUnread) view.Set("countErrorFeeds", navMetadata.CountErrorFeeds) response.HTML(w, r, view.Render("webauthn_rename")) } func (h *handler) saveCredential(w http.ResponseWriter, r *http.Request) { user, err := h.store.UserByID(request.UserID(r)) if err != nil { response.HTMLServerError(w, r, err) return } credentialHandleEncoded := request.RouteStringParam(r, "credentialHandle") credentialHandle, err := hex.DecodeString(credentialHandleEncoded) if err != nil { response.HTMLServerError(w, r, err) return } credUserID, credential, err := h.store.WebAuthnCredentialByHandle(credentialHandle) if err != nil { response.HTMLServerError(w, r, err) return } if credUserID != user.ID { response.HTMLForbidden(w, r) return } webauthnForm := form.NewWebauthnForm(r) if validationErr := webauthnForm.Validate(); validationErr != nil { v := view.New(h.tpl, r) v.Set("form", webauthnForm) v.Set("cred", credential) v.Set("menu", "settings") v.Set("user", user) v.Set("errorMessage", validationErr.Translate(request.WebSession(r).Language())) navMetadata, _ := h.store.GetNavMetadata(user.ID) v.Set("countUnread", navMetadata.CountUnread) v.Set("countErrorFeeds", navMetadata.CountErrorFeeds) response.HTML(w, r, v.Render("webauthn_rename")) return } rowsAffected, err := h.store.WebAuthnUpdateName(user.ID, credentialHandle, webauthnForm.Name) if err != nil { response.HTMLServerError(w, r, err) return } if rowsAffected == 0 { response.HTMLNotFound(w, r) return } response.HTMLRedirect(w, r, h.routePath("/settings")) } func (h *handler) deleteCredential(w http.ResponseWriter, r *http.Request) { credentialHandleEncoded := request.RouteStringParam(r, "credentialHandle") credentialHandle, err := hex.DecodeString(credentialHandleEncoded) if err != nil { response.JSONServerError(w, r, err) return } err = h.store.DeleteCredentialByHandle(request.UserID(r), credentialHandle) if err != nil { response.JSONServerError(w, r, err) return } response.NoContent(w, r) } func (h *handler) deleteAllCredentials(w http.ResponseWriter, r *http.Request) { err := h.store.DeleteAllWebAuthnCredentialsByUserID(request.UserID(r)) if err != nil { response.JSONServerError(w, r, err) return } response.NoContent(w, r) }