add tests for new password features
* reset link creation * own password change * setPasswordWithToken * also: move member handler funcs to own file
This commit is contained in:
parent
336596552e
commit
be35f154b7
|
@ -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 }
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -33,6 +33,9 @@ ErrorPageNotFound = "Die angeforderte Seite <strong> ({{.Path}}) </ strong> 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
|
||||
################
|
||||
|
|
|
@ -36,6 +36,9 @@ ErrorPageNotFound = "The requested page <strong>({{.Path}})</strong> 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"
|
||||
|
|
|
@ -84,12 +84,15 @@
|
|||
<label class="mt-10 mb-1 font-bold text-gray-400 text-sm">{{i18n "AdminMemberDetailsInitiatePasswordChange"}}</label>
|
||||
<a
|
||||
id="change-password"
|
||||
href="{{urlTo "members:change-password:form" "id" .Member.ID}}"
|
||||
href="{{urlTo "members:change-password:form"}}"
|
||||
class="mb-8 self-start shadow rounded px-3 py-1 text-yellow-600 ring-1 ring-yellow-400 bg-white hover:bg-yellow-600 hover:text-gray-100 focus:outline-none focus:ring-2 focus:ring-red-400 cursor-pointer"
|
||||
>{{i18n "AdminMemberDetailsChangePassword"}}</a>
|
||||
{{ else if member_is_elevated }}
|
||||
<label class="mt-10 mb-1 font-bold text-gray-400 text-sm">{{i18n "AdminMemberDetailsInitiatePasswordChange"}}</label>
|
||||
<form method="POST" action="{{urlTo "admin:members:create-password-reset-link"}}">
|
||||
<form
|
||||
id="create-reset-token"
|
||||
method="POST"
|
||||
action="{{urlTo "admin:members:create-password-reset-link"}}">
|
||||
{{ .csrfField }}
|
||||
<input type="hidden" name="member_id" value="{{.Member.ID}}">
|
||||
<input type="submit"
|
||||
|
|
|
@ -37,7 +37,10 @@
|
|||
</svg>
|
||||
<!-- TODO: i'd like to include the key SVG in the lnk but this breaks the layout -->
|
||||
<div class="text-green-500 text-sm truncate w-32">
|
||||
<a href="{{urlTo "admin:member:details" "id" $user.ID}}">{{$user.PubKey.Ref}}</a>
|
||||
<a
|
||||
id="own-details-page"
|
||||
href="{{urlTo "admin:member:details" "id" $user.ID}}"
|
||||
>{{$user.PubKey.Ref}}</a>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
|
|
|
@ -26,13 +26,20 @@ func Localized(t *testing.T, html *goquery.Document, elems []LocalizedElement) {
|
|||
}
|
||||
}
|
||||
|
||||
func CSRFTokenPresent(t *testing.T, sel *goquery.Selection) {
|
||||
// CSRFTokenPresent checks a CSRF token is in side the passed selection (ususally a form).
|
||||
// The function returns a url.Values map with the token, which can be used to craft further requests.
|
||||
func CSRFTokenPresent(t *testing.T, sel *goquery.Selection) url.Values {
|
||||
a := assert.New(t)
|
||||
csrfField := sel.Find("input[name='gorilla.csrf.Token']")
|
||||
a.EqualValues(1, csrfField.Length(), "no csrf-token input tag")
|
||||
tipe, ok := csrfField.Attr("type")
|
||||
a.True(ok, "csrf input has a type")
|
||||
a.Equal("hidden", tipe, "wrong type on csrf field")
|
||||
val, ok := csrfField.Attr("value")
|
||||
a.True(ok, "should have a value")
|
||||
return url.Values{
|
||||
"gorilla.csrf.Token": []string{val},
|
||||
}
|
||||
}
|
||||
|
||||
type FormElement struct {
|
||||
|
|
Loading…
Reference in New Issue