Skip to content

feat: add conditional enforcement of password strength rules on admin apis #1964

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions internal/api/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ type AdminUserParams struct {
UserMetaData map[string]interface{} `json:"user_metadata"`
AppMetaData map[string]interface{} `json:"app_metadata"`
BanDuration string `json:"ban_duration"`
EnforcePasswordCheck bool `json:"enforce_password_check"`
}

type adminUserDeleteParams struct {
Expand Down Expand Up @@ -179,8 +180,10 @@ func (a *API) adminUserUpdate(w http.ResponseWriter, r *http.Request) error {
if params.Password != nil {
password := *params.Password

if err := a.checkPasswordStrength(ctx, password); err != nil {
return err
if params.EnforcePasswordCheck {
if err := a.checkPasswordStrength(ctx, password); err != nil {
return err
}
}

if err := user.SetPassword(ctx, password, config.Security.DBEncryption.Encrypt, config.Security.DBEncryption.EncryptionKeyID, config.Security.DBEncryption.EncryptionKey); err != nil {
Expand Down Expand Up @@ -381,6 +384,14 @@ func (a *API) adminUserCreate(w http.ResponseWriter, r *http.Request) error {
params.Password = &password
}

if params.Password != nil {
if params.EnforcePasswordCheck {
if err := a.checkPasswordStrength(ctx, *params.Password); err != nil {
return err
}
}
}

var user *models.User
if params.PasswordHash != "" {
user, err = models.NewUserWithPasswordHash(params.Phone, params.Email, params.PasswordHash, aud, params.UserMetaData)
Expand Down
187 changes: 172 additions & 15 deletions internal/api/admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,31 +238,31 @@ func (ts *AdminTestSuite) TestAdminUserCreate() {
desc: "Only phone",
params: map[string]interface{}{
"phone": "123456789",
"password": "test1",
"password": "StrongPassword123!",
},
expected: map[string]interface{}{
"email": "",
"phone": "123456789",
"isAuthenticated": true,
"provider": "phone",
"providers": []string{"phone"},
"password": "test1",
"password": "StrongPassword123!",
},
},
{
desc: "With password",
params: map[string]interface{}{
"email": "[email protected]",
"phone": "123456789",
"password": "test1",
"password": "StrongPassword123!",
},
expected: map[string]interface{}{
"email": "[email protected]",
"phone": "123456789",
"isAuthenticated": true,
"provider": "email",
"providers": []string{"email", "phone"},
"password": "test1",
"password": "StrongPassword123!",
},
},
{
Expand Down Expand Up @@ -300,7 +300,7 @@ func (ts *AdminTestSuite) TestAdminUserCreate() {
params: map[string]interface{}{
"email": "[email protected]",
"phone": "",
"password": "test1",
"password": "StrongPassword123!",
"ban_duration": "24h",
},
expected: map[string]interface{}{
Expand All @@ -309,7 +309,7 @@ func (ts *AdminTestSuite) TestAdminUserCreate() {
"isAuthenticated": true,
"provider": "email",
"providers": []string{"email"},
"password": "test1",
"password": "StrongPassword123!",
},
},
{
Expand All @@ -332,7 +332,7 @@ func (ts *AdminTestSuite) TestAdminUserCreate() {
params: map[string]interface{}{
"id": "fc56ab41-2010-4870-a9b9-767c1dc573fb",
"email": "[email protected]",
"password": "test",
"password": "StrongPassword123!",
},
expected: map[string]interface{}{
"id": "fc56ab41-2010-4870-a9b9-767c1dc573fb",
Expand All @@ -341,7 +341,7 @@ func (ts *AdminTestSuite) TestAdminUserCreate() {
"isAuthenticated": true,
"provider": "email",
"providers": []string{"email"},
"password": "test",
"password": "StrongPassword123!",
},
},
}
Expand Down Expand Up @@ -505,6 +505,7 @@ func (ts *AdminTestSuite) TestAdminUserUpdatePasswordFailed() {
var buffer bytes.Buffer
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
"password": "",
"enforce_password_check": true,
}))

// Setup request
Expand Down Expand Up @@ -711,7 +712,7 @@ func (ts *AdminTestSuite) TestAdminUserCreateWithDisabledLogin() {
},
userData: map[string]interface{}{
"email": "[email protected]",
"password": "test1",
"password": "StrongPassword123!",
},
expected: http.StatusOK,
},
Expand All @@ -727,7 +728,7 @@ func (ts *AdminTestSuite) TestAdminUserCreateWithDisabledLogin() {
},
userData: map[string]interface{}{
"phone": "123456789",
"password": "test1",
"password": "StrongPassword123!",
},
expected: http.StatusOK,
},
Expand All @@ -739,7 +740,7 @@ func (ts *AdminTestSuite) TestAdminUserCreateWithDisabledLogin() {
},
userData: map[string]interface{}{
"email": "[email protected]",
"password": "test2",
"password": "StrongPassword123!",
},
expected: http.StatusOK,
},
Expand Down Expand Up @@ -860,43 +861,62 @@ func (ts *AdminTestSuite) TestAdminUserCreateValidationErrors() {
cases := []struct {
desc string
params map[string]interface{}
code int
}{
{
desc: "create user without email and phone",
params: map[string]interface{}{
"password": "test_password",
"password": "StrongPassword123!",
},
code: http.StatusBadRequest,
},
{
desc: "weak password that doesn't meet the minimum length",
params: map[string]interface{}{
"email": "[email protected]",
"password": "weak",
"enforce_password_check": true,
},
code: http.StatusUnprocessableEntity,
},
{
desc: "create user with password and password hash",
params: map[string]interface{}{
"email": "[email protected]",
"password": "test_password",
"password": "StrongPassword123!",
"password_hash": "$2y$10$Tk6yEdmTbb/eQ/haDMaCsuCsmtPVprjHMcij1RqiJdLGPDXnL3L1a",
},
code: http.StatusBadRequest,
},
{
desc: "invalid ban duration",
params: map[string]interface{}{
"email": "[email protected]",
"ban_duration": "never",
},
code: http.StatusBadRequest,
},
{
desc: "custom id is nil",
params: map[string]interface{}{
"id": "00000000-0000-0000-0000-000000000000",
"email": "[email protected]",
},
code: http.StatusBadRequest,
},
{
desc: "bad id format",
params: map[string]interface{}{
"id": "bad_uuid_format",
"email": "[email protected]",
},
code: http.StatusBadRequest,
},
}

originalMinLength := ts.Config.Password.MinLength
ts.Config.Password.MinLength = 8

for _, c := range cases {
ts.Run(c.desc, func() {
var buffer bytes.Buffer
Expand All @@ -905,12 +925,149 @@ func (ts *AdminTestSuite) TestAdminUserCreateValidationErrors() {
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
w := httptest.NewRecorder()
ts.API.handler.ServeHTTP(w, req)
require.Equal(ts.T(), http.StatusBadRequest, w.Code, w)

require.Equal(ts.T(), c.code, w.Code, "Expected status code %d but got %d for test case: %s", c.code, w.Code, c.desc)

data := map[string]interface{}{}
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
require.Equal(ts.T(), data["error_code"], apierrors.ErrorCodeValidationFailed)

if c.code == http.StatusBadRequest {
require.Equal(ts.T(), data["error_code"], apierrors.ErrorCodeValidationFailed)
} else if c.code == http.StatusUnprocessableEntity && c.desc == "weak password that doesn't meet the minimum length" {
require.Equal(ts.T(), "weak_password", data["error_code"])
}
})

}

ts.Config.Password.MinLength = originalMinLength
}

func (ts *AdminTestSuite) TestAdminUserCreateWithEnforcePasswordCheck() {
originalMinLength := ts.Config.Password.MinLength
ts.Config.Password.MinLength = 20

weakPassword := "short"

ts.Run("With enforce flag, should fail validation", func() {
var buffer bytes.Buffer
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
"email": "[email protected]",
"password": weakPassword,
"enforce_password_check": true,
}))

req := httptest.NewRequest(http.MethodPost, "/admin/users", &buffer)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
w := httptest.NewRecorder()
ts.API.handler.ServeHTTP(w, req)

require.Equal(ts.T(), http.StatusUnprocessableEntity, w.Code)
})

ts.Run("Without enforce flag, should succeed despite weak password", func() {
var buffer bytes.Buffer
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
"email": "[email protected]",
"password": weakPassword,
"enforce_password_check": false,
}))

req := httptest.NewRequest(http.MethodPost, "/admin/users", &buffer)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
w := httptest.NewRecorder()
ts.API.handler.ServeHTTP(w, req)

require.Equal(ts.T(), http.StatusOK, w.Code)

data := models.User{}
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
require.Equal(ts.T(), "[email protected]", data.GetEmail())

u, err := models.FindUserByID(ts.API.db, data.ID)
require.NoError(ts.T(), err)
isAuthenticated, _, err := u.Authenticate(context.Background(), ts.API.db, weakPassword,
ts.API.config.Security.DBEncryption.DecryptionKeys,
ts.API.config.Security.DBEncryption.Encrypt,
ts.API.config.Security.DBEncryption.EncryptionKeyID)
require.NoError(ts.T(), err)
require.True(ts.T(), isAuthenticated)
})

