webauthn.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
  2. // SPDX-License-Identifier: Apache-2.0
  3. package ui // import "miniflux.app/v2/internal/ui"
  4. import (
  5. "bytes"
  6. "encoding/hex"
  7. "errors"
  8. "fmt"
  9. "log/slog"
  10. "net/http"
  11. "net/url"
  12. "github.com/go-webauthn/webauthn/protocol"
  13. "github.com/go-webauthn/webauthn/webauthn"
  14. "miniflux.app/v2/internal/config"
  15. "miniflux.app/v2/internal/crypto"
  16. "miniflux.app/v2/internal/http/request"
  17. "miniflux.app/v2/internal/http/response"
  18. "miniflux.app/v2/internal/model"
  19. "miniflux.app/v2/internal/ui/form"
  20. "miniflux.app/v2/internal/ui/view"
  21. )
  22. type WebAuthnUser struct {
  23. User *model.User
  24. AuthnID []byte
  25. Credentials []model.WebAuthnCredential
  26. }
  27. func (u WebAuthnUser) WebAuthnID() []byte {
  28. return u.AuthnID
  29. }
  30. func (u WebAuthnUser) WebAuthnName() string {
  31. return u.User.Username
  32. }
  33. func (u WebAuthnUser) WebAuthnDisplayName() string {
  34. return u.User.Username
  35. }
  36. func (u WebAuthnUser) WebAuthnIcon() string {
  37. return ""
  38. }
  39. func (u WebAuthnUser) WebAuthnCredentials() []webauthn.Credential {
  40. creds := make([]webauthn.Credential, len(u.Credentials))
  41. for i, cred := range u.Credentials {
  42. creds[i] = cred.Credential
  43. }
  44. return creds
  45. }
  46. func newWebAuthn() (*webauthn.WebAuthn, error) {
  47. url, err := url.Parse(config.Opts.BaseURL())
  48. if err != nil {
  49. return nil, err
  50. }
  51. return webauthn.New(&webauthn.Config{
  52. RPDisplayName: "Miniflux",
  53. RPID: url.Hostname(),
  54. RPOrigins: []string{config.Opts.RootURL()},
  55. })
  56. }
  57. func (h *handler) beginRegistration(w http.ResponseWriter, r *http.Request) {
  58. web, err := newWebAuthn()
  59. if err != nil {
  60. response.JSONServerError(w, r, err)
  61. return
  62. }
  63. uid := request.UserID(r)
  64. if uid == 0 {
  65. response.JSONUnauthorized(w, r)
  66. return
  67. }
  68. user, err := h.store.UserByID(uid)
  69. if err != nil {
  70. response.JSONServerError(w, r, err)
  71. return
  72. }
  73. var creds []model.WebAuthnCredential
  74. creds, err = h.store.WebAuthnCredentialsByUserID(user.ID)
  75. if err != nil {
  76. response.JSONServerError(w, r, err)
  77. return
  78. }
  79. credsDescriptors := make([]protocol.CredentialDescriptor, len(creds))
  80. for i, cred := range creds {
  81. credsDescriptors[i] = cred.Credential.Descriptor()
  82. }
  83. options, sessionData, err := web.BeginRegistration(
  84. WebAuthnUser{
  85. user,
  86. crypto.GenerateRandomBytes(32),
  87. nil,
  88. },
  89. webauthn.WithExclusions(credsDescriptors),
  90. webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred),
  91. webauthn.WithExtensions(protocol.AuthenticationExtensions{"credProps": true}),
  92. )
  93. if err != nil {
  94. response.JSONServerError(w, r, err)
  95. return
  96. }
  97. request.WebSession(r).SetWebAuthn(sessionData)
  98. response.JSON(w, r, options)
  99. }
  100. func (h *handler) finishRegistration(w http.ResponseWriter, r *http.Request) {
  101. web, err := newWebAuthn()
  102. if err != nil {
  103. response.JSONServerError(w, r, err)
  104. return
  105. }
  106. uid := request.UserID(r)
  107. if uid == 0 {
  108. response.JSONUnauthorized(w, r)
  109. return
  110. }
  111. user, err := h.store.UserByID(uid)
  112. if err != nil {
  113. response.JSONServerError(w, r, err)
  114. return
  115. }
  116. sessionData := request.WebSession(r).ConsumeWebAuthnSession()
  117. if sessionData == nil {
  118. response.JSONBadRequest(w, r, errors.New("missing webauthn session data"))
  119. return
  120. }
  121. webAuthnUser := WebAuthnUser{user, sessionData.UserID, nil}
  122. cred, err := web.FinishRegistration(webAuthnUser, *sessionData, r)
  123. if err != nil {
  124. response.JSONServerError(w, r, err)
  125. return
  126. }
  127. err = h.store.AddWebAuthnCredential(uid, sessionData.UserID, cred)
  128. if err != nil {
  129. response.JSONServerError(w, r, err)
  130. return
  131. }
  132. handleEncoded := model.WebAuthnCredential{Handle: sessionData.UserID}.HandleEncoded()
  133. redirect := h.routePath("/webauthn/%s/rename", handleEncoded)
  134. response.JSON(w, r, map[string]string{"redirect": redirect})
  135. }
  136. func (h *handler) beginLogin(w http.ResponseWriter, r *http.Request) {
  137. web, err := newWebAuthn()
  138. if err != nil {
  139. response.JSONServerError(w, r, err)
  140. return
  141. }
  142. var user *model.User
  143. username := request.QueryStringParam(r, "username", "")
  144. if username != "" {
  145. user, err = h.store.UserByUsername(username)
  146. if err != nil {
  147. response.JSONUnauthorized(w, r)
  148. return
  149. }
  150. }
  151. var assertion *protocol.CredentialAssertion
  152. var sessionData *webauthn.SessionData
  153. if user != nil {
  154. creds, err := h.store.WebAuthnCredentialsByUserID(user.ID)
  155. if err != nil {
  156. response.JSONServerError(w, r, err)
  157. return
  158. }
  159. assertion, sessionData, err = web.BeginLogin(WebAuthnUser{user, nil, creds})
  160. if err != nil {
  161. response.JSONServerError(w, r, err)
  162. return
  163. }
  164. } else {
  165. assertion, sessionData, err = web.BeginDiscoverableLogin()
  166. if err != nil {
  167. response.JSONServerError(w, r, err)
  168. return
  169. }
  170. }
  171. request.WebSession(r).SetWebAuthn(sessionData)
  172. response.JSON(w, r, assertion)
  173. }
  174. func (h *handler) finishLogin(w http.ResponseWriter, r *http.Request) {
  175. web, err := newWebAuthn()
  176. if err != nil {
  177. response.JSONServerError(w, r, err)
  178. return
  179. }
  180. parsedResponse, err := protocol.ParseCredentialRequestResponseBody(r.Body)
  181. if err != nil {
  182. response.JSONServerError(w, r, err)
  183. return
  184. }
  185. slog.Debug("WebAuthn: parsed response flags",
  186. slog.Bool("user_present", parsedResponse.Response.AuthenticatorData.Flags.HasUserPresent()),
  187. slog.Bool("user_verified", parsedResponse.Response.AuthenticatorData.Flags.HasUserVerified()),
  188. slog.Bool("has_attested_credential_data", parsedResponse.Response.AuthenticatorData.Flags.HasAttestedCredentialData()),
  189. slog.Bool("has_backup_eligible", parsedResponse.Response.AuthenticatorData.Flags.HasBackupEligible()),
  190. slog.Bool("has_backup_state", parsedResponse.Response.AuthenticatorData.Flags.HasBackupState()),
  191. )
  192. sessionData := request.WebSession(r).ConsumeWebAuthnSession()
  193. if sessionData == nil {
  194. response.JSONBadRequest(w, r, errors.New("missing webauthn session data"))
  195. return
  196. }
  197. var user *model.User
  198. username := request.QueryStringParam(r, "username", "")
  199. if username != "" {
  200. user, err = h.store.UserByUsername(username)
  201. if err != nil {
  202. response.JSONUnauthorized(w, r)
  203. return
  204. }
  205. }
  206. var matchingCredential *model.WebAuthnCredential
  207. if user != nil {
  208. storedCredentials, err := h.store.WebAuthnCredentialsByUserID(user.ID)
  209. if err != nil {
  210. response.JSONServerError(w, r, err)
  211. return
  212. }
  213. sessionData.UserID = parsedResponse.Response.UserHandle
  214. webAuthUser := WebAuthnUser{user, parsedResponse.Response.UserHandle, storedCredentials}
  215. // Since go-webauthn v0.11.0, the backup eligibility flag is strictly validated, but Miniflux does not store this flag.
  216. // This workaround set the flag based on the parsed response, and avoid "BackupEligible flag inconsistency detected during login validation" error.
  217. // See https://github.com/go-webauthn/webauthn/pull/240
  218. for index := range webAuthUser.Credentials {
  219. webAuthUser.Credentials[index].Credential.Flags.BackupEligible = parsedResponse.Response.AuthenticatorData.Flags.HasBackupEligible()
  220. }
  221. for _, webAuthCredential := range webAuthUser.WebAuthnCredentials() {
  222. slog.Debug("WebAuthn: stored credential flags",
  223. slog.Bool("user_present", webAuthCredential.Flags.UserPresent),
  224. slog.Bool("user_verified", webAuthCredential.Flags.UserVerified),
  225. slog.Bool("backup_eligible", webAuthCredential.Flags.BackupEligible),
  226. slog.Bool("backup_state", webAuthCredential.Flags.BackupState),
  227. )
  228. }
  229. credCredential, err := web.ValidateLogin(webAuthUser, *sessionData, parsedResponse)
  230. if err != nil {
  231. slog.Warn("WebAuthn: ValidateLogin failed", slog.Any("error", err))
  232. response.JSONUnauthorized(w, r)
  233. return
  234. }
  235. for _, storedCredential := range storedCredentials {
  236. if bytes.Equal(credCredential.ID, storedCredential.Credential.ID) {
  237. matchingCredential = &storedCredential
  238. }
  239. }
  240. if matchingCredential == nil {
  241. response.JSONServerError(w, r, fmt.Errorf("no matching credential for %v", credCredential))
  242. return
  243. }
  244. } else {
  245. userByHandle := func(rawID, userHandle []byte) (webauthn.User, error) {
  246. var uid int64
  247. uid, matchingCredential, err = h.store.WebAuthnCredentialByHandle(userHandle)
  248. if err != nil {
  249. return nil, err
  250. }
  251. if uid == 0 {
  252. return nil, fmt.Errorf("no user found for handle %x", userHandle)
  253. }
  254. user, err = h.store.UserByID(uid)
  255. if err != nil {
  256. return nil, err
  257. }
  258. if user == nil {
  259. return nil, fmt.Errorf("no user found for handle %x", userHandle)
  260. }
  261. // Since go-webauthn v0.11.0, the backup eligibility flag is strictly validated, but Miniflux does not store this flag.
  262. // This workaround set the flag based on the parsed response, and avoid "BackupEligible flag inconsistency detected during login validation" error.
  263. // See https://github.com/go-webauthn/webauthn/pull/240
  264. matchingCredential.Credential.Flags.BackupEligible = parsedResponse.Response.AuthenticatorData.Flags.HasBackupEligible()
  265. return WebAuthnUser{user, userHandle, []model.WebAuthnCredential{*matchingCredential}}, nil
  266. }
  267. _, err = web.ValidateDiscoverableLogin(userByHandle, *sessionData, parsedResponse)
  268. if err != nil {
  269. slog.Warn("WebAuthn: ValidateDiscoverableLogin failed", slog.Any("error", err))
  270. response.JSONUnauthorized(w, r)
  271. return
  272. }
  273. }
  274. h.store.WebAuthnSaveLogin(matchingCredential.Handle)
  275. slog.Info("User authenticated successfully with webauthn",
  276. slog.Bool("authentication_successful", true),
  277. slog.String("client_ip", request.ClientIP(r)),
  278. slog.String("user_agent", r.UserAgent()),
  279. slog.Int64("user_id", user.ID),
  280. slog.String("username", user.Username),
  281. )
  282. h.store.SetLastLogin(user.ID)
  283. if err := authenticateWebSession(w, r, h.store, user); err != nil {
  284. response.JSONServerError(w, r, err)
  285. return
  286. }
  287. response.NoContent(w, r)
  288. }
  289. func (h *handler) renameCredential(w http.ResponseWriter, r *http.Request) {
  290. view := view.New(h.tpl, r)
  291. user, err := h.store.UserByID(request.UserID(r))
  292. if err != nil {
  293. response.HTMLServerError(w, r, err)
  294. return
  295. }
  296. credentialHandleEncoded := request.RouteStringParam(r, "credentialHandle")
  297. credentialHandle, err := hex.DecodeString(credentialHandleEncoded)
  298. if err != nil {
  299. response.HTMLServerError(w, r, err)
  300. return
  301. }
  302. cred_uid, cred, err := h.store.WebAuthnCredentialByHandle(credentialHandle)
  303. if err != nil {
  304. response.HTMLServerError(w, r, err)
  305. return
  306. }
  307. if cred_uid != user.ID {
  308. response.HTMLForbidden(w, r)
  309. return
  310. }
  311. webauthnForm := form.WebauthnForm{Name: cred.Name}
  312. view.Set("form", webauthnForm)
  313. view.Set("cred", cred)
  314. view.Set("menu", "settings")
  315. view.Set("user", user)
  316. view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
  317. view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
  318. response.HTML(w, r, view.Render("webauthn_rename"))
  319. }
  320. func (h *handler) saveCredential(w http.ResponseWriter, r *http.Request) {
  321. _, err := h.store.UserByID(request.UserID(r))
  322. if err != nil {
  323. response.HTMLServerError(w, r, err)
  324. return
  325. }
  326. credentialHandleEncoded := request.RouteStringParam(r, "credentialHandle")
  327. credentialHandle, err := hex.DecodeString(credentialHandleEncoded)
  328. if err != nil {
  329. response.HTMLServerError(w, r, err)
  330. return
  331. }
  332. newName := r.FormValue("name")
  333. err = h.store.WebAuthnUpdateName(credentialHandle, newName)
  334. if err != nil {
  335. response.HTMLServerError(w, r, err)
  336. return
  337. }
  338. response.HTMLRedirect(w, r, h.routePath("/settings"))
  339. }
  340. func (h *handler) deleteCredential(w http.ResponseWriter, r *http.Request) {
  341. uid := request.UserID(r)
  342. if uid == 0 {
  343. response.JSONUnauthorized(w, r)
  344. return
  345. }
  346. credentialHandleEncoded := request.RouteStringParam(r, "credentialHandle")
  347. credentialHandle, err := hex.DecodeString(credentialHandleEncoded)
  348. if err != nil {
  349. response.JSONServerError(w, r, err)
  350. return
  351. }
  352. err = h.store.DeleteCredentialByHandle(uid, credentialHandle)
  353. if err != nil {
  354. response.JSONServerError(w, r, err)
  355. return
  356. }
  357. response.NoContent(w, r)
  358. }
  359. func (h *handler) deleteAllCredentials(w http.ResponseWriter, r *http.Request) {
  360. err := h.store.DeleteAllWebAuthnCredentialsByUserID(request.UserID(r))
  361. if err != nil {
  362. response.JSONServerError(w, r, err)
  363. return
  364. }
  365. response.NoContent(w, r)
  366. }