Skip to content

fix: improve password validation flow #15132

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

Merged
merged 21 commits into from
Nov 5, 2024
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b93dc6b
fix(setup): improve password validation flow on first user setup
defelmnq Oct 18, 2024
cf31dde
fix(setup): improve password validation flow on first user setup
defelmnq Oct 18, 2024
309d839
feat(password): WIP
defelmnq Oct 22, 2024
388a58b
feat(password): apply backend logic to all password set fields
defelmnq Oct 22, 2024
f9cce4c
Merge remote-tracking branch 'origin/main' into fix-password-validation
defelmnq Oct 23, 2024
8a5e63f
Merge remote-tracking branch 'origin/main' into fix-password-validation
defelmnq Oct 23, 2024
8f695ab
feat(password): apply backend logic to all password set fields
defelmnq Oct 23, 2024
c103559
feat(password): apply backend logic to all password set fields
defelmnq Oct 23, 2024
dc46019
feat(password): apply backend logic to all password set fields
defelmnq Oct 23, 2024
37072ee
feat(password): apply backend logic to all password set fields
defelmnq Oct 23, 2024
b4b8b06
feat(password): apply backend logic to all password set fields
defelmnq Oct 23, 2024
0efd24f
feat(password): add test for validate password method
defelmnq Oct 23, 2024
a6ee1cc
fix(password): display only if password is set
defelmnq Oct 24, 2024
51b1e51
WIP: Working on testing improvement
defelmnq Oct 24, 2024
e2128e6
site: change logic to generate error instead of helper text on passwo…
defelmnq Oct 24, 2024
f37ef9e
fix storybook
defelmnq Oct 24, 2024
36cadeb
Merge remote-tracking branch 'origin/main' into fix-password-validation
defelmnq Oct 25, 2024
175b4bf
feat(password): add details field to validate password endpoint
defelmnq Oct 28, 2024
f33eac2
feat(password): add details field to validate password endpoint
defelmnq Oct 28, 2024
2e0941b
Extract validation logic to a component
BrunoQuaresma Nov 4, 2024
3869dd5
Merge remote-tracking branch 'origin/main' into fix-password-validation
defelmnq Nov 5, 2024
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
58 changes: 58 additions & 0 deletions coderd/apidoc/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

