Ctrl K
ComunidadGuías…

tests/cloud_mfa_recovery_test.go

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package main

import (
	"context"
	"crypto/hmac"
	"crypto/sha1" //nolint:depguard,gosec // RFC 6238 TOTP, which the server checks, is HMAC-SHA1
	"encoding/base32"
	"encoding/binary"
	"fmt"
	"net/http"
	"testing"
	"time"

	"github.com/shellhub-io/shellhub/pkg/models"
	"github.com/shellhub-io/shellhub/pkg/uuid"
	"github.com/shellhub-io/shellhub/tests/environment"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

const mfaResetCodeLifetime = 24 * time.Hour

// TestCloudMFARecovery runs a cloud instance, the only edition that sends email, for a user who
// lost their authenticator. A recovery code stands in for the TOTP code after the password, never
// for the password. The reset of a user's MFA by the two codes it emails to their main and
// recovery addresses signs no one in: the user signs in with the password afterwards. Each code
// lasts 24 hours in the cache, and the reset refuses the pair once either code has expired.
func TestCloudMFARecovery(t *testing.T) {
	t.Parallel()

	ctx := context.Background()

	compose := environment.New(t, run).WithEdition(environment.EditionCloud).Up(ctx)
	t.Cleanup(compose.Down)

	t.Run("a recovery code completes the password step", func(t *testing.T) {
		user := newMFAUser(t, compose, "mfa-recover")
		mfaToken := passwordStep(t, compose, user)

		auth := new(models.UserAuthResponse)
		resp, err := compose.Anonymous(t.Context()).
			SetBody(map[string]string{"token": mfaToken, "recovery_code": user.recoveryCodes[0]}).
			SetResult(auth).
			Post("/api/user/mfa/recover")
		require.NoError(t, err)
		require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())
		assert.Equal(t, user.id, auth.ID)
		assert.NotEmpty(t, auth.Token)
	})

	t.Run("a recovery code without the password step signs no one in", func(t *testing.T) {
		user := newMFAUser(t, compose, "mfa-recover-alone")

		auth := new(models.UserAuthResponse)
		resp, err := compose.Anonymous(t.Context()).
			SetBody(map[string]string{"identifier": user.username, "recovery_code": user.recoveryCodes[0]}).
			SetResult(auth).
			Post("/api/user/mfa/recover")
		require.NoError(t, err)
		assert.Equal(t, http.StatusBadRequest, resp.StatusCode(), resp.String())
		assert.Empty(t, auth.Token)

		resp, err = compose.Anonymous(t.Context()).
			SetBody(map[string]string{"token": uuid.Generate(), "recovery_code": user.recoveryCodes[0]}).
			SetResult(auth).
			Post("/api/user/mfa/recover")
		require.NoError(t, err)
		assert.Equal(t, http.StatusUnauthorized, resp.StatusCode(), resp.String())
		assert.Empty(t, auth.Token)
	})

	t.Run("the emailed codes reset MFA and the user signs in with the password", func(t *testing.T) {
		user := newMFAUser(t, compose, "mfa-reset")
		codes := requestMFAReset(t, compose, user)

		resp, err := compose.Anonymous(t.Context()).SetBody(codes).Put("/api/user/mfa/reset/" + user.id)
		require.NoError(t, err)
		require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())
		assert.Empty(t, resp.Body(), "the reset signed the user in")

		requireAnswer(t, asBearer(t, compose, user.token), http.MethodGet, "/api/auth/user", http.StatusUnauthorized)

		signedIn := compose.AuthUser(t, user.username, ShellHubPassword)
		assert.NotEmpty(t, signedIn.Token)
		assert.False(t, signedIn.MFA, "the reset left MFA on")
	})

	t.Run("the emailed codes last 24 hours and the pair is refused once one of them expires", func(t *testing.T) {
		user := newMFAUser(t, compose, "mfa-expired")
		codes := requestMFAReset(t, compose, user)

		for _, address := range []string{"main", "recovery"} {
			key := mfaResetCodeCacheKey(address, user.id)
			left := compose.CacheEntryTTL(t, key)
			assert.Greater(t, left, mfaResetCodeLifetime-5*time.Minute, key)
			assert.LessOrEqual(t, left, mfaResetCodeLifetime, key)
		}

		compose.ExpireCacheEntryIn(t, mfaResetCodeCacheKey("recovery", user.id), 0)

		resp, err := compose.Anonymous(t.Context()).SetBody(codes).Put("/api/user/mfa/reset/" + user.id)
		require.NoError(t, err)
		require.Equal(t, http.StatusForbidden, resp.StatusCode(), resp.String())

		auth := new(models.UserAuthResponse)
		resp, err = asBearer(t, compose, user.token).SetResult(auth).Get("/api/auth/user")
		require.NoError(t, err)
		require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())
		assert.True(t, auth.MFA, "expired codes turned MFA off")
	})
}

