Repository: supabase/gotrue
Pull request #1323 (2023-11-28 13:39:08)

feat: add required characters password strength check
Adds the `GOTRUE_PASSWORD_REQUIRED_CHARACTERS` config option, which if set, will reject passwords that do not contain at least one character of each set of characters.

It is defined like so: `abc...xyz:0123...89`. This means that at least one lowercase and one digit has to be present in the password to be accepted. All other characters are also allowed. To include the `:` character, escape it with `\:`.

When a weak password is detected, the HTTP 429 error is sent with an additional JSON field `weak_password` that includes a `reasons` property -- an array of the strings:

- `length` if the password is not long enough
- `characters` if the password does not use all required character sets 


Patch:
diff --git a/README.md b/README.md
index 413e7f91..92e9594d 100644
--- a/README.md
+++ b/README.md
@@ -217,6 +217,8 @@ Rate limit the number of emails sent per hr on the following endpoints: `/signup
 
 Minimum password length, defaults to 6.
 
+`GOTRUE_PASSWORD_REQUIRED_CHARACTERS` - a string of character sets separated by `:`. A password must contain at least one character of each set to be accepted. To use the `:` character escape it with `\`.
+
 `GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED` - `bool`
 
 If refresh token rotation is enabled, gotrue will automatically detect malicious attempts to reuse a revoked refresh token. When a malicious attempt is detected, gotrue immediately revokes all tokens that descended from the offending token.
diff --git a/internal/api/errors.go b/internal/api/errors.go
index 8efdbe81..bb7ba487 100644
--- a/internal/api/errors.go
+++ b/internal/api/errors.go
@@ -65,10 +65,6 @@ func (e *OAuthError) Cause() error {
 	return e
 }
 
-func invalidPasswordLengthError(passwordMinLength int) *HTTPError {
-	return unprocessableEntityError(fmt.Sprintf("Password should be at least %d characters", passwordMinLength))
-}
-
 func invalidSignupError(config *conf.GlobalConfiguration) *HTTPError {
 	var msg string
 	if config.External.Email.Enabled && config.External.Phone.Enabled {
@@ -245,6 +241,22 @@ func handleError(err error, w http.ResponseWriter, r *http.Request) {
 	log := observability.GetLogEntry(r)
 	errorID := getRequestID(r.Context())
 	switch e := err.(type) {
+	case *WeakPasswordError:
+		var output struct {
+			HTTPError
+			Payload struct {
+				Reasons []string `json:"reasons,omitempty"`
+			} `json:"weak_password,omitempty"`
+		}
+
+		output.Code = http.StatusUnprocessableEntity
+		output.Message = e.Message
+		output.Payload.Reasons = e.Reasons
+
+		if jsonErr := sendJSON(w, output.Code, output); jsonErr != nil {
+			handleError(jsonErr, w, r)
+		}
+
 	case *HTTPError:
 		if e.Code >= http.StatusInternalServerError {
 			e.ErrorID = errorID
diff --git a/internal/api/password.go b/internal/api/password.go
index 7b98d4ee..a614d67b 100644
--- a/internal/api/password.go
+++ b/internal/api/password.go
@@ -1,12 +1,49 @@
 package api
 
-import "context"
+import (
+	"context"
+	"fmt"
+	"strings"
+)
+
+// WeakPasswordError encodes an error that a password does not meet strength
+// requirements. It is handled specially in errors.go as it gets transformed to
+// a HTTPError with a special weak_password field that encodes the Reasons
+// slice.
+type WeakPasswordError struct {
+	Message string
+	Reasons []string
+}
+
+func (e *WeakPasswordError) Error() string {
+	return e.Message
+}
 
 func (a *API) checkPasswordStrength(ctx context.Context, password string) error {
 	config := a.config
 
-	if len(password) < config.PasswordMinLength {
-		return invalidPasswordLengthError(config.PasswordMinLength)
+	var messages, reasons []string
+
+	if len(password) < config.Password.MinLength {
+		reasons = append(reasons, "length")
+		messages = append(messages, fmt.Sprintf("Password should be at least %d characters.", config.Password.MinLength))
+	}
+
+	for _, characterSet := range config.Password.RequiredCharacters {
+		if characterSet != "" && !strings.ContainsAny(password, characterSet) {
+			reasons = append(reasons, "characters")
+
+			messages = append(messages, fmt.Sprintf("Password should contain at least one character of each: %s.", strings.Join(config.Password.RequiredCharacters, ", ")))
+
+			break
+		}
+	}
+
+	if len(reasons) > 0 {
+		return &WeakPasswordError{
+			Message: strings.Join(messages, " "),
+			Reasons: reasons,
+		}
 	}
 
 	return nil
diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go
index a3778a5c..4025bd53 100644
--- a/internal/conf/configuration.go
+++ b/internal/conf/configuration.go
@@ -131,6 +131,41 @@ func (c *SessionsConfiguration) Validate() error {
 	return nil
 }
 
+type PasswordRequiredCharacters []string
+
+func (v *PasswordRequiredCharacters) Decode(value string) error {
+	parts := strings.Split(value, ":")
+
+	for i := 0; i < len(parts)-1; i += 1 {
+		part := parts[i]
+
+		if part == "" {
+			continue
+		}
+
+		// part ended in escape character, so it should be joined with the next one
+		if part[len(part)-1] == '\\' {
+			parts[i] = part[0:len(part)-1] + ":" + parts[i+1]
+			parts[i+1] = ""
+			continue
+		}
+	}
+
+	for _, part := range parts {
+		if part != "" {
+			*v = append(*v, part)
+		}
+	}
+
+	return nil
+}
+
+type PasswordConfiguration struct {
+	MinLength int `json:"min_length" split_words:"true"`
+
+	RequiredCharacters PasswordRequiredCharacters `json:"required_characters" split_words:"true"`
+}
+
 // GlobalConfiguration holds all the configuration that applies to all instances.
 type GlobalConfiguration struct {
 	API                   APIConfiguration
@@ -149,19 +184,19 @@ type GlobalConfiguration struct {
 	RateLimitTokenRefresh float64 `split_words:"true" default:"150"`
 	RateLimitSso          float64 `split_words:"true" default:"30"`
 
-	SiteURL           string   `json:"site_url" split_words:"true" required:"true"`
-	URIAllowList      []string `json:"uri_allow_list" split_words:"true"`
-	URIAllowListMap   map[string]glob.Glob
-	PasswordMinLength int                      `json:"password_min_length" split_words:"true"`
-	JWT               JWTConfiguration         `json:"jwt"`
-	Mailer            MailerConfiguration      `json:"mailer"`
-	Sms               SmsProviderConfiguration `json:"sms"`
-	DisableSignup     bool                     `json:"disable_signup" split_words:"true"`
-	Webhook           WebhookConfig            `json:"webhook" split_words:"true"`
-	Security          SecurityConfiguration    `json:"security"`
-	Sessions          SessionsConfiguration    `json:"sessions"`
-	MFA               MFAConfiguration         `json:"MFA"`
-	Cookie            struct {
+	SiteURL         string   `json:"site_url" split_words:"true" required:"true"`
+	URIAllowList    []string `json:"uri_allow_list" split_words:"true"`
+	URIAllowListMap map[string]glob.Glob
+	Password        PasswordConfiguration    `json:"password"`
+	JWT             JWTConfiguration         `json:"jwt"`
+	Mailer          MailerConfiguration      `json:"mailer"`
+	Sms             SmsProviderConfiguration `json:"sms"`
+	DisableSignup   bool                     `json:"disable_signup" split_words:"true"`
+	Webhook         WebhookConfig            `json:"webhook" split_words:"true"`
+	Security        SecurityConfiguration    `json:"security"`
+	Sessions        SessionsConfiguration    `json:"sessions"`
+	MFA             MFAConfiguration         `json:"MFA"`
+	Cookie          struct {
 		Key      string `json:"key"`
 		Domain   string `json:"domain"`
 		Duration int    `json:"duration"`
@@ -516,8 +551,8 @@ func (config *GlobalConfiguration) ApplyDefaults() error {
 		}
 	}
 
-	if config.PasswordMinLength < defaultMinPasswordLength {
-		config.PasswordMinLength = defaultMinPasswordLength
+	if config.Password.MinLength < defaultMinPasswordLength {
+		config.Password.MinLength = defaultMinPasswordLength
 	}
 	if config.MFA.ChallengeExpiryDuration < defaultChallengeExpiryDuration {
 		config.MFA.ChallengeExpiryDuration = defaultChallengeExpiryDuration


Test patch:
diff --git a/internal/api/admin_test.go b/internal/api/admin_test.go
index ea2fab89..f886ca88 100644
--- a/internal/api/admin_test.go
+++ b/internal/api/admin_test.go
@@ -456,7 +456,7 @@ func (ts *AdminTestSuite) TestAdminUserUpdatePasswordFailed() {
 	require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user")
 
 	var updateEndpoint = fmt.Sprintf("/admin/users/%s", u.ID)
-	ts.Config.PasswordMinLength = 6
+	ts.Config.Password.MinLength = 6
 	ts.Run("Password doesn't meet minimum length", func() {
 		var buffer bytes.Buffer
 		require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
@@ -479,7 +479,7 @@ func (ts *AdminTestSuite) TestAdminUserUpdateBannedUntilFailed() {
 	require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user")
 
 	var updateEndpoint = fmt.Sprintf("/admin/users/%s", u.ID)
-	ts.Config.PasswordMinLength = 6
+	ts.Config.Password.MinLength = 6
 	ts.Run("Incorrect format for ban_duration", func() {
 		var buffer bytes.Buffer
 		require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
diff --git a/internal/api/mfa_test.go b/internal/api/mfa_test.go
index e3686c9a..8d4d6ef7 100644
--- a/internal/api/mfa_test.go
+++ b/internal/api/mfa_test.go
@@ -5,6 +5,7 @@ import (
 	"context"
 	"encoding/json"
 	"fmt"
+	"github.com/gofrs/uuid"
 	"net/http"
 	"net/http/httptest"
 	"strings"
@@ -25,11 +26,14 @@ import (
 
 type MFATestSuite struct {
 	suite.Suite
-	API        *API
-	Config     *conf.GlobalConfiguration
-	TestDomain string
-	TestEmail  string
-	TestOTPKey *otp.Key
+	API          *API
+	Config       *conf.GlobalConfiguration
+	TestDomain   string
+	TestEmail    string
+	TestOTPKey   *otp.Key
+	TestPassword string
+	TestUser     *models.User
+	TestSession  *models.Session
 }
 
 func TestMFA(t *testing.T) {
@@ -53,13 +57,16 @@ func (ts *MFATestSuite) SetupTest() {
 	f, err := models.NewFactor(u, "test_factor", models.TOTP, models.FactorStateUnverified, "secretkey")
 	require.NoError(ts.T(), err, "Error creating test factor model")
 	require.NoError(ts.T(), ts.API.db.Create(f), "Error saving new test factor")
-	// Create corresponding sessoin
+	// Create corresponding session
 	s, err := models.NewSession()
 	require.NoError(ts.T(), err, "Error creating test session")
 	s.UserID = u.ID
 	s.FactorID = &f.ID
 	require.NoError(ts.T(), ts.API.db.Create(s), "Error saving test session")
 
+	ts.TestUser = u
+	ts.TestSession = s
+
 	// Generate TOTP related settings
 	emailValue, err := u.Email.Value()
 	require.NoError(ts.T(), err)
@@ -67,6 +74,7 @@ func (ts *MFATestSuite) SetupTest() {
 	testDomain := strings.Split(testEmail, "@")[1]
 	ts.TestDomain = testDomain
 	ts.TestEmail = testEmail
+	ts.TestPassword = "password"
 
 	key, err := totp.Generate(totp.GenerateOpts{
 		Issuer:      ts.TestDomain,
@@ -80,6 +88,12 @@ func (ts *MFATestSuite) SetupTest() {
 func (ts *MFATestSuite) TestEnrollFactor() {
 	testFriendlyName := "bob"
 	alternativeFriendlyName := "john"
+	user, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
+	ts.Require().NoError(err)
+
+	token, _, err := generateAccessToken(ts.API.db, user, nil, &ts.Config.JWT)
+
+	require.NoError(ts.T(), err)
 	var cases = []struct {
 		desc         string
 		friendlyName string
@@ -119,20 +133,8 @@ func (ts *MFATestSuite) TestEnrollFactor() {
 	}
 	for _, c := range cases {
 		ts.Run(c.desc, func() {
-			var buffer bytes.Buffer
-			require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]string{"friendly_name": c.friendlyName, "factor_type": c.factorType, "issuer": c.issuer}))
-			user, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
-			ts.Require().NoError(err)
 
-			token, _, err := generateAccessToken(ts.API.db, user, nil, &ts.Config.JWT)
-			require.NoError(ts.T(), err)
-
-			w := httptest.NewRecorder()
-			req := httptest.NewRequest(http.MethodPost, "/factors", &buffer)
-			req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
-			req.Header.Set("Content-Type", "application/json")
-			ts.API.handler.ServeHTTP(w, req)
-			require.Equal(ts.T(), c.expectedCode, w.Code)
+			w := performEnrollFlow(ts, token, c.friendlyName, c.factorType, c.issuer, c.expectedCode)
 
 			factors, err := models.FindFactorsByUser(ts.API.db, user)
 			ts.Require().NoError(err)
@@ -165,17 +167,13 @@ func (ts *MFATestSuite) TestChallengeFactor() {
 	token, _, err := generateAccessToken(ts.API.db, u, nil, &ts.Config.JWT)
 	require.NoError(ts.T(), err, "Error generating access token")
 
-	var buffer bytes.Buffer
-	req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost/factors/%s/challenge", f.ID), &buffer)
-	req.Header.Set("Content-Type", "application/json")
-	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
-
-	w := httptest.NewRecorder()
-	ts.API.handler.ServeHTTP(w, req)
+	w := performChallengeFlow(ts, f.ID, token)
 	require.Equal(ts.T(), http.StatusOK, w.Code)
 }
 
 func (ts *MFATestSuite) TestMFAVerifyFactor() {
+	user, err := models.FindUserByEmailAndAudience(ts.API.db, ts.TestEmail, ts.Config.JWT.Aud)
+	ts.Require().NoError(err)
 	cases := []struct {
 		desc             string
 		validChallenge   bool
@@ -204,8 +202,7 @@ func (ts *MFATestSuite) TestMFAVerifyFactor() {
 	for _, v := range cases {
 		ts.Run(v.desc, func() {
 			// Authenticate users and set secret
-			user, err := models.FindUserByEmailAndAudience(ts.API.db, ts.TestEmail, ts.Config.JWT.Aud)
-			ts.Require().NoError(err)
+
 			var buffer bytes.Buffer
 			r, err := models.GrantAuthenticatedUser(ts.API.db, user, models.GrantParams{})
 			require.NoError(ts.T(), err)
@@ -272,6 +269,7 @@ func (ts *MFATestSuite) TestMFAVerifyFactor() {
 }
 
 func (ts *MFATestSuite) TestUnenrollVerifiedFactor() {
+
 	cases := []struct {
 		desc             string
 		isAAL2           bool
@@ -289,25 +287,20 @@ func (ts *MFATestSuite) TestUnenrollVerifiedFactor() {
 		},
 	}
 	for _, v := range cases {
-
 		ts.Run(v.desc, func() {
 			// Create User
-			u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
-			require.NoError(ts.T(), err)
-			s, err := models.FindSessionByUserID(ts.API.db, u.ID)
-			require.NoError(ts.T(), err)
 			if v.isAAL2 {
-				s.UpdateAssociatedAAL(ts.API.db, models.AAL2.String())
+				ts.TestSession.UpdateAssociatedAAL(ts.API.db, models.AAL2.String())
 			}
 			var secondarySession *models.Session
 
 			// Create Session to test behaviour which downgrades other sessions
-			factors, err := models.FindFactorsByUser(ts.API.db, u)
+			factors, err := models.FindFactorsByUser(ts.API.db, ts.TestUser)
 			require.NoError(ts.T(), err, "error finding factors")
 			f := factors[0]
 			secondarySession, err = models.NewSession()
 			require.NoError(ts.T(), err, "Error creating test session")
-			secondarySession.UserID = u.ID
+			secondarySession.UserID = ts.TestUser.ID
 			secondarySession.FactorID = &f.ID
 			require.NoError(ts.T(), ts.API.db.Create(secondarySession), "Error saving test session")
 
@@ -319,7 +312,7 @@ func (ts *MFATestSuite) TestUnenrollVerifiedFactor() {
 
 			var buffer bytes.Buffer
 
-			token, _, err := generateAccessToken(ts.API.db, u, &s.ID, &ts.Config.JWT)
+			token, _, err := generateAccessToken(ts.API.db, ts.TestUser, &ts.TestSession.ID, &ts.Config.JWT)
 			require.NoError(ts.T(), err)
 
 			w := httptest.NewRecorder()
@@ -342,17 +335,13 @@ func (ts *MFATestSuite) TestUnenrollVerifiedFactor() {
 }
 
 func (ts *MFATestSuite) TestUnenrollUnverifiedFactor() {
-	u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
-	require.NoError(ts.T(), err)
-	s, err := models.FindSessionByUserID(ts.API.db, u.ID)
-	require.NoError(ts.T(), err)
 	var secondarySession *models.Session
-	factors, err := models.FindFactorsByUser(ts.API.db, u)
+	factors, err := models.FindFactorsByUser(ts.API.db, ts.TestUser)
 	require.NoError(ts.T(), err, "error finding factors")
 	f := factors[0]
 	secondarySession, err = models.NewSession()
 	require.NoError(ts.T(), err, "Error creating test session")
-	secondarySession.UserID = u.ID
+	secondarySession.UserID = ts.TestUser.ID
 	secondarySession.FactorID = &f.ID
 	require.NoError(ts.T(), ts.API.db.Create(secondarySession), "Error saving test session")
 
@@ -361,7 +350,7 @@ func (ts *MFATestSuite) TestUnenrollUnverifiedFactor() {
 
 	var buffer bytes.Buffer
 
-	token, _, err := generateAccessToken(ts.API.db, u, &s.ID, &ts.Config.JWT)
+	token, _, err := generateAccessToken(ts.API.db, ts.TestUser, &ts.TestSession.ID, &ts.Config.JWT)
 	require.NoError(ts.T(), err)
 	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
 		"factor_id": f.ID,
@@ -382,14 +371,16 @@ func (ts *MFATestSuite) TestUnenrollUnverifiedFactor() {
 
 // Integration Tests
 func (ts *MFATestSuite) TestSessionsMaintainAALOnRefresh() {
-	email := "test1@example.com"
-	password := "test123"
-	token := signUpAndVerify(ts, email, password)
+	resp := performTestSignupAndVerify(ts, ts.TestEmail, ts.TestPassword)
+	accessTokenResp := &AccessTokenResponse{}
+	require.NoError(ts.T(), json.NewDecoder(resp.Body).Decode(&accessTokenResp))
+
 	ts.Config.Security.RefreshTokenRotationEnabled = true
 	var buffer bytes.Buffer
 	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
-		"refresh_token": token.RefreshToken,
+		"refresh_token": accessTokenResp.RefreshToken,
 	}))
+
 	req := httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=refresh_token", &buffer)
 	req.Header.Set("Content-Type", "application/json")
 	w := httptest.NewRecorder()
@@ -408,14 +399,15 @@ func (ts *MFATestSuite) TestSessionsMaintainAALOnRefresh() {
 
 // Performing MFA Verification followed by a sign in should return an AAL1 session and an AAL2 session
 func (ts *MFATestSuite) TestMFAFollowedByPasswordSignIn() {
-	email := "test1@example.com"
-	password := "test123"
-	token := signUpAndVerify(ts, email, password)
+	resp := performTestSignupAndVerify(ts, ts.TestEmail, ts.TestPassword)
+	accessTokenResp := &AccessTokenResponse{}
+	require.NoError(ts.T(), json.NewDecoder(resp.Body).Decode(&accessTokenResp))
+
 	ts.Config.Security.RefreshTokenRotationEnabled = true
 	var buffer bytes.Buffer
 	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
-		"email":    email,
-		"password": password,
+		"email":    ts.TestEmail,
+		"password": ts.TestPassword,
 	}))
 	req := httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=password", &buffer)
 	req.Header.Set("Content-Type", "application/json")
@@ -430,7 +422,7 @@ func (ts *MFATestSuite) TestMFAFollowedByPasswordSignIn() {
 	ctx, err = ts.API.maybeLoadUserOrSession(ctx)
 	require.NoError(ts.T(), err)
 	require.Equal(ts.T(), models.AAL1.String(), getSession(ctx).GetAAL())
-	session, err := models.FindSessionByUserID(ts.API.db, token.User.ID)
+	session, err := models.FindSessionByUserID(ts.API.db, accessTokenResp.User.ID)
 	require.NoError(ts.T(), err)
 	require.True(ts.T(), session.IsAAL2())
 }
@@ -455,43 +447,30 @@ func signUp(ts *MFATestSuite, email, password string) (signUpResp AccessTokenRes
 	return data
 }
 
-func signUpAndVerify(ts *MFATestSuite, email, password string) (verifyResp *AccessTokenResponse) {
+func performTestSignupAndVerify(ts *MFATestSuite, email, password string) *httptest.ResponseRecorder {
 
 	signUpResp := signUp(ts, email, password)
-	verifyResp = enrollAndVerify(ts, signUpResp.User, signUpResp.Token)
+	resp := performEnrollAndVerify(ts, signUpResp.User, signUpResp.Token)
 
-	return verifyResp
+	return resp
 
 }
 
-func enrollAndVerify(ts *MFATestSuite, user *models.User, token string) (verifyResp *AccessTokenResponse) {
+func performEnrollFlow(ts *MFATestSuite, token, friendlyName, factorType, issuer string, expectedCode int) *httptest.ResponseRecorder {
 	var buffer bytes.Buffer
 	w := httptest.NewRecorder()
-	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]string{"friendly_name": "john", "factor_type": models.TOTP, "issuer": ts.TestDomain}))
+	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]string{"friendly_name": friendlyName, "factor_type": factorType, "issuer": issuer}))
 
 	req := httptest.NewRequest(http.MethodPost, "http://localhost/factors/", &buffer)
 	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
 	req.Header.Set("Content-Type", "application/json")
 
 	ts.API.handler.ServeHTTP(w, req)
-	require.Equal(ts.T(), http.StatusOK, w.Code)
-	enrollResp := EnrollFactorResponse{}
-	require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&enrollResp))
-	factorID := enrollResp.ID
-
-	// Challenge
-	var challengeBuffer bytes.Buffer
-	x := httptest.NewRecorder()
-	req = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost/factors/%s/challenge", factorID), &challengeBuffer)
-	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
-	req.Header.Set("Content-Type", "application/json")
-	ts.API.handler.ServeHTTP(x, req)
-	require.Equal(ts.T(), http.StatusOK, x.Code)
-	challengeResp := EnrollFactorResponse{}
-	require.NoError(ts.T(), json.NewDecoder(x.Body).Decode(&challengeResp))
-	challengeID := challengeResp.ID
+	require.Equal(ts.T(), expectedCode, w.Code)
+	return w
 
-	// Verify
+}
+func performVerifyFlow(ts *MFATestSuite, challengeID, factorID uuid.UUID, token string, expectedCode int) *httptest.ResponseRecorder {
 	var verifyBuffer bytes.Buffer
 	y := httptest.NewRecorder()
 
@@ -511,13 +490,42 @@ func enrollAndVerify(ts *MFATestSuite, user *models.User, token string) (verifyR
 		"challenge_id": challengeID,
 		"code":         code,
 	}))
-	req = httptest.NewRequest(http.MethodPost, fmt.Sprintf("/factors/%s/verify", factorID), &verifyBuffer)
+	req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/factors/%s/verify", factorID), &verifyBuffer)
 	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
 	req.Header.Set("Content-Type", "application/json")
 
 	ts.API.handler.ServeHTTP(y, req)
 	require.Equal(ts.T(), http.StatusOK, y.Code)
-	verifyResp = &AccessTokenResponse{}
-	require.NoError(ts.T(), json.NewDecoder(y.Body).Decode(&verifyResp))
-	return verifyResp
+	return y
+}
+
+func performChallengeFlow(ts *MFATestSuite, factorID uuid.UUID, token string) *httptest.ResponseRecorder {
+	var challengeBuffer bytes.Buffer
+	w := httptest.NewRecorder()
+	req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost/factors/%s/challenge", factorID), &challengeBuffer)
+	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
+	req.Header.Set("Content-Type", "application/json")
+	ts.API.handler.ServeHTTP(w, req)
+	require.Equal(ts.T(), http.StatusOK, w.Code)
+	return w
+
+}
+
+func performEnrollAndVerify(ts *MFATestSuite, user *models.User, token string) *httptest.ResponseRecorder {
+	w := performEnrollFlow(ts, token, "", models.TOTP, ts.TestDomain, http.StatusOK)
+	enrollResp := EnrollFactorResponse{}
+	require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&enrollResp))
+	factorID := enrollResp.ID
+
+	// Challenge
+	w = performChallengeFlow(ts, factorID, token)
+
+	challengeResp := EnrollFactorResponse{}
+	require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&challengeResp))
+	challengeID := challengeResp.ID
+
+	// Verify
+	y := performVerifyFlow(ts, challengeID, factorID, token, http.StatusOK)
+
+	return y
 }
diff --git a/internal/api/password_test.go b/internal/api/password_test.go
new file mode 100644
index 00000000..a35fe2d0
--- /dev/null
+++ b/internal/api/password_test.go
@@ -0,0 +1,107 @@
+package api
+
+import (
+	"context"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+	"github.com/supabase/gotrue/internal/conf"
+)
+
+func TestPasswordStrengthChecks(t *testing.T) {
+	examples := []struct {
+		MinLength          int
+		RequiredCharacters []string
+
+		Password string
+		Reasons  []string
+	}{
+		{
+			MinLength: 6,
+			Password:  "12345",
+			Reasons: []string{
+				"length",
+			},
+		},
+		{
+			MinLength: 6,
+			RequiredCharacters: []string{
+				"a",
+				"b",
+				"c",
+			},
+			Password: "123",
+			Reasons: []string{
+				"length",
+				"characters",
+			},
+		},
+		{
+			MinLength: 6,
+			RequiredCharacters: []string{
+				"a",
+				"b",
+				"c",
+			},
+			Password: "a123",
+			Reasons: []string{
+				"length",
+				"characters",
+			},
+		},
+		{
+			MinLength: 6,
+			RequiredCharacters: []string{
+				"a",
+				"b",
+				"c",
+			},
+			Password: "ab123",
+			Reasons: []string{
+				"length",
+				"characters",
+			},
+		},
+		{
+			MinLength: 6,
+			RequiredCharacters: []string{
+				"a",
+				"b",
+				"c",
+			},
+			Password: "c123",
+			Reasons: []string{
+				"length",
+				"characters",
+			},
+		},
+		{
+			MinLength: 6,
+			RequiredCharacters: []string{
+				"a",
+				"b",
+				"c",
+			},
+			Password: "abc123",
+			Reasons:  nil,
+		},
+	}
+
+	for i, example := range examples {
+		api := &API{
+			config: &conf.GlobalConfiguration{
+				Password: conf.PasswordConfiguration{
+					MinLength:          example.MinLength,
+					RequiredCharacters: conf.PasswordRequiredCharacters(example.RequiredCharacters),
+				},
+			},
+		}
+
+		err := api.checkPasswordStrength(context.Background(), example.Password)
+		if example.Reasons == nil {
+			require.NoError(t, err, "Example %d failed with error", i)
+		} else {
+			require.Equal(t, err.(*WeakPasswordError).Reasons, example.Reasons, "Example %d failed with wrong reasons", i)
+		}
+	}
+}
diff --git a/internal/conf/configuration_test.go b/internal/conf/configuration_test.go
index 69317537..a9178085 100644
--- a/internal/conf/configuration_test.go
+++ b/internal/conf/configuration_test.go
@@ -26,3 +26,72 @@ func TestGlobal(t *testing.T) {
 	require.NotNil(t, gc)
 	assert.Equal(t, "X-Request-ID", gc.API.RequestIDHeader)
 }
+
+func TestPasswordRequiredCharactersDecode(t *testing.T) {
+	examples := []struct {
+		Value  string
+		Result []string
+	}{
+		{
+			Value: "a:b:c",
+			Result: []string{
+				"a",
+				"b",
+				"c",
+			},
+		},
+		{
+			Value: "a\\:b:c",
+			Result: []string{
+				"a:b",
+				"c",
+			},
+		},
+		{
+			Value: "a:b\\:c",
+			Result: []string{
+				"a",
+				"b:c",
+			},
+		},
+		{
+			Value: "\\:a:b:c",
+			Result: []string{
+				":a",
+				"b",
+				"c",
+			},
+		},
+		{
+			Value: "a:b:c\\:",
+			Result: []string{
+				"a",
+				"b",
+				"c:",
+			},
+		},
+		{
+			Value: "::\\::",
+			Result: []string{
+				":",
+			},
+		},
+		{
+			Value:  "",
+			Result: nil,
+		},
+		{
+			Value: " ",
+			Result: []string{
+				" ",
+			},
+		},
+	}
+
+	for i, example := range examples {
+		var into PasswordRequiredCharacters
+		require.NoError(t, into.Decode(example.Value), "Example %d failed with error", i)
+
+		require.Equal(t, []string(into), example.Result, "Example %d got unexpected result", i)
+	}
+}