A wrong password or an unknown phone number was answered 900 (HTTP 500), like a server error, so clients could only tell them apart by the cause text. Both are now 304 (HTTP 400) from customer_auth_service with one cause, "invalid phone number or password", so a login does not tell which numbers have an account. A customer who never set a password gets 304 "customer not properly registered". Anything else stays 900. Login is limited per phone number: 5 attempts in 15 minutes, counted in Redis before the password is checked, so attempts sent at once all count, and for numbers without a customer too. The sixth is refused with 429 and data.locked_until, even with the right password, until the window ends. A successful login starts the count again. Since the number is normalized first, 0812… and 62812… count as one. When Redis fails, logins go on unlimited and the error is logged. There is no limit per IP: the client IP comes from X-Forwarded-For, which anyone can set while no trusted proxies are configured. Tested over HTTP with fakes; the Redis commands were checked against miniredis outside the repo, not against a real Redis. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
246 lines
8.5 KiB
Go
246 lines
8.5 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"apskel-pos-be/internal/entities"
|
|
applogger "apskel-pos-be/internal/logger"
|
|
"apskel-pos-be/internal/processor"
|
|
"apskel-pos-be/internal/service"
|
|
"apskel-pos-be/internal/validator"
|
|
)
|
|
|
|
// customerAuthRepoFake holds customers by phone number. Only the login lookup is used.
|
|
type customerAuthRepoFake struct {
|
|
customers map[string]*entities.Customer
|
|
err error
|
|
}
|
|
|
|
func (f *customerAuthRepoFake) GetCustomerByPhoneNumber(_ context.Context, phone string) (*entities.Customer, error) {
|
|
if f.err != nil {
|
|
return nil, f.err
|
|
}
|
|
return f.customers[phone], nil
|
|
}
|
|
|
|
func (f *customerAuthRepoFake) GetCustomerByID(context.Context, string) (*entities.Customer, error) {
|
|
return nil, nil
|
|
}
|
|
func (f *customerAuthRepoFake) CreateCustomer(context.Context, *entities.Customer) error { return nil }
|
|
func (f *customerAuthRepoFake) UpdateCustomer(context.Context, *entities.Customer) error { return nil }
|
|
func (f *customerAuthRepoFake) CheckPhoneNumberExists(context.Context, string) (bool, error) {
|
|
return false, nil
|
|
}
|
|
func (f *customerAuthRepoFake) SetCustomerPassword(context.Context, string, string) error {
|
|
return nil
|
|
}
|
|
func (f *customerAuthRepoFake) OrganizationExists(context.Context, uuid.UUID) (bool, error) {
|
|
return false, nil
|
|
}
|
|
func (f *customerAuthRepoFake) OrganizationIDs(context.Context, int) ([]uuid.UUID, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
// loginAttemptsFake counts attempts per phone number in one window that never ends.
|
|
type loginAttemptsFake struct {
|
|
counts map[string]int64
|
|
err error
|
|
}
|
|
|
|
func (f *loginAttemptsFake) Hit(_ context.Context, phone string, window time.Duration) (int64, time.Duration, error) {
|
|
if f.err != nil {
|
|
return 0, 0, f.err
|
|
}
|
|
f.counts[phone]++
|
|
return f.counts[phone], window, nil
|
|
}
|
|
|
|
func (f *loginAttemptsFake) Reset(_ context.Context, phone string) error {
|
|
delete(f.counts, phone)
|
|
return nil
|
|
}
|
|
|
|
const (
|
|
loginTestPassword = "rahasia123"
|
|
loginTestRegistered = "6281234561234"
|
|
loginTestUnfinished = "6281234569999"
|
|
)
|
|
|
|
type loginTest struct {
|
|
repo *customerAuthRepoFake
|
|
attempts *loginAttemptsFake
|
|
router *gin.Engine
|
|
}
|
|
|
|
func newLoginTest(t *testing.T) *loginTest {
|
|
t.Helper()
|
|
applogger.Setup("fatal", "json")
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(loginTestPassword), bcrypt.MinCost)
|
|
require.NoError(t, err)
|
|
hashStr := string(hash)
|
|
registered, unfinished := loginTestRegistered, loginTestUnfinished
|
|
birth := time.Date(2000, 1, 31, 0, 0, 0, 0, time.UTC)
|
|
lt := &loginTest{
|
|
repo: &customerAuthRepoFake{customers: map[string]*entities.Customer{
|
|
registered: {ID: uuid.New(), Name: "Budi", PhoneNumber: ®istered, BirthDate: &birth, PasswordHash: &hashStr},
|
|
unfinished: {ID: uuid.New(), Name: "Sari", PhoneNumber: &unfinished},
|
|
}},
|
|
attempts: &loginAttemptsFake{counts: map[string]int64{}},
|
|
}
|
|
h := NewCustomerAuthHandler(
|
|
service.NewCustomerAuthService(processor.NewCustomerAuthProcessor(lt.repo, lt.attempts, nil, nil, "test", 60)),
|
|
validator.NewCustomerAuthValidator(),
|
|
)
|
|
gin.SetMode(gin.TestMode)
|
|
lt.router = gin.New()
|
|
lt.router.POST("/customer-auth/login", h.Login)
|
|
return lt
|
|
}
|
|
|
|
func (lt *loginTest) login(t *testing.T, phone, password string) (int, map[string]any) {
|
|
t.Helper()
|
|
body, _ := json.Marshal(map[string]string{"phone_number": phone, "password": password})
|
|
rec := httptest.NewRecorder()
|
|
lt.router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/customer-auth/login", bytes.NewReader(body)))
|
|
var out map[string]any
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out))
|
|
return rec.Code, out
|
|
}
|
|
|
|
func firstLoginError(t *testing.T, out map[string]any) map[string]any {
|
|
t.Helper()
|
|
errs, ok := out["errors"].([]any)
|
|
require.True(t, ok, "errors: %v", out)
|
|
require.NotEmpty(t, errs)
|
|
return errs[0].(map[string]any)
|
|
}
|
|
|
|
func TestCustomerLoginRefusalsAreValidationErrors(t *testing.T) {
|
|
lt := newLoginTest(t)
|
|
|
|
for name, tc := range map[string]struct{ phone, password, cause string }{
|
|
"wrong password": {loginTestRegistered, "salah", "invalid phone number or password"},
|
|
"unknown phone": {"6280000000000", loginTestPassword, "invalid phone number or password"},
|
|
"registration unfinished": {loginTestUnfinished, loginTestPassword, "customer not properly registered"},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
status, out := lt.login(t, tc.phone, tc.password)
|
|
assert.Equal(t, http.StatusBadRequest, status)
|
|
e := firstLoginError(t, out)
|
|
assert.Equal(t, "304", e["code"])
|
|
assert.Equal(t, "customer_auth_service", e["entity"])
|
|
assert.Equal(t, tc.cause, e["cause"])
|
|
})
|
|
}
|
|
|
|
t.Run("right password", func(t *testing.T) {
|
|
status, out := lt.login(t, loginTestRegistered, loginTestPassword)
|
|
require.Equal(t, http.StatusOK, status, "%v", out)
|
|
inner := out["data"].(map[string]any)["data"].(map[string]any)
|
|
assert.NotEmpty(t, inner["access_token"])
|
|
})
|
|
|
|
t.Run("the number written another way finds the same customer", func(t *testing.T) {
|
|
for _, phone := range []string{"081234561234", "+62 812-3456-1234", "81234561234"} {
|
|
status, out := lt.login(t, phone, loginTestPassword)
|
|
require.Equal(t, http.StatusOK, status, "%s: %v", phone, out)
|
|
user := out["data"].(map[string]any)["data"].(map[string]any)["user"].(map[string]any)
|
|
assert.Equal(t, loginTestRegistered, user["phone_number"], phone)
|
|
}
|
|
})
|
|
|
|
t.Run("a number that is not an Indonesian mobile number", func(t *testing.T) {
|
|
status, out := lt.login(t, "021-1234567", loginTestPassword)
|
|
assert.Equal(t, http.StatusBadRequest, status)
|
|
e := firstLoginError(t, out)
|
|
assert.Equal(t, "304", e["code"])
|
|
assert.Equal(t, "invalid phone number format", e["cause"])
|
|
})
|
|
|
|
t.Run("a failing lookup is still a server error", func(t *testing.T) {
|
|
lt.repo.err = errors.New("connection refused")
|
|
defer func() { lt.repo.err = nil }()
|
|
status, out := lt.login(t, loginTestRegistered, loginTestPassword)
|
|
assert.Equal(t, http.StatusInternalServerError, status)
|
|
assert.Equal(t, "900", firstLoginError(t, out)["code"])
|
|
})
|
|
}
|
|
|
|
func TestCustomerLoginLocksAfterTooManyAttempts(t *testing.T) {
|
|
t.Run("the sixth attempt is refused, even with the right password", func(t *testing.T) {
|
|
lt := newLoginTest(t)
|
|
for i := 0; i < 5; i++ {
|
|
status, _ := lt.login(t, loginTestRegistered, "salah")
|
|
require.Equal(t, http.StatusBadRequest, status, "attempt %d", i+1)
|
|
}
|
|
status, out := lt.login(t, loginTestRegistered, loginTestPassword)
|
|
assert.Equal(t, http.StatusTooManyRequests, status)
|
|
e := firstLoginError(t, out)
|
|
assert.Equal(t, "429", e["code"])
|
|
assert.Equal(t, "customer_auth_service", e["entity"])
|
|
data, ok := out["data"].(map[string]any)
|
|
require.True(t, ok, "data: %v", out)
|
|
until, err := time.Parse(time.RFC3339, data["locked_until"].(string))
|
|
require.NoError(t, err)
|
|
assert.WithinDuration(t, time.Now().Add(15*time.Minute), until, time.Minute)
|
|
|
|
// Another number is not affected.
|
|
status, _ = lt.login(t, loginTestUnfinished, loginTestPassword)
|
|
assert.Equal(t, http.StatusBadRequest, status)
|
|
})
|
|
|
|
t.Run("writing the number another way counts toward the same limit", func(t *testing.T) {
|
|
lt := newLoginTest(t)
|
|
for _, phone := range []string{"081234561234", "+6281234561234", "81234561234", "0812-3456-1234", loginTestRegistered} {
|
|
lt.login(t, phone, "salah")
|
|
}
|
|
status, _ := lt.login(t, "081234561234", loginTestPassword)
|
|
assert.Equal(t, http.StatusTooManyRequests, status)
|
|
})
|
|
|
|
t.Run("unknown numbers lock the same way", func(t *testing.T) {
|
|
lt := newLoginTest(t)
|
|
for i := 0; i < 5; i++ {
|
|
lt.login(t, "6280000000000", "salah")
|
|
}
|
|
status, _ := lt.login(t, "6280000000000", "salah")
|
|
assert.Equal(t, http.StatusTooManyRequests, status)
|
|
})
|
|
|
|
t.Run("a successful login starts the count again", func(t *testing.T) {
|
|
lt := newLoginTest(t)
|
|
for i := 0; i < 4; i++ {
|
|
lt.login(t, loginTestRegistered, "salah")
|
|
}
|
|
status, _ := lt.login(t, loginTestRegistered, loginTestPassword)
|
|
require.Equal(t, http.StatusOK, status)
|
|
for i := 0; i < 5; i++ {
|
|
status, _ := lt.login(t, loginTestRegistered, "salah")
|
|
require.Equal(t, http.StatusBadRequest, status, "attempt %d after the login", i+1)
|
|
}
|
|
})
|
|
|
|
t.Run("logins go on when the counter is down", func(t *testing.T) {
|
|
lt := newLoginTest(t)
|
|
lt.attempts.err = errors.New("redis: connection refused")
|
|
for i := 0; i < 6; i++ {
|
|
lt.login(t, loginTestRegistered, "salah")
|
|
}
|
|
status, _ := lt.login(t, loginTestRegistered, loginTestPassword)
|
|
assert.Equal(t, http.StatusOK, status)
|
|
})
|
|
}
|