webauthn.go 12 KB

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