303 lines
10 KiB
Go
303 lines
10 KiB
Go
package controllers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"nearle/middleware"
|
|
"nearle/models"
|
|
"nearle/services"
|
|
"nearle/utils"
|
|
|
|
fiberv1 "github.com/gofiber/fiber"
|
|
"github.com/gofiber/fiber/v2"
|
|
)
|
|
|
|
/*
|
|
Setting a first password, with no session and no way to get one.
|
|
|
|
A branch login created by `createtenantlocation` arrives with an empty password.
|
|
The console signs in, is told to set one, and does — and until now it did that
|
|
through `PUT /users/update`, which is behind the session guard. Once
|
|
WEB_AUTH_REQUIRED began defaulting on, that answered
|
|
|
|
401 "a session token is required; sign in again"
|
|
|
|
to somebody who could not sign in, because signing in needs the password they
|
|
were trying to set. Every such account was unusable, and the 401 read as an
|
|
authentication bug rather than a deadlock.
|
|
|
|
`publicWebPaths` had named `/users/setpassword` since the guard was written. The
|
|
path was reserved; the handler never existed, so it answered 404.
|
|
|
|
The tests that matter are about the two halves: it must be reachable WITHOUT a
|
|
session, and it must refuse everything except the one case it exists for.
|
|
*/
|
|
|
|
type fakePasswords struct {
|
|
// set records what reached the write, so a refusal can be shown to have
|
|
// refused rather than merely reported.
|
|
set []string
|
|
lastUserid int
|
|
refuseIt error
|
|
}
|
|
|
|
func (f *fakePasswords) SetInitialPassword(userid int, password string) error {
|
|
f.lastUserid = userid
|
|
if f.refuseIt != nil {
|
|
return f.refuseIt
|
|
}
|
|
f.set = append(f.set, password)
|
|
return nil
|
|
}
|
|
|
|
// The rest of UserService, unused here.
|
|
func (f *fakePasswords) GetAllUsers(int, int, int, int, string) ([]models.UserInfo, error) {
|
|
return nil, nil
|
|
}
|
|
func (f *fakePasswords) GetUserByID(int) (models.UserInfo, error) { return models.UserInfo{}, nil }
|
|
func (f *fakePasswords) Login(models.User) (models.UserInfo, error) {
|
|
return models.UserInfo{}, nil
|
|
}
|
|
func (f *fakePasswords) TenantLogin(models.User) (models.TenantUserInfo, error) {
|
|
return models.TenantUserInfo{}, nil
|
|
}
|
|
func (f *fakePasswords) UpdateStaff(models.User) error { return nil }
|
|
func (f *fakePasswords) AppLogin(models.User) (models.TenantUserInfo, fiberv1.Map, error) {
|
|
return models.TenantUserInfo{}, fiberv1.Map{}, nil
|
|
}
|
|
func (f *fakePasswords) CreateUser(models.User) (models.UserInfo, services.InviteOutcome, error) {
|
|
return models.UserInfo{}, services.InviteOutcome{}, nil
|
|
}
|
|
|
|
func passwordApp(t *testing.T, service *fakePasswords) *fiber.App {
|
|
t.Helper()
|
|
t.Setenv("POS_TOKEN_SECRET", testSecret)
|
|
|
|
app := fiber.New()
|
|
// The real guard, mounted exactly as routes.go mounts it. The point of this
|
|
// file is which side of it this endpoint lands on.
|
|
app.Use("/live/api/v1/web", middleware.WebAuth(nil))
|
|
app.Post("/live/api/v1/web/users/setpassword", NewUserController(service).SetPassword)
|
|
app.Put("/live/api/v1/web/users/update", NewUserController(service).UpdateStaff)
|
|
|
|
return app
|
|
}
|
|
|
|
func send(t *testing.T, app *fiber.App, method, path, body string) (int, string) {
|
|
t.Helper()
|
|
|
|
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := app.Test(req, -1)
|
|
if err != nil {
|
|
t.Fatalf("%s %s: %v", method, path, err)
|
|
}
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
return resp.StatusCode, string(raw)
|
|
}
|
|
|
|
func TestAFirstPasswordCanBeSetWithoutASession(t *testing.T) {
|
|
// The whole point. There is no session to present and no way to obtain one.
|
|
service := &fakePasswords{}
|
|
app := passwordApp(t, service)
|
|
|
|
status, body := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
|
|
`{"token":"`+invite(t, 904)+`","password":"opensesame"}`)
|
|
|
|
if status == fiber.StatusUnauthorized {
|
|
t.Fatalf("the guard blocked the one call that cannot present a token: %s", body)
|
|
}
|
|
if status != fiber.StatusOK {
|
|
t.Fatalf("HTTP %d: %s", status, body)
|
|
}
|
|
if len(service.set) != 1 || service.set[0] != "opensesame" {
|
|
t.Fatalf("the password did not reach the service: %v", service.set)
|
|
}
|
|
}
|
|
|
|
func TestTheGeneralUpdateStaysBehindTheGuard(t *testing.T) {
|
|
// The reason this is a new endpoint rather than `/users/update` being
|
|
// opened up: that one writes whatever struct it is handed, so unauthenticated
|
|
// it would let anybody change any field of any user.
|
|
service := &fakePasswords{}
|
|
app := passwordApp(t, service)
|
|
|
|
status, body := send(t, app, "PUT", "/live/api/v1/web/users/update",
|
|
`{"userid":904,"roleid":1,"tenantid":9}`)
|
|
|
|
if status != fiber.StatusUnauthorized {
|
|
t.Fatalf("an untokened user update was not refused: %d %s", status, body)
|
|
}
|
|
}
|
|
|
|
func TestAnAccountThatAlreadyHasOneIsRefusedAsAConflict(t *testing.T) {
|
|
// 409, never 401. Nothing here is an authentication failure — the caller is
|
|
// not supposed to have a session — and a 401 would send the console into its
|
|
// sign-out-and-reload path on the one screen with nothing to sign out of.
|
|
service := &fakePasswords{refuseIt: errAlreadySet{}}
|
|
app := passwordApp(t, service)
|
|
|
|
status, body := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
|
|
`{"token":"`+invite(t, 904)+`","password":"opensesame"}`)
|
|
|
|
if status != fiber.StatusConflict {
|
|
t.Fatalf("expected 409, got %d: %s", status, body)
|
|
}
|
|
if len(service.set) != 0 {
|
|
t.Fatalf("a refused call still wrote: %v", service.set)
|
|
}
|
|
}
|
|
|
|
func TestTheRefusalDoesNotSayWhichAccountsExist(t *testing.T) {
|
|
// "No such user" and "already has a password" must read identically, or
|
|
// this becomes a way to ask whether a userid exists and whether it has been
|
|
// set up — unauthenticated, one request at a time.
|
|
service := &fakePasswords{refuseIt: errAlreadySet{}}
|
|
app := passwordApp(t, service)
|
|
|
|
_, body := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
|
|
`{"token":"`+invite(t, 904)+`","password":"opensesame"}`)
|
|
|
|
for _, leak := range []string{"not found", "no such", "does not exist"} {
|
|
if strings.Contains(strings.ToLower(body), leak) {
|
|
t.Fatalf("the refusal distinguishes a missing account: %s", body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAMalformedBodyIsRefusedWithoutPanicking(t *testing.T) {
|
|
app := passwordApp(t, &fakePasswords{})
|
|
|
|
status, _ := send(t, app, "POST", "/live/api/v1/web/users/setpassword", `{"userid":`)
|
|
if status != fiber.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d", status)
|
|
}
|
|
}
|
|
|
|
func TestTheAnswerIsTheEnvelopeTheConsoleUnwraps(t *testing.T) {
|
|
// A handler answering at the top level passes a service test and hands the
|
|
// console `undefined`.
|
|
_, body := send(t, passwordApp(t, &fakePasswords{}), "POST",
|
|
"/live/api/v1/web/users/setpassword", `{"token":"`+invite(t, 904)+`","password":"opensesame"}`)
|
|
|
|
var envelope struct {
|
|
Status bool `json:"status"`
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
if err := json.Unmarshal([]byte(body), &envelope); err != nil {
|
|
t.Fatalf("not an envelope: %s", body)
|
|
}
|
|
if !envelope.Status || envelope.Code != fiber.StatusOK {
|
|
t.Fatalf("success did not read as success: %s", body)
|
|
}
|
|
}
|
|
|
|
type errAlreadySet struct{}
|
|
|
|
func (errAlreadySet) Error() string {
|
|
return "that account cannot have its password set here — it may already have one"
|
|
}
|
|
|
|
func (f *fakePasswords) TenantWebLogin(models.User) (models.TenantUserInfo, map[string]interface{}) {
|
|
return models.TenantUserInfo{}, map[string]interface{}{}
|
|
}
|
|
func (f *fakePasswords) DeleteUser(int) error { return nil }
|
|
|
|
// invite mints a real invitation for the test's account.
|
|
//
|
|
// A helper rather than a literal, because the token is signed: a hand-written
|
|
// string would test the refusal path and nothing else, and the point of these
|
|
// is what happens when a genuine invitation arrives.
|
|
func invite(t *testing.T, userid int) string {
|
|
t.Helper()
|
|
token, _, err := utils.MintInviteToken(utils.InviteClaims{Userid: userid, Tenantid: 1147}, time.Now())
|
|
if err != nil {
|
|
t.Fatalf("minting an invitation: %v", err)
|
|
}
|
|
return token
|
|
}
|
|
|
|
/*
|
|
The invitation replaced a userid, and that was a security fix rather than a
|
|
tidy-up.
|
|
|
|
`applogin` answers a POST carrying an email and no password with 409 and the
|
|
userid, for any account that has not set one. So the recipe was: know a
|
|
merchant's primary email — usually printed on their shopfront — POST it, receive
|
|
their userid, set their password, own the business's admin account. No guessing
|
|
at any step, and the empty-password check was no defence because an un-set-up
|
|
account is exactly what such an attacker wants.
|
|
*/
|
|
|
|
func TestAUseridIsNoLongerEnoughToSetAPassword(t *testing.T) {
|
|
// The hole, asserted closed. A body carrying a userid and no invitation
|
|
// must not set anything, whatever the userid is.
|
|
service := &fakePasswords{}
|
|
app := passwordApp(t, service)
|
|
|
|
status, body := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
|
|
`{"userid":904,"password":"opensesame"}`)
|
|
|
|
if status == fiber.StatusOK {
|
|
t.Fatalf("a bare userid still set a password: %s", body)
|
|
}
|
|
if len(service.set) != 0 {
|
|
t.Fatalf("a bare userid reached the service: %v", service.set)
|
|
}
|
|
}
|
|
|
|
func TestAnInvitationSetsThePasswordForTheAccountItNames(t *testing.T) {
|
|
service := &fakePasswords{}
|
|
app := passwordApp(t, service)
|
|
|
|
status, body := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
|
|
`{"token":"`+invite(t, 904)+`","password":"opensesame"}`)
|
|
|
|
if status != fiber.StatusOK {
|
|
t.Fatalf("HTTP %d: %s", status, body)
|
|
}
|
|
if len(service.set) != 1 || service.set[0] != "opensesame" {
|
|
t.Fatalf("the password did not reach the service: %v", service.set)
|
|
}
|
|
}
|
|
|
|
func TestTheUseridComesFromTheSignatureNotTheRequest(t *testing.T) {
|
|
// An invitation for 904 with a `userid` field claiming 999 must set 904's
|
|
// password. If the body could override it, the token would be decoration.
|
|
service := &fakePasswords{}
|
|
app := passwordApp(t, service)
|
|
|
|
status, _ := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
|
|
`{"token":"`+invite(t, 904)+`","userid":999,"password":"opensesame"}`)
|
|
|
|
if status != fiber.StatusOK {
|
|
t.Fatalf("a valid invitation was refused: %d", status)
|
|
}
|
|
if service.lastUserid != 904 {
|
|
t.Fatalf("the request's userid won: set the password for %d", service.lastUserid)
|
|
}
|
|
}
|
|
|
|
func TestAForgedInvitationIsRefused(t *testing.T) {
|
|
service := &fakePasswords{}
|
|
app := passwordApp(t, service)
|
|
|
|
for _, token := range []string{"", "i1.forged.signature", "not-a-token", "w1.a.b"} {
|
|
status, _ := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
|
|
`{"token":"`+token+`","password":"opensesame"}`)
|
|
if status == fiber.StatusOK {
|
|
t.Fatalf("%q was accepted as an invitation", token)
|
|
}
|
|
}
|
|
if len(service.set) != 0 {
|
|
t.Fatalf("a forged invitation wrote: %v", service.set)
|
|
}
|
|
}
|