Skip to content

feat: Add update user password endpoint #1310

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 17 commits into from
May 6, 2022
Merged
Show file tree
Hide file tree
Changes from 14 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
4 changes: 4 additions & 0 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,10 @@ func New(options *Options) (http.Handler, func()) {
r.Get("/", api.userByName)
r.Put("/profile", api.putUserProfile)
r.Put("/suspend", api.putUserSuspend)
r.Route("/password", func(r chi.Router) {
r.Use(httpmw.WithRBACObject(rbac.ResourceUserPasswordRole))
r.Put("/", authorize(api.putUserPassword, rbac.ActionUpdate))
})
r.Get("/organizations", api.organizationsByUser)
r.Post("/organizations", api.postOrganizationsByUser)
// These roles apply to the site wide permissions.
Expand Down
19 changes: 10 additions & 9 deletions coderd/coderdtest/coderdtest.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,21 +174,22 @@ func NewProvisionerDaemon(t *testing.T, client *codersdk.Client) io.Closer {
return closer
}

var FirstUserParams = codersdk.CreateFirstUserRequest{
Email: "testuser@coder.com",
Username: "testuser",
Password: "testpass",
OrganizationName: "testorg",
}

// CreateFirstUser creates a user with preset credentials and authenticates
// with the passed in codersdk client.
func CreateFirstUser(t *testing.T, client *codersdk.Client) codersdk.CreateFirstUserResponse {
req := codersdk.CreateFirstUserRequest{
Email: "testuser@coder.com",
Username: "testuser",
Password: "testpass",
OrganizationName: "testorg",
}
resp, err := client.CreateFirstUser(context.Background(), req)
resp, err := client.CreateFirstUser(context.Background(), FirstUserParams)
require.NoError(t, err)

login, err := client.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{
Email: req.Email,
Password: req.Password,
Email: FirstUserParams.Email,
Password: FirstUserParams.Password,
})
require.NoError(t, err)
client.SessionToken = login.SessionToken
Expand Down
15 changes: 15 additions & 0 deletions coderd/database/databasefake/databasefake.go
Original file line number Diff line number Diff line change
Expand Up @@ -1314,6 +1314,21 @@ func (q *fakeQuerier) UpdateUserStatus(_ context.Context, arg database.UpdateUse
return database.User{}, sql.ErrNoRows
}

func (q *fakeQuerier) UpdateUserHashedPassword(_ context.Context, arg database.UpdateUserHashedPasswordParams) error {
q.mutex.Lock()
defer q.mutex.Unlock()

for i, user := range q.users {
if user.ID != arg.ID {
continue
}
user.HashedPassword = arg.HashedPassword
q.users[i] = user
return nil
}
return sql.ErrNoRows
}

func (q *fakeQuerier) InsertWorkspace(_ context.Context, arg database.InsertWorkspaceParams) (database.Workspace, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
Expand Down
1 change: 1 addition & 0 deletions coderd/database/querier.go

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

19 changes: 19 additions & 0 deletions coderd/database/queries.sql.go

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

10 changes: 9 additions & 1 deletion coderd/database/queries/users.sql
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ WHERE
id = @id
RETURNING *;

-- name: UpdateUserHashedPassword :exec
UPDATE
users
SET
hashed_password = $2
WHERE
id = $1;

-- name: GetUsers :many
SELECT
*
Expand Down Expand Up @@ -133,4 +141,4 @@ FROM
LEFT JOIN organization_members
ON id = user_id
WHERE
id = @user_id;
id = @user_id;
4 changes: 4 additions & 0 deletions coderd/rbac/object.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ var (
Type: "user_role",
}

ResourceUserPasswordRole = Object{
Type: "user_password",
}

// ResourceWildcard represents all resource types
ResourceWildcard = Object{
Type: WildcardSymbol,
Expand Down
46 changes: 45 additions & 1 deletion coderd/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,51 @@ func (api *api) putUserSuspend(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(rw, http.StatusOK, convertUser(suspendedUser, organizations))
}

func (api *api) putUserPassword(rw http.ResponseWriter, r *http.Request) {
user := httpmw.UserParam(r)

var params codersdk.UpdateUserPasswordRequest
if !httpapi.Read(rw, r, &params) {
return
}

// Check if the new password and the confirmation match
if params.Password != params.ConfirmPassword {
requestErrors := []httpapi.Error{
{
Field: "confirm_new_password",
Detail: "The value does not match the new password",
},
}
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
Message: fmt.Sprintf("The new password and the new password confirmation don't match"),
Errors: requestErrors,
})
return
}

// Hash password and update it in the database
hashedPassword, err := userpassword.Hash(params.Password)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("hash password: %s", err.Error()),
})
return
}
err = api.Database.UpdateUserHashedPassword(r.Context(), database.UpdateUserHashedPasswordParams{
ID: user.ID,
HashedPassword: []byte(hashedPassword),
})
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("put user password: %s", err.Error()),
})
return
}

httpapi.Write(rw, http.StatusNoContent, nil)
}

