-
Notifications
You must be signed in to change notification settings - Fork 875
feat: implement api for "forgot password?" flow #14915
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
What does this look like in the settings? I know we want to avoid adding notification-specific functionality but I'm not sure that this should appear in a "Notification Preferences" table. Users probably shouldn't be able to disable the flow at all. |
It appears like the rest of the notifications. I definitely agree that a user shouldn't be able to disable it. I'm happy to look into stopping it from being displayed on this page. @dannykopping What're your thoughts on this? |
This case is easily fixable and diagnosable, so I think special-casing is not worth the extra complexity. |
To add some more context: In my experience, special-casing ends up rotting over time. I definitely understand the motivation here though @stirby, and it will indeed be quite a simple special-casing, so if you feel strongly about it then let's do it. I think the back-end should be responsibly for hiding that option, and it should be done based on the template ID (not the name). |
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 appreciate your security awareness here, and the methodical approach.
There are a few things to fix, but nothing major.
coderd/database/migrations/000260_notifications_forgot_password.up.sql
Outdated
Show resolved
Hide resolved
coderd/database/migrations/000260_notifications_forgot_password.up.sql
Outdated
Show resolved
Hide resolved
coderd/userauth.go
Outdated
|
||
if equal, _ = userpassword.Compare(string(user.HashedPassword), req.Password); equal { | ||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ | ||
Message: "New password cannot match old password.", |
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 curious: why not? If they're forgotten their password but have now somehow remembered it, do we have to force a change?
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 happy to go either way on this. The decision to do this was based on similar logic being performed on the putUserPassword
endpoint.
Lines 1048 to 1054 in 32a8df4
// Prevent users reusing their old password. | |
if match, _ := userpassword.Compare(string(user.HashedPassword), params.Password); match { | |
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ | |
Message: "New password cannot match old password.", | |
}) | |
return | |
} |
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.
WDYT @stirby?
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.
Did we come to a decision on this?
coderd/userauth.go
Outdated
return xerrors.Errorf("update user hashed password: %w", err) | ||
} | ||
|
||
//nolint:gocritic // We need to delete all API keys for the user. |
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.
Why? API keys are separate authorization contexts to user passwords.
For example, as an operator I might've created an API key to use in CI to do x
. It would be pretty weird and unexpected to invalidate that key if I forgot my personal password.
I'm not against this, but the reasons need to be carefully articulated and documented. When in doubt, follow the principle of least surprise.
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.
The putUserPassword
endpoint has this behaviour. My assumption is that we'd want the behaviour to match. Unfortunately there are no comments in the immediate area justifying the decision.
Line 977 in 32a8df4
func (api *API) putUserPassword(rw http.ResponseWriter, r *http.Request) { |
Lines 1074 to 1077 in 32a8df4
err = tx.DeleteAPIKeysByUserID(ctx, user.ID) | |
if err != nil { | |
return xerrors.Errorf("delete api keys by user ID: %w", err) | |
} |
I'm happy to have a discussion around whether this should stay or go.
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.
Hhmm, do we document this behaviour anywhere?
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.
If we do I am unable to find it.
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.
Given that this behaviour exists, we can't change it now - and I think that it'd be the expectation of operators for this to behave similarly.
I do think we should document this explicitly (could be done out-of-band of this PR), and raise an issue to clarify why we do this and possibly remove it for the reasons I outlined initially.
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.
@DanielleMaywood would you mind creating an issue for follow-up?
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.
Nice work! Had a few suggestions, random thoughts and one larger concern.
} | ||
aReq.Old = user | ||
|
||
passcode := uuid.New() |
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.
Side-note: I always feel weirded out using plain UUIDs for auth, but we do it elsewhere and UUIDv4 is supposedly OK. There are varying views on this though. I bring this up because I wonder if we should consider using something with more entropy in the future. 🤔
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.
(According to ChatGPT o1-preview) there are 122 bits of entropy in UUIDv4, isn't that enough for you? 😆
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.
Also worth noting, the UUIDv4 implementation we use does use a cryptographically secure RNG.
Line 122 in 764b0cc
github.com/google/uuid v1.6.0 |
https://github.com/google/uuid/blob/0e97ed3b537927cb4afea366bc4cc36f6eb37e75/uuid.go#L45
https://github.com/google/uuid/blob/0e97ed3b537927cb4afea366bc4cc36f6eb37e75/uuid.go#L9
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.
@dannykopping exactly, it's a few bits short. I believe 128 or more bits of entropy is the recommendation (required by RFC6749, for example). 😄
@DanielleMaywood yes, definitely worth noting 👍🏻
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.
Ha! TIL 🥲
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.
In that case, should we go for an alternative to using a UUID 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.
@mtojek and I discussed this and have decided that the current solution should be sufficient. The tokens are generated with a cryptographically secure RNG, the rate limiter is pretty strict (60req/s) and tokens do not last very long so the few bits short in entropy should be sufficient. We can always review this in the future if we decide a different approach should be taken.
} | ||
aReq.Old = user | ||
|
||
equal, err := userpassword.Compare(string(user.HashedOneTimePasscode), req.OneTimePasscode) |
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.
What happens here when you give a valid user email, but the token is null? And where do we verify the expiry of the token?
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.
Oh I've completely forgotten to check the expiry 🤦♀️ Thank you for spotting that. Will definitely get that added.
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've now added a test to verify that tokens do expire.
} | ||
|
||
//nolint:gocritic // We need the system auth context to be able to update the user's password. | ||
err = tx.UpdateUserHashedPassword(dbauthz.AsSystemRestricted(ctx), database.UpdateUserHashedPasswordParams{ |
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 wish we could do this without system context somehow, seems like a potential security risk.
|
||
notif := notifyEnq.Sent[0] | ||
require.NotEqual(t, notifications.TemplateUserRequestedOneTimePasscode, notif.TemplateID) | ||
}) |
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.
A few more test-cases could be good, like expired token and resetting the pw of a user that hasn't requested one.
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.
Can do!
} | ||
aReq.Old = user | ||
|
||
passcode := uuid.New() |
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.
(According to ChatGPT o1-preview) there are 122 bits of entropy in UUIDv4, isn't that enough for you? 😆
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.
Last to the party, but added a few cents 👍
Nice work, Danielle!
...otifications/testdata/rendered-templates/TemplateUserRequestedOneTimePasscode-body.md.golden
Show resolved
Hide resolved
...tifications/testdata/rendered-templates/TemplateUserRequestedOneTimePasscode-title.md.golden
Outdated
Show resolved
Hide resolved
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.
Perfect 👍 I think it is in good shape to merge, but let's wait for @mafredri 's approval as Danny is out.
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.
Looking great! Approving since I think the remaining stuff is pretty minor mainly documenting the reasoning and adding one more test.
...otifications/testdata/rendered-templates/TemplateUserRequestedOneTimePasscode-body.md.golden
Show resolved
Hide resolved
Great work @DanielleMaywood! |
Relates to #14232
This implements two endpoints (names subject to change):
/api/v2/users/otp/request
/api/v2/users/otp/change-password
They are both in the same group as
/api/v2/users/login
so they have the same rate limiting logic applied.