tests/cloud_mfa_recovery_test.go
Rama master · réplica de lectura del repositorio original.
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)
}