diff --git a/coderd/coderd.go b/coderd/coderd.go index 191d3339aa2b1..aa5cdbb21b86c 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -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. diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 074767f35920c..e205e272db3ce 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -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 diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 5726c9f7befe5..a9c7dea5fdbd5 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -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() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index db2f3f0d49987..dbf6b5cfed8cd 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -100,6 +100,7 @@ type querier interface { UpdateTemplateActiveVersionByID(ctx context.Context, arg UpdateTemplateActiveVersionByIDParams) error UpdateTemplateDeletedByID(ctx context.Context, arg UpdateTemplateDeletedByIDParams) error UpdateTemplateVersionByID(ctx context.Context, arg UpdateTemplateVersionByIDParams) error + UpdateUserHashedPassword(ctx context.Context, arg UpdateUserHashedPasswordParams) error UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (User, error) UpdateUserRoles(ctx context.Context, arg UpdateUserRolesParams) (User, error) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusParams) (User, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index f91070915c097..450d540a192ac 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2264,6 +2264,25 @@ func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User return i, err } +const updateUserHashedPassword = `-- name: UpdateUserHashedPassword :exec +UPDATE + users +SET + hashed_password = $2 +WHERE + id = $1 +` + +type UpdateUserHashedPasswordParams struct { + ID uuid.UUID `db:"id" json:"id"` + HashedPassword []byte `db:"hashed_password" json:"hashed_password"` +} + +func (q *sqlQuerier) UpdateUserHashedPassword(ctx context.Context, arg UpdateUserHashedPasswordParams) error { + _, err := q.db.ExecContext(ctx, updateUserHashedPassword, arg.ID, arg.HashedPassword) + return err +} + const updateUserProfile = `-- name: UpdateUserProfile :one UPDATE users diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index 2f0ecb3709b9b..d0c3d4c3e7b66 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -59,6 +59,14 @@ WHERE id = @id RETURNING *; +-- name: UpdateUserHashedPassword :exec +UPDATE + users +SET + hashed_password = $2 +WHERE + id = $1; + -- name: GetUsers :many SELECT * @@ -133,4 +141,4 @@ FROM LEFT JOIN organization_members ON id = user_id WHERE - id = @user_id; \ No newline at end of file + id = @user_id; diff --git a/coderd/httpmw/userparam.go b/coderd/httpmw/userparam.go index ba91414de8f4c..16e768c4ba8c8 100644 --- a/coderd/httpmw/userparam.go +++ b/coderd/httpmw/userparam.go @@ -76,14 +76,6 @@ func ExtractUserParam(db database.Store) func(http.Handler) http.Handler { } } - apiKey := APIKey(r) - if apiKey.UserID != user.ID { - httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ - Message: "getting non-personal users isn't supported yet", - }) - return - } - ctx := context.WithValue(r.Context(), userParamContextKey{}, user) next.ServeHTTP(rw, r.WithContext(ctx)) }) diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go index a4be9b1edab5b..dd12efc25fcb2 100644 --- a/coderd/rbac/object.go +++ b/coderd/rbac/object.go @@ -24,6 +24,10 @@ var ( Type: "user_role", } + ResourceUserPasswordRole = Object{ + Type: "user_password", + } + // ResourceWildcard represents all resource types ResourceWildcard = Object{ Type: WildcardSymbol, diff --git a/coderd/users.go b/coderd/users.go index 310bee8e79597..108ad0813cb49 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -360,6 +360,36 @@ 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) { + var ( + user = httpmw.UserParam(r) + params codersdk.UpdateUserPasswordRequest + ) + if !httpapi.Read(rw, r, ¶ms) { + return + } + + 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) @@ -577,7 +607,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{ diff --git a/coderd/users_test.go b/coderd/users_test.go index 531ac93be7aa6..aaf5737ce6b61 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -287,6 +287,44 @@ func TestUpdateUserProfile(t *testing.T) { }) } +func TestUpdateUserPassword(t *testing.T) { + t.Parallel() + + t.Run("MemberCantUpdateAdminPassword", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + admin := coderdtest.CreateFirstUser(t, client) + member := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID) + err := member.UpdateUserPassword(context.Background(), admin.UserID, codersdk.UpdateUserPasswordRequest{ + Password: "newpassword", + }) + require.Error(t, err, "member should not be able to update admin password") + }) + + t.Run("AdminCanUpdateMemberPassword", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + admin := coderdtest.CreateFirstUser(t, client) + member, err := client.CreateUser(context.Background(), codersdk.CreateUserRequest{ + Email: "coder@coder.com", + Username: "coder", + Password: "password", + OrganizationID: admin.OrganizationID, + }) + require.NoError(t, err, "create member") + err = client.UpdateUserPassword(context.Background(), member.ID, codersdk.UpdateUserPasswordRequest{ + Password: "newpassword", + }) + require.NoError(t, err, "admin should be able to update member password") + // Check if the member can login using the new password + _, err = client.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{ + Email: "coder@coder.com", + Password: "newpassword", + }) + require.NoError(t, err, "member should login successfully with the new password") + }) +} + func TestGrantRoles(t *testing.T) { t.Parallel() t.Run("UpdateIncorrectRoles", func(t *testing.T) { diff --git a/codersdk/users.go b/codersdk/users.go index e2c8261febdec..dd0ad207d91e3 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -72,6 +72,10 @@ type UpdateUserProfileRequest struct { Username string `json:"username" validate:"required,username"` } +type UpdateUserPasswordRequest struct { + Password string `json:"password" validate:"required"` +} + type UpdateRoles struct { Roles []string `json:"roles" validate:"required"` } @@ -181,6 +185,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) { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index ee4add928bb11..e3fa4e53fc0d5 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -12,7 +12,7 @@ export interface AgentGitSSHKey { readonly private_key: string } -// From codersdk/users.go:105:6 +// From codersdk/users.go:109:6 export interface AuthMethods { readonly password: boolean readonly github: boolean @@ -44,7 +44,7 @@ export interface CreateFirstUserResponse { readonly organization_id: string } -// From codersdk/users.go:100:6 +// From codersdk/users.go:104:6 export interface CreateOrganizationRequest { readonly name: string } @@ -101,7 +101,7 @@ export interface CreateWorkspaceRequest { readonly parameter_values: CreateParameterRequest[] } -// From codersdk/users.go:96:6 +// From codersdk/users.go:100:6 export interface GenerateAPIKeyResponse { readonly key: string } @@ -119,13 +119,13 @@ export interface GoogleInstanceIdentityToken { readonly json_web_token: string } -// From codersdk/users.go:85:6 +// From codersdk/users.go:89:6 export interface LoginWithPasswordRequest { readonly email: string readonly password: string } -// From codersdk/users.go:91:6 +// From codersdk/users.go:95:6 export interface LoginWithPasswordResponse { readonly session_token: string } @@ -255,11 +255,16 @@ export interface UpdateActiveTemplateVersion { readonly id: string } -// From codersdk/users.go:75:6 +// From codersdk/users.go:79:6 export interface UpdateRoles { readonly roles: string[] } +// From codersdk/users.go:75:6 +export interface UpdateUserPasswordRequest { + readonly password: string +} + // From codersdk/users.go:70:6 export interface UpdateUserProfileRequest { readonly email: string @@ -291,7 +296,7 @@ export interface User { readonly organization_ids: string[] } -// From codersdk/users.go:79:6 +// From codersdk/users.go:83:6 export interface UserRoles { readonly roles: string[] readonly organization_roles: Record