ts.Config.Password.MinLength = originalMinLength
}

func (ts *AdminTestSuite) TestAdminUserUpdateWithEnforcePasswordCheck() {
u, err := models.NewUser("", "[email protected]", "original", ts.Config.JWT.Aud, nil)
require.NoError(ts.T(), err, "Error making new user")
require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user")

originalMinLength := ts.Config.Password.MinLength
ts.Config.Password.MinLength = 20 // Set a high minimum to ensure our test password is "weak"

weakPassword := "short"

ts.Run("Without enforce_password_check parameter (default), should accept weak password", func() {
var buffer bytes.Buffer
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
"password": weakPassword,
}))

req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/admin/users/%s", u.ID), &buffer)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
w := httptest.NewRecorder()
ts.API.handler.ServeHTTP(w, req)

require.Equal(ts.T(), http.StatusOK, w.Code, "Expected weak password to be accepted when enforce_password_check is not provided")

updatedUser, err := models.FindUserByID(ts.API.db, u.ID)
require.NoError(ts.T(), err)
isAuthenticated, _, err := updatedUser.Authenticate(context.Background(), ts.API.db, weakPassword,
ts.API.config.Security.DBEncryption.DecryptionKeys,
ts.API.config.Security.DBEncryption.Encrypt,
ts.API.config.Security.DBEncryption.EncryptionKeyID)
require.NoError(ts.T(), err)
require.True(ts.T(), isAuthenticated, "Should be able to authenticate with the weak password")
})

