From 135e45890a172cc86062c69fff07748259c0dc54 Mon Sep 17 00:00:00 2001 From: Bruno Michel Date: Thu, 9 Nov 2023 15:42:25 +0100 Subject: [PATCH] Add email_verified_code to bypass 2FA by email It will allow the cloudery to inform the stack that it has already checked that the user can receives email on their address. --- assets/scripts/login.js | 2 + assets/templates/login.html | 1 + docs/admin.md | 27 +++++++++ model/instance/auth.go | 17 ++++++ model/instance/store.go | 57 +++++++++++++++++-- web/auth/auth.go | 52 ++++++++++------- web/instances/instances.go | 35 ++++++++++++ web/statik/statik.go | 109 ++++++++++++++++++------------------ 8 files changed, 220 insertions(+), 80 deletions(-) diff --git a/assets/scripts/login.js b/assets/scripts/login.js index 5a9583458a3..e2c6e8686e3 100644 --- a/assets/scripts/login.js +++ b/assets/scripts/login.js @@ -11,6 +11,7 @@ const loginField = d.getElementById('login-field') const longRunCheckbox = d.getElementById('long-run-session') const trustedTokenInput = d.getElementById('trusted-device-token') + const emailVerifiedCodeInput = d.getElementById('email_verified_code') const magicCodeInput = d.getElementById('magic_code') // Set the trusted device token from the localstorage in the form if it exists @@ -44,6 +45,7 @@ const data = new URLSearchParams() data.append('passphrase', pass) data.append('trusted-device-token', trustedTokenInput.value) + data.append('email_verified_code', emailVerifiedCodeInput.value) data.append('long-run-session', longRun) data.append('redirect', redirect) data.append('csrf_token', csrfTokenInput.value) diff --git a/assets/templates/login.html b/assets/templates/login.html index a676de47c26..af79f781a48 100644 --- a/assets/templates/login.html +++ b/assets/templates/login.html @@ -22,6 +22,7 @@ +
diff --git a/docs/admin.md b/docs/admin.md index 2223f2faeb8..a88d85b603f 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -252,6 +252,33 @@ Content-Type: application/json } ``` +### POST /instances/:domain/email_verified_code + +Creates an email_verified_code that can be used on the given instance to avoid +the 2FA by email. + +#### Request + +```http +POST /instances/alice.cozy.localhost/email_verified_code HTTP/1.1 +``` + +#### Response + +```http +HTTP/1.1 200 OK +Content-Type: application/json +``` + +```json +{ + "email_verified_code": "jBPF5Kvpv1oztdaSgdA2315hVpAf6BCd" +} +``` + +Note: if the two factor authentication by email is not enabled on this +instance, it will return a 400 Bad Request error. + ### DELETE /instances/:domain/sessions Delete the databases for io.cozy.sessions and io.cozy.sessions.logins. diff --git a/model/instance/auth.go b/model/instance/auth.go index ff9c59dcc2d..85d7a524537 100644 --- a/model/instance/auth.go +++ b/model/instance/auth.go @@ -19,6 +19,7 @@ const ( RegisterTokenLen = 16 PasswordResetTokenLen = 16 SessionCodeLen = 32 + EmailVerifiedCodeLen = 32 SessionSecretLen = 64 MagicLinkCodeLen = 32 OauthSecretLen = 128 @@ -204,3 +205,19 @@ func (i *Instance) CreateSessionCode() (string, error) { func (i *Instance) CheckAndClearSessionCode(code string) bool { return GetStore().CheckAndClearSessionCode(i, code) } + +// CreateEmailVerifiedCode returns an email_verified_code that can be used to +// avoid the 2FA by email. +func (i *Instance) CreateEmailVerifiedCode() (string, error) { + code := crypto.GenerateRandomString(EmailVerifiedCodeLen) + store := GetStore() + if err := store.SaveEmailVerfiedCode(i, code); err != nil { + return "", err + } + return code, nil +} + +// CheckEmailVerifiedCode will return true if the email verified code is valid. +func (i *Instance) CheckEmailVerifiedCode(code string) bool { + return GetStore().CheckEmailVerifiedCode(i, code) +} diff --git a/model/instance/store.go b/model/instance/store.go index a60394f5e3a..3bb73853fc1 100644 --- a/model/instance/store.go +++ b/model/instance/store.go @@ -13,11 +13,16 @@ import ( // Store is an object to store and retrieve session codes. type Store interface { SaveSessionCode(db prefixer.Prefixer, code string) error + SaveEmailVerfiedCode(db prefixer.Prefixer, code string) error CheckAndClearSessionCode(db prefixer.Prefixer, code string) bool + CheckEmailVerifiedCode(db prefixer.Prefixer, code string) bool } -// storeTTL is the time an entry stay alive (1 week) -var storeTTL = 7 * 24 * time.Hour +// sessionCodeTTL is the time an entry for a session_code stays alive (1 week) +var sessionCodeTTL = 7 * 24 * time.Hour + +// emailVerifiedCodeTTL is the time an entry for an email_verified_code stays alive +var emailVerifiedCodeTTL = 15 * time.Minute // storeCleanInterval is the time interval between each cleanup. var storeCleanInterval = 1 * time.Hour @@ -67,21 +72,46 @@ func (s *memStore) cleaner() { func (s *memStore) SaveSessionCode(db prefixer.Prefixer, code string) error { s.mu.Lock() defer s.mu.Unlock() - s.vals[code] = time.Now().Add(storeTTL) + key := sessionCodeKey(db, code) + s.vals[key] = time.Now().Add(sessionCodeTTL) + return nil +} + +func (s *memStore) SaveEmailVerfiedCode(db prefixer.Prefixer, code string) error { + s.mu.Lock() + defer s.mu.Unlock() + key := emailVerifiedCodeKey(db, code) + s.vals[key] = time.Now().Add(emailVerifiedCodeTTL) return nil } func (s *memStore) CheckAndClearSessionCode(db prefixer.Prefixer, code string) bool { s.mu.Lock() defer s.mu.Unlock() - exp, ok := s.vals[code] + key := sessionCodeKey(db, code) + exp, ok := s.vals[key] if !ok { return false } - delete(s.vals, code) + delete(s.vals, key) return time.Now().Before(exp) } +func (s *memStore) CheckEmailVerifiedCode(db prefixer.Prefixer, code string) bool { + s.mu.Lock() + defer s.mu.Unlock() + key := emailVerifiedCodeKey(db, code) + exp, ok := s.vals[key] + if !ok { + return false + } + if time.Now().After(exp) { + delete(s.vals, key) + return false + } + return true +} + type redisStore struct { c redis.UniversalClient ctx context.Context @@ -89,7 +119,12 @@ type redisStore struct { func (s *redisStore) SaveSessionCode(db prefixer.Prefixer, code string) error { key := sessionCodeKey(db, code) - return s.c.Set(s.ctx, key, "1", storeTTL).Err() + return s.c.Set(s.ctx, key, "1", sessionCodeTTL).Err() +} + +func (s *redisStore) SaveEmailVerfiedCode(db prefixer.Prefixer, code string) error { + key := emailVerifiedCodeKey(db, code) + return s.c.Set(s.ctx, key, "1", emailVerifiedCodeTTL).Err() } func (s *redisStore) CheckAndClearSessionCode(db prefixer.Prefixer, code string) bool { @@ -98,6 +133,16 @@ func (s *redisStore) CheckAndClearSessionCode(db prefixer.Prefixer, code string) return err == nil && n > 0 } +func (s *redisStore) CheckEmailVerifiedCode(db prefixer.Prefixer, code string) bool { + key := emailVerifiedCodeKey(db, code) + n, err := s.c.Exists(s.ctx, key).Result() + return err == nil && n > 0 +} + func sessionCodeKey(db prefixer.Prefixer, suffix string) string { return db.DBPrefix() + ":sessioncode:" + suffix } + +func emailVerifiedCodeKey(db prefixer.Prefixer, suffix string) string { + return db.DBPrefix() + ":emailverifiedcode:" + suffix +} diff --git a/web/auth/auth.go b/web/auth/auth.go index 2607ebf39df..52c1bdca51a 100644 --- a/web/auth/auth.go +++ b/web/auth/auth.go @@ -95,9 +95,12 @@ func Home(c echo.Context) error { } } - var params url.Values + params := make(url.Values) if jwt := c.QueryParam("jwt"); jwt != "" { - params = url.Values{"jwt": {jwt}} + params.Add("jwt", jwt) + } + if code := c.QueryParam("email_verified_code"); code != "" { + params.Add("email_verified_code", code) } return c.Redirect(http.StatusSeeOther, instance.PageURL("/auth/login", params)) } @@ -123,6 +126,13 @@ func isTrustedDevice(c echo.Context, inst *instance.Instance) bool { return inst.ValidateTwoFactorTrustedDeviceSecret(c.Request(), trustedDeviceToken) } +// hasEmailVerified checks if the email has already been verified, and if it is +// the case, the stack can skip the 2FA by email. +func hasEmailVerified(c echo.Context, inst *instance.Instance) bool { + code := c.FormValue("email_verified_code") + return inst.CheckEmailVerifiedCode(code) +} + func getLogoutURL(context string) string { auth := config.GetConfig().Authentication delegated, _ := auth[context].(map[string]interface{}) @@ -196,23 +206,24 @@ func renderLoginForm(c echo.Context, i *instance.Instance, code int, credsErrors } return c.Render(code, "login.html", echo.Map{ - "TemplateTitle": i.TemplateTitle(), - "Domain": i.ContextualDomain(), - "ContextName": i.ContextName, - "Locale": i.Locale, - "Favicon": middlewares.Favicon(i), - "CryptoPolyfill": middlewares.CryptoPolyfill(c), - "BottomNavBar": middlewares.BottomNavigationBar(c), - "Iterations": iterations, - "Salt": string(i.PassphraseSalt()), - "Title": title, - "PasswordHelp": help, - "CredentialsError": credsErrors, - "Redirect": redirectStr, - "CSRF": c.Get("csrf"), - "MagicLink": i.MagicLink, - "OAuth": hasOAuth, - "FranceConnect": hasFranceConnect, + "TemplateTitle": i.TemplateTitle(), + "Domain": i.ContextualDomain(), + "ContextName": i.ContextName, + "Locale": i.Locale, + "Favicon": middlewares.Favicon(i), + "CryptoPolyfill": middlewares.CryptoPolyfill(c), + "BottomNavBar": middlewares.BottomNavigationBar(c), + "Iterations": iterations, + "Salt": string(i.PassphraseSalt()), + "Title": title, + "PasswordHelp": help, + "CredentialsError": credsErrors, + "Redirect": redirectStr, + "CSRF": c.Get("csrf"), + "EmailVerifiedCode": c.QueryParam("email_verified_code"), + "MagicLink": i.MagicLink, + "OAuth": hasOAuth, + "FranceConnect": hasFranceConnect, }) } @@ -342,7 +353,8 @@ func login(c echo.Context) error { // check that the mail has been confirmed. If not, 2FA is not // activated. // If device is trusted, skip the 2FA. - if inst.HasAuthMode(instance.TwoFactorMail) && !isTrustedDevice(c, inst) { + // If the email has already been verified, skip the 2FA too. + if inst.HasAuthMode(instance.TwoFactorMail) && !isTrustedDevice(c, inst) && !hasEmailVerified(c, inst) { twoFactorToken, err := lifecycle.SendTwoFactorPasscode(inst) if err != nil { return err diff --git a/web/instances/instances.go b/web/instances/instances.go index 292c51dbe66..f27e6739f1f 100644 --- a/web/instances/instances.go +++ b/web/instances/instances.go @@ -379,6 +379,40 @@ func createSessionCode(c echo.Context) error { }) } +func createEmailVerifiedCode(c echo.Context) error { + domain := c.Param("domain") + inst, err := lifecycle.GetInstance(domain) + if err != nil { + return err + } + + if !inst.HasAuthMode(instance.TwoFactorMail) { + return jsonapi.BadRequest(errors.New("2FA by email is not enabled on this instance")) + } + + code, err := inst.CreateEmailVerifiedCode() + if err != nil { + return c.JSON(http.StatusInternalServerError, echo.Map{ + "error": err, + }) + } + + req := c.Request() + var ip string + if forwardedFor := req.Header.Get(echo.HeaderXForwardedFor); forwardedFor != "" { + ip = strings.TrimSpace(strings.SplitN(forwardedFor, ",", 2)[0]) + } + if ip == "" { + ip = strings.Split(req.RemoteAddr, ":")[0] + } + inst.Logger().WithField("nspace", "loginaudit"). + Infof("New email_verified_code created from %s at %s", ip, time.Now()) + + return c.JSON(http.StatusCreated, echo.Map{ + "email_verified_code": code, + }) +} + func cleanSessions(c echo.Context) error { domain := c.Param("domain") inst, err := lifecycle.GetInstance(domain) @@ -650,6 +684,7 @@ func Routes(router *echo.Group) { router.POST("/:domain/auth-mode", setAuthMode) router.POST("/:domain/magic_link", createMagicLink) router.POST("/:domain/session_code", createSessionCode) + router.POST("/:domain/email_verified_code", createEmailVerifiedCode) router.DELETE("/:domain/sessions", cleanSessions) // Advanced features for instances diff --git a/web/statik/statik.go b/web/statik/statik.go index 0de7d1f49dc..a056bac2e7b 100644 --- a/web/statik/statik.go +++ b/web/statik/statik.go @@ -38755,29 +38755,29 @@ wYnBkGaJUaJUQjZ2bgRHkNML+wA5TP4WMskE -----END COZY ASSET----- -----BEGIN COZY ASSET----- Name: /scripts/login.js -Size: 3384 +Size: 3530 -GzcNABwHdqyxIK0e5Aun+TV/1tNU73Rhjo1Xf45cbVrHIHG+eYMopPStFBaL9nuN -BJQRzmZn9+4T3QuSCruoOTAhUlGxKQpb4StVZRfp8uktrHNCooXzq6VmlVZouPLH -q3Kwddlmj67DXT0rpCrdHoqgp+ZN6C6Z70cGtmqgSRgNFr9EyMRfbPATqEmRJXYu -5JM6XQOwVKCiMm078ZrCncPi7d4mTbatmRVSCtpgkR2ZFp5dSAcUJtF488Zne1Ws -sDj4Bvk76uWUHgUnJjzxu5/c12vMUgveMSe556jILJA03uOsvxhmi2P0jexDyCGk -5bNCLkBrLCLoIu1xMByO64AJmMYUfBM5RHsztlPtUUuMXj9kSJZrAnpNXoFejmzP -oBzwmf4lNBMK51cwRHKff85KHI4RuPoGCYD/2bxaiLgHofbqSLq0CB8INuysSLAo -8blyovOVooQnpWhkAo4V/pHMIhoKnEyQiT6FuaxkQBssIlQySfllGFZwpCfwMfBl -mLj44v39v22NgfuPT6AxIUAjEpCmX4BJ3sLN/5N9eusB5ah2SJmlDhmd3cnaVwsG -f9kq+kHuuqbizGB1Fu/zPhmZivG0MQAy7t4hc4ncrRSmaW+GlHZ72ngkDZkwwRYw -Z6F5E/TulKOCYShDwJ3sgIYezxli2lX4MltLVmWLaXk77sfM/6GYiHWx5OuCXIAg -NB29H4gbrWxc//95K3t13cDzOgDRzMknktQeUqEluPk0d6cpLcsom911RsU8ERcU -VX+WYHPhMOgfGbX8MQyHTWx+M1zl2X7YdWRjWE0Q7u9hVkMC5oKysMjiix1L8lm9 -rPAJEeR86HrPmAqn8jcXUVV0pSLyzka2DzsULowPVGqUKxOXHVWURa5NRVc1Xtvp -1cwdjqmG499mljXYyvtEkXG1XHH5AavngPPkPFu7CvlpLy6TnMTgZ0IzYmGUcTai -A3KOPEaXEtEAWfyBNJSld4I/IrhMHhY8NYL8zSZx1vnssDqnAUpXF8dmLvPe6tcf -cebgbFbOINg5DkPmbExb0YkqRMNRwd+sVsrRI2ajfDEOUGviOkamEsYFRqsULoeJ -pMHBm/ttJsA2IdWgSTkBuGFUXmT2PP6rlbWSLPvQO2bX+v/ERmw0ri7XyS6XugLy -o8kDArCZwVXGMKnP1+vMsz1zUbgGgypKl5kxqq+GSFBLNQPmKCL5bEszajjQNfUo -jiCwcsblIEkCu4y74HRm9PhYn27Zsk0m/21topdVJo7vcvqmPFErirLGz4G+IcC7 -QTmJbdUI +G8kNAJwFdiwb0HZg3DzIC0mLrGZqf021cgLGf+BU/Rg5VeqcWyWQMj8pBkilsPRJ +CgpTK/e+GpYKDRi+1Um/NT2X1pjRSjrbaRUFhaYDGsADUSYoj+F8F6EyRVzkMy2c +360jp9ATGq78dlLrjS/WOwwDrk5YMVfp9oAIPTVvwu+ifD9l4UoDTRgNdn5RIBO5 +6OAnUJOiSOxDyAcXdFcBrQ46VKZtZ6IxbjwWr3fXebZpzcxIKWiDebFnWgjbSAcU +JnHx5gNn7uyw4nwvDZI76uVY94KTEl6E7XcZ6jUWqQXvnLMycFRkFkgaH+OdXBSz +xTH6mSwhJKB/9d9n+83eu4/ESQvocV7tG/MYy8ygau7BBGie9pxgzEgTqE+nMYXQ +aA/pR632XAOps/0G+6LznlUC+nFeAf7yZLsWteYz/UNo6ijkWMESyXV+VlgkiDHB ++oogABWirbV+wj0S6tOOpE/UkEB0SfdJgkWp2Gc4nc+kKTxJNUCbeFb4IatrGoGz +KTvTyzGvkwZiE0pUlYJOeDcsKwQyEISo/G7YSP3Om5uf9a8H9x9fIO4hFncIUA7v +gNUTwo1/DJLevEU9zh6S+bmAjE7+oC2+RYtv+l//oHA9qbwgxY8nrBKVsbIYzxsa +yLi7+8Jn7m7msI2NC6S02Y3u71yDNifYAqbQqvks+LuqtdpVlCEQTg5AIxphGmL6 +hMKXDHJHrsdEwZ2PCnb6g8NErEtdfprIBQhC9WOQgYRRM9m1v+9n4u5Phhf8AES1 +S9+QvD0UHbXgltPcC01pRUYWZfXc2s7OrKiAO+O0hShkXdWvFmzOrgZ/KIQv7KMx +zZB+YZZlsTPdazZMKSXe3MC0FgkMGLDwsS5khKo7iv7S7DAmKEno6ZExZZf2J2ea +VTqrE+VgI/+AZYwQpjvKfuyeSTOhKqUptLl478zpdn5WA4yjvOKEpxqKDbY7PxOF +ubfUcP0CO2yfy+w0o72M/FxPN7OcpAjChGpWw0xkfeQGLZ0Ixi23ooEog0QRlF3v +xF8SuEywFoSaQP5knXnnQ7FfnvJY5MuzZ8ObeW+Oir6kWsZb0axpODgeQ+a1TDs6 +8QrRcFQIF625c/iMpzsFm6mgWggcY+MNYyfjyi63k0zS+1AAaTMB1hlXFZp1JwIX +jFyMzDDIv7V0TnIckuCZPT3+kpjVydjDPC1KClKfQj40edAEOnu6bAyz/vnyqgjs +jpynrsKijOuVGaOGUk2GWkoxmMeJJNlWxKjhQU+pmzmCwFwal4Msq9g27oLTUdZT +iPX51j/bCiHPa1y9W5GtFLrlfhYJ0RXTilbUPhrVMOn9wKXESGsQ -----END COZY ASSET----- -----BEGIN COZY ASSET----- Name: /scripts/new-password.js @@ -39211,39 +39211,40 @@ XFrtR+aavtcw/0xFIYOvEPAWCTsmsKXAN+uFgcZeRx0iDQ== -----END COZY ASSET----- -----BEGIN COZY ASSET----- Name: /templates/login.html -Size: 5878 +Size: 5991 -G/UWACwKbOcO4c7lNYIo2LyoRVYulYegG/GV3Fk6amW4R5buS76quYuTGLEUYGYD -JoKmapC+PbsJYyTlf7/f//qfP51IiJRICqTyts5VxDyZ7rPXeQ+3phoayXIndoaS -MBkbdSHtdgtBPirswCK3jaeTHEt0HufEzRUzRF4A8PuqOG+l5y6+RNRvz3xFkkhv -RmcxtpBpMWtR+0po427X08x4jwmvxEeeoKMJtBu2/CQHRfGQqVtzYj4eIEMS+OzT -SYfAkaR6vEIoUuvDV7Y23SDP0kQUrf0404yZkxcxw/GAzX/S1X3mEs+W3/u73j7Y -1N6nntj8kzy12h1zZfUjvzomCGu3KsHKoE6OeW7YloIO1+LnFB+p9qV1K7XpiSr0 -lwHcGtlWZGddkBy2SKxkVw6KDGiMvC3C06PmrAE5Ax/UKVYqwdzaeW2NBR56oYQn -eVnM/9MWw3FRCYpqO+vIHkm7wL9Ln4JW52KRxOPtNPQ17o/N3NuKXeoI5OBB5apC -xIYYjCyc9/AdTqXeRd3ketAEX6zhG9/HOMXrCz3+3DVD9ma2gIna/HFsK6kogMlZ -OzpAsMYYHGFLYnWQifZoBPZ4P0Qiw3FErH/1XW8ftH8MQC2YrR5mxvDz0FkqQsQa -I5eb8hPPFemZ9EUPIRUX0yyTmRg5+FMkoRCbn5k0U5NAW0VZvJW5wNTPiPUoGrZG -jSkBAeLQXH8jUHxT1oMSVtvpI2ZkETqcH7ysXX64G8CWymKSuLtgtQeY1WVF6AeP -w/FAj+2QbIEU22jGG7aY9mUi/LaQu2ssfsrhlr7oYaezctVB9I9YQwG2oFdivFd6 -mlbTKydbDlF3wNz3L18OUCcsaJjNBB6wXFTWoqix/KZaUDVDZ6nbYEJQbpVkASqC -kxVRTLuTlO0i+a7OYvl4sEuR7Y3sBLq6dOwtQ2bRg/Q0ikoRoD/ZnuSvvJ2xopdR -HbRbwRVbRFt3mCom4UNZlZY4YU2rvT+6lt7vLzON2lP7z4tRR5/cZNsKk1TbZ3ni -mHWYX9r6PFtTRLt/XniJZr6aDdwTeaxmmQdmoziU54ihtkYVFZ83UWaUA0yoZ/rZ -wwx5pSwg4Zdn//w7SPAbPOktm05UWgO8g/YM7xNBOyZArW8lMmS1+245pK0svBI3 -X+luBQfCeAa5xNXa+tdtRXAfmzVWQ4VBrR4xbfeKA+mtHFIuCPkA6VfIC7P+MsXE -LKwIhJPPA6yVFnYGBx7uLNqSyLPjErNBStXA2AIqdzxLTyB+RJJwi1q6lTnmTxHl -a7crLdy1JcD7QWViW8bbz0pBBz8HKVM9/ucseysdsFoV3WNWxwfzzLby7DlL9bEW -AdK3YuFZSlU4OEyX2GvFADG2zeBgabXcLkF6/U1PWcs1yznv4GjpeVostqWP1pbd -VldHVJ7VJTCNe0Spp67LRaaDF02cbNd+QYVlkVVTXVANO9gu2jp5wimXoKot0xW6 -VMu+uiztuJK8Ky/pdN5A79rFB4aLAOtemAUMEjY+esbUjboN83mUKcp75wKm9AEu -J4IgNY9rEmnkjdDeS+M4NbUqyaJ366bEsmOobdqO6R6sYACPOGKJ/mYaBX7r1l4v -7r5ojoy31srl11aKFuZ7TRQx8sZ08JqwLnVtQsuVySRGDOn7NWh74P0f9ylWKo3D -Dbt/0xph2AJpO2wzpA/uH8HOY/sSPvVwNCleoWs9ZosBCBXMV51AJpCj5gP7kUao -DD9xM/JA2FUlSnFomSy8qBtS2wEkQrUoFncilFQQXfW9SlKrypoKadHgV3ZRf0AS -G/elC9jpJOsDrwub82AX0U81MWh+0mjjT7Zpma0hqmd4WY0DvYsr9h9qn/pCfuZA -aNUtZxQOCF/V7CuoYUkM952t/A0SCM3pKqTCH3IY4/A5tIeibttc5kf+BqVDZxLm -L9qimVXSZZ8smXsVX92ZZxgF6cV7Z56DtUo94A== +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 +OnLv4Dlaq6LLHQ== -----END COZY ASSET----- -----BEGIN COZY ASSET----- Name: /templates/magic_link_twofactor.html