50 changes: 50 additions & 0 deletions coderd/apidoc/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -991,6 +991,7 @@ func New(options *Options) *API {
r.Use(httpmw.RateLimit(options.LoginRateLimit, time.Minute))
r.Post("/login", api.postLogin)
r.Post("/otp/request", api.postRequestOneTimePasscode)
r.Post("/validate-password", api.validateUserPassword)
r.Post("/otp/change-password", api.postChangePasswordWithOneTimePasscode)
r.Route("/oauth2", func(r chi.Router) {
r.Route("/github", func(r chi.Router) {
Expand Down
32 changes: 32 additions & 0 deletions coderd/userauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,38 @@ func (api *API) postChangePasswordWithOneTimePasscode(rw http.ResponseWriter, r
}
}

// ValidateUserPassword validates the complexity of a user password and that it is secured enough.
//
// @Summary Validate user password
// @ID validate-user-password
// @Security CoderSessionToken
// @Produce json
// @Accept json
// @Tags Authorization
// @Param request body codersdk.ValidateUserPasswordRequest true "Validate user password request"
// @Success 200 {object} codersdk.ValidateUserPasswordResponse
// @Router /users/validate-password [post]
func (*API) validateUserPassword(rw http.ResponseWriter, r *http.Request) {
Copy link
Member

@aslilac aslilac Nov 4, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit about referring to all of this stuff as "user password" validation: the password is not yet attached to a user, so it's not a user password, it's just a password, so I'd expect it to be called validatePassword and so on.

var (
ctx = r.Context()
valid = true
)

var req codersdk.ValidateUserPasswordRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}

err := userpassword.Validate(req.Password)
if err != nil {
valid = false
}

httpapi.Write(ctx, rw, http.StatusOK, codersdk.ValidateUserPasswordResponse{
Valid: valid,
})
}

// Authenticates the user with an email and password.
//
// @Summary Log in user
Expand Down
33 changes: 0 additions & 33 deletions coderd/userauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1880,39 +1880,6 @@ func TestUserForgotPassword(t *testing.T) {
requireCanLogin(t, ctx, anotherClient, anotherUser.Email, oldPassword)
})

t.Run("CannotChangePasswordWithWeakPassword", func(t *testing.T) {
t.Parallel()

notifyEnq := &testutil.FakeNotificationsEnqueuer{}

client := coderdtest.New(t, &coderdtest.Options{
NotificationsEnqueuer: notifyEnq,
})
user := coderdtest.CreateFirstUser(t, client)

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()

anotherClient, anotherUser := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)

oneTimePasscode := requireRequestOneTimePasscode(t, ctx, anotherClient, notifyEnq, anotherUser.Email, anotherUser.ID)

err := anotherClient.ChangePasswordWithOneTimePasscode(ctx, codersdk.ChangePasswordWithOneTimePasscodeRequest{
Email: anotherUser.Email,
OneTimePasscode: oneTimePasscode,
Password: "notstrong",
})
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
require.Contains(t, apiErr.Message, "Invalid password.")
require.Equal(t, 1, len(apiErr.Validations))
require.Equal(t, "password", apiErr.Validations[0].Field)

requireCannotLogin(t, ctx, anotherClient, anotherUser.Email, "notstrong")
requireCanLogin(t, ctx, anotherClient, anotherUser.Email, oldPassword)
})

t.Run("CannotChangePasswordOfAnotherUser", func(t *testing.T) {
t.Parallel()

Expand Down
7 changes: 2 additions & 5 deletions coderd/userpassword/userpassword.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (
"strconv"
"strings"

passwordvalidator "github.com/wagslane/go-password-validator"
"golang.org/x/crypto/pbkdf2"
"golang.org/x/exp/slices"
"golang.org/x/xerrors"
Expand Down Expand Up @@ -138,10 +137,8 @@ func hashWithSaltAndIter(password string, salt []byte, iter int) string {
// It returns properly formatted errors for detailed form validation on the client.
func Validate(password string) error {
// Ensure passwords are secure enough!
// See: https://github.com/wagslane/go-password-validator#what-entropy-value-should-i-use
err := passwordvalidator.Validate(password, 52)
if err != nil {
return err
if len(password) < 6 {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now this logic stays pretty simple - here's the place where we'll be able to apply all the validation logic we want

Copy link
Member

@johnstcn johnstcn Oct 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I'm concerned here, we are squishing together three distinct separate changes:

  1. Expose a password complexity validation endpoint on the backend
  2. Modify the password validation logic on the FE to query the BE
  3. Modify the password validation logic on the BE

Of these three options, 1) and 2) are relatively innocuous. 3) is the one I'm mainly concerned about.

I propose keeping the existing password validation logic (via entropy) in this PR and opening a separate PR to modify the password validation logic if required.

My proposal for a roughly equivalent non-entropy-based password validation logic is:

  1. At least one non-alphanumeric non-whitespace character ([^a-zA-Z0-9\s])
  2. Minimum length of 9 runes

EDIT: we should however be extremely careful if we change the validation, as users could have existing passwords that are valid per entropy but invalid per the above rule.

return xerrors.Errorf("password must be at least %d characters", 6)
}
if len(password) > 64 {
return xerrors.Errorf("password must be no more than %d characters", 64)
Expand Down
102 changes: 61 additions & 41 deletions coderd/userpassword/userpassword_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,53 +5,73 @@
package userpassword_test

import (
"strings"
"testing"

"github.com/stretchr/testify/require"

"github.com/coder/coder/v2/coderd/userpassword"
)

func TestUserPassword(t *testing.T) {
func TestUserPasswordValidate(t *testing.T) {
t.Parallel()
t.Run("Legacy", func(t *testing.T) {
t.Parallel()
// Ensures legacy v1 passwords function for v2.
// This has is manually generated using a print statement from v1 code.
equal, err := userpassword.Compare("$pbkdf2-sha256$65535$z8c1p1C2ru9EImBP1I+ZNA$pNjE3Yk0oG0PmJ0Je+y7ENOVlSkn/b0BEqqdKsq6Y97wQBq0xT+lD5bWJpyIKJqQICuPZcEaGDKrXJn8+SIHRg", "tomato")
require.NoError(t, err)
require.True(t, equal)
})

t.Run("Same", func(t *testing.T) {
t.Parallel()
hash, err := userpassword.Hash("password")
require.NoError(t, err)
equal, err := userpassword.Compare(hash, "password")
require.NoError(t, err)
require.True(t, equal)
})

t.Run("Different", func(t *testing.T) {
t.Parallel()
hash, err := userpassword.Hash("password")
require.NoError(t, err)
equal, err := userpassword.Compare(hash, "notpassword")
require.NoError(t, err)
require.False(t, equal)
})

t.Run("Invalid", func(t *testing.T) {
t.Parallel()
equal, err := userpassword.Compare("invalidhash", "password")
require.False(t, equal)
require.Error(t, err)
})

t.Run("InvalidParts", func(t *testing.T) {
t.Parallel()
equal, err := userpassword.Compare("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz", "test")
require.False(t, equal)
require.Error(t, err)
})
tests := []struct {
name string
password string
wantErr bool
}{
{"Invalid - Too short password", "pass", true},
{"Invalid - Too long password", strings.Repeat("a", 65), true},
{"Ok", "CorrectPassword", false},
}

for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := userpassword.Validate(tt.password)
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}

func TestUserPasswordCompare(t *testing.T) {
t.Parallel()
tests := []struct {
name string
passwordToValidate string
password string
shouldHash bool
wantErr bool
wantEqual bool
}{
{"Legacy", "$pbkdf2-sha256$65535$z8c1p1C2ru9EImBP1I+ZNA$pNjE3Yk0oG0PmJ0Je+y7ENOVlSkn/b0BEqqdKsq6Y97wQBq0xT+lD5bWJpyIKJqQICuPZcEaGDKrXJn8+SIHRg", "tomato", false, false, true},
{"Same", "password", "password", true, false, true},
{"Different", "password", "notpassword", true, false, false},
{"Invalid", "invalidhash", "password", false, true, false},
{"InvalidParts", "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz", "test", false, true, false},
}

for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if tt.shouldHash {
hash, err := userpassword.Hash(tt.passwordToValidate)
require.NoError(t, err)
tt.passwordToValidate = hash
}
equal, err := userpassword.Compare(tt.passwordToValidate, tt.password)
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
require.Equal(t, tt.wantEqual, equal)
})
}
}
Loading
Loading