Blame SOURCES/0006-notifications-use-HMAC-SHA256-to-generate-password-r.patch

625261
From 5749f50533225b5d38fed1ed86b1c893cc0466b5 Mon Sep 17 00:00:00 2001
625261
From: Andreas Gerstmayr <agerstmayr@redhat.com>
625261
Date: Thu, 25 Nov 2021 18:49:52 +0100
625261
Subject: [PATCH] notifications: use HMAC-SHA256 to generate password reset
625261
 tokens
5bde66
625261
* changes the time limit code generation function to use HMAC-SHA256
625261
  instead of SHA-1
625261
* multiple new testcases
5bde66
5bde66
diff --git a/pkg/services/notifications/codes.go b/pkg/services/notifications/codes.go
625261
index 32cd5dd7cd..72d33e3814 100644
5bde66
--- a/pkg/services/notifications/codes.go
5bde66
+++ b/pkg/services/notifications/codes.go
5bde66
@@ -1,48 +1,53 @@
5bde66
 package notifications
5bde66
 
5bde66
 import (
5bde66
-	"crypto/sha1" // #nosec
5bde66
+	"crypto/hmac"
5bde66
+	"crypto/sha256"
5bde66
 	"encoding/hex"
5bde66
 	"fmt"
5bde66
+	"strconv"
5bde66
 	"time"
5bde66
 
5bde66
-	"github.com/unknwon/com"
5bde66
-
5bde66
 	"github.com/grafana/grafana/pkg/models"
5bde66
 	"github.com/grafana/grafana/pkg/setting"
5bde66
 )
5bde66
 
5bde66
-const timeLimitCodeLength = 12 + 6 + 40
5bde66
+const timeLimitStartDateLength = 12
5bde66
+const timeLimitMinutesLength = 6
5bde66
+const timeLimitHmacLength = 64
5bde66
+const timeLimitCodeLength = timeLimitStartDateLength + timeLimitMinutesLength + timeLimitHmacLength
5bde66
 
5bde66
 // create a time limit code
