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:
Henry 2021-05-12 08:34:28 +02:00
parent 336596552e
commit be35f154b7
12 changed files with 451 additions and 118 deletions

View File

@ -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 }

View File

@ -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")

View File

@ -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)
}

View File

@ -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,

View File

@ -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) {

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
################

View File

@ -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"

View File

@ -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"

View File

@ -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

View File

@ -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 {