type mfaUser struct {
	id            string
	token         string
	email         string
	recoveryEmail string
	username      string
	recoveryCodes []string
}

func newMFAUser(t *testing.T, compose *environment.DockerCompose, username string) mfaUser {
	t.Helper()

	user := mfaUser{
		username:      username,
		email:         username + stripeTestEmailDomain,
		recoveryEmail: username + "-recovery" + stripeTestEmailDomain,
	}

	compose.NewUser(t, username, user.email, ShellHubPassword)
	compose.NewNamespace(t, username, username, uuid.Generate())

	login := compose.AuthUser(t, username, ShellHubPassword)
	user.id, user.token = login.ID, login.Token

	resp, err := asBearer(t, compose, user.token).
		SetBody(map[string]string{"recovery_email": user.recoveryEmail}).
		Patch("/api/users")
	require.NoError(t, err)
	require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())

	generated := struct {
		Secret        string   `json:"secret"`
		RecoveryCodes []string `json:"recovery_codes"`
	}{}
	resp, err = asBearer(t, compose, user.token).SetResult(&generated).Get("/api/user/mfa/generate")
	require.NoError(t, err)
	require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())
	user.recoveryCodes = generated.RecoveryCodes

	key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(generated.Secret)
	require.NoError(t, err)

	require.EventuallyWithT(t, func(tt *assert.CollectT) {
		resp, err := asBearer(t, compose, user.token).
			SetBody(map[string]string{"code": totp(key)}).
			Put("/api/user/mfa/enable")
		assert.NoError(tt, err)
		assert.Equal(tt, http.StatusOK, resp.StatusCode(), resp.String())
	}, time.Minute, 5*time.Second)

	return user
}

func passwordStep(t *testing.T, compose *environment.DockerCompose, user mfaUser) string {
	t.Helper()

	resp, err := compose.Anonymous(t.Context()).
		SetBody(map[string]string{"username": user.username, "password": ShellHubPassword}).
		Post("/api/login")
	require.NoError(t, err)

	mfaToken := resp.Header().Get("X-MFA-Token")
	require.NotEmpty(t, mfaToken, resp.String())

	return mfaToken
}

func requestMFAReset(t *testing.T, compose *environment.DockerCompose, user mfaUser) map[string]string {
	t.Helper()

	resp, err := compose.Anonymous(t.Context()).
		SetBody(map[string]string{"identifier": user.username}).
		Post("/api/user/mfa/reset")
	require.NoError(t, err)
	require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())

	return map[string]string{
		"main_email_code":     emailedMFAResetCode(t, compose, user.email),
		"recovery_email_code": emailedMFAResetCode(t, compose, user.recoveryEmail),
	}
}

func emailedMFAResetCode(t *testing.T, compose *environment.DockerCompose, to string) string {
	t.Helper()

	code, ok := compose.AwaitEmail(t, to, "/reset-mfa").Labelled("MFA reset code")
	require.True(t, ok, "no reset code in the email to %s", to)

	return code
}

func mfaResetCodeCacheKey(address, userID string) string {
	return "reset-mfa={" + address + ":" + userID + "}"
}

func totp(key []byte) string {
	counter := make([]byte, 8)
	binary.BigEndian.PutUint64(counter, uint64(wallClock.Now().Unix()/30)) //nolint:gosec // a Unix time after 1970 is positive

	mac := hmac.New(sha1.New, key)
	mac.Write(counter)
	sum := mac.Sum(nil)

	offset := sum[len(sum)-1] & 0xf
	code := binary.BigEndian.Uint32(sum[offset:]) & 0x7fffffff

	return fmt.Sprintf("%06d", code%1_000_000)
}