5bde66
-// code format: 12 length date time string + 6 minutes string + 40 sha1 encoded string
5bde66
-func createTimeLimitCode(data string, minutes int, startInf interface{}) (string, error) {
5bde66
+// code format: 12 length date time string + 6 minutes string + 64 HMAC-SHA256 encoded string
5bde66
+func createTimeLimitCode(payload string, minutes int, startStr string) (string, error) {
5bde66
 	format := "200601021504"
5bde66
 
5bde66
 	var start, end time.Time
5bde66
-	var startStr, endStr string
5bde66
+	var endStr string
5bde66
 
5bde66
-	if startInf == nil {
5bde66
+	if startStr == "" {
5bde66
 		// Use now time create code
5bde66
 		start = time.Now()
5bde66
 		startStr = start.Format(format)
5bde66
 	} else {
5bde66
 		// use start string create code
5bde66
-		startStr = startInf.(string)
5bde66
-		start, _ = time.ParseInLocation(format, startStr, time.Local)
5bde66
-		startStr = start.Format(format)
5bde66
+		var err error
5bde66
+		start, err = time.ParseInLocation(format, startStr, time.Local)
5bde66
+		if err != nil {
5bde66
+			return "", err
5bde66
+		}
5bde66
 	}
5bde66
 
5bde66
 	end = start.Add(time.Minute * time.Duration(minutes))
5bde66
 	endStr = end.Format(format)
5bde66
 
5bde66
-	// create sha1 encode string
5bde66
-	sh := sha1.New()
5bde66
-	if _, err := sh.Write([]byte(data + setting.SecretKey + startStr + endStr +
5bde66
-		com.ToStr(minutes))); err != nil {
5bde66
-		return "", err
5bde66
+	// create HMAC-SHA256 encoded string
5bde66
+	key := []byte(setting.SecretKey)
5bde66
+	h := hmac.New(sha256.New, key)
5bde66
+	if _, err := h.Write([]byte(payload + startStr + endStr)); err != nil {
5bde66
+		return "", fmt.Errorf("cannot create hmac: %v", err)
5bde66
 	}
5bde66
-	encoded := hex.EncodeToString(sh.Sum(nil))
5bde66
+	encoded := hex.EncodeToString(h.Sum(nil))
5bde66
 
5bde66
 	code := fmt.Sprintf("%s%06d%s", startStr, minutes, encoded)
5bde66
 	return code, nil
625261
@@ -50,29 +55,32 @@ func createTimeLimitCode(data string, minutes int, startInf interface{}) (string
5bde66
 
5bde66
 // verify time limit code
625261
 func validateUserEmailCode(cfg *setting.Cfg, user *models.User, code string) (bool, error) {
5bde66
-	if len(code) <= 18 {
5bde66
+	if len(code) < timeLimitCodeLength {
5bde66
 		return false, nil
5bde66
 	}
5bde66
 
625261
-	minutes := cfg.EmailCodeValidMinutes
5bde66
 	code = code[:timeLimitCodeLength]
5bde66
 
5bde66
 	// split code
5bde66
-	start := code[:12]
5bde66
-	lives := code[12:18]
5bde66
-	if d, err := com.StrTo(lives).Int(); err == nil {
5bde66
-		minutes = d
5bde66
+	startStr := code[:timeLimitStartDateLength]
5bde66
+	minutesStr := code[timeLimitStartDateLength : timeLimitStartDateLength+timeLimitMinutesLength]
5bde66
+	minutes, err := strconv.Atoi(minutesStr)
5bde66
+	if err != nil {
5bde66
+		return false, fmt.Errorf("invalid time limit code: %v", err)
5bde66
 	}
5bde66
 
625261
 	// right active code
5bde66
-	data := com.ToStr(user.Id) + user.Email + user.Login + user.Password + user.Rands
5bde66
-	retCode, err := createTimeLimitCode(data, minutes, start)
5bde66
+	payload := strconv.FormatInt(user.Id, 10) + user.Email + user.Login + user.Password + user.Rands
5bde66
+	expectedCode, err := createTimeLimitCode(payload, minutes, startStr)
5bde66
 	if err != nil {
5bde66
 		return false, err
5bde66
 	}
5bde66
-	if retCode == code && minutes > 0 {
5bde66
+	if hmac.Equal([]byte(code), []byte(expectedCode)) && minutes > 0 {
5bde66
 		// check time is expired or not
5bde66
-		before, _ := time.ParseInLocation("200601021504", start, time.Local)
625261
+		before, err := time.ParseInLocation("200601021504", startStr, time.Local)
625261
+		if err != nil {
625261
+			return false, err
625261
+		}
5bde66
 		now := time.Now()
5bde66
 		if before.Add(time.Minute*time.Duration(minutes)).Unix() > now.Unix() {
5bde66
 			return true, nil
625261
@@ -93,15 +101,15 @@ func getLoginForEmailCode(code string) string {
5bde66
 	return string(b)
5bde66
 }
5bde66
 
625261
-func createUserEmailCode(cfg *setting.Cfg, u *models.User, startInf interface{}) (string, error) {
625261
+func createUserEmailCode(cfg *setting.Cfg, user *models.User, startStr string) (string, error) {
625261
 	minutes := cfg.EmailCodeValidMinutes
5bde66
-	data := com.ToStr(u.Id) + u.Email + u.Login + u.Password + u.Rands
5bde66
-	code, err := createTimeLimitCode(data, minutes, startInf)
5bde66
+	payload := strconv.FormatInt(user.Id, 10) + user.Email + user.Login + user.Password + user.Rands
5bde66
+	code, err := createTimeLimitCode(payload, minutes, startStr)
5bde66
 	if err != nil {
5bde66
 		return "", err
5bde66
 	}
5bde66
 
5bde66
 	// add tail hex username
5bde66
-	code += hex.EncodeToString([]byte(u.Login))
5bde66
+	code += hex.EncodeToString([]byte(user.Login))
5bde66
 	return code, nil
5bde66
 }
5bde66
diff --git a/pkg/services/notifications/codes_test.go b/pkg/services/notifications/codes_test.go
625261
index a314c8deca..be9b68ca69 100644
5bde66
--- a/pkg/services/notifications/codes_test.go
5bde66
+++ b/pkg/services/notifications/codes_test.go
625261
@@ -1,7 +1,10 @@
5bde66
 package notifications
5bde66
 
5bde66
 import (
5bde66
+	"fmt"
5bde66
+	"strconv"
5bde66
 	"testing"
5bde66
+	"time"
5bde66
 
5bde66
 	"github.com/grafana/grafana/pkg/models"
5bde66
 	"github.com/grafana/grafana/pkg/setting"
625261
@@ -9,18 +12,126 @@ import (
625261
 	"github.com/stretchr/testify/require"
5bde66
 )
5bde66
 
5bde66
+func TestTimeLimitCodes(t *testing.T) {
625261
+	cfg := setting.NewCfg()
625261
+	cfg.EmailCodeValidMinutes = 120
5bde66
+	user := &models.User{Id: 10, Email: "t@a.com", Login: "asd", Password: "1", Rands: "2"}
5bde66
+
5bde66
+	format := "200601021504"
5bde66
+	mailPayload := strconv.FormatInt(user.Id, 10) + user.Email + user.Login + user.Password + user.Rands
5bde66
+	tenMinutesAgo := time.Now().Add(-time.Minute * 10)
5bde66
+
5bde66
+	tests := []struct {
5bde66
+		desc    string
5bde66
+		payload string
5bde66
+		start   time.Time
5bde66
+		minutes int
5bde66
+		valid   bool
5bde66
+	}{
5bde66
+		{
5bde66
+			desc:    "code generated 10 minutes ago, 5 minutes valid",
5bde66
+			payload: mailPayload,
5bde66
+			start:   tenMinutesAgo,
5bde66
+			minutes: 5,
5bde66
+			valid:   false,
5bde66
+		},
5bde66
+		{
5bde66
+			desc:    "code generated 10 minutes ago, 9 minutes valid",
5bde66
+			payload: mailPayload,
5bde66
+			start:   tenMinutesAgo,
5bde66
+			minutes: 9,
5bde66
+			valid:   false,
5bde66
+		},
5bde66
+		{
5bde66
+			desc:    "code generated 10 minutes ago, 10 minutes valid",
5bde66
+			payload: mailPayload,
5bde66
+			start:   tenMinutesAgo,
5bde66
+			minutes: 10,
5bde66
+			// code was valid exactly 10 minutes since evaluating the tenMinutesAgo assignment
5bde66
+			// by the time this test is run the code is already expired
5bde66
+			valid: false,
5bde66
+		},
5bde66
+		{
5bde66
+			desc:    "code generated 10 minutes ago, 11 minutes valid",
5bde66
+			payload: mailPayload,
5bde66
+			start:   tenMinutesAgo,
5bde66
+			minutes: 11,
5bde66
+			valid:   true,
5bde66
+		},
5bde66
+		{
5bde66
+			desc:    "code generated 10 minutes ago, 20 minutes valid",
5bde66
+			payload: mailPayload,
5bde66
+			start:   tenMinutesAgo,
5bde66
+			minutes: 20,
5bde66
+			valid:   true,
5bde66
+		},
5bde66
+		{
5bde66
+			desc:    "code generated 10 minutes ago, 20 minutes valid, tampered payload",
5bde66
+			payload: mailPayload[:len(mailPayload)-1] + "x",
5bde66
+			start:   tenMinutesAgo,
5bde66
+			minutes: 20,
5bde66
+			valid:   false,
5bde66
+		},
5bde66
+	}
5bde66
+
5bde66
+	for _, test := range tests {
5bde66
+		t.Run(test.desc, func(t *testing.T) {
5bde66
+			code, err := createTimeLimitCode(test.payload, test.minutes, test.start.Format(format))
5bde66
+			require.NoError(t, err)
5bde66
+
625261
+			isValid, err := validateUserEmailCode(cfg, user, code)
5bde66
+			require.NoError(t, err)
5bde66
+			require.Equal(t, test.valid, isValid)
5bde66
+		})
5bde66
+	}
5bde66
+
5bde66
+	t.Run("tampered minutes", func(t *testing.T) {
5bde66
+		code, err := createTimeLimitCode(mailPayload, 5, tenMinutesAgo.Format(format))
5bde66
+		require.NoError(t, err)
5bde66
+
5bde66
+		// code is expired
625261
+		isValid, err := validateUserEmailCode(cfg, user, code)
5bde66
+		require.NoError(t, err)
5bde66
+		require.Equal(t, false, isValid)
5bde66
+
5bde66
+		// let's try to extend the code by tampering the minutes
5bde66
+		code = code[:12] + fmt.Sprintf("%06d", 20) + code[18:]
625261
+		isValid, err = validateUserEmailCode(cfg, user, code)
5bde66
+		require.NoError(t, err)
5bde66
+		require.Equal(t, false, isValid)
5bde66
+	})
5bde66
+
5bde66
+	t.Run("tampered start string", func(t *testing.T) {
5bde66
+		code, err := createTimeLimitCode(mailPayload, 5, tenMinutesAgo.Format(format))
5bde66
+		require.NoError(t, err)
5bde66
+
5bde66
+		// code is expired
625261
+		isValid, err := validateUserEmailCode(cfg, user, code)
5bde66
+		require.NoError(t, err)
5bde66
+		require.Equal(t, false, isValid)
5bde66
+
5bde66
+		// let's try to extend the code by tampering the start string
5bde66
+		oneMinuteAgo := time.Now().Add(-time.Minute)
5bde66
+
5bde66
+		code = oneMinuteAgo.Format(format) + code[12:]
625261
+		isValid, err = validateUserEmailCode(cfg, user, code)
5bde66
+		require.NoError(t, err)
5bde66
+		require.Equal(t, false, isValid)
5bde66
+	})
5bde66
+}
5bde66
+
5bde66
 func TestEmailCodes(t *testing.T) {
625261
 	t.Run("When generating code", func(t *testing.T) {
625261
 		cfg := setting.NewCfg()
625261
 		cfg.EmailCodeValidMinutes = 120
5bde66
 
5bde66
 		user := &models.User{Id: 10, Email: "t@a.com", Login: "asd", Password: "1", Rands: "2"}
625261
-		code, err := createUserEmailCode(cfg, user, nil)
625261
+		code, err := createUserEmailCode(cfg, user, "")
625261
 		require.NoError(t, err)
625261
 
625261
 		t.Run("getLoginForCode should return login", func(t *testing.T) {
625261
 			login := getLoginForEmailCode(code)
625261
-			require.Equal(t, login, "asd")
625261
+			require.Equal(t, "asd", login)
625261
 		})
5bde66
 
625261
 		t.Run("Can verify valid code", func(t *testing.T) {
625261
@@ -29,7 +140,7 @@ func TestEmailCodes(t *testing.T) {
625261
 			require.True(t, isValid)
5bde66
 		})
5bde66
 
625261
-		t.Run("Cannot verify in-valid code", func(t *testing.T) {
625261
+		t.Run("Cannot verify invalid code", func(t *testing.T) {
5bde66
 			code = "ASD"
625261
 			isValid, err := validateUserEmailCode(cfg, user, code)
625261
 			require.NoError(t, err)
5bde66
diff --git a/pkg/services/notifications/notifications.go b/pkg/services/notifications/notifications.go
625261
index 84a0d42cb6..52facd0992 100644
5bde66
--- a/pkg/services/notifications/notifications.go
5bde66
+++ b/pkg/services/notifications/notifications.go
625261
@@ -168,7 +168,7 @@ func (ns *NotificationService) SendEmailCommandHandler(ctx context.Context, cmd
5bde66
 }
5bde66
 
625261
 func (ns *NotificationService) SendResetPasswordEmail(ctx context.Context, cmd *models.SendResetPasswordEmailCommand) error {
625261
-	code, err := createUserEmailCode(ns.Cfg, cmd.User, nil)
625261
+	code, err := createUserEmailCode(ns.Cfg, cmd.User, "")
5bde66
 	if err != nil {
5bde66
 		return err
5bde66
 	}
5bde66
diff --git a/pkg/services/notifications/notifications_test.go b/pkg/services/notifications/notifications_test.go
625261
index 71970e20a0..6f4b318fe0 100644
5bde66
--- a/pkg/services/notifications/notifications_test.go
5bde66
+++ b/pkg/services/notifications/notifications_test.go
625261
@@ -2,6 +2,7 @@ package notifications
5bde66
 
5bde66
 import (
625261
 	"context"
5bde66
+	"regexp"
5bde66
 	"testing"
5bde66
 
5bde66
 	"github.com/grafana/grafana/pkg/bus"
625261
@@ -185,7 +186,8 @@ func TestSendEmailAsync(t *testing.T) {
5bde66
 
625261
 	t.Run("When sending reset email password", func(t *testing.T) {
625261
 		sut, _ := createSut(t, bus)
625261
-		err := sut.SendResetPasswordEmail(context.Background(), &models.SendResetPasswordEmailCommand{User: &models.User{Email: "asd@asd.com"}})
625261
+		user := models.User{Email: "asd@asd.com", Login: "asd@asd.com"}
625261
+		err := sut.SendResetPasswordEmail(context.Background(), &models.SendResetPasswordEmailCommand{User: &user})
625261
 		require.NoError(t, err)
5bde66
 
625261
 		sentMsg := <-sut.mailQueue
625261
@@ -194,6 +196,21 @@ func TestSendEmailAsync(t *testing.T) {
625261
 		assert.Equal(t, "Reset your Grafana password - asd@asd.com", sentMsg.Subject)
625261
 		assert.NotContains(t, sentMsg.Body["text/html"], "Subject")
625261
 		assert.NotContains(t, sentMsg.Body["text/plain"], "Subject")
5bde66
+
625261
+		// find code in mail
625261
+		r, _ := regexp.Compile(`code=(\w+)`)
625261
+		match := r.FindString(sentMsg.Body["text/plain"])
625261
+		code := match[len("code="):]
5bde66
+
625261
+		// verify code
625261
+		query := models.ValidateResetPasswordCodeQuery{Code: code}
625261
+		getUserByLogin := func(ctx context.Context, login string) (*models.User, error) {
625261
+			query := models.GetUserByLoginQuery{LoginOrEmail: login}
625261
+			query.Result = &user
625261
+			return query.Result, nil
625261
+		}
625261
+		err = sut.ValidateResetPasswordCode(context.Background(), &query, getUserByLogin)
625261
+		require.NoError(t, err)
5bde66
 	})
625261
 
625261
 	t.Run("When SMTP disabled in configuration", func(t *testing.T) {