From be35f154b74b11385cd47567577175a0bb24f0b6 Mon Sep 17 00:00:00 2001 From: Henry Date: Wed, 12 May 2021 08:34:28 +0200 Subject: [PATCH] add tests for new password features * reset link creation * own password change * setPasswordWithToken * also: move member handler funcs to own file --- web/errors/badrequest.go | 21 ++- web/errors/errhandler.go | 4 + web/handlers/admin/members_test.go | 58 ++++++++ web/handlers/admin/setup_test.go | 3 + web/handlers/http.go | 113 +--------------- web/handlers/members_password.go | 156 +++++++++++++++++++++ web/handlers/members_password_test.go | 187 ++++++++++++++++++++++++++ web/i18n/defaults/active.de.toml | 3 + web/i18n/defaults/active.en.toml | 3 + web/templates/admin/member.tmpl | 7 +- web/templates/base.tmpl | 5 +- web/webassert/asserts.go | 9 +- 12 files changed, 451 insertions(+), 118 deletions(-) create mode 100644 web/handlers/members_password.go create mode 100644 web/handlers/members_password_test.go diff --git a/web/errors/badrequest.go b/web/errors/badrequest.go index 6e17188..c22e695 100644 --- a/web/errors/badrequest.go +++ b/web/errors/badrequest.go @@ -12,11 +12,16 @@ var ( ErrNotAuthorized = errors.New("rooms/web: not authorized") ErrDenied = errors.New("rooms: this key has been banned") - - ErrInsecurePassword = errors.New("room: password was found on the insecure password list of have-i-been-pwned") - ErrPasswordMissmatch = errors.New("room: the entered password did not match the repeated one") ) +// ErrGenericLocalized is used for one-off errors that primarily are presented for the user. +// The contained label is passed to the i18n engine for translation. +type ErrGenericLocalized struct{ Label string } + +func (err ErrGenericLocalized) Error() string { + return fmt.Sprintf("rooms/web: localized error (%s)", err.Label) +} + type ErrNotFound struct{ What string } func (nf ErrNotFound) Error() string { @@ -28,6 +33,10 @@ type ErrBadRequest struct { Details error } +func (err ErrBadRequest) Unwrap() error { + return err.Details +} + func (br ErrBadRequest) Error() string { return fmt.Sprintf("rooms/web: bad request error: %s", br.Details) } @@ -46,8 +55,12 @@ type ErrRedirect struct { Reason error } +func (err ErrRedirect) Unwrap() error { + return err.Reason +} + func (err ErrRedirect) Error() string { - return fmt.Sprintf("rooms/web: redirecting to: %s", err.Path) + return fmt.Sprintf("rooms/web: redirecting to: %s (reason: %s)", err.Path, err.Reason) } type PageNotFound struct{ Path string } diff --git a/web/errors/errhandler.go b/web/errors/errhandler.go index 17b3b2f..eed5372 100644 --- a/web/errors/errhandler.go +++ b/web/errors/errhandler.go @@ -94,6 +94,7 @@ func localizeError(ih *i18n.Localizer, err error) (int, template.HTML) { pnf PageNotFound br ErrBadRequest f ErrForbidden + gl ErrGenericLocalized ) code := http.StatusInternalServerError @@ -107,6 +108,9 @@ func localizeError(ih *i18n.Localizer, err error) (int, template.HTML) { case err == auth.ErrBadLogin: msg = ih.LocalizeSimple("ErrorAuthBadLogin") + case errors.As(err, &gl): + msg = ih.LocalizeSimple(gl.Label) + case errors.Is(err, roomdb.ErrNotFound): code = http.StatusNotFound msg = ih.LocalizeSimple("ErrorNotFound") diff --git a/web/handlers/admin/members_test.go b/web/handlers/admin/members_test.go index ac24bb0..36813e3 100644 --- a/web/handlers/admin/members_test.go +++ b/web/handlers/admin/members_test.go @@ -9,6 +9,7 @@ import ( "github.com/PuerkitoBio/goquery" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/ssb-ngi-pointer/go-ssb-room/roomdb" "github.com/ssb-ngi-pointer/go-ssb-room/web/router" @@ -363,3 +364,60 @@ func TestMembersRemove(t *testing.T) { webassert.HasFlashMessages(t, ts.Client, listURL, "ErrorNotFound") } + +func TestMembersCreateResetToken(t *testing.T) { + ts := newSession(t) + a := assert.New(t) + + // setup mock + + ts.MembersDB.GetByIDReturns(roomdb.Member{ + ID: 2342, + Role: roomdb.RoleMember, + PubKey: refs.FeedRef{ID: make([]byte, 32), Algo: refs.RefAlgoFeedSSB1}, + }, nil) + + urlViewDetails := ts.URLTo(router.AdminMemberDetails, "id", "2342") + + doc, resp := ts.Client.GetHTML(urlViewDetails) + a.Equal(http.StatusOK, resp.Code) + + form := doc.Find("#create-reset-token") + a.Equal(1, form.Length(), "form missing from page") + + formMethod, hasMethod := form.Attr("method") + a.True(hasMethod, "missing method") + a.Equal(http.MethodPost, formMethod, "wrong method") + + formAction, hasAction := form.Attr("action") + a.True(hasAction, "missing action") + + resetURL := ts.URLTo(router.AdminMembersCreateFallbackReset) + a.Equal(resetURL.String(), formAction, "wrong action") + + webassert.ElementsInForm(t, form, []webassert.FormElement{ + {Name: "member_id", Value: "2342", Type: "hidden"}, + }) + + // now create the reset link + + ts.User.Role = roomdb.RoleAdmin + + testToken := "super-secure-token" + ts.FallbackDB.CreateResetTokenReturns(testToken, nil) + + resp = ts.Client.PostForm(resetURL, url.Values{ + "member_id": []string{"2342"}, + // dont need to setup csrf on admin tests + }) + a.Equal(http.StatusOK, resp.Code) + + doc, err := goquery.NewDocumentFromReader(resp.Body) + require.NoError(t, err) + + gotResetURL, has := doc.Find("#password-reset-link").Attr("href") + a.True(has, "should have an href") + + wantResetURL := ts.URLTo(router.MembersChangePassword, "token", testToken) + a.Equal(wantResetURL.String(), gotResetURL) +} diff --git a/web/handlers/admin/setup_test.go b/web/handlers/admin/setup_test.go index e95d41f..017f91b 100644 --- a/web/handlers/admin/setup_test.go +++ b/web/handlers/admin/setup_test.go @@ -46,6 +46,7 @@ type testSession struct { AliasesDB *mockdb.FakeAliasesService ConfigDB *mockdb.FakeRoomConfig DeniedKeysDB *mockdb.FakeDeniedKeysService + FallbackDB *mockdb.FakeAuthFallbackService InvitesDB *mockdb.FakeInvitesService NoticeDB *mockdb.FakeNoticesService MembersDB *mockdb.FakeMembersService @@ -74,6 +75,7 @@ func newSession(t *testing.T) *testSession { ts.ConfigDB.GetPrivacyModeReturns(roomdb.ModeCommunity, nil) ts.ConfigDB.GetDefaultLanguageReturns("en", nil) ts.DeniedKeysDB = new(mockdb.FakeDeniedKeysService) + ts.FallbackDB = new(mockdb.FakeAuthFallbackService) ts.MembersDB = new(mockdb.FakeMembersService) ts.PinnedDB = new(mockdb.FakePinnedNoticesService) ts.NoticeDB = new(mockdb.FakeNoticesService) @@ -178,6 +180,7 @@ func newSession(t *testing.T) *testSession { locHelper, Databases{ Aliases: ts.AliasesDB, + AuthFallback: ts.FallbackDB, Config: ts.ConfigDB, DeniedKeys: ts.DeniedKeysDB, Members: ts.MembersDB, diff --git a/web/handlers/http.go b/web/handlers/http.go index 0892903..6bea756 100644 --- a/web/handlers/http.go +++ b/web/handlers/http.go @@ -13,7 +13,6 @@ import ( "github.com/go-kit/kit/log/level" "github.com/gorilla/csrf" "github.com/gorilla/sessions" - hibp "github.com/mattevans/pwned-passwords" "github.com/russross/blackfriday/v2" "go.mindeco.de/http/auth" "go.mindeco.de/http/render" @@ -233,10 +232,6 @@ func New( })), ) - // Init the have-i-been-pwned client for insecure password checks. - const storeExpiry = 1 * time.Hour - hibpClient := hibp.NewClient(storeExpiry) - // this router is a bit of a qurik // TODO: explain problem between gorilla/mux named routers and authentication mainMux := &http.ServeMux{} @@ -302,111 +297,9 @@ func New( ) mainMux.Handle("/admin/", members.AuthenticateFromContext(r)(adminHandler)) - m.Get(router.MembersChangePasswordForm).HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - resetToken := req.URL.Query().Get("token") - if members.FromContext(req.Context()) == nil && resetToken == "" { - r.Error(w, req, http.StatusUnauthorized, weberrs.ErrNotAuthorized) - return - } - - var pageData = make(map[string]interface{}) - pageData[csrf.TemplateTag] = csrf.TemplateField(req) - - pageData["Flashes"], err = flashHelper.GetAll(w, req) - if err != nil { - r.Error(w, req, http.StatusInternalServerError, err) - return - } - - pageData["ResetToken"] = resetToken - - err = r.Render(w, req, "change-member-password.tmpl", http.StatusOK, pageData) - if err != nil { - r.Error(w, req, http.StatusInternalServerError, err) - } - }) - - m.Get(router.MembersChangePassword).HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - var ( - ctx = req.Context() - memberID = int64(-1) - redirectURL = req.Header.Get("Referer") - - resetToken string - ) - - if redirectURL == "" { - http.Error(w, "TODO: add correct redirect handling", http.StatusInternalServerError) - return - } - - if req.Method != http.MethodPost { - r.Error(w, req, http.StatusBadRequest, fmt.Errorf("expected POST method")) - return - } - - err := req.ParseForm() - if err != nil { - r.Error(w, req, http.StatusBadRequest, err) - return - } - - resetToken = req.FormValue("reset-token") - if m := members.FromContext(ctx); m != nil { - memberID = m.ID - - // shouldn't have both token and logged in user - if resetToken != "" { - r.Error(w, req, http.StatusBadRequest, fmt.Errorf("can't have logged in user and reset-token present. Log out and try again")) - return - } - } - - // check the passwords match and it hasnt been pwned - repeat := req.FormValue("repeat-password") - newpw := req.FormValue("new-password") - - if newpw != repeat { - flashHelper.AddError(w, req, weberrs.ErrPasswordMissmatch) - http.Redirect(w, req, redirectURL, http.StatusSeeOther) - return - } - - if len(newpw) < 10 { - flashHelper.AddError(w, req, fmt.Errorf("password too short")) - http.Redirect(w, req, redirectURL, http.StatusSeeOther) - return - } - - isPwned, err := hibpClient.Pwned.Compromised(newpw) - if err != nil { - r.Error(w, req, http.StatusInternalServerError, fmt.Errorf("have-i-been-pwned client failed: %w", err)) - return - } - - if isPwned { - flashHelper.AddError(w, req, weberrs.ErrInsecurePassword) - http.Redirect(w, req, redirectURL, http.StatusSeeOther) - return - } - - // update the password - if resetToken == "" { - err = dbs.AuthFallback.SetPassword(ctx, memberID, []byte(newpw)) - } else { - err = dbs.AuthFallback.SetPasswordWithToken(ctx, resetToken, []byte(newpw)) - } - - // add flash msg about the outcome and redirect the user - if err != nil { - flashHelper.AddError(w, req, err) - } else { - flashHelper.AddMessage(w, req, "AuthFallbackPasswordUpdated") - } - - redirectURL = urlTo(router.AuthFallbackLogin).Path - http.Redirect(w, req, redirectURL, http.StatusSeeOther) - }) + var mh = newMembersHandler(netInfo.Development, r, urlTo, flashHelper, dbs.AuthFallback) + m.Get(router.MembersChangePasswordForm).HandlerFunc(r.HTML("change-member-password.tmpl", mh.changePasswordForm)) + m.Get(router.MembersChangePassword).HandlerFunc(mh.changePassword) // handle setting language m.Get(router.CompleteSetLanguage).HandlerFunc(func(w http.ResponseWriter, req *http.Request) { diff --git a/web/handlers/members_password.go b/web/handlers/members_password.go new file mode 100644 index 0000000..ed449ac --- /dev/null +++ b/web/handlers/members_password.go @@ -0,0 +1,156 @@ +package handlers + +import ( + "fmt" + "net/http" + "time" + + "github.com/gorilla/csrf" + hibp "github.com/mattevans/pwned-passwords" + "go.mindeco.de/http/render" + + "github.com/ssb-ngi-pointer/go-ssb-room/roomdb" + "github.com/ssb-ngi-pointer/go-ssb-room/web" + weberrs "github.com/ssb-ngi-pointer/go-ssb-room/web/errors" + "github.com/ssb-ngi-pointer/go-ssb-room/web/members" + "github.com/ssb-ngi-pointer/go-ssb-room/web/router" +) + +type membersHandler struct { + r *render.Renderer + urlTo web.URLMaker + fh *weberrs.FlashHelper + + authFallbackDB roomdb.AuthFallbackService + + leakedLookup func(string) (bool, error) +} + +func newMembersHandler(devMode bool, r *render.Renderer, urlTo web.URLMaker, fh *weberrs.FlashHelper, db roomdb.AuthFallbackService) membersHandler { + mh := membersHandler{ + r: r, + urlTo: urlTo, + fh: fh, + + authFallbackDB: db, + } + + // we dont want to need network for our tests. + if devMode { + mh.leakedLookup = func(_ string) (bool, error) { + return false, nil + } + } else { + // Init the have-i-been-pwned client for insecure password checks. + const storeExpiry = 1 * time.Hour + hibpClient := hibp.NewClient(storeExpiry) + + mh.leakedLookup = hibpClient.Pwned.Compromised + } + + return mh +} + +func (mh membersHandler) changePasswordForm(w http.ResponseWriter, req *http.Request) (interface{}, error) { + resetToken := req.URL.Query().Get("token") + if members.FromContext(req.Context()) == nil && resetToken == "" { + return nil, weberrs.ErrNotAuthorized + } + + // you can't do anything with a wrong/guessed token + + var pageData = make(map[string]interface{}) + pageData[csrf.TemplateTag] = csrf.TemplateField(req) + + var err error + pageData["Flashes"], err = mh.fh.GetAll(w, req) + if err != nil { + return nil, err + } + + pageData["ResetToken"] = resetToken + + return pageData, nil +} + +func (mh membersHandler) changePassword(w http.ResponseWriter, req *http.Request) { + var ( + ctx = req.Context() + memberID = int64(-1) + redirectURL = req.Header.Get("Referer") + + resetToken string + ) + + if redirectURL == "" { + http.Error(w, "TODO: add correct redirect handling", http.StatusInternalServerError) + return + } + + if req.Method != http.MethodPost { + mh.r.Error(w, req, http.StatusBadRequest, fmt.Errorf("expected POST method")) + return + } + + err := req.ParseForm() + if err != nil { + mh.r.Error(w, req, http.StatusBadRequest, err) + return + } + + resetToken = req.FormValue("reset-token") + if m := members.FromContext(ctx); m != nil { + memberID = m.ID + + // shouldn't have both token and logged in user + if resetToken != "" { + mh.r.Error(w, req, http.StatusBadRequest, fmt.Errorf("can't have logged in user and reset-token present. Log out and try again")) + return + } + } + + // check the passwords match and it hasnt been pwned + repeat := req.FormValue("repeat-password") + newpw := req.FormValue("new-password") + + if newpw != repeat { + mh.fh.AddError(w, req, weberrs.ErrGenericLocalized{Label: "ErrorPasswordDidntMatch"}) + http.Redirect(w, req, redirectURL, http.StatusSeeOther) + return + } + + if len(newpw) < 10 { + mh.fh.AddError(w, req, weberrs.ErrGenericLocalized{Label: "ErrorPasswordTooShort"}) + http.Redirect(w, req, redirectURL, http.StatusSeeOther) + return + } + + isPwned, err := mh.leakedLookup(newpw) + if err != nil { + mh.r.Error(w, req, http.StatusInternalServerError, fmt.Errorf("have-i-been-pwned client failed: %w", err)) + return + } + + if isPwned { + mh.fh.AddError(w, req, weberrs.ErrGenericLocalized{Label: "ErrorPasswordLeaked"}) + http.Redirect(w, req, redirectURL, http.StatusSeeOther) + return + } + + // update the password + if resetToken == "" { + err = mh.authFallbackDB.SetPassword(ctx, memberID, []byte(newpw)) + } else { + err = mh.authFallbackDB.SetPasswordWithToken(ctx, resetToken, []byte(newpw)) + } + + // add flash msg about the outcome and redirect the user + if err != nil { + mh.fh.AddError(w, req, err) + } else { + mh.fh.AddMessage(w, req, "AuthFallbackPasswordUpdated") + } + + redirectURL = mh.urlTo(router.AuthFallbackLogin).Path + http.Redirect(w, req, redirectURL, http.StatusSeeOther) +} diff --git a/web/handlers/members_password_test.go b/web/handlers/members_password_test.go new file mode 100644 index 0000000..9042fa3 --- /dev/null +++ b/web/handlers/members_password_test.go @@ -0,0 +1,187 @@ +package handlers + +import ( + "net/http" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/ssb-ngi-pointer/go-ssb-room/roomdb" + "github.com/ssb-ngi-pointer/go-ssb-room/web/router" + "github.com/ssb-ngi-pointer/go-ssb-room/web/webassert" +) + +func TestLoginAndChangePassword(t *testing.T) { + ts := setup(t) + a := assert.New(t) + + signInFormURL := ts.URLTo(router.AuthFallbackLogin) + + doc, resp := ts.Client.GetHTML(signInFormURL) + a.Equal(http.StatusOK, resp.Code) + + csrfCookie := resp.Result().Cookies() + a.True(len(csrfCookie) > 0, "should have one cookie for CSRF protection validation") + + passwordForm := doc.Find("#password-fallback") + webassert.CSRFTokenPresent(t, passwordForm) + + csrfTokenElem := passwordForm.Find("input[type=hidden]") + a.Equal(1, csrfTokenElem.Length()) + + csrfName, has := csrfTokenElem.Attr("name") + a.True(has, "should have a name attribute") + + csrfValue, has := csrfTokenElem.Attr("value") + a.True(has, "should have value attribute") + + loginVals := url.Values{ + "user": []string{"test"}, + "pass": []string{"test"}, + + csrfName: []string{csrfValue}, + } + ts.AuthFallbackDB.CheckReturns(int64(23), nil) + ts.MembersDB.GetByIDReturns(roomdb.Member{ID: 23}, nil) + + signInURL := ts.URLTo(router.AuthFallbackFinalize) + + // important for CSRF + var refererHeader = make(http.Header) + refererHeader.Set("Referer", "https://localhost") + ts.Client.SetHeaders(refererHeader) + + resp = ts.Client.PostForm(signInURL, loginVals) + a.Equal(http.StatusSeeOther, resp.Code, "wrong HTTP status code for sign in") + + a.Equal(1, ts.AuthFallbackDB.CheckCallCount()) + + // now request the protected dashboard page + dashboardURL := ts.URLTo(router.AdminDashboard) + + html, resp := ts.Client.GetHTML(dashboardURL) + if !a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code for dashboard") { + t.Log(html.Find("body").Text()) + } + + // check the link to the own details is there + gotDetailsPageURL, has := html.Find("#own-details-page").Attr("href") + a.True(has, "did not get href for own details page") + + wantDetailsPageURL := ts.URLTo(router.AdminMemberDetails, "id", "23") + a.Equal(wantDetailsPageURL.String(), gotDetailsPageURL) + + // check the details page has the link to change the password + html, resp = ts.Client.GetHTML(wantDetailsPageURL) + a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code for own details page") + + gotChangePasswordURL, has := html.Find("#change-password").Attr("href") + a.True(has, "did not get href for pw change page") + + wantChangePasswordURL := ts.URLTo(router.MembersChangePasswordForm) + a.Equal(wantChangePasswordURL.String(), gotChangePasswordURL) + + // query the form to assert the form and get a csrf token + html, resp = ts.Client.GetHTML(wantChangePasswordURL) + a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code for change password form") + + pwForm := html.Find("#change-password") + + postData := webassert.CSRFTokenPresent(t, pwForm) + + webassert.ElementsInForm(t, pwForm, []webassert.FormElement{ + {Name: "new-password", Type: "password"}, + {Name: "repeat-password", Type: "password"}, + }) + + // construct the password change request(s) + + testPassword := "our-super-secret-new-password" + + // first we make sure they need to match + postData.Set("new-password", testPassword) + postData.Set("repeat-password", testPassword+"-whoops") + resp = ts.Client.PostForm(wantChangePasswordURL, postData) + a.Equal(http.StatusSeeOther, resp.Code) // redirects back with a flash message + webassert.HasFlashMessages(t, ts.Client, wantChangePasswordURL, "ErrorPasswordDidntMatch") + a.Equal(0, ts.AuthFallbackDB.SetPasswordCallCount(), "shouldnt call database") + + // now check it can't be too short + postData.Set("new-password", "nope") + postData.Set("repeat-password", "nope") + resp = ts.Client.PostForm(wantChangePasswordURL, postData) + a.Equal(http.StatusSeeOther, resp.Code) + webassert.HasFlashMessages(t, ts.Client, wantChangePasswordURL, "ErrorPasswordTooShort") + a.Equal(0, ts.AuthFallbackDB.SetPasswordCallCount(), "shouldnt call database") + + // now check it goes through + postData.Set("new-password", testPassword) + postData.Set("repeat-password", testPassword) + resp = ts.Client.PostForm(wantChangePasswordURL, postData) + a.Equal(http.StatusSeeOther, resp.Code) + webassert.HasFlashMessages(t, ts.Client, wantChangePasswordURL, "AuthFallbackPasswordUpdated") + a.Equal(1, ts.AuthFallbackDB.SetPasswordCallCount(), "should have called the database") + _, mid, pw := ts.AuthFallbackDB.SetPasswordArgsForCall(0) + a.EqualValues(23, mid) + a.EqualValues(testPassword, pw) +} + +func TestChangePasswordWithToken(t *testing.T) { + ts := setup(t) + a := assert.New(t) + + testToken := "foo-bar" + changePasswordURL := ts.URLTo(router.MembersChangePasswordForm, "token", testToken) + + // query the form to assert the form and get a csrf token + html, resp := ts.Client.GetHTML(changePasswordURL) + a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code for change password form") + + pwForm := html.Find("#change-password") + + // important for CSRF + var refererHeader = make(http.Header) + refererHeader.Set("Referer", "https://localhost") + ts.Client.SetHeaders(refererHeader) + postData := webassert.CSRFTokenPresent(t, pwForm) + + webassert.ElementsInForm(t, pwForm, []webassert.FormElement{ + {Name: "new-password", Type: "password"}, + {Name: "repeat-password", Type: "password"}, + {Name: "reset-token", Type: "hidden", Value: testToken}, + }) + + // construct the password change request(s) + + postData.Set("reset-token", testToken) + + testPassword := "our-super-secret-new-password" + + // first we make sure they need to match + postData.Set("new-password", testPassword) + postData.Set("repeat-password", testPassword+"-whoops") + resp = ts.Client.PostForm(changePasswordURL, postData) + a.Equal(http.StatusSeeOther, resp.Code) // redirects back with a flash message + webassert.HasFlashMessages(t, ts.Client, changePasswordURL, "ErrorPasswordDidntMatch") + a.Equal(0, ts.AuthFallbackDB.SetPasswordWithTokenCallCount(), "shouldnt call database") + + // now check it can't be too short + postData.Set("new-password", "nope") + postData.Set("repeat-password", "nope") + resp = ts.Client.PostForm(changePasswordURL, postData) + a.Equal(http.StatusSeeOther, resp.Code) + webassert.HasFlashMessages(t, ts.Client, changePasswordURL, "ErrorPasswordTooShort") + a.Equal(0, ts.AuthFallbackDB.SetPasswordWithTokenCallCount(), "shouldnt call database") + + // now check it goes through + postData.Set("new-password", testPassword) + postData.Set("repeat-password", testPassword) + resp = ts.Client.PostForm(changePasswordURL, postData) + a.Equal(http.StatusSeeOther, resp.Code) + webassert.HasFlashMessages(t, ts.Client, changePasswordURL, "AuthFallbackPasswordUpdated") + a.Equal(1, ts.AuthFallbackDB.SetPasswordWithTokenCallCount(), "should have called the database") + _, gotTok, gotPassword := ts.AuthFallbackDB.SetPasswordWithTokenArgsForCall(0) + a.EqualValues(testPassword, gotPassword) + a.EqualValues(testToken, gotTok) +} diff --git a/web/i18n/defaults/active.de.toml b/web/i18n/defaults/active.de.toml index 14c70a9..98514a6 100644 --- a/web/i18n/defaults/active.de.toml +++ b/web/i18n/defaults/active.de.toml @@ -33,6 +33,9 @@ ErrorPageNotFound = "Die angeforderte Seite ({{.Path}}) ist ErrorNotAuthorized = "Sie sind nicht autorisiert auf diese Seite zuzugreifen." ErrorForbidden = "Die Anforderung konnte wegen fehlender Berechtigungen ({{.Details}}) nicht ausgeführt werden." ErrorBadRequest = "Bei Ihrer Anfrage ist ein Problem aufgetreten: {{.Where}} ({{.Details}}" +ErrorPasswordDidntMatch = "Die eingegebenen Passwörter sind nicht identisch." +ErrorPasswordTooShort = "Das neue Passwort ist zu kurz. Brauche mindestens 10 Zeichen." +ErrorPasswordLeaked = "Das neue Passwort wurde in der Liste der unsicheren Passwörter von have-i-been-pwned gefunden. Sie müssen ein anderes wählen." # authentication ################ diff --git a/web/i18n/defaults/active.en.toml b/web/i18n/defaults/active.en.toml index 707b65c..297eeb7 100644 --- a/web/i18n/defaults/active.en.toml +++ b/web/i18n/defaults/active.en.toml @@ -36,6 +36,9 @@ ErrorPageNotFound = "The requested page ({{.Path}}) is not ther ErrorNotAuthorized = "You are not authorized to access this page." ErrorForbidden = "The request could not be executed because of lacking privileges ({{.Details}})" ErrorBadRequest = "There was a problem with your Request: {{.Where}} ({{.Details}}" +ErrorPasswordDidntMatch = "The passwords you entered did not match." +ErrorPasswordTooShort = "The new password is to short. Need at least 10 characters." +ErrorPasswordLeaked = "The new password was found on the insecure password list of have-i-been-pwned. You need to choose a different one." # TODO: might be obsolete with notices LandingTitle = "ohai my room" diff --git a/web/templates/admin/member.tmpl b/web/templates/admin/member.tmpl index 96f3fa3..06c2fdf 100644 --- a/web/templates/admin/member.tmpl +++ b/web/templates/admin/member.tmpl @@ -84,12 +84,15 @@ {{i18n "AdminMemberDetailsChangePassword"}} {{ else if member_is_elevated }} -
+ {{ .csrfField }}