270 lines
8.3 KiB
Go
270 lines
8.3 KiB
Go
package public
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
|
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.sendico.io/tech/sxchnge/product/auth/config"
|
|
"git.sendico.io/tech/sxchnge/product/auth/request"
|
|
"git.sendico.io/tech/sxchnge/shared/errors"
|
|
"git.sendico.io/tech/sxchnge/shared/service"
|
|
"github.com/go-chi/chi/v5"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
func TestLoginRejectsUnknownClientID(t *testing.T) {
|
|
testRejectsUnknownClientID(t, "/login", `{
|
|
"login":{"login":"user@example.com","password":"correct horse battery staple"},
|
|
"clientId":"missing-client",
|
|
"deviceId":"device-1"
|
|
}`, 0, func(router *PublicRouter, req *http.Request) http.HandlerFunc {
|
|
return router.login(req)
|
|
})
|
|
}
|
|
|
|
func TestAPILoginRejectsUnknownClientID(t *testing.T) {
|
|
testRejectsUnknownClientID(t, "/login/api", `{
|
|
"login":{"login":"user@example.com","password":"correct horse battery staple"},
|
|
"clientId":"missing-client",
|
|
"deviceId":"device-1",
|
|
"clientSecret":"secret"
|
|
}`, 1, func(router *PublicRouter, req *http.Request) http.HandlerFunc {
|
|
return router.apiLogin(req)
|
|
})
|
|
}
|
|
|
|
func TestLoginAuthenticatesAgainstInjectedAccountStorage(t *testing.T) {
|
|
account := testActiveAccount(t)
|
|
unusedDB := &testAccountDB{err: merrors.ErrNoData}
|
|
activeDB := &testAccountDB{result: account}
|
|
router := &PublicRouter{
|
|
logger: zap.NewNop(),
|
|
db: activeDB,
|
|
service: mservice.Accounts,
|
|
}
|
|
|
|
got, failed, h := router.authenticateAccount(context.Background(), &srequest.Login{
|
|
LoginData: customermodel.LoginData{
|
|
UserDataBase: customermodel.UserDataBase{
|
|
Login: "user@example.com",
|
|
},
|
|
Password: "correct horse battery staple",
|
|
},
|
|
})
|
|
if h != nil {
|
|
t.Fatal("expected login authentication to succeed against injected account store")
|
|
}
|
|
if failed {
|
|
t.Fatal("successful login was classified as a credential failure")
|
|
}
|
|
if got == nil || got.Login != account.Login {
|
|
t.Fatalf("expected account %q, got %#v", account.Login, got)
|
|
}
|
|
if activeDB.getByEmailCalls != 1 {
|
|
t.Fatalf("expected one active account store lookup, got %d", activeDB.getByEmailCalls)
|
|
}
|
|
if unusedDB.getByEmailCalls != 0 {
|
|
t.Fatalf("expected no unused account store lookups, got %d", unusedDB.getByEmailCalls)
|
|
}
|
|
}
|
|
|
|
func testRejectsUnknownClientID(
|
|
t *testing.T,
|
|
path string,
|
|
body string,
|
|
wantLookups int,
|
|
handler func(*PublicRouter, *http.Request) http.HandlerFunc,
|
|
) {
|
|
t.Helper()
|
|
|
|
account := testActiveAccount(t)
|
|
accountDB := &testAccountDB{result: account}
|
|
router := &PublicRouter{
|
|
logger: zap.NewNop(),
|
|
db: accountDB,
|
|
rtdb: &testRefreshTokenDB{clientErr: merrors.ErrNoData},
|
|
service: mservice.Accounts,
|
|
}
|
|
|
|
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, path, strings.NewReader(body))
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler(router, req)(rec, req)
|
|
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code)
|
|
}
|
|
if accountDB.getByEmailCalls != wantLookups {
|
|
t.Fatalf("expected %d account lookups, got %d", wantLookups, accountDB.getByEmailCalls)
|
|
}
|
|
|
|
resp := decodeErrorResponse(t, rec.Body.Bytes())
|
|
if resp.Data.Details != "unknown client id" {
|
|
t.Fatalf("expected unknown client id message, got %q", resp.Data.Details)
|
|
}
|
|
}
|
|
|
|
func TestLoginRejectsPendingVerificationAccount(t *testing.T) {
|
|
account := testAccountWithStatus(t, customermodel.AccountPendingVerification)
|
|
router := &PublicRouter{
|
|
logger: zap.NewNop(),
|
|
db: &testAccountDB{result: account},
|
|
service: mservice.Accounts,
|
|
}
|
|
|
|
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/login", strings.NewReader(`{
|
|
"login":{"login":"user@example.com","password":"correct horse battery staple"}
|
|
}`))
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.login(req)(rec, req)
|
|
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected status %d, got %d", http.StatusForbidden, rec.Code)
|
|
}
|
|
resp := decodeErrorResponse(t, rec.Body.Bytes())
|
|
if resp.Data.Details != "Account verification required" {
|
|
t.Fatalf("expected account verification required message, got %q", resp.Data.Details)
|
|
}
|
|
}
|
|
|
|
func TestLoginRejectsBlockedAccount(t *testing.T) {
|
|
account := testAccountWithStatus(t, customermodel.AccountBlocked)
|
|
router := &PublicRouter{
|
|
logger: zap.NewNop(),
|
|
db: &testAccountDB{result: account},
|
|
service: mservice.Accounts,
|
|
}
|
|
|
|
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/login", strings.NewReader(`{
|
|
"login":{"login":"user@example.com","password":"correct horse battery staple"}
|
|
}`))
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.login(req)(rec, req)
|
|
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected status %d, got %d", http.StatusForbidden, rec.Code)
|
|
}
|
|
resp := decodeErrorResponse(t, rec.Body.Bytes())
|
|
if resp.Data.Details != "Account verification required" {
|
|
t.Fatalf("expected account verification required message, got %q", resp.Data.Details)
|
|
}
|
|
}
|
|
|
|
func TestAPILoginAllowsLoginAgainAfterRevokeSameSession(t *testing.T) {
|
|
account := testActiveAccount(t)
|
|
clientID := "api-client"
|
|
deviceID := "device-1"
|
|
|
|
rtdb := &testRefreshTokenDB{
|
|
client: &sessionmodel.Client{
|
|
ClientID: clientID,
|
|
ClientSecret: "secret",
|
|
GrantTypes: []string{"password"},
|
|
TokenEndpointAuthMethod: "client_secret_post",
|
|
AccountRef: account.GetID(),
|
|
},
|
|
}
|
|
|
|
var cfg middleware.TokenConfig
|
|
cfg.Length = 16
|
|
cfg.Expiration.Account = 1
|
|
cfg.Expiration.Refresh = 24
|
|
|
|
signature := middleware.Signature{
|
|
Algorithm: "HS256",
|
|
PrivateKey: []byte("test-secret"),
|
|
}
|
|
|
|
router := NewRouter(
|
|
zap.NewNop(),
|
|
"/",
|
|
&testAccountDB{result: account},
|
|
nil,
|
|
rtdb,
|
|
nil,
|
|
chi.NewRouter(),
|
|
&cfg,
|
|
&signature,
|
|
middleware.LoginRateLimits{},
|
|
)
|
|
|
|
body := `{
|
|
"login":{"login":"user@example.com","password":"correct horse battery staple"},
|
|
"clientId":"api-client",
|
|
"deviceId":"device-1",
|
|
"clientSecret":"secret"
|
|
}`
|
|
|
|
firstReq := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/login/api", strings.NewReader(body))
|
|
firstRec := httptest.NewRecorder()
|
|
router.apiLogin(firstReq)(firstRec, firstReq)
|
|
|
|
if firstRec.Code != http.StatusOK {
|
|
t.Fatalf("expected first login status %d, got %d", http.StatusOK, firstRec.Code)
|
|
}
|
|
firstResp := decodeLoginResponse(t, firstRec.Body.Bytes())
|
|
if firstResp.Data.RefreshToken.Token == "" {
|
|
t.Fatal("expected first login to return refresh token")
|
|
}
|
|
|
|
err := rtdb.Revoke(context.Background(), *account.GetID(), &sessionmodel.SessionIdentifier{
|
|
ClientID: clientID,
|
|
DeviceID: deviceID,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to revoke refresh token: %v", err)
|
|
}
|
|
|
|
secondReq := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/login/api", strings.NewReader(body))
|
|
secondRec := httptest.NewRecorder()
|
|
router.apiLogin(secondReq)(secondRec, secondReq)
|
|
|
|
if secondRec.Code != http.StatusOK {
|
|
t.Fatalf("expected second login status %d, got %d", http.StatusOK, secondRec.Code)
|
|
}
|
|
secondResp := decodeLoginResponse(t, secondRec.Body.Bytes())
|
|
if secondResp.Data.RefreshToken.Token == "" {
|
|
t.Fatal("expected second login to return refresh token")
|
|
}
|
|
if secondResp.Data.RefreshToken.Token == firstResp.Data.RefreshToken.Token {
|
|
t.Fatal("expected second login to issue a different refresh token after revocation")
|
|
}
|
|
|
|
_, err = rtdb.GetByCRT(context.Background(), &sessionmodel.ClientRefreshToken{
|
|
SessionIdentifier: sessionmodel.SessionIdentifier{
|
|
ClientID: clientID,
|
|
DeviceID: deviceID,
|
|
},
|
|
RefreshToken: firstResp.Data.RefreshToken.Token,
|
|
})
|
|
if !errors.Is(err, merrors.ErrNoData) {
|
|
t.Fatalf("expected old refresh token to be unusable, got %v", err)
|
|
}
|
|
|
|
_, err = rtdb.GetByCRT(context.Background(), &sessionmodel.ClientRefreshToken{
|
|
SessionIdentifier: sessionmodel.SessionIdentifier{
|
|
ClientID: clientID,
|
|
DeviceID: deviceID,
|
|
},
|
|
RefreshToken: secondResp.Data.RefreshToken.Token,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected new refresh token to be active, got %v", err)
|
|
}
|
|
|
|
if got := rtdb.tokenCountForSession(*account.GetID(), clientID, deviceID); got != 2 {
|
|
t.Fatalf("expected 2 refresh token records for session, got %d", got)
|
|
}
|
|
if got := rtdb.activeTokenCountForSession(*account.GetID(), clientID, deviceID); got != 1 {
|
|
t.Fatalf("expected 1 active refresh token for session, got %d", got)
|
|
}
|
|
}
|