You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

132 lines
5.6 KiB

package api
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"tera-clone/services/tera-api/internal/adapters/mock"
"tera-clone/services/tera-api/internal/authcaptcha"
"tera-clone/services/tera-api/internal/domain"
)
type captchaServiceStub struct {
createdFor string
verifiedFor string
verifiedID string
verifiedAns string
}
func (s *captchaServiceStub) Create(subject string) (authcaptcha.Challenge, error) {
s.createdFor = subject
return authcaptcha.Challenge{
ChallengeID: "challenge-1",
ImageDataURL: "data:image/png;base64,iVBORw0KGgo=",
ExpiresAt: fixedClock().Add(2 * time.Minute),
}, nil
}
func (s *captchaServiceStub) Verify(subject, challengeID, answer string) bool {
s.verifiedFor, s.verifiedID, s.verifiedAns = subject, challengeID, answer
return subject == "192.0.2.44" && challengeID == "challenge-1" && answer == "23456789"
}
func TestLoginPolicyDefaultsAndDisabledCaptchaEndpoint(t *testing.T) {
handler := NewRouter(mock.NewStore(fixedClock))
policy := getJSON(t, handler, http.MethodGet, "/api/v1/auth/login-policy", "")
if policy["captcha_enabled"] != false || policy["login_detection_minutes"] != float64(0) || policy["login_security_hit_count"] != float64(0) {
t.Fatalf("default login policy = %#v", policy)
}
if policy["fixed_safety_threshold"] != float64(5) || policy["fixed_safety_minutes"] != float64(15) {
t.Fatalf("fixed login safety policy = %#v", policy)
}
policyResponse := httptest.NewRecorder()
handler.ServeHTTP(policyResponse, httptest.NewRequest(http.MethodGet, "/api/v1/auth/login-policy", nil))
if policyResponse.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("login policy cache control=%q", policyResponse.Header().Get("Cache-Control"))
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/api/v1/auth/captcha", nil))
if response.Code != http.StatusConflict {
t.Fatalf("disabled captcha endpoint status=%d body=%s", response.Code, response.Body.String())
}
assertErrorCode(t, response, "captcha_disabled")
}
func TestEnabledCaptchaIsPeerBoundAndRequiredBeforeCredentialVerification(t *testing.T) {
store := mock.NewStore(fixedClock)
enabled, detection, hits := true, 9, 4
if _, err := store.UpdateManagementAccess(context.Background(), domain.ManagementAccessUpdate{
CaptchaEnabled: &enabled,
LoginDetectionMinutes: &detection,
LoginSecurityHitCount: &hits,
}); err != nil {
t.Fatal(err)
}
if _, err := store.ApplyConfig(context.Background()); err != nil {
t.Fatal(err)
}
captcha := &captchaServiceStub{}
handler := NewRouterWithOptions(store, RouterOptions{CaptchaService: captcha})
policy := getJSON(t, handler, http.MethodGet, "/api/v1/auth/login-policy", "")
if policy["captcha_enabled"] != true || policy["login_detection_minutes"] != float64(9) || policy["login_security_hit_count"] != float64(4) {
t.Fatalf("applied login policy = %#v", policy)
}
challengeResponse := httptest.NewRecorder()
challengeRequest := httptest.NewRequest(http.MethodPost, "/api/v1/auth/captcha", nil)
challengeRequest.RemoteAddr = "127.0.0.1:41000"
challengeRequest.Header.Set("X-Forwarded-For", "192.0.2.44")
handler.ServeHTTP(challengeResponse, challengeRequest)
if challengeResponse.Code != http.StatusOK || captcha.createdFor != "192.0.2.44" || !strings.Contains(challengeResponse.Body.String(), "challenge-1") {
t.Fatalf("challenge status=%d subject=%q body=%s", challengeResponse.Code, captcha.createdFor, challengeResponse.Body.String())
}
missingResponse := httptest.NewRecorder()
missingRequest := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"username":"admin","password":"mock-admin-secret"}`))
missingRequest.RemoteAddr = "127.0.0.1:41001"
missingRequest.Header.Set("X-Forwarded-For", "192.0.2.44")
handler.ServeHTTP(missingResponse, missingRequest)
if missingResponse.Code != http.StatusUnauthorized {
t.Fatalf("login without captcha status=%d body=%s", missingResponse.Code, missingResponse.Body.String())
}
assertErrorCode(t, missingResponse, "captcha_failed")
loginResponse := httptest.NewRecorder()
loginRequest := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"username":"admin","password":"mock-admin-secret","captcha_id":"challenge-1","captcha_answer":"23456789"}`))
loginRequest.RemoteAddr = "127.0.0.1:41002"
loginRequest.Header.Set("X-Forwarded-For", "192.0.2.44")
handler.ServeHTTP(loginResponse, loginRequest)
if loginResponse.Code != http.StatusOK {
t.Fatalf("login with captcha status=%d body=%s", loginResponse.Code, loginResponse.Body.String())
}
if captcha.verifiedFor != "192.0.2.44" || captcha.verifiedID != "challenge-1" || captcha.verifiedAns != "23456789" {
t.Fatalf("captcha verification = subject=%q id=%q answer=%q", captcha.verifiedFor, captcha.verifiedID, captcha.verifiedAns)
}
}
func TestCaptchaPeerSubjectTrustsOnlySingleAddressFromLoopbackGateway(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/", nil)
request.RemoteAddr = "127.0.0.1:1234"
request.Header.Set("X-Forwarded-For", "198.51.100.20")
if got := requestPeerSubject(request); got != "198.51.100.20" {
t.Fatalf("loopback gateway subject=%q", got)
}
request.Header.Set("X-Forwarded-For", "203.0.113.9, 198.51.100.20")
if got := requestPeerSubject(request); got != "127.0.0.1" {
t.Fatalf("forwarded chain subject=%q, want loopback peer", got)
}
request.RemoteAddr = "192.0.2.77:4321"
request.Header.Set("X-Forwarded-For", "198.51.100.20")
if got := requestPeerSubject(request); got != "192.0.2.77" {
t.Fatalf("direct peer subject=%q, want direct peer", got)
}
}