406 lines
11 KiB
Go
406 lines
11 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"cmroubao/backend-api/internal/domain"
|
|
"cmroubao/backend-api/internal/transport/authcommon"
|
|
"cmroubao/backend-api/internal/usecase"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
const (
|
|
testOpaqueToken = "YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE"
|
|
testCSRFOpaqueToken = "YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI"
|
|
)
|
|
|
|
type fakeBuyerTokenService struct {
|
|
command usecase.LoginBuyerDeviceCommand
|
|
result usecase.AccessTokenResult
|
|
err error
|
|
calls int
|
|
}
|
|
|
|
func (service *fakeBuyerTokenService) LoginBuyerDevice(
|
|
_ context.Context,
|
|
command usecase.LoginBuyerDeviceCommand,
|
|
) (usecase.AccessTokenResult, error) {
|
|
service.calls++
|
|
service.command = command
|
|
return service.result, service.err
|
|
}
|
|
|
|
func TestIssueBuyerTokenReturnsOnlyPublicIdentityAndAccessToken(t *testing.T) {
|
|
service := &fakeBuyerTokenService{
|
|
result: usecase.AccessTokenResult{
|
|
Token: testOpaqueToken,
|
|
ExpiresAt: time.Now().Add(time.Hour),
|
|
User: domain.User{
|
|
ID: "buyer-1",
|
|
Username: "buyer01",
|
|
Role: domain.UserRoleBuyer,
|
|
},
|
|
Device: domain.Device{
|
|
ID: "device-1",
|
|
IsEnabled: true,
|
|
},
|
|
},
|
|
}
|
|
router := newPublicAuthTestRouter(t, service)
|
|
body := `{
|
|
"username":"buyer01",
|
|
"password":"private-password",
|
|
"device_id":"device-1",
|
|
"device_token":"private-device-token",
|
|
"app_version":"0.1.0",
|
|
"android_version":"15"
|
|
}`
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/api/v1/auth/token",
|
|
strings.NewReader(body),
|
|
)
|
|
request.Header.Set("Content-Type", "application/json")
|
|
response := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(response, request)
|
|
|
|
if response.Code != http.StatusOK {
|
|
t.Fatalf("status/body = %d / %s", response.Code, response.Body)
|
|
}
|
|
if service.command.DeviceID != "device-1" ||
|
|
service.command.AppVersion != "0.1.0" ||
|
|
service.command.AndroidVersion != "15" ||
|
|
service.command.Password != "private-password" {
|
|
t.Fatalf("command = %+v", service.command)
|
|
}
|
|
var decoded map[string]any
|
|
if err := json.Unmarshal(response.Body.Bytes(), &decoded); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if decoded["access_token"] != testOpaqueToken ||
|
|
decoded["token_type"] != "Bearer" ||
|
|
decoded["expires_in"] != float64(3600) {
|
|
t.Fatalf("response = %#v", decoded)
|
|
}
|
|
responseText := response.Body.String()
|
|
for _, secret := range []string{
|
|
"private-password",
|
|
"private-device-token",
|
|
"android_version",
|
|
"app_version",
|
|
} {
|
|
if strings.Contains(responseText, secret) {
|
|
t.Fatalf("response leaked %q: %s", secret, responseText)
|
|
}
|
|
}
|
|
if response.Header().Get("Cache-Control") != "no-store" {
|
|
t.Fatalf("Cache-Control = %q", response.Header().Get("Cache-Control"))
|
|
}
|
|
}
|
|
|
|
func TestIssueBuyerTokenUsesGenericCredentialErrors(t *testing.T) {
|
|
service := &fakeBuyerTokenService{
|
|
err: &usecase.Error{
|
|
Kind: usecase.ErrorKindInvalid,
|
|
Code: "AUTH_VALIDATION_FAILED",
|
|
Message: "private validation detail",
|
|
Fields: map[string]string{"password": "private detail"},
|
|
},
|
|
}
|
|
router := newPublicAuthTestRouter(t, service)
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/api/v1/auth/token",
|
|
strings.NewReader(`{
|
|
"username":"buyer",
|
|
"password":"secret-value",
|
|
"device_id":"device",
|
|
"device_token":"token",
|
|
"app_version":"0.1",
|
|
"android_version":"15"
|
|
}`),
|
|
)
|
|
request.Header.Set("Content-Type", "application/json")
|
|
response := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(response, request)
|
|
|
|
if response.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status/body = %d / %s", response.Code, response.Body)
|
|
}
|
|
assertErrorCode(t, response, "AUTH_INVALID_CREDENTIALS")
|
|
for _, privateValue := range []string{
|
|
"secret-value",
|
|
"private validation detail",
|
|
"private detail",
|
|
} {
|
|
if strings.Contains(response.Body.String(), privateValue) {
|
|
t.Fatalf("error leaked %q", privateValue)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIssueBuyerTokenRateLimitSkipsAuthenticationWork(t *testing.T) {
|
|
service := &fakeBuyerTokenService{
|
|
err: &usecase.Error{
|
|
Kind: usecase.ErrorKindUnauthorized,
|
|
Code: "AUTH_INVALID_CREDENTIALS",
|
|
},
|
|
}
|
|
limiter, err := authcommon.NewAttemptLimiter(1, time.Minute, 10)
|
|
if err != nil {
|
|
t.Fatalf("NewAttemptLimiter() error = %v", err)
|
|
}
|
|
registrar, err := NewPublicAuthRegistrar(service, limiter)
|
|
if err != nil {
|
|
t.Fatalf("NewPublicAuthRegistrar() error = %v", err)
|
|
}
|
|
router := gin.New()
|
|
router.Use(requestIDMiddleware())
|
|
if err := registrar(router); err != nil {
|
|
t.Fatalf("register public auth: %v", err)
|
|
}
|
|
body := `{
|
|
"username":"buyer",
|
|
"password":"secret-value",
|
|
"device_id":"device",
|
|
"device_token":"token",
|
|
"app_version":"0.1",
|
|
"android_version":"15"
|
|
}`
|
|
requestToken := func() *httptest.ResponseRecorder {
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/api/v1/auth/token",
|
|
strings.NewReader(body),
|
|
)
|
|
request.Header.Set("Content-Type", "application/json")
|
|
response := httptest.NewRecorder()
|
|
router.ServeHTTP(response, request)
|
|
return response
|
|
}
|
|
|
|
if response := requestToken(); response.Code != http.StatusUnauthorized {
|
|
t.Fatalf("first status = %d", response.Code)
|
|
}
|
|
response := requestToken()
|
|
if response.Code != http.StatusTooManyRequests ||
|
|
response.Header().Get("Retry-After") == "" ||
|
|
!strings.Contains(response.Body.String(), `"code":"AUTH_RATE_LIMITED"`) ||
|
|
service.calls != 1 {
|
|
t.Fatalf(
|
|
"second status/retry/calls/body = %d / %q / %d / %s",
|
|
response.Code,
|
|
response.Header().Get("Retry-After"),
|
|
service.calls,
|
|
response.Body,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestAdminSessionMiddlewareSeparatesWebAPIAndCSRF(t *testing.T) {
|
|
authenticator := &stubAuthenticator{
|
|
adminPrincipal: domain.AuthPrincipal{
|
|
UserID: "admin-1",
|
|
Username: "admin",
|
|
Role: domain.UserRoleAdmin,
|
|
SessionID: "session-1",
|
|
},
|
|
}
|
|
router := gin.New()
|
|
router.Use(requestIDMiddleware())
|
|
protected := router.Group("")
|
|
protected.Use(requireAdminSession(authenticator))
|
|
protected.GET("/tasks/item", func(ctx *gin.Context) {
|
|
principal, ok := authcommon.Principal(ctx.Request.Context())
|
|
if !ok {
|
|
ctx.Status(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
ctx.String(http.StatusOK, principal.UserID)
|
|
})
|
|
protected.POST("/api/v1/tasks", func(ctx *gin.Context) {
|
|
ctx.Status(http.StatusNoContent)
|
|
})
|
|
|
|
webRequest := httptest.NewRequest(
|
|
http.MethodGet,
|
|
"/tasks/item?q=1",
|
|
nil,
|
|
)
|
|
webResponse := httptest.NewRecorder()
|
|
router.ServeHTTP(webResponse, webRequest)
|
|
if webResponse.Code != http.StatusSeeOther ||
|
|
webResponse.Header().Get("Location") !=
|
|
"/login?next=%2Ftasks%2Fitem%3Fq%3D1" {
|
|
t.Fatalf(
|
|
"web status/location = %d / %q",
|
|
webResponse.Code,
|
|
webResponse.Header().Get("Location"),
|
|
)
|
|
}
|
|
|
|
apiRequest := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/api/v1/tasks",
|
|
nil,
|
|
)
|
|
apiRequest.Header.Set("Authorization", "Bearer "+testOpaqueToken)
|
|
apiResponse := httptest.NewRecorder()
|
|
router.ServeHTTP(apiResponse, apiRequest)
|
|
if apiResponse.Code != http.StatusUnauthorized {
|
|
t.Fatalf("API status/body = %d / %s", apiResponse.Code, apiResponse.Body)
|
|
}
|
|
assertErrorCode(t, apiResponse, "ADMIN_SESSION_REQUIRED")
|
|
|
|
badCSRF := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/api/v1/tasks",
|
|
nil,
|
|
)
|
|
badCSRF.AddCookie(&http.Cookie{
|
|
Name: authcommon.AdminSessionCookieName,
|
|
Value: testOpaqueToken,
|
|
})
|
|
badCSRFResponse := httptest.NewRecorder()
|
|
router.ServeHTTP(badCSRFResponse, badCSRF)
|
|
if badCSRFResponse.Code != http.StatusForbidden {
|
|
t.Fatalf(
|
|
"CSRF status/body = %d / %s",
|
|
badCSRFResponse.Code,
|
|
badCSRFResponse.Body,
|
|
)
|
|
}
|
|
assertErrorCode(t, badCSRFResponse, "CSRF_INVALID")
|
|
|
|
goodRequest := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/api/v1/tasks",
|
|
nil,
|
|
)
|
|
goodRequest.AddCookie(&http.Cookie{
|
|
Name: authcommon.AdminSessionCookieName,
|
|
Value: testOpaqueToken,
|
|
})
|
|
goodRequest.AddCookie(&http.Cookie{
|
|
Name: authcommon.CSRFCookieName,
|
|
Value: testCSRFOpaqueToken,
|
|
})
|
|
goodRequest.Header.Set(authcommon.CSRFHeader, testCSRFOpaqueToken)
|
|
goodResponse := httptest.NewRecorder()
|
|
router.ServeHTTP(goodResponse, goodRequest)
|
|
if goodResponse.Code != http.StatusNoContent {
|
|
t.Fatalf("authenticated status = %d", goodResponse.Code)
|
|
}
|
|
}
|
|
|
|
func TestDeviceMiddlewareRejectsCookieAndAcceptsBuyerBearer(t *testing.T) {
|
|
authenticator := &stubAuthenticator{
|
|
devicePrincipal: domain.AuthPrincipal{
|
|
UserID: "buyer-1",
|
|
Username: "buyer",
|
|
Role: domain.UserRoleBuyer,
|
|
DeviceID: "device-1",
|
|
},
|
|
}
|
|
router := gin.New()
|
|
router.Use(requestIDMiddleware(), RequireDeviceAccess(authenticator))
|
|
router.GET("/api/v1/device-probe", func(ctx *gin.Context) {
|
|
principal, _ := authcommon.Principal(ctx.Request.Context())
|
|
ctx.String(http.StatusOK, principal.DeviceID)
|
|
})
|
|
|
|
cookieRequest := httptest.NewRequest(
|
|
http.MethodGet,
|
|
"/api/v1/device-probe",
|
|
nil,
|
|
)
|
|
cookieRequest.AddCookie(&http.Cookie{
|
|
Name: authcommon.AdminSessionCookieName,
|
|
Value: testOpaqueToken,
|
|
})
|
|
cookieResponse := httptest.NewRecorder()
|
|
router.ServeHTTP(cookieResponse, cookieRequest)
|
|
if cookieResponse.Code != http.StatusUnauthorized {
|
|
t.Fatalf("cookie status = %d", cookieResponse.Code)
|
|
}
|
|
assertErrorCode(t, cookieResponse, "DEVICE_ACCESS_REQUIRED")
|
|
|
|
bearerRequest := httptest.NewRequest(
|
|
http.MethodGet,
|
|
"/api/v1/device-probe",
|
|
nil,
|
|
)
|
|
bearerRequest.Header.Set("Authorization", "Bearer "+testOpaqueToken)
|
|
bearerResponse := httptest.NewRecorder()
|
|
router.ServeHTTP(bearerResponse, bearerRequest)
|
|
if bearerResponse.Code != http.StatusOK ||
|
|
bearerResponse.Body.String() != "device-1" {
|
|
t.Fatalf(
|
|
"bearer status/body = %d / %q",
|
|
bearerResponse.Code,
|
|
bearerResponse.Body.String(),
|
|
)
|
|
}
|
|
}
|
|
|
|
type stubAuthenticator struct {
|
|
adminPrincipal domain.AuthPrincipal
|
|
adminErr error
|
|
devicePrincipal domain.AuthPrincipal
|
|
deviceErr error
|
|
}
|
|
|
|
func (auth *stubAuthenticator) AuthenticateAdmin(
|
|
context.Context,
|
|
string,
|
|
) (domain.AuthPrincipal, error) {
|
|
return auth.adminPrincipal, auth.adminErr
|
|
}
|
|
|
|
func (auth *stubAuthenticator) AuthenticateAccessToken(
|
|
context.Context,
|
|
string,
|
|
) (domain.AuthPrincipal, error) {
|
|
return auth.devicePrincipal, auth.deviceErr
|
|
}
|
|
|
|
func newPublicAuthTestRouter(
|
|
t *testing.T,
|
|
service BuyerTokenService,
|
|
) http.Handler {
|
|
t.Helper()
|
|
limiter, err := authcommon.NewAttemptLimiter(
|
|
100,
|
|
time.Minute,
|
|
100,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewAttemptLimiter() error = %v", err)
|
|
}
|
|
registrar, err := NewPublicAuthRegistrar(service, limiter)
|
|
if err != nil {
|
|
t.Fatalf("NewPublicAuthRegistrar() error = %v", err)
|
|
}
|
|
router := gin.New()
|
|
router.Use(requestIDMiddleware())
|
|
if err := registrar(router); err != nil {
|
|
t.Fatalf("register public auth: %v", err)
|
|
}
|
|
return router
|
|
}
|
|
|
|
var (
|
|
_ BuyerTokenService = (*fakeBuyerTokenService)(nil)
|
|
_ AdminAuthenticator = (*stubAuthenticator)(nil)
|
|
_ DeviceAuthenticator = (*stubAuthenticator)(nil)
|
|
)
|