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")
|
ErrNotAuthorized = errors.New("rooms/web: not authorized")
|
||||||
|
|
||||||
ErrDenied = errors.New("rooms: this key has been banned")
|
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 }
|
type ErrNotFound struct{ What string }
|
||||||
|
|
||||||
func (nf ErrNotFound) Error() string {
|
func (nf ErrNotFound) Error() string {
|
||||||
|
@ -28,6 +33,10 @@ type ErrBadRequest struct {
|
||||||
Details error
|
Details error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (err ErrBadRequest) Unwrap() error {
|
||||||
|
return err.Details
|
||||||
|
}
|
||||||
|
|
||||||
func (br ErrBadRequest) Error() string {
|
func (br ErrBadRequest) Error() string {
|
||||||
return fmt.Sprintf("rooms/web: bad request error: %s", br.Details)
|
return fmt.Sprintf("rooms/web: bad request error: %s", br.Details)
|
||||||
}
|
}
|
||||||
|
@ -46,8 +55,12 @@ type ErrRedirect struct {
|
||||||
Reason error
|
Reason error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (err ErrRedirect) Unwrap() error {
|
||||||
|
return err.Reason
|
||||||
|
}
|
||||||
|
|
||||||
func (err ErrRedirect) Error() string {
|
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 }
|
type PageNotFound struct{ Path string }
|
||||||
|
|
|
@ -94,6 +94,7 @@ func localizeError(ih *i18n.Localizer, err error) (int, template.HTML) {
|
||||||
pnf PageNotFound
|
pnf PageNotFound
|
||||||
br ErrBadRequest
|
br ErrBadRequest
|
||||||
f ErrForbidden
|
f ErrForbidden
|
||||||
|
gl ErrGenericLocalized
|
||||||
)
|
)
|
||||||
|
|
||||||
code := http.StatusInternalServerError
|
code := http.StatusInternalServerError
|
||||||
|
@ -107,6 +108,9 @@ func localizeError(ih *i18n.Localizer, err error) (int, template.HTML) {
|
||||||
case err == auth.ErrBadLogin:
|
case err == auth.ErrBadLogin:
|
||||||
msg = ih.LocalizeSimple("ErrorAuthBadLogin")
|
msg = ih.LocalizeSimple("ErrorAuthBadLogin")
|
||||||
|
|
||||||
|
case errors.As(err, &gl):
|
||||||
|
msg = ih.LocalizeSimple(gl.Label)
|
||||||
|
|
||||||
case errors.Is(err, roomdb.ErrNotFound):
|
case errors.Is(err, roomdb.ErrNotFound):
|
||||||
code = http.StatusNotFound
|
code = http.StatusNotFound
|
||||||
msg = ih.LocalizeSimple("ErrorNotFound")
|
msg = ih.LocalizeSimple("ErrorNotFound")
|
||||||
|
|
|
@ -9,6 +9,7 @@ import (
|
||||||
|
|
||||||
"github.com/PuerkitoBio/goquery"
|
"github.com/PuerkitoBio/goquery"
|
||||||
"github.com/stretchr/testify/assert"
|
"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/roomdb"
|
||||||
"github.com/ssb-ngi-pointer/go-ssb-room/web/router"
|
"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")
|
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
|
AliasesDB *mockdb.FakeAliasesService
|
||||||
ConfigDB *mockdb.FakeRoomConfig
|
ConfigDB *mockdb.FakeRoomConfig
|
||||||
DeniedKeysDB *mockdb.FakeDeniedKeysService
|
DeniedKeysDB *mockdb.FakeDeniedKeysService
|
||||||
|
FallbackDB *mockdb.FakeAuthFallbackService
|
||||||
InvitesDB *mockdb.FakeInvitesService
|
InvitesDB *mockdb.FakeInvitesService
|
||||||
NoticeDB *mockdb.FakeNoticesService
|
NoticeDB *mockdb.FakeNoticesService
|
||||||
MembersDB *mockdb.FakeMembersService
|
MembersDB *mockdb.FakeMembersService
|
||||||
|
@ -74,6 +75,7 @@ func newSession(t *testing.T) *testSession {
|
||||||
ts.ConfigDB.GetPrivacyModeReturns(roomdb.ModeCommunity, nil)
|
ts.ConfigDB.GetPrivacyModeReturns(roomdb.ModeCommunity, nil)
|
||||||
ts.ConfigDB.GetDefaultLanguageReturns("en", nil)
|
ts.ConfigDB.GetDefaultLanguageReturns("en", nil)
|
||||||
ts.DeniedKeysDB = new(mockdb.FakeDeniedKeysService)
|
ts.DeniedKeysDB = new(mockdb.FakeDeniedKeysService)
|
||||||
|
ts.FallbackDB = new(mockdb.FakeAuthFallbackService)
|
||||||
ts.MembersDB = new(mockdb.FakeMembersService)
|
ts.MembersDB = new(mockdb.FakeMembersService)
|
||||||
ts.PinnedDB = new(mockdb.FakePinnedNoticesService)
|
ts.PinnedDB = new(mockdb.FakePinnedNoticesService)
|
||||||
ts.NoticeDB = new(mockdb.FakeNoticesService)
|
ts.NoticeDB = new(mockdb.FakeNoticesService)
|
||||||
|
@ -178,6 +180,7 @@ func newSession(t *testing.T) *testSession {
|
||||||
locHelper,
|
locHelper,
|
||||||
Databases{
|
Databases{
|
||||||
Aliases: ts.AliasesDB,
|
Aliases: ts.AliasesDB,
|
||||||
|
AuthFallback: ts.FallbackDB,
|
||||||
Config: ts.ConfigDB,
|
Config: ts.ConfigDB,
|
||||||
DeniedKeys: ts.DeniedKeysDB,
|
DeniedKeys: ts.DeniedKeysDB,
|
||||||
Members: ts.MembersDB,
|
Members: ts.MembersDB,
|
||||||
|
|
|
@ -13,7 +13,6 @@ import (
|
||||||
"github.com/go-kit/kit/log/level"
|
"github.com/go-kit/kit/log/level"
|
||||||
"github.com/gorilla/csrf"
|
"github.com/gorilla/csrf"
|
||||||
"github.com/gorilla/sessions"
|
"github.com/gorilla/sessions"
|
||||||
hibp "github.com/mattevans/pwned-passwords"
|
|
||||||
"github.com/russross/blackfriday/v2"
|
"github.com/russross/blackfriday/v2"
|
||||||
"go.mindeco.de/http/auth"
|
"go.mindeco.de/http/auth"
|
||||||
"go.mindeco.de/http/render"
|
"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
|
// this router is a bit of a qurik
|
||||||
// TODO: explain problem between gorilla/mux named routers and authentication
|
// TODO: explain problem between gorilla/mux named routers and authentication
|
||||||
mainMux := &http.ServeMux{}
|
mainMux := &http.ServeMux{}
|
||||||
|
@ -302,111 +297,9 @@ func New(
|
||||||
)
|
)
|
||||||
mainMux.Handle("/admin/", members.AuthenticateFromContext(r)(adminHandler))
|
mainMux.Handle("/admin/", members.AuthenticateFromContext(r)(adminHandler))
|
||||||
|
|
||||||
m.Get(router.MembersChangePasswordForm).HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
var mh = newMembersHandler(netInfo.Development, r, urlTo, flashHelper, dbs.AuthFallback)
|
||||||
resetToken := req.URL.Query().Get("token")
|
m.Get(router.MembersChangePasswordForm).HandlerFunc(r.HTML("change-member-password.tmpl", mh.changePasswordForm))
|
||||||
if members.FromContext(req.Context()) == nil && resetToken == "" {
|
m.Get(router.MembersChangePassword).HandlerFunc(mh.changePassword)
|
||||||
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)
|
|
||||||
})
|
|
||||||
|
|
||||||
// handle setting language
|
// handle setting language
|
||||||
m.Get(router.CompleteSetLanguage).HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
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."
|
ErrorNotAuthorized = "Sie sind nicht autorisiert auf diese Seite zuzugreifen."
|
||||||
ErrorForbidden = "Die Anforderung konnte wegen fehlender Berechtigungen ({{.Details}}) nicht ausgeführt werden."
|
ErrorForbidden = "Die Anforderung konnte wegen fehlender Berechtigungen ({{.Details}}) nicht ausgeführt werden."
|
||||||
ErrorBadRequest = "Bei Ihrer Anfrage ist ein Problem aufgetreten: {{.Where}} ({{.Details}}"
|
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
|
# authentication
|
||||||
################
|
################
|
||||||
|
|
|
@ -36,6 +36,9 @@ ErrorPageNotFound = "The requested page <strong>({{.Path}})</strong> is not ther
|
||||||
ErrorNotAuthorized = "You are not authorized to access this page."
|
ErrorNotAuthorized = "You are not authorized to access this page."
|
||||||
ErrorForbidden = "The request could not be executed because of lacking privileges ({{.Details}})"
|
ErrorForbidden = "The request could not be executed because of lacking privileges ({{.Details}})"
|
||||||
ErrorBadRequest = "There was a problem with your Request: {{.Where}} ({{.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
|
# TODO: might be obsolete with notices
|
||||||
LandingTitle = "ohai my room"
|
LandingTitle = "ohai my room"
|
||||||
|
|
|
@ -84,12 +84,15 @@
|
||||||
<label class="mt-10 mb-1 font-bold text-gray-400 text-sm">{{i18n "AdminMemberDetailsInitiatePasswordChange"}}</label>
|
<label class="mt-10 mb-1 font-bold text-gray-400 text-sm">{{i18n "AdminMemberDetailsInitiatePasswordChange"}}</label>
|
||||||
<a
|
<a
|
||||||
id="change-password"
|
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"
|
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>
|
>{{i18n "AdminMemberDetailsChangePassword"}}</a>
|
||||||
{{ else if member_is_elevated }}
|
{{ else if member_is_elevated }}
|
||||||
<label class="mt-10 mb-1 font-bold text-gray-400 text-sm">{{i18n "AdminMemberDetailsInitiatePasswordChange"}}</label>
|
<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 }}
|
{{ .csrfField }}
|
||||||
<input type="hidden" name="member_id" value="{{.Member.ID}}">
|
<input type="hidden" name="member_id" value="{{.Member.ID}}">
|
||||||
<input type="submit"
|
<input type="submit"
|
||||||
|
|
|
@ -37,7 +37,10 @@
|
||||||
</svg>
|
</svg>
|
||||||
<!-- TODO: i'd like to include the key SVG in the lnk but this breaks the layout -->
|
<!-- 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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<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)
|
a := assert.New(t)
|
||||||
csrfField := sel.Find("input[name='gorilla.csrf.Token']")
|
csrfField := sel.Find("input[name='gorilla.csrf.Token']")
|
||||||
a.EqualValues(1, csrfField.Length(), "no csrf-token input tag")
|
a.EqualValues(1, csrfField.Length(), "no csrf-token input tag")
|
||||||
tipe, ok := csrfField.Attr("type")
|
tipe, ok := csrfField.Attr("type")
|
||||||
a.True(ok, "csrf input has a type")
|
a.True(ok, "csrf input has a type")
|
||||||
a.Equal("hidden", tipe, "wrong type on csrf field")
|
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 {
|
type FormElement struct {
|
||||||
|
|
Loading…
Reference in New Issue