// SendResetPasswordEmail sends a password reset email to the user specified by the email addresss, containing a link with a signed token which can be visitied to initiate the password change/reset process. // This method DOES NOT check for client ID, redirect URL validity - it is expected that upstream users have already done so. // A link that can be used to reset the given user's password is returned. func (u *UserEmailer) SendResetPasswordEmail(email string, redirectURL url.URL, clientID string) (*url.URL, error) { usr, pwi, err := u.userPasswordInfo(email) if err != nil { return nil, err } passwordReset := user.NewPasswordReset(usr.ID, pwi.Password, u.issuerURL, clientID, redirectURL, u.tokenValidityWindow) token, err := u.signedClaimsToken(passwordReset.Claims) if err != nil { return nil, err } resetURL := u.passwordResetURL q := resetURL.Query() q.Set("token", token) resetURL.RawQuery = q.Encode() if u.emailer != nil { err = u.emailer.SendMail(u.fromAddress, "Reset Your Password", "password-reset", map[string]interface{}{ "email": usr.Email, "link": resetURL.String(), }, usr.Email) if err != nil { log.Errorf("error sending password reset email %v: ", err) } return nil, err } return &resetURL, nil }
// SendResetPasswordEmail sends a password reset email to the user specified by the email addresss, containing a link with a signed token which can be visitied to initiate the password change/reset process. // This method DOES NOT check for client ID, redirect URL validity - it is expected that upstream users have already done so. // If there is no emailer is configured, the URL of the aforementioned link is returned, otherwise nil is returned. func (u *UserEmailer) SendResetPasswordEmail(email string, redirectURL url.URL, clientID string) (*url.URL, error) { usr, err := u.ur.GetByEmail(nil, email) if err == user.ErrorNotFound { log.Errorf("No Such user for email: %q", email) return nil, err } if err != nil { log.Errorf("Error getting user: %q", err) return nil, err } pwi, err := u.pwi.Get(nil, usr.ID) if err == user.ErrorNotFound { // TODO(bobbyrullo): In this case, maybe send a different email explaining that // they don't have a local password. log.Errorf("No Password for userID: %q", usr.ID) return nil, err } if err != nil { log.Errorf("Error getting password: %q", err) return nil, err } signer, err := u.signerFn() if err != nil || signer == nil { log.Errorf("error getting signer: %v (%v)", err, signer) return nil, err } passwordReset := user.NewPasswordReset(usr, pwi.Password, u.issuerURL, clientID, redirectURL, u.tokenValidityWindow) jwt, err := jose.NewSignedJWT(passwordReset.Claims, signer) if err != nil { log.Errorf("error constructing or signing PasswordReset JWT: %v", err) return nil, err } token := jwt.Encode() resetURL := u.passwordResetURL q := resetURL.Query() q.Set("token", token) resetURL.RawQuery = q.Encode() if u.emailer != nil { err = u.emailer.SendMail(u.fromAddress, "Reset your password.", "password-reset", map[string]interface{}{ "email": usr.Email, "link": resetURL.String(), }, usr.Email) if err != nil { log.Errorf("error sending password reset email %v: ", err) } return nil, err } return &resetURL, nil }
func TestResetPasswordHandler(t *testing.T) { makeToken := func(userID, password, clientID string, callback url.URL, expires time.Duration, signer jose.Signer) string { pr := user.NewPasswordReset("ID-1", user.Password(password), testIssuerURL, clientID, callback, expires) jwt, err := jose.NewSignedJWT(pr.Claims, signer) if err != nil { t.Fatalf("couldn't make token: %q", err) } token := jwt.Encode() return token } goodSigner := key.NewPrivateKeySet([]*key.PrivateKey{testPrivKey}, time.Now().Add(time.Minute)).Active().Signer() badKey, err := key.GeneratePrivateKey() if err != nil { t.Fatalf("couldn't make new key: %q", err) } badSigner := key.NewPrivateKeySet([]*key.PrivateKey{badKey}, time.Now().Add(time.Minute)).Active().Signer() str := func(s string) []string { return []string{s} } user.PasswordHasher = func(s string) ([]byte, error) { return []byte(strings.ToUpper(s)), nil } defer func() { user.PasswordHasher = user.DefaultPasswordHasher }() tokenForCase := map[int]string{ 0: makeToken("ID-1", "password", testClientID, testRedirectURL, time.Hour*1, goodSigner), 2: makeToken("ID-1", "password", testClientID, url.URL{}, time.Hour*1, goodSigner), 5: makeToken("ID-1", "password", testClientID, url.URL{}, time.Hour*1, goodSigner), } tests := []struct { query url.Values method string wantFormValues *url.Values wantCode int wantPassword string }{ // Scenario 1: Happy Path { // Case 0 // Step 1.1 - User clicks link in email, has valid token. query: url.Values{ "token": str(tokenForCase[0]), }, method: "GET", wantCode: http.StatusOK, wantFormValues: &url.Values{ "password": str(""), "token": str(tokenForCase[0]), }, wantPassword: "******", }, { // Case 1 // Step 1.2 - User enters in new valid password, password is changed, user is redirected. query: url.Values{ "token": str(makeToken("ID-1", "password", testClientID, testRedirectURL, time.Hour*1, goodSigner)), "password": str("new_password"), }, method: "POST", wantCode: http.StatusSeeOther, wantFormValues: &url.Values{}, wantPassword: "******", }, // Scenario 2: Happy Path, but without redirect. { // Case 2 // Step 2.1 - User clicks link in email, has valid token. query: url.Values{ "token": str(tokenForCase[2]), }, method: "GET", wantCode: http.StatusOK, wantFormValues: &url.Values{ "password": str(""), "token": str(tokenForCase[2]), }, wantPassword: "******", }, { // Case 3 // Step 2.2 - User enters in new valid password, password is changed, user is redirected. query: url.Values{ "token": str(makeToken("ID-1", "password", testClientID, url.URL{}, time.Hour*1, goodSigner)), "password": str("new_password"), }, method: "POST", // no redirect wantCode: http.StatusOK, wantFormValues: &url.Values{}, wantPassword: "******", }, // Errors { // Case 4 // Step 1.1.1 - User clicks link in email, has invalid token. query: url.Values{ "token": str(makeToken("ID-1", "password", testClientID, testRedirectURL, time.Hour*1, badSigner)), }, method: "GET", wantCode: http.StatusBadRequest, wantFormValues: &url.Values{}, wantPassword: "******", }, { // Case 5 // Step 2.2.1 - User enters in new valid password, password is changed, no redirect query: url.Values{ "token": str(tokenForCase[5]), "password": str("shrt"), }, method: "POST", // no redirect wantCode: http.StatusBadRequest, wantFormValues: &url.Values{ "password": str(""), "token": str(tokenForCase[5]), }, wantPassword: "******", }, { // Case 6 // Step 2.2.2 - User enters in new valid password, with suspicious token. query: url.Values{ "token": str(makeToken("ID-1", "password", testClientID, url.URL{}, time.Hour*1, badSigner)), "password": str("shrt"), }, method: "POST", // no redirect wantCode: http.StatusBadRequest, wantFormValues: &url.Values{}, wantPassword: "******", }, { // Case 7 // Token lacking client id query: url.Values{ "token": str(makeToken("ID-1", "password", "", url.URL{}, time.Hour*1, goodSigner)), "password": str("shrt"), }, method: "GET", wantCode: http.StatusBadRequest, wantPassword: "******", }, { // Case 8 // Token lacking client id query: url.Values{ "token": str(makeToken("ID-1", "password", "", url.URL{}, time.Hour*1, goodSigner)), "password": str("shrt"), }, method: "POST", wantCode: http.StatusBadRequest, wantPassword: "******", }, } for i, tt := range tests { f, err := makeTestFixtures() if err != nil { t.Fatalf("case %d: could not make test fixtures: %v", i, err) } hdlr := ResetPasswordHandler{ tpl: f.srv.ResetPasswordTemplate, issuerURL: testIssuerURL, um: f.srv.UserManager, keysFunc: f.srv.KeyManager.PublicKeys, } w := httptest.NewRecorder() var req *http.Request u := testIssuerURL u.Path = httpPathResetPassword if tt.method == "POST" { req, err = http.NewRequest(tt.method, u.String(), strings.NewReader(tt.query.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } else { u.RawQuery = tt.query.Encode() req, err = http.NewRequest(tt.method, u.String(), nil) } if err != nil { t.Errorf("case %d: unable to form HTTP request: %v", i, err) } hdlr.ServeHTTP(w, req) if tt.wantCode != w.Code { t.Errorf("case %d: wantCode=%v, got=%v", i, tt.wantCode, w.Code) continue } values, err := html.FormValues("#resetPasswordForm", bytes.NewReader(w.Body.Bytes())) if err != nil { t.Errorf("case %d: could not parse form: %v", i, err) } if tt.wantFormValues != nil { if diff := pretty.Compare(*tt.wantFormValues, values); diff != "" { t.Errorf("case %d: Compare(wantFormValues, got) = %v", i, diff) } } pwi, err := f.srv.PasswordInfoRepo.Get(nil, "ID-1") if err != nil { t.Errorf("case %d: Error getting Password info: %v", i, err) } if tt.wantPassword != string(pwi.Password) { t.Errorf("case %d: wantPassword=%v, got=%v", i, tt.wantPassword, string(pwi.Password)) } } }