func (api *api) userRoles(rw http.ResponseWriter, r *http.Request) {
user := httpmw.UserParam(r)

Expand Down Expand Up @@ -577,7 +622,6 @@ func (api *api) postLogin(rw http.ResponseWriter, r *http.Request) {
}

// If the user doesn't exist, it will be a default struct.

equal, err := userpassword.Compare(string(user.HashedPassword), loginWithPassword.Password)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Expand Down
35 changes: 35 additions & 0 deletions coderd/users_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,41 @@ func TestUpdateUserProfile(t *testing.T) {
})
}

func TestUpdateUserPassword(t *testing.T) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's also add some tests to make sure the rbac is working here, I'd like to ensure that the user itself cannot perform this action, and neither can other non-admin users.

Copy link
Collaborator Author

@BrunoQuaresma BrunoQuaresma May 5, 2022

Choose a reason for hiding this comment

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

So from what I'm understanding we want to test:

  • A non-admin user can't update any password
  • An admin can update another user's password

Is that?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Updated!

t.Parallel()

t.Run("DifferentPasswordConfirmation", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
coderdtest.CreateFirstUser(t, client)
err := client.UpdateUserPassword(context.Background(), codersdk.Me, codersdk.UpdateUserPasswordRequest{
Password: "newpassword",
ConfirmPassword: "wrongconfirmation",
})
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
})

t.Run("Success", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
coderdtest.CreateFirstUser(t, client)
err := client.UpdateUserPassword(context.Background(), codersdk.Me, codersdk.UpdateUserPasswordRequest{
Password: "newpassword",
ConfirmPassword: "newpassword",
})
require.NoError(t, err, "update password request should be successful")

// Check if the user can login using the new password
_, err = client.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{
Email: coderdtest.FirstUserParams.Email,
Password: "newpassword",
})
require.NoError(t, err, "login should be successful")
})
}

func TestGrantRoles(t *testing.T) {
t.Parallel()
t.Run("UpdateIncorrectRoles", func(t *testing.T) {
Expand Down
19 changes: 19 additions & 0 deletions codersdk/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ type UpdateUserProfileRequest struct {
Username string `json:"username" validate:"required,username"`
}

type UpdateUserPasswordRequest struct {
Password string `json:"password" validate:"required"`
ConfirmPassword string `json:"confirm_new_password" validate:"required"`
}

type UpdateRoles struct {
Roles []string `json:"roles" validate:"required"`
}
Expand Down Expand Up @@ -181,6 +186,20 @@ func (c *Client) SuspendUser(ctx context.Context, userID uuid.UUID) (User, error
return user, json.NewDecoder(res.Body).Decode(&user)
}

// UpdateUserPassword updates a user password.
// It calls PUT /users/{user}/password
func (c *Client) UpdateUserPassword(ctx context.Context, userID uuid.UUID, req UpdateUserPasswordRequest) error {
res, err := c.request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/password", uuidOrMe(userID)), req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return readBodyAsError(res)
}
return nil
}

// UpdateUserRoles grants the userID the specified roles.
// Include ALL roles the user has.
func (c *Client) UpdateUserRoles(ctx context.Context, userID uuid.UUID, req UpdateRoles) (User, error) {
Expand Down
20 changes: 13 additions & 7 deletions site/src/api/typesGenerated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export interface AgentGitSSHKey {
readonly private_key: string
}

// From codersdk/users.go:105:6
// From codersdk/users.go:110:6
export interface AuthMethods {
readonly password: boolean
readonly github: boolean
Expand Down Expand Up @@ -44,7 +44,7 @@ export interface CreateFirstUserResponse {
readonly organization_id: string
}

// From codersdk/users.go:100:6
// From codersdk/users.go:105:6
export interface CreateOrganizationRequest {
readonly name: string
}
Expand Down Expand Up @@ -101,7 +101,7 @@ export interface CreateWorkspaceRequest {
readonly parameter_values: CreateParameterRequest[]
}

// From codersdk/users.go:96:6
// From codersdk/users.go:101:6
export interface GenerateAPIKeyResponse {
readonly key: string
}
Expand All @@ -119,13 +119,13 @@ export interface GoogleInstanceIdentityToken {
readonly json_web_token: string
}

// From codersdk/users.go:85:6
// From codersdk/users.go:90:6
export interface LoginWithPasswordRequest {
readonly email: string
readonly password: string
}

// From codersdk/users.go:91:6
// From codersdk/users.go:96:6
export interface LoginWithPasswordResponse {
readonly session_token: string
}
Expand Down Expand Up @@ -255,11 +255,17 @@ export interface UpdateActiveTemplateVersion {
readonly id: string
}

// From codersdk/users.go:75:6
// From codersdk/users.go:80:6
export interface UpdateRoles {
readonly roles: string[]
}

// From codersdk/users.go:75:6
export interface UpdateUserPasswordRequest {
readonly password: string
readonly confirm_new_password: string
}

// From codersdk/users.go:70:6
export interface UpdateUserProfileRequest {
readonly email: string
Expand Down Expand Up @@ -291,7 +297,7 @@ export interface User {
readonly organization_ids: string[]
}

// From codersdk/users.go:79:6
// From codersdk/users.go:84:6
export interface UserRoles {
readonly roles: string[]
readonly organization_roles: Record<string, string[]>
Expand Down