Skip to content

Commit

Permalink
Add email_verified_code to bypass 2FA by email (#4213)
Browse files Browse the repository at this point in the history
It will allow the cloudery to inform the stack that it has already
checked that the user can receives email on their address.
  • Loading branch information
nono authored Nov 15, 2023
2 parents 51c741f + 135e458 commit a4258d9
Show file tree
Hide file tree
Showing 8 changed files with 220 additions and 80 deletions.
2 changes: 2 additions & 0 deletions assets/scripts/login.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions assets/templates/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
<input id="redirect" type="hidden" name="redirect" value="{{.Redirect}}" />
<input id="csrf_token" type="hidden" name="csrf_token" value="{{.CSRF}}" />
<input id="trusted-device-token" type="hidden" name="trusted-device-token" value="" />
<input id="email_verified_code" type="hidden" name="email_verified_code" value="{{.EmailVerifiedCode}}" />
<main class="wrapper">

<header class="wrapper-top">
Expand Down
27 changes: 27 additions & 0 deletions docs/admin.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions model/instance/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const (
RegisterTokenLen = 16
PasswordResetTokenLen = 16
SessionCodeLen = 32
EmailVerifiedCodeLen = 32
SessionSecretLen = 64
MagicLinkCodeLen = 32
OauthSecretLen = 128
Expand Down Expand Up @@ -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)
}
57 changes: 51 additions & 6 deletions model/instance/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -67,29 +72,59 @@ 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
}

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 {
Expand All @@ -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
}
52 changes: 32 additions & 20 deletions web/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand All @@ -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{})
Expand Down Expand Up @@ -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,
})
}

Expand Down Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions web/instances/instances.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading

0 comments on commit a4258d9

Please sign in to comment.