webauthn.go 12 KB

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