-
Notifications
You must be signed in to change notification settings - Fork 887
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
Conversation
All contributors have signed the CLA ✍️ ✅ |
07708bb
to
b93dc6b
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm in two minds about this change.
On the one hand, I agree that the entropy-based approach is obtuse and annoying, and that it is in some way the user's responsibility to have a minimum password security value. Also, if security is your top priority, password authentication is maybe not the best approach.
On the other hand, allowing folks to just use password
or abc123
feels wrong to me, and this seems like the kind of thing a security audit would find deeply wanting.
I considered the below rules:
- Mandate use of at least one character from each of the following classes:
[a-z]
[A-Z]
[0-9]
- Mandate minimum length 9
However this would immediately discount passwords consisting entirely of non-English υηι¢σ∂є ¢нαяα¢тєяѕ into account.
Here's an idea: given that the entropy calculation check is relatively cheap, would it be a terrible idea to expose an endpoint to validate this? The frontend could then consume this and show the entropy calculation transparently.
EDIT: alternative proposition: adjust the rule-based approach to
- At least one non-alphanumeric non-whitespace character (e.g.
[^a-zA-Z0-9\s]+
) - Minimum length of 9
I think the way you did it is fine, but I’d suggest trying to use Yup features to simplify it since we’re already using it. Reposting from Slack:
Sorry for the late message 🙏 |
Hey @BrunoQuaresma thanks for the review and validation on this piece of code. A bit more context can be found here but globally the idea is to keep frontend validation as small as possible - (probably just for the obvious things) and then have a debounced backend one for the more detailed validation - so we ensure consistent validation everywhere. I'll put the same kind of changes in all other password fields - so the PR will be fully reviewable. |
coderd/userpassword/userpassword.go
Outdated
err := passwordvalidator.Validate(password, 52) | ||
if err != nil { | ||
return err | ||
if len(password) < 6 { |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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:
- Expose a password complexity validation endpoint on the backend
- Modify the password validation logic on the FE to query the BE
- 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:
- At least one non-alphanumeric non-whitespace character (
[^a-zA-Z0-9\s]
) - 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.
43124c0
to
207d5cc
Compare
207d5cc
to
388a58b
Compare
PR is for me ready for a first review - both frontend and backend side. We'd just need to :
|
Based on conversation @johnstcn - I changed a bit the PR. We pushed back the validation of the password as a prerequisite. If we then want it to just be a hint, it will be done in another PR. Also we continue to use entropy-based validation as before. Edit : @BrunoQuaresma I am not sure yet how validation should be done for storybook ? |
Here are the steps that I would take:
You can see an example of a "test like this"here. Please, let me know if you this is helpful enough or if you have any additional questions 🙏 . |
277cb57
to
f33eac2
Compare
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Backed changes look good to me! @BrunoQuaresma could you take a look at the FE changes?
test("update password with invalid password", async () => { | ||
jest.spyOn(API, "updateUserPassword").mockRejectedValueOnce( | ||
mockApiError({ | ||
message: "Invalid password.", | ||
validations: [{ detail: "Invalid password.", field: "password" }], | ||
}), | ||
); | ||
|
||
const { user } = await renderPage(); | ||
fillAndSubmitSecurityForm(); | ||
|
||
const errorMessage = await screen.findAllByText("Invalid password."); | ||
expect(errorMessage).toBeDefined(); | ||
expect(errorMessage).toHaveLength(2); | ||
expect(API.updateUserPassword).toBeCalledTimes(1); | ||
expect(API.updateUserPassword).toBeCalledWith(user.id, newSecurityFormValues); | ||
}); | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we instead modify this test to spy on the validate user password endpoint.
@@ -1322,6 +1322,15 @@ class ApiMethods { | |||
await this.axios.put(`/api/v2/users/${userId}/password`, updatePassword); | |||
}; | |||
|
|||
validateUserPassword = async ( | |||
password: string, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we be using the ValidateUserPasswordRequest
interface here?
// typesGenerated.ts
// From codersdk/users.go
export interface ValidateUserPasswordRequest {
readonly password: string;
}
@@ -63,6 +63,8 @@ export const authMethodLanguage = { | |||
export interface CreateUserFormProps { | |||
onSubmit: (user: TypesGen.CreateUserRequestWithOrgs) => void; | |||
onCancel: () => void; | |||
onPasswordChange: (password: string) => void; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we can also use ValidateUserPasswordRequest
here
@@ -9,6 +9,7 @@ import type { | |||
UpdateUserProfileRequest, | |||
User, | |||
UsersRequest, | |||
ValidateUserPasswordRequest, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Where are we using this?
@@ -104,6 +114,10 @@ export const CreateUserForm: FC< | |||
error, | |||
); | |||
|
|||
useEffect(() => { | |||
onPasswordChange?.(form.values.password); | |||
}, [form.values.password, onPasswordChange]); // Run effect when password changes |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we want to avoid useEffect
as much as possible - it causes unexpected rerenders, which, decoupled with your debouncing function, are going to be difficult to troubleshoot. Instead, I suggest you add an onChange
attribute to the TextField
component used for the password input. Something like:
onChange={(e) => {
form.handleChange(e);
handlePasswordChange(e.target.value);
}}
If you search in the file you can see us using this pattern for other inputs.
@@ -17,6 +18,25 @@ export const CreateUserPage: FC = () => { | |||
const queryClient = useQueryClient(); | |||
const createUserMutation = useMutation(createUser(queryClient)); | |||
const authMethodsQuery = useQuery(authMethods()); | |||
const validatePasswordMutation = useMutation(validatePassword()); | |||
|
|||
const [passwordValidator, setPasswordValidator] = useState({ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a reason all this logic must live in the parent component instead of directly inside CreateUserForm
? I could be missing something and @BrunoQuaresma might be able to weigh in.
But if this logic can live in the form component itself, I think it should, for simplicity.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Scanning down through the PR, I see that we're duplicating this logic across several views. To avoid doing this, I think we should create a hook called useValidatePassword
or something similar, and then call that hook across these components.
I suggest pairing with @BrunoQuaresma or @aslilac if you need help here!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for taking a look at this user improvement and nice job committing some full stack code!
I left some comments on your FE changes. Might be easiest to hop in Discord with one of our UI engineers. LMK if you have any questions!
// @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) { |
There was a problem hiding this comment.
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.
@defelmnq what do you think about the changes? I would also appreciate a QA from you just to ensure I didn't miss any use cases. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I’m self-approving this for now to avoid blocking the PR any longer. If needed, I can take any follow-up actions later on. @defelmnq it is up to you when to merge this PR 🙏
#15382 (merged to main) fixes the errors in the |
Refers to #14984 Currently, password validation is done backend side and is not explicit enough so it can be painful to create first users. We'd like to make this validation easier - but also duplicate it frontend side to make it smoother. Flows involved : - First user set password - New user set password - Change password --------- Co-authored-by: BrunoQuaresma <bruno_nonato_quaresma@hotmail.com>
Refers to #14984
Currently, password validation is done backend side and is not explicit enough so it can be painful to create first users.
We'd like to make this validation easier - but also duplicate it frontend side to make it smoother.
Flows involved :