ts.Run("With enforce_password_check=false, should accept weak password", func() {
var buffer bytes.Buffer
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
"password": weakPassword,
"enforce_password_check": false,
}))

req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/admin/users/%s", u.ID), &buffer)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
w := httptest.NewRecorder()
ts.API.handler.ServeHTTP(w, req)

require.Equal(ts.T(), http.StatusOK, w.Code, "Expected weak password to be accepted when enforce_password_check=false")

updatedUser, err := models.FindUserByID(ts.API.db, u.ID)
require.NoError(ts.T(), err)
isAuthenticated, _, err := updatedUser.Authenticate(context.Background(), ts.API.db, weakPassword,
ts.API.config.Security.DBEncryption.DecryptionKeys,
ts.API.config.Security.DBEncryption.Encrypt,
ts.API.config.Security.DBEncryption.EncryptionKeyID)
require.NoError(ts.T(), err)
require.True(ts.T(), isAuthenticated, "Should be able to authenticate with the weak password")
})

ts.Run("With enforce_password_check=true, should reject weak password", func() {
var buffer bytes.Buffer
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
"password": weakPassword,
"enforce_password_check": true,
}))

req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/admin/users/%s", u.ID), &buffer)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
w := httptest.NewRecorder()
ts.API.handler.ServeHTTP(w, req)

require.Equal(ts.T(), http.StatusUnprocessableEntity, w.Code, "Expected weak password to be rejected when enforce_password_check=true")
})

ts.Config.Password.MinLength = originalMinLength
}
3 changes: 2 additions & 1 deletion internal/api/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type GenerateLinkParams struct {
Password string `json:"password"`
Data map[string]interface{} `json:"data"`
RedirectTo string `json:"redirect_to"`
EnforcePasswordCheck bool `json:"enforce_password_check"`
}

type GenerateLinkResponse struct {
Expand Down Expand Up @@ -102,7 +103,7 @@ func (a *API) adminGenerateLink(w http.ResponseWriter, r *http.Request) error {
Aud: aud,
}

if err := a.validateSignupParams(ctx, signupParams); err != nil {
if err := a.validateSignupParams(ctx, signupParams, params.EnforcePasswordCheck); err != nil {
return err
}

Expand Down
Loading