diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index a8719397a1559..6c770c18232ac 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -5373,6 +5373,45 @@ const docTemplate = `{ } } }, + "/users/validate-password": { + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Authorization" + ], + "summary": "Validate user password", + "operationId": "validate-user-password", + "parameters": [ + { + "description": "Validate user password request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.ValidateUserPasswordRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ValidateUserPasswordResponse" + } + } + } + } + }, "/users/{user}": { "get": { "security": [ @@ -14096,6 +14135,28 @@ const docTemplate = `{ "UserStatusSuspended" ] }, + "codersdk.ValidateUserPasswordRequest": { + "type": "object", + "required": [ + "password" + ], + "properties": { + "password": { + "type": "string" + } + } + }, + "codersdk.ValidateUserPasswordResponse": { + "type": "object", + "properties": { + "details": { + "type": "string" + }, + "valid": { + "type": "boolean" + } + } + }, "codersdk.ValidationError": { "type": "object", "required": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 88bf71bf05758..4f5ca444f703e 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -4737,6 +4737,39 @@ } } }, + "/users/validate-password": { + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Authorization"], + "summary": "Validate user password", + "operationId": "validate-user-password", + "parameters": [ + { + "description": "Validate user password request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.ValidateUserPasswordRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ValidateUserPasswordResponse" + } + } + } + } + }, "/users/{user}": { "get": { "security": [ @@ -12817,6 +12850,26 @@ "UserStatusSuspended" ] }, + "codersdk.ValidateUserPasswordRequest": { + "type": "object", + "required": ["password"], + "properties": { + "password": { + "type": "string" + } + } + }, + "codersdk.ValidateUserPasswordResponse": { + "type": "object", + "properties": { + "details": { + "type": "string" + }, + "valid": { + "type": "boolean" + } + } + }, "codersdk.ValidationError": { "type": "object", "required": ["detail", "field"], diff --git a/coderd/coderd.go b/coderd/coderd.go index 39df674fecca8..c3a780b2b1106 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1047,6 +1047,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) { diff --git a/coderd/userauth.go b/coderd/userauth.go index 317bb5b6a9e58..f6cf0e5292db7 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -447,6 +447,41 @@ 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) { + var ( + ctx = r.Context() + valid = true + details = "" + ) + + var req codersdk.ValidateUserPasswordRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + err := userpassword.Validate(req.Password) + if err != nil { + valid = false + details = err.Error() + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.ValidateUserPasswordResponse{ + Valid: valid, + Details: details, + }) +} + // Authenticates the user with an email and password. // // @Summary Log in user diff --git a/coderd/userpassword/userpassword_test.go b/coderd/userpassword/userpassword_test.go index 1617748d5ada1..41eebf49c974d 100644 --- a/coderd/userpassword/userpassword_test.go +++ b/coderd/userpassword/userpassword_test.go @@ -5,6 +5,7 @@ package userpassword_test import ( + "strings" "testing" "github.com/stretchr/testify/require" @@ -12,46 +13,101 @@ import ( "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 + }{ + {name: "Invalid - Too short password", password: "pass", wantErr: true}, + {name: "Invalid - Too long password", password: strings.Repeat("a", 65), wantErr: true}, + {name: "Invalid - easy password", password: "password", wantErr: true}, + {name: "Ok", password: "PasswordSecured123!", wantErr: 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 + }{ + { + name: "Legacy", + passwordToValidate: "$pbkdf2-sha256$65535$z8c1p1C2ru9EImBP1I+ZNA$pNjE3Yk0oG0PmJ0Je+y7ENOVlSkn/b0BEqqdKsq6Y97wQBq0xT+lD5bWJpyIKJqQICuPZcEaGDKrXJn8+SIHRg", + password: "tomato", + shouldHash: false, + wantErr: false, + wantEqual: true, + }, + { + name: "Same", + passwordToValidate: "password", + password: "password", + shouldHash: true, + wantErr: false, + wantEqual: true, + }, + { + name: "Different", + passwordToValidate: "password", + password: "notpassword", + shouldHash: true, + wantErr: false, + wantEqual: false, + }, + { + name: "Invalid", + passwordToValidate: "invalidhash", + password: "password", + shouldHash: false, + wantErr: true, + wantEqual: false, + }, + { + name: "InvalidParts", + passwordToValidate: "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz", + password: "test", + shouldHash: false, + wantErr: true, + wantEqual: 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) + }) + } } diff --git a/coderd/users_test.go b/coderd/users_test.go index 3c88d3e5022ac..375e4b3168066 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -1219,6 +1219,24 @@ func TestUpdateUserPassword(t *testing.T) { require.Equal(t, database.AuditActionWrite, auditor.AuditLogs()[numLogs-1].Action) }) + t.Run("ValidateUserPassword", func(t *testing.T) { + t.Parallel() + auditor := audit.NewMock() + client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor}) + + _ = coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + resp, err := client.ValidateUserPassword(ctx, codersdk.ValidateUserPasswordRequest{ + Password: "MySecurePassword!", + }) + + require.NoError(t, err, "users shoud be able to validate complexity of a potential new password") + require.True(t, resp.Valid) + }) + t.Run("ChangingPasswordDeletesKeys", func(t *testing.T) { t.Parallel() diff --git a/codersdk/users.go b/codersdk/users.go index 546fcc99e9fbe..4dbdc0d4e4f91 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -178,6 +178,15 @@ type UpdateUserProfileRequest struct { Name string `json:"name" validate:"user_real_name"` } +type ValidateUserPasswordRequest struct { + Password string `json:"password" validate:"required"` +} + +type ValidateUserPasswordResponse struct { + Valid bool `json:"valid"` + Details string `json:"details"` +} + type UpdateUserAppearanceSettingsRequest struct { ThemePreference string `json:"theme_preference" validate:"required"` } @@ -407,6 +416,20 @@ func (c *Client) UpdateUserProfile(ctx context.Context, user string, req UpdateU return resp, json.NewDecoder(res.Body).Decode(&resp) } +// ValidateUserPassword validates the complexity of a user password and that it is secured enough. +func (c *Client) ValidateUserPassword(ctx context.Context, req ValidateUserPasswordRequest) (ValidateUserPasswordResponse, error) { + res, err := c.Request(ctx, http.MethodPost, "/api/v2/users/validate-password", req) + if err != nil { + return ValidateUserPasswordResponse{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return ValidateUserPasswordResponse{}, ReadBodyAsError(res) + } + var resp ValidateUserPasswordResponse + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + // UpdateUserStatus sets the user status to the given status func (c *Client) UpdateUserStatus(ctx context.Context, user string, status UserStatus) (User, error) { path := fmt.Sprintf("/api/v2/users/%s/status/", user) diff --git a/docs/reference/api/authorization.md b/docs/reference/api/authorization.md index 86cee5d0fd727..9dfbfb620870f 100644 --- a/docs/reference/api/authorization.md +++ b/docs/reference/api/authorization.md @@ -178,6 +178,53 @@ curl -X POST http://coder-server:8080/api/v2/users/otp/request \ | ------ | --------------------------------------------------------------- | ----------- | ------ | | 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | +## Validate user password + +### Code samples + +```shell +# Example request using curl +curl -X POST http://coder-server:8080/api/v2/users/validate-password \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`POST /users/validate-password` + +> Body parameter + +```json +{ + "password": "string" +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +| ------ | ---- | -------------------------------------------------------------------------------------- | -------- | ------------------------------ | +| `body` | body | [codersdk.ValidateUserPasswordRequest](schemas.md#codersdkvalidateuserpasswordrequest) | true | Validate user password request | + +### Example responses + +> 200 Response + +```json +{ + "details": "string", + "valid": true +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.ValidateUserPasswordResponse](schemas.md#codersdkvalidateuserpasswordresponse) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Convert user from password to oauth authentication ### Code samples diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index c7c1a729476c8..dab7703345b08 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -6446,6 +6446,36 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `dormant` | | `suspended` | +## codersdk.ValidateUserPasswordRequest + +```json +{ + "password": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ---------- | ------ | -------- | ------------ | ----------- | +| `password` | string | true | | | + +## codersdk.ValidateUserPasswordResponse + +```json +{ + "details": "string", + "valid": true +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| --------- | ------- | -------- | ------------ | ----------- | +| `details` | string | false | | | +| `valid` | boolean | false | | | + ## codersdk.ValidationError ```json diff --git a/site/src/api/api.ts b/site/src/api/api.ts index b79fea12a0c31..e9d5e3fbf3f15 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1322,6 +1322,15 @@ class ApiMethods { await this.axios.put(`/api/v2/users/${userId}/password`, updatePassword); }; + validateUserPassword = async ( + password: string, + ): Promise => { + const response = await this.axios.post("/api/v2/users/validate-password", { + password, + }); + return response.data; + }; + getRoles = async (): Promise> => { const response = await this.axios.get( "/api/v2/users/roles", diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts index 833d88e6baeef..77d879abe3258 100644 --- a/site/src/api/queries/users.ts +++ b/site/src/api/queries/users.ts @@ -9,6 +9,7 @@ import type { UpdateUserProfileRequest, User, UsersRequest, + ValidateUserPasswordRequest, } from "api/typesGenerated"; import { type MetadataState, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 619961c457b36..f89d9eada822c 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1779,6 +1779,17 @@ export interface UsersRequest extends Pagination { readonly q?: string; } +// From codersdk/users.go +export interface ValidateUserPasswordRequest { + readonly password: string; +} + +// From codersdk/users.go +export interface ValidateUserPasswordResponse { + readonly valid: boolean; + readonly details: string; +} + // From codersdk/client.go export interface ValidationError { readonly field: string; diff --git a/site/src/components/PasswordField/PasswordField.stories.tsx b/site/src/components/PasswordField/PasswordField.stories.tsx new file mode 100644 index 0000000000000..4eba909c4c6ef --- /dev/null +++ b/site/src/components/PasswordField/PasswordField.stories.tsx @@ -0,0 +1,66 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { expect, spyOn, userEvent, waitFor, within } from "@storybook/test"; +import { API } from "api/api"; +import { useState } from "react"; +import { PasswordField } from "./PasswordField"; + +const meta: Meta = { + title: "components/PasswordField", + component: PasswordField, + args: { + label: "Password", + }, + render: function StatefulPasswordField(args) { + const [value, setValue] = useState(""); + return ( + setValue(e.currentTarget.value)} + /> + ); + }, +}; + +export default meta; +type Story = StoryObj; + +export const Idle: Story = {}; + +const securePassword = "s3curePa$$w0rd"; +export const Valid: Story = { + play: async ({ canvasElement }) => { + const validatePasswordSpy = spyOn( + API, + "validateUserPassword", + ).mockResolvedValueOnce({ valid: true, details: "" }); + const user = userEvent.setup(); + const canvas = within(canvasElement); + const input = canvas.getByLabelText("Password"); + await user.type(input, securePassword); + await waitFor(() => + expect(validatePasswordSpy).toHaveBeenCalledWith(securePassword), + ); + expect(validatePasswordSpy).toHaveBeenCalledTimes(1); + }, +}; + +export const Invalid: Story = { + play: async ({ canvasElement }) => { + const validatePasswordSpy = spyOn( + API, + "validateUserPassword", + ).mockResolvedValueOnce({ + valid: false, + details: "Password is too short.", + }); + const user = userEvent.setup(); + const canvas = within(canvasElement); + const input = canvas.getByLabelText("Password"); + await user.type(input, securePassword); + await waitFor(() => + expect(validatePasswordSpy).toHaveBeenCalledWith(securePassword), + ); + expect(validatePasswordSpy).toHaveBeenCalledTimes(1); + }, +}; diff --git a/site/src/components/PasswordField/PasswordField.tsx b/site/src/components/PasswordField/PasswordField.tsx new file mode 100644 index 0000000000000..276526bb84632 --- /dev/null +++ b/site/src/components/PasswordField/PasswordField.tsx @@ -0,0 +1,37 @@ +import TextField, { type TextFieldProps } from "@mui/material/TextField"; +import { API } from "api/api"; +import { useDebouncedValue } from "hooks/debounce"; +import type { FC } from "react"; +import { useQuery } from "react-query"; + +// TODO: @BrunoQuaresma: Unable to integrate Yup + Formik for validation. The +// validation was triggering on the onChange event, but the form.errors were not +// updating accordingly. Tried various combinations of validateOnBlur and +// validateOnChange without success. Further investigation is needed. + +/** + * A password field component that validates the password against the API with + * debounced calls. It uses a debounced value to minimize the number of API + * calls and displays validation errors. + */ +export const PasswordField: FC = (props) => { + const debouncedValue = useDebouncedValue(`${props.value}`, 500); + const validatePasswordQuery = useQuery({ + queryKey: ["validatePassword", debouncedValue], + queryFn: () => API.validateUserPassword(debouncedValue), + keepPreviousData: true, + enabled: debouncedValue.length > 0, + }); + const valid = validatePasswordQuery.data?.valid ?? true; + + return ( + + ); +}; diff --git a/site/src/pages/CreateUserPage/CreateUserForm.tsx b/site/src/pages/CreateUserPage/CreateUserForm.tsx index 635c26387c00c..51dae50df26fa 100644 --- a/site/src/pages/CreateUserPage/CreateUserForm.tsx +++ b/site/src/pages/CreateUserPage/CreateUserForm.tsx @@ -6,9 +6,10 @@ import type * as TypesGen from "api/typesGenerated"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { FormFooter } from "components/FormFooter/FormFooter"; import { FullPageForm } from "components/FullPageForm/FullPageForm"; +import { PasswordField } from "components/PasswordField/PasswordField"; import { Stack } from "components/Stack/Stack"; import { type FormikContextType, useFormik } from "formik"; -import type { FC } from "react"; +import { type FC, useEffect } from "react"; import { displayNameValidator, getFormHelpers, @@ -186,7 +187,7 @@ export const CreateUserForm: FC< ); })} - { ); }); + it("renders the password validation error", async () => { + server.use( + http.post("/api/v2/users/validate-password", () => { + return HttpResponse.json({ + valid: false, + details: "Password is too short", + }); + }), + ); + + renderWithRouter( + createMemoryRouter( + [ + { + path: "/setup", + element: , + }, + ], + { initialEntries: ["/setup"] }, + ), + ); + await waitForLoaderToBeRemoved(); + await fillForm({ password: "short" }); + await waitFor(() => screen.findByText("Password is too short")); + }); + it("redirects to the app when setup is successful", async () => { let userHasBeenCreated = false; @@ -99,6 +125,7 @@ describe("Setup Page", () => { await fillForm(); await waitFor(() => screen.findByText("Templates")); }); + it("calls sendBeacon with telemetry", async () => { const sendBeacon = jest.fn(); Object.defineProperty(window.navigator, "sendBeacon", { diff --git a/site/src/pages/SetupPage/SetupPage.tsx b/site/src/pages/SetupPage/SetupPage.tsx index ed07534919481..100c02e21334e 100644 --- a/site/src/pages/SetupPage/SetupPage.tsx +++ b/site/src/pages/SetupPage/SetupPage.tsx @@ -3,7 +3,7 @@ import { createFirstUser } from "api/queries/users"; import { Loader } from "components/Loader/Loader"; import { useAuthContext } from "contexts/auth/AuthProvider"; import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; -import { type FC, useEffect } from "react"; +import { type FC, useEffect, useState } from "react"; import { Helmet } from "react-helmet-async"; import { useMutation, useQuery } from "react-query"; import { Navigate, useNavigate } from "react-router-dom"; @@ -24,6 +24,7 @@ export const SetupPage: FC = () => { const { metadata } = useEmbeddedMetadata(); const buildInfoQuery = useQuery(buildInfo(metadata["build-info"])); const navigate = useNavigate(); + useEffect(() => { if (!buildInfoQuery.data) { return; diff --git a/site/src/pages/SetupPage/SetupPageView.tsx b/site/src/pages/SetupPage/SetupPageView.tsx index a4b0536ae0b85..d76a55924aa63 100644 --- a/site/src/pages/SetupPage/SetupPageView.tsx +++ b/site/src/pages/SetupPage/SetupPageView.tsx @@ -10,10 +10,12 @@ import { isAxiosError } from "axios"; import { Alert, AlertDetail } from "components/Alert/Alert"; import { FormFields, VerticalForm } from "components/Form/Form"; import { CoderIcon } from "components/Icons/CoderIcon"; +import { PasswordField } from "components/PasswordField/PasswordField"; import { SignInLayout } from "components/SignInLayout/SignInLayout"; import { Stack } from "components/Stack/Stack"; import { type FormikContextType, useFormik } from "formik"; import type { FC } from "react"; +import { useEffect } from "react"; import { docs } from "utils/docs"; import { getFormHelpers, @@ -33,7 +35,6 @@ export const Language = { passwordRequired: "Please enter a password.", create: "Create account", welcomeMessage: <>Welcome to Coder, - firstNameLabel: "First name", lastNameLabel: "Last name", companyLabel: "Company", @@ -167,13 +168,11 @@ export const SetupPageView: FC = ({ fullWidth label={Language.emailLabel} /> -