| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379 |
- // 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)
- }
|