Skip to content

Commit 309d839

Browse files
committed
feat(password): WIP
1 parent cf31dde commit 309d839

File tree

7 files changed

+151
-71
lines changed

7 files changed

+151
-71
lines changed

coderd/coderd.go

+1
Original file line numberDiff line numberDiff line change
@@ -991,6 +991,7 @@ func New(options *Options) *API {
991991
r.Use(httpmw.RateLimit(options.LoginRateLimit, time.Minute))
992992
r.Post("/login", api.postLogin)
993993
r.Post("/otp/request", api.postRequestOneTimePasscode)
994+
r.Post("/validate-password", api.validateUserPassword)
994995
r.Post("/otp/change-password", api.postChangePasswordWithOneTimePasscode)
995996
r.Route("/oauth2", func(r chi.Router) {
996997
r.Route("/github", func(r chi.Router) {

coderd/userauth.go

+32
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,38 @@ func (api *API) postChangePasswordWithOneTimePasscode(rw http.ResponseWriter, r
435435
}
436436
}
437437

438+
// ValidateUserPassword validates the complexity of a user password and that it is secured enough.
439+
//
440+
// @Summary Validate the complexity of a user password
441+
// @ID validate-user-password
442+
// @Accept json
443+
// @Tags Authorization
444+
// @Param request body codersdk.ValidateUserPasswordRequest true "Validate user password request"
445+
// @Success 200 {object} codersdk.ValidateUserPasswordResponse
446+
// @Router /users/validate-password [post]
447+
func (api *API) validateUserPassword(rw http.ResponseWriter, r *http.Request) {
448+
var (
449+
ctx = r.Context()
450+
)
451+
452+
var req codersdk.ValidateUserPasswordRequest
453+
if !httpapi.Read(ctx, rw, r, &req) {
454+
return
455+
}
456+
457+
err := userpassword.Validate(req.Password)
458+
if err != nil {
459+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.ValidateUserPasswordResponse{
460+
Valid: false,
461+
})
462+
return
463+
}
464+
465+
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ValidateUserPasswordResponse{
466+
Valid: true,
467+
})
468+
}
469+
438470
// Authenticates the user with an email and password.
439471
//
440472
// @Summary Log in user

coderd/userpassword/userpassword_test.go

+55-66
Original file line numberDiff line numberDiff line change
@@ -13,72 +13,61 @@ import (
1313
"github.com/coder/coder/v2/coderd/userpassword"
1414
)
1515

16-
func TestUserPassword(t *testing.T) {
16+
func TestUserPasswordValidate(t *testing.T) {
1717
t.Parallel()
18+
tests := []struct {
19+
name string
20+
password string
21+
wantErr bool
22+
}{
23+
{"Invalid - Too short password", "pass", true},
24+
{"Invalid - Too long password", strings.Repeat("a", 65), true},
25+
{"Ok", "CorrectPassword", false},
26+
}
27+
28+
for _, tt := range tests {
29+
t.Run(tt.name, func(t *testing.T) {
30+
t.Parallel()
31+
err := userpassword.Validate(tt.password)
32+
if tt.wantErr {
33+
require.Error(t, err)
34+
} else {
35+
require.NoError(t, err)
36+
}
37+
})
38+
}
39+
}
1840

19-
t.Run("Invalid - Too short password", func(t *testing.T) {
20-
t.Parallel()
21-
err := userpassword.Validate("pass")
22-
require.Error(t, err)
23-
})
24-
25-
t.Run("Invalid - Too long password", func(t *testing.T) {
26-
t.Parallel()
27-
28-
var sb strings.Builder
29-
for i := 0; i < 65; i++ {
30-
sb.WriteString("a")
31-
}
32-
33-
err := userpassword.Validate(sb.String())
34-
require.Error(t, err)
35-
})
36-
37-
t.Run("Ok", func(t *testing.T) {
38-
t.Parallel()
39-
40-
err := userpassword.Validate("CorrectPassword")
41-
require.NoError(t, err)
42-
})
43-
44-
t.Run("Legacy", func(t *testing.T) {
45-
t.Parallel()
46-
// Ensures legacy v1 passwords function for v2.
47-
// This has is manually generated using a print statement from v1 code.
48-
equal, err := userpassword.Compare("$pbkdf2-sha256$65535$z8c1p1C2ru9EImBP1I+ZNA$pNjE3Yk0oG0PmJ0Je+y7ENOVlSkn/b0BEqqdKsq6Y97wQBq0xT+lD5bWJpyIKJqQICuPZcEaGDKrXJn8+SIHRg", "tomato")
49-
require.NoError(t, err)
50-
require.True(t, equal)
51-
})
52-
53-
t.Run("Same", func(t *testing.T) {
54-
t.Parallel()
55-
hash, err := userpassword.Hash("password")
56-
require.NoError(t, err)
57-
equal, err := userpassword.Compare(hash, "password")
58-
require.NoError(t, err)
59-
require.True(t, equal)
60-
})
61-
62-
t.Run("Different", func(t *testing.T) {
63-
t.Parallel()
64-
hash, err := userpassword.Hash("password")
65-
require.NoError(t, err)
66-
equal, err := userpassword.Compare(hash, "notpassword")
67-
require.NoError(t, err)
68-
require.False(t, equal)
69-
})
70-
71-
t.Run("Invalid", func(t *testing.T) {
72-
t.Parallel()
73-
equal, err := userpassword.Compare("invalidhash", "password")
74-
require.False(t, equal)
75-
require.Error(t, err)
76-
})
77-
78-
t.Run("InvalidParts", func(t *testing.T) {
79-
t.Parallel()
80-
equal, err := userpassword.Compare("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz", "test")
81-
require.False(t, equal)
82-
require.Error(t, err)
83-
})
41+
func TestUserPasswordCompare(t *testing.T) {
42+
tests := []struct {
43+
name string
44+
hash string
45+
password string
46+
wantErr bool
47+
wantEqual bool
48+
}{
49+
{"Legacy", "$pbkdf2-sha256$65535$z8c1p1C2ru9EImBP1I+ZNA$pNjE3Yk0oG0PmJ0Je+y7ENOVlSkn/b0BEqqdKsq6Y97wQBq0xT+lD5bWJpyIKJqQICuPZcEaGDKrXJn8+SIHRg", "tomato", false, true},
50+
{"Same", "", "password", false, true},
51+
{"Different", "", "password", false, false},
52+
{"Invalid", "invalidhash", "password", true, false},
53+
{"InvalidParts", "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz", "test", true, false},
54+
}
55+
56+
for _, tt := range tests {
57+
t.Run(tt.name, func(t *testing.T) {
58+
t.Parallel()
59+
if tt.hash == "" {
60+
hash, err := userpassword.Hash(tt.password)
61+
require.NoError(t, err)
62+
tt.hash = hash
63+
}
64+
equal, err := userpassword.Compare(tt.hash, tt.password)
65+
if tt.wantErr {
66+
require.Error(t, err)
67+
} else {
68+
require.NoError(t, err)
69+
}
70+
require.Equal(t, tt.wantEqual, equal)
71+
})
72+
}
8473
}

codersdk/users.go

+22
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,14 @@ type UpdateUserProfileRequest struct {
176176
Name string `json:"name" validate:"user_real_name"`
177177
}
178178

179+
type ValidateUserPasswordRequest struct {
180+
Password string `json:"password" validate:"required"`
181+
}
182+
183+
type ValidateUserPasswordResponse struct {
184+
Valid bool `json:"valid"`
185+
}
186+
179187
type UpdateUserAppearanceSettingsRequest struct {
180188
ThemePreference string `json:"theme_preference" validate:"required"`
181189
}
@@ -405,6 +413,20 @@ func (c *Client) UpdateUserProfile(ctx context.Context, user string, req UpdateU
405413
return resp, json.NewDecoder(res.Body).Decode(&resp)
406414
}
407415

416+
// ValidateUserPassword validates the complexity of a user password and that it is secured enough.
417+
func (c *Client) ValidateUserPassword(ctx context.Context, req ValidateUserPasswordRequest) (ValidateUserPasswordResponse, error) {
418+
res, err := c.Request(ctx, http.MethodPost, "/api/v2/users/validate-password", req)
419+
if err != nil {
420+
return ValidateUserPasswordResponse{}, err
421+
}
422+
defer res.Body.Close()
423+
if res.StatusCode != http.StatusOK {
424+
return ValidateUserPasswordResponse{}, ReadBodyAsError(res)
425+
}
426+
var resp ValidateUserPasswordResponse
427+
return resp, json.NewDecoder(res.Body).Decode(&resp)
428+
}
429+
408430
// UpdateUserStatus sets the user status to the given status
409431
func (c *Client) UpdateUserStatus(ctx context.Context, user string, status UserStatus) (User, error) {
410432
path := fmt.Sprintf("/api/v2/users/%s/status/", user)

site/src/api/api.ts

+7
Original file line numberDiff line numberDiff line change
@@ -1322,6 +1322,13 @@ class ApiMethods {
13221322
await this.axios.put(`/api/v2/users/${userId}/password`, updatePassword);
13231323
};
13241324

1325+
validateUserPassword = async (
1326+
password: string,
1327+
): Promise<boolean> => {
1328+
const response = await this.axios.post("/api/v2/users/validate-password", { password });
1329+
return response.data.isValid;
1330+
};
1331+
13251332
getRoles = async (): Promise<Array<TypesGen.AssignableRoles>> => {
13261333
const response = await this.axios.get<TypesGen.AssignableRoles[]>(
13271334
"/api/v2/users/roles",

site/src/pages/SetupPage/SetupPage.tsx

+14-1
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ import { createFirstUser } from "api/queries/users";
33
import { Loader } from "components/Loader/Loader";
44
import { useAuthContext } from "contexts/auth/AuthProvider";
55
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
6-
import { type FC, useEffect } from "react";
6+
import { type FC, useEffect, useState } from "react";
77
import { Helmet } from "react-helmet-async";
88
import { useMutation, useQuery } from "react-query";
99
import { Navigate, useNavigate } from "react-router-dom";
1010
import { pageTitle } from "utils/page";
1111
import { sendDeploymentEvent } from "utils/telemetry";
1212
import { SetupPageView } from "./SetupPageView";
13+
import { useDebouncedFunction } from "hooks/debounce";
14+
1315

1416
export const SetupPage: FC = () => {
1517
const {
@@ -24,6 +26,9 @@ export const SetupPage: FC = () => {
2426
const { metadata } = useEmbeddedMetadata();
2527
const buildInfoQuery = useQuery(buildInfo(metadata["build-info"]));
2628
const navigate = useNavigate();
29+
30+
const [isPasswordValid, setIsPasswordValid] = useState<boolean | null>(null);
31+
2732
useEffect(() => {
2833
if (!buildInfoQuery.data) {
2934
return;
@@ -47,12 +52,20 @@ export const SetupPage: FC = () => {
4752
return <Navigate to="/login" state={{ isRedirect: true }} replace />;
4853
}
4954

55+
const validateUserPassword = async (password: string) => {
56+
const isValid = await validateUserPassword(password);
57+
setIsPasswordValid(isValid);
58+
};
59+
60+
const { debounced: debouncedValidateUserPassword } = useDebouncedFunction(validateUserPassword, 500);
61+
5062
return (
5163
<>
5264
<Helmet>
5365
<title>{pageTitle("Set up your account")}</title>
5466
</Helmet>
5567
<SetupPageView
68+
onPasswordChange={validateUserPassword}
5669
isLoading={isSigningIn || createFirstUserMutation.isLoading}
5770
error={createFirstUserMutation.error}
5871
onSubmit={async (firstUser) => {

site/src/pages/SetupPage/SetupPageView.tsx

+20-4
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import {
2222
} from "utils/formUtils";
2323
import * as Yup from "yup";
2424
import { countries } from "./countries";
25+
import { useEffect, useState } from "react";
26+
import { debounce } from "lodash";
2527

2628
export const Language = {
2729
emailLabel: "Email",
@@ -30,8 +32,6 @@ export const Language = {
3032
usernameLabel: "Username",
3133
emailInvalid: "Please enter a valid email address.",
3234
emailRequired: "Please enter an email address.",
33-
passwordTooShort: "Password should be at least 6 characters.",
34-
passwordTooLong: "Password should be no more than 64 characters.",
3535
passwordRequired: "Please enter a password.",
3636
create: "Create account",
3737
welcomeMessage: <>Welcome to Coder</>,
@@ -57,8 +57,6 @@ const validationSchema = Yup.object({
5757
.email(Language.emailInvalid)
5858
.required(Language.emailRequired),
5959
password: Yup.string()
60-
.min(6, Language.passwordTooShort)
61-
.max(64, Language.passwordTooLong)
6260
.required(Language.passwordRequired),
6361
username: nameValidator(Language.usernameLabel),
6462
trial: Yup.bool(),
@@ -96,6 +94,14 @@ export const SetupPageView: FC<SetupPageViewProps> = ({
9694
error,
9795
isLoading,
9896
}) => {
97+
const [isPasswordValid, setIsPasswordValid] = useState<boolean | null>(null);
98+
99+
// Debounce function to validate password
100+
const validatePassword = debounce(async (password: string) => {
101+
const isValid = await validateUserPassword(password);
102+
setIsPasswordValid(isValid); // Update state based on response
103+
}, 500); // Adjust debounce time as needed
104+
99105
const form: FormikContextType<TypesGen.CreateFirstUserRequest> =
100106
useFormik<TypesGen.CreateFirstUserRequest>({
101107
initialValues: {
@@ -122,6 +128,14 @@ export const SetupPageView: FC<SetupPageViewProps> = ({
122128
error,
123129
);
124130

131+
useEffect(() => {
132+
if (form.values.password) {
133+
validatePassword(form.values.password); // Call the debounce function
134+
} else {
135+
setIsPasswordValid(null); // Reset validation state if password is empty
136+
}
137+
}, [form.values.password]); // Run effect when password changes
138+
125139
return (
126140
<SignInLayout>
127141
<header css={{ textAlign: "center", marginBottom: 32 }}>
@@ -179,6 +193,8 @@ export const SetupPageView: FC<SetupPageViewProps> = ({
179193
id="password"
180194
label={Language.passwordLabel}
181195
type="password"
196+
error={isPasswordValid === false} // Show error if password is invalid
197+
helperText={isPasswordValid === false ? "Password is not strong enough." : ""} // Provide feedback
182198
/>
183199
<label
184200
htmlFor="trial"

0 commit comments

Comments
 (0)