feat(auth): implement user and device authentication
This commit is contained in:
@@ -1,36 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func loopbackAdminOnly() gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
host, _, err := net.SplitHostPort(ctx.Request.RemoteAddr)
|
||||
if err != nil {
|
||||
denyNonLocalAdmin(ctx)
|
||||
return
|
||||
}
|
||||
address := net.ParseIP(host)
|
||||
if address == nil || !address.IsLoopback() {
|
||||
denyNonLocalAdmin(ctx)
|
||||
return
|
||||
}
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func denyNonLocalAdmin(ctx *gin.Context) {
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
ctx.AbortWithStatusJSON(
|
||||
http.StatusForbidden,
|
||||
errorResponse(
|
||||
ctx,
|
||||
"ADMIN_SESSION_REQUIRED",
|
||||
"admin session required",
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestLoopbackAdminOnlyAllowsLoopbackAddresses(t *testing.T) {
|
||||
for _, remoteAddress := range []string{
|
||||
"127.0.0.1:12345",
|
||||
"[::1]:12345",
|
||||
} {
|
||||
t.Run(remoteAddress, func(t *testing.T) {
|
||||
router := gin.New()
|
||||
router.Use(requestIDMiddleware(), loopbackAdminOnly())
|
||||
router.GET("/tasks", func(ctx *gin.Context) {
|
||||
ctx.Status(http.StatusNoContent)
|
||||
})
|
||||
request := httptest.NewRequest(http.MethodGet, "/tasks", nil)
|
||||
request.RemoteAddr = remoteAddress
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d", response.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopbackAdminOnlyRejectsRemoteOrMalformedAddresses(t *testing.T) {
|
||||
for _, remoteAddress := range []string{
|
||||
"192.0.2.1:12345",
|
||||
"not-an-address",
|
||||
} {
|
||||
t.Run(remoteAddress, func(t *testing.T) {
|
||||
router := gin.New()
|
||||
router.Use(requestIDMiddleware(), loopbackAdminOnly())
|
||||
router.GET("/tasks", func(ctx *gin.Context) {
|
||||
ctx.Status(http.StatusNoContent)
|
||||
})
|
||||
request := httptest.NewRequest(http.MethodGet, "/tasks", nil)
|
||||
request.RemoteAddr = remoteAddress
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d", response.Code)
|
||||
}
|
||||
assertErrorCode(t, response, "ADMIN_SESSION_REQUIRED")
|
||||
if response.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf(
|
||||
"Cache-Control = %q",
|
||||
response.Header().Get("Cache-Control"),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
"cmroubao/backend-api/internal/transport/authcommon"
|
||||
"cmroubao/backend-api/internal/usecase"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -209,6 +210,7 @@ func (h *adminHandlers) createTask(ctx *gin.Context) {
|
||||
ctx.Request.Context(),
|
||||
usecase.CreateTaskCommand{
|
||||
CreatorSubject: localAdminSubject,
|
||||
ActorUserID: adminActorUserID(ctx),
|
||||
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
|
||||
SourceRef: request.SourceRef,
|
||||
Title: request.Title,
|
||||
@@ -226,6 +228,14 @@ func (h *adminHandlers) createTask(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusCreated, taskSummaryResponse(result.Task))
|
||||
}
|
||||
|
||||
func adminActorUserID(ctx *gin.Context) string {
|
||||
principal, ok := authcommon.Principal(ctx.Request.Context())
|
||||
if !ok || principal.Role != domain.UserRoleAdmin {
|
||||
return ""
|
||||
}
|
||||
return principal.UserID
|
||||
}
|
||||
|
||||
func (h *adminHandlers) listTasks(ctx *gin.Context) {
|
||||
query := usecase.ListTasksQuery{
|
||||
CreatorSubject: localAdminSubject,
|
||||
@@ -292,10 +302,11 @@ func (h *adminHandlers) taskDetail(ctx *gin.Context) {
|
||||
events := make([]gin.H, 0, len(detail.Events))
|
||||
for _, event := range detail.Events {
|
||||
events = append(events, gin.H{
|
||||
"id": event.ID,
|
||||
"type": event.Type,
|
||||
"message": event.Message,
|
||||
"occurred_at": formatTime(event.OccurredAt),
|
||||
"id": event.ID,
|
||||
"actor_user_id": event.ActorUserID,
|
||||
"type": event.Type,
|
||||
"message": event.Message,
|
||||
"occurred_at": formatTime(event.OccurredAt),
|
||||
})
|
||||
}
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
@@ -355,6 +366,7 @@ func (h *adminHandlers) cancelTask(ctx *gin.Context) {
|
||||
ctx.Request.Context(),
|
||||
usecase.CancelTaskCommand{
|
||||
CreatorSubject: localAdminSubject,
|
||||
ActorUserID: adminActorUserID(ctx),
|
||||
TaskID: ctx.Param("id"),
|
||||
Reason: request.Reason,
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmroubao/backend-api/internal/platform/assetstore"
|
||||
"cmroubao/backend-api/internal/platform/database"
|
||||
@@ -210,6 +211,32 @@ func TestAdminAPIAssetAndTaskLifecycle(t *testing.T) {
|
||||
canceled,
|
||||
)
|
||||
}
|
||||
canceledDetailResponse := performAdminRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodGet,
|
||||
"/api/v1/tasks/"+taskID,
|
||||
"",
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
var canceledDetail map[string]any
|
||||
decodeResponse(t, canceledDetailResponse, &canceledDetail)
|
||||
events, _ := canceledDetail["events"].([]any)
|
||||
if canceledDetailResponse.Code != http.StatusOK || len(events) != 2 {
|
||||
t.Fatalf(
|
||||
"canceled detail status/body = %d / %#v",
|
||||
canceledDetailResponse.Code,
|
||||
canceledDetail,
|
||||
)
|
||||
}
|
||||
for _, value := range events {
|
||||
event, _ := value.(map[string]any)
|
||||
if event["actor_user_id"] !=
|
||||
"00000000-0000-4000-8000-000000000099" {
|
||||
t.Fatalf("event actor = %#v", event)
|
||||
}
|
||||
}
|
||||
|
||||
secondCancel := performAdminRequest(
|
||||
t,
|
||||
@@ -229,7 +256,7 @@ func TestAdminAPIAssetAndTaskLifecycle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRoutesRejectNonLoopbackRequests(t *testing.T) {
|
||||
func TestAdminRoutesRejectRequestsWithoutAdminSession(t *testing.T) {
|
||||
router := newAdminIntegrationRouter(t)
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/tasks", nil)
|
||||
request.RemoteAddr = "192.0.2.10:3210"
|
||||
@@ -237,7 +264,7 @@ func TestAdminRoutesRejectNonLoopbackRequests(t *testing.T) {
|
||||
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusForbidden {
|
||||
if response.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
@@ -275,7 +302,7 @@ func TestAdminAssetUploadRequiresIdempotencyKey(t *testing.T) {
|
||||
|
||||
type emptyAdminWeb struct{}
|
||||
|
||||
func (emptyAdminWeb) Register(gin.IRoutes) {}
|
||||
func (emptyAdminWeb) RegisterProtected(gin.IRoutes) {}
|
||||
|
||||
func newAdminIntegrationRouter(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
@@ -292,6 +319,18 @@ func newAdminIntegrationRouter(t *testing.T) http.Handler {
|
||||
if _, err := runner.Up(ctx); err != nil {
|
||||
t.Fatalf("migration.Up() error = %v", err)
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err := db.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO users (
|
||||
id, username, password_hash, role, is_active, created_at, updated_at
|
||||
) VALUES (?, 'admin', 'test-only-hash', 'ADMIN', 1, ?, ?)`,
|
||||
"00000000-0000-4000-8000-000000000099",
|
||||
now,
|
||||
now,
|
||||
); err != nil {
|
||||
t.Fatalf("seed admin user: %v", err)
|
||||
}
|
||||
repositories, err := repository.New(db)
|
||||
if err != nil {
|
||||
t.Fatalf("repository.New() error = %v", err)
|
||||
@@ -318,9 +357,11 @@ func newAdminIntegrationRouter(t *testing.T) http.Handler {
|
||||
t.Fatalf("NewAdminRouteRegistrar() error = %v", err)
|
||||
}
|
||||
router, err := NewRouter(RouterDependencies{
|
||||
Database: db,
|
||||
RegisterAdminRoutes: registrar,
|
||||
LogEvent: discardEvent,
|
||||
Database: db,
|
||||
RegisterPublicRoutes: discardRoutes,
|
||||
RegisterAdminRoutes: registrar,
|
||||
AdminSessions: allowAdminAuthenticator{},
|
||||
LogEvent: discardEvent,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRouter() error = %v", err)
|
||||
@@ -375,7 +416,18 @@ func performAdminRequest(
|
||||
) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest(method, target, body)
|
||||
request.RemoteAddr = "127.0.0.1:3210"
|
||||
request.AddCookie(&http.Cookie{
|
||||
Name: "cmroubao_admin_session",
|
||||
Value: "test-session",
|
||||
})
|
||||
if method != http.MethodGet && method != http.MethodHead {
|
||||
const csrfToken = "YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE"
|
||||
request.AddCookie(&http.Cookie{
|
||||
Name: "cmroubao_admin_csrf",
|
||||
Value: csrfToken,
|
||||
})
|
||||
request.Header.Set("X-CSRF-Token", csrfToken)
|
||||
}
|
||||
if contentType != "" {
|
||||
request.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
"cmroubao/backend-api/internal/transport/authcommon"
|
||||
"cmroubao/backend-api/internal/usecase"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AdminAuthenticator interface {
|
||||
AuthenticateAdmin(
|
||||
context.Context,
|
||||
string,
|
||||
) (domain.AuthPrincipal, error)
|
||||
}
|
||||
|
||||
type DeviceAuthenticator interface {
|
||||
AuthenticateAccessToken(
|
||||
context.Context,
|
||||
string,
|
||||
) (domain.AuthPrincipal, error)
|
||||
}
|
||||
|
||||
type BuyerTokenService interface {
|
||||
LoginBuyerDevice(
|
||||
context.Context,
|
||||
usecase.LoginBuyerDeviceCommand,
|
||||
) (usecase.AccessTokenResult, error)
|
||||
}
|
||||
|
||||
func NewPublicAuthRegistrar(
|
||||
service BuyerTokenService,
|
||||
limiter authcommon.AttemptLimiter,
|
||||
) (RouteRegistrar, error) {
|
||||
if service == nil {
|
||||
return nil, errors.New("buyer token service is required")
|
||||
}
|
||||
if limiter == nil {
|
||||
return nil, errors.New("buyer login limiter is required")
|
||||
}
|
||||
handler := &authHandlers{service: service, limiter: limiter}
|
||||
return func(routes gin.IRoutes) error {
|
||||
routes.POST("/api/v1/auth/token", handler.issueBuyerToken)
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
type authHandlers struct {
|
||||
service BuyerTokenService
|
||||
limiter authcommon.AttemptLimiter
|
||||
}
|
||||
|
||||
func (h *authHandlers) issueBuyerToken(ctx *gin.Context) {
|
||||
if !hasMediaType(ctx, "application/json") {
|
||||
writePublicError(
|
||||
ctx,
|
||||
http.StatusUnsupportedMediaType,
|
||||
"UNSUPPORTED_MEDIA_TYPE",
|
||||
"application/json is required",
|
||||
false,
|
||||
gin.H{},
|
||||
)
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
DeviceID string `json:"device_id"`
|
||||
DeviceToken string `json:"device_token"`
|
||||
AppVersion string `json:"app_version"`
|
||||
AndroidVersion string `json:"android_version"`
|
||||
}
|
||||
if err := decodeJSON(ctx, &request); err != nil {
|
||||
writePublicError(
|
||||
ctx,
|
||||
http.StatusBadRequest,
|
||||
"INVALID_JSON",
|
||||
"request body must be valid JSON",
|
||||
false,
|
||||
gin.H{},
|
||||
)
|
||||
return
|
||||
}
|
||||
attemptKey := authcommon.LoginAttemptKey(
|
||||
"buyer",
|
||||
ctx.Request.RemoteAddr,
|
||||
)
|
||||
if allowed, wait := h.limiter.Allow(attemptKey); !allowed {
|
||||
ctx.Header(
|
||||
"Retry-After",
|
||||
strconv.Itoa(authcommon.RetryAfterSeconds(wait)),
|
||||
)
|
||||
writePublicError(
|
||||
ctx,
|
||||
http.StatusTooManyRequests,
|
||||
"AUTH_RATE_LIMITED",
|
||||
"too many authentication attempts",
|
||||
true,
|
||||
gin.H{},
|
||||
)
|
||||
return
|
||||
}
|
||||
result, err := h.service.LoginBuyerDevice(
|
||||
ctx.Request.Context(),
|
||||
usecase.LoginBuyerDeviceCommand{
|
||||
Username: request.Username,
|
||||
Password: request.Password,
|
||||
DeviceID: request.DeviceID,
|
||||
DeviceToken: request.DeviceToken,
|
||||
AppVersion: request.AppVersion,
|
||||
AndroidVersion: request.AndroidVersion,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
writeAuthError(ctx, err)
|
||||
return
|
||||
}
|
||||
h.limiter.Reset(attemptKey)
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"access_token": result.Token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": int(usecase.AccessTokenLifetime.Seconds()),
|
||||
"user": gin.H{
|
||||
"id": result.User.ID,
|
||||
"username": result.User.Username,
|
||||
"role": result.User.Role,
|
||||
},
|
||||
"device": gin.H{
|
||||
"id": result.Device.ID,
|
||||
"enabled": result.Device.IsEnabled,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func requireAdminSession(
|
||||
authenticator AdminAuthenticator,
|
||||
) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
cookie, err := ctx.Request.Cookie(
|
||||
authcommon.AdminSessionCookieName,
|
||||
)
|
||||
if err != nil || cookie.Value == "" {
|
||||
denyAdminSession(ctx)
|
||||
return
|
||||
}
|
||||
principal, err := authenticator.AuthenticateAdmin(
|
||||
ctx.Request.Context(),
|
||||
cookie.Value,
|
||||
)
|
||||
if err != nil ||
|
||||
principal.Role != domain.UserRoleAdmin ||
|
||||
principal.DeviceID != "" {
|
||||
denyAdminSession(ctx)
|
||||
return
|
||||
}
|
||||
ctx.Request = ctx.Request.WithContext(
|
||||
authcommon.WithPrincipal(
|
||||
ctx.Request.Context(),
|
||||
principal,
|
||||
),
|
||||
)
|
||||
if isUnsafeAdminAPIRequest(ctx.Request) &&
|
||||
!validAPIRequestCSRF(ctx.Request) {
|
||||
writePublicError(
|
||||
ctx,
|
||||
http.StatusForbidden,
|
||||
"CSRF_INVALID",
|
||||
"CSRF token is invalid",
|
||||
false,
|
||||
gin.H{},
|
||||
)
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func RequireDeviceAccess(
|
||||
authenticator DeviceAuthenticator,
|
||||
) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
token, ok := bearerToken(ctx.GetHeader("Authorization"))
|
||||
if !ok {
|
||||
denyDeviceAccess(ctx)
|
||||
return
|
||||
}
|
||||
principal, err := authenticator.AuthenticateAccessToken(
|
||||
ctx.Request.Context(),
|
||||
token,
|
||||
)
|
||||
if err != nil ||
|
||||
principal.Role != domain.UserRoleBuyer ||
|
||||
principal.DeviceID == "" {
|
||||
denyDeviceAccess(ctx)
|
||||
return
|
||||
}
|
||||
ctx.Request = ctx.Request.WithContext(
|
||||
authcommon.WithPrincipal(
|
||||
ctx.Request.Context(),
|
||||
principal,
|
||||
),
|
||||
)
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func denyAdminSession(ctx *gin.Context) {
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
if strings.HasPrefix(ctx.Request.URL.Path, "/api/") {
|
||||
ctx.Abort()
|
||||
writePublicError(
|
||||
ctx,
|
||||
http.StatusUnauthorized,
|
||||
"ADMIN_SESSION_REQUIRED",
|
||||
"admin session required",
|
||||
false,
|
||||
gin.H{},
|
||||
)
|
||||
return
|
||||
}
|
||||
next := ctx.Request.URL.RequestURI()
|
||||
if next == "" ||
|
||||
(next != "/tasks" && !strings.HasPrefix(next, "/tasks?") &&
|
||||
!strings.HasPrefix(next, "/tasks/")) {
|
||||
next = "/tasks"
|
||||
}
|
||||
ctx.Abort()
|
||||
ctx.Redirect(
|
||||
http.StatusSeeOther,
|
||||
"/login?next="+url.QueryEscape(next),
|
||||
)
|
||||
}
|
||||
|
||||
func denyDeviceAccess(ctx *gin.Context) {
|
||||
ctx.Abort()
|
||||
writePublicError(
|
||||
ctx,
|
||||
http.StatusUnauthorized,
|
||||
"DEVICE_ACCESS_REQUIRED",
|
||||
"device access token required",
|
||||
false,
|
||||
gin.H{},
|
||||
)
|
||||
}
|
||||
|
||||
func validAPIRequestCSRF(request *http.Request) bool {
|
||||
cookie, err := request.Cookie(authcommon.CSRFCookieName)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return authcommon.ValidCSRFPair(
|
||||
cookie.Value,
|
||||
request.Header.Get(authcommon.CSRFHeader),
|
||||
)
|
||||
}
|
||||
|
||||
func isUnsafeAdminAPIRequest(request *http.Request) bool {
|
||||
if !strings.HasPrefix(request.URL.Path, "/api/") {
|
||||
return false
|
||||
}
|
||||
switch request.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func bearerToken(value string) (string, bool) {
|
||||
parts := strings.Fields(value)
|
||||
if len(parts) != 2 ||
|
||||
!strings.EqualFold(parts[0], "Bearer") ||
|
||||
!authcommon.ValidOpaqueValue(parts[1]) {
|
||||
return "", false
|
||||
}
|
||||
return parts[1], true
|
||||
}
|
||||
|
||||
func writeAuthError(ctx *gin.Context, err error) {
|
||||
var typed *usecase.Error
|
||||
if !errors.As(err, &typed) {
|
||||
writePublicError(
|
||||
ctx,
|
||||
http.StatusInternalServerError,
|
||||
"INTERNAL_ERROR",
|
||||
"internal server error",
|
||||
false,
|
||||
gin.H{},
|
||||
)
|
||||
return
|
||||
}
|
||||
switch typed.Kind {
|
||||
case usecase.ErrorKindUnauthorized, usecase.ErrorKindInvalid:
|
||||
writePublicError(
|
||||
ctx,
|
||||
http.StatusUnauthorized,
|
||||
"AUTH_INVALID_CREDENTIALS",
|
||||
"invalid credentials",
|
||||
false,
|
||||
gin.H{},
|
||||
)
|
||||
case usecase.ErrorKindForbidden:
|
||||
code := "AUTH_FORBIDDEN"
|
||||
message := "authentication is not allowed"
|
||||
if typed.Code == "AUTH_ACCOUNT_OR_DEVICE_DISABLED" {
|
||||
code = "AUTH_ACCOUNT_OR_DEVICE_DISABLED"
|
||||
message = "account or device is disabled"
|
||||
}
|
||||
writePublicError(
|
||||
ctx,
|
||||
http.StatusForbidden,
|
||||
code,
|
||||
message,
|
||||
false,
|
||||
gin.H{},
|
||||
)
|
||||
case usecase.ErrorKindConflict:
|
||||
writePublicError(
|
||||
ctx,
|
||||
http.StatusConflict,
|
||||
"AUTH_RESOURCE_CONFLICT",
|
||||
"authentication resource conflict",
|
||||
false,
|
||||
gin.H{},
|
||||
)
|
||||
case usecase.ErrorKindUnavailable:
|
||||
writePublicError(
|
||||
ctx,
|
||||
http.StatusServiceUnavailable,
|
||||
"AUTH_UNAVAILABLE",
|
||||
"authentication service is unavailable",
|
||||
true,
|
||||
gin.H{},
|
||||
)
|
||||
default:
|
||||
writePublicError(
|
||||
ctx,
|
||||
http.StatusInternalServerError,
|
||||
"INTERNAL_ERROR",
|
||||
"internal server error",
|
||||
false,
|
||||
gin.H{},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
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)
|
||||
)
|
||||
@@ -22,13 +22,15 @@ type EventLogger func(string)
|
||||
type RouteRegistrar func(gin.IRoutes) error
|
||||
|
||||
type RouterDependencies struct {
|
||||
Database DatabasePinger
|
||||
RegisterAdminRoutes RouteRegistrar
|
||||
LogEvent EventLogger
|
||||
Database DatabasePinger
|
||||
RegisterPublicRoutes RouteRegistrar
|
||||
RegisterAdminRoutes RouteRegistrar
|
||||
AdminSessions AdminAuthenticator
|
||||
LogEvent EventLogger
|
||||
}
|
||||
|
||||
type AdminWeb interface {
|
||||
Register(gin.IRoutes)
|
||||
RegisterProtected(gin.IRoutes)
|
||||
}
|
||||
|
||||
func NewAdminRouteRegistrar(
|
||||
@@ -45,7 +47,7 @@ func NewAdminRouteRegistrar(
|
||||
if err := registerAdminAPI(routes, services); err != nil {
|
||||
return err
|
||||
}
|
||||
web.Register(routes)
|
||||
web.RegisterProtected(routes)
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
@@ -57,6 +59,12 @@ func NewRouter(dependencies RouterDependencies) (http.Handler, error) {
|
||||
if dependencies.RegisterAdminRoutes == nil {
|
||||
return nil, errors.New("admin route registrar is required")
|
||||
}
|
||||
if dependencies.RegisterPublicRoutes == nil {
|
||||
return nil, errors.New("public route registrar is required")
|
||||
}
|
||||
if dependencies.AdminSessions == nil {
|
||||
return nil, errors.New("admin authenticator is required")
|
||||
}
|
||||
if dependencies.LogEvent == nil {
|
||||
return nil, errors.New("event logger is required")
|
||||
}
|
||||
@@ -70,8 +78,11 @@ func NewRouter(dependencies RouterDependencies) (http.Handler, error) {
|
||||
}
|
||||
|
||||
router.GET("/healthz", healthHandler(dependencies.Database))
|
||||
if err := dependencies.RegisterPublicRoutes(router); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
adminRoutes := router.Group("")
|
||||
adminRoutes.Use(loopbackAdminOnly())
|
||||
adminRoutes.Use(requireAdminSession(dependencies.AdminSessions))
|
||||
if err := dependencies.RegisterAdminRoutes(adminRoutes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -114,9 +117,11 @@ func TestSafeRecoveryReturnsStableErrorWithoutLoggingRequestHeaders(t *testing.T
|
||||
|
||||
func TestRouterRequiresDependencies(t *testing.T) {
|
||||
valid := RouterDependencies{
|
||||
Database: fakePinger{},
|
||||
RegisterAdminRoutes: discardRoutes,
|
||||
LogEvent: discardEvent,
|
||||
Database: fakePinger{},
|
||||
RegisterPublicRoutes: discardRoutes,
|
||||
RegisterAdminRoutes: discardRoutes,
|
||||
AdminSessions: allowAdminAuthenticator{},
|
||||
LogEvent: discardEvent,
|
||||
}
|
||||
missingDatabase := valid
|
||||
missingDatabase.Database = nil
|
||||
@@ -128,6 +133,16 @@ func TestRouterRequiresDependencies(t *testing.T) {
|
||||
if _, err := NewRouter(missingRoutes); err == nil {
|
||||
t.Fatal("NewRouter(nil routes) error = nil")
|
||||
}
|
||||
missingPublicRoutes := valid
|
||||
missingPublicRoutes.RegisterPublicRoutes = nil
|
||||
if _, err := NewRouter(missingPublicRoutes); err == nil {
|
||||
t.Fatal("NewRouter(nil public routes) error = nil")
|
||||
}
|
||||
missingAuth := valid
|
||||
missingAuth.AdminSessions = nil
|
||||
if _, err := NewRouter(missingAuth); err == nil {
|
||||
t.Fatal("NewRouter(nil admin auth) error = nil")
|
||||
}
|
||||
missingLogger := valid
|
||||
missingLogger.LogEvent = nil
|
||||
if _, err := NewRouter(missingLogger); err == nil {
|
||||
@@ -140,9 +155,11 @@ func newTestRouter(
|
||||
logEvent EventLogger,
|
||||
) (http.Handler, error) {
|
||||
return NewRouter(RouterDependencies{
|
||||
Database: database,
|
||||
RegisterAdminRoutes: discardRoutes,
|
||||
LogEvent: logEvent,
|
||||
Database: database,
|
||||
RegisterPublicRoutes: discardRoutes,
|
||||
RegisterAdminRoutes: discardRoutes,
|
||||
AdminSessions: allowAdminAuthenticator{},
|
||||
LogEvent: logEvent,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -223,6 +240,21 @@ func discardEvent(string) {}
|
||||
|
||||
func discardRoutes(gin.IRoutes) error { return nil }
|
||||
|
||||
type allowAdminAuthenticator struct{}
|
||||
|
||||
func (allowAdminAuthenticator) AuthenticateAdmin(
|
||||
context.Context,
|
||||
string,
|
||||
) (domain.AuthPrincipal, error) {
|
||||
return domain.AuthPrincipal{
|
||||
UserID: "00000000-0000-4000-8000-000000000099",
|
||||
Username: "admin",
|
||||
Role: domain.UserRoleAdmin,
|
||||
SessionID: "admin-session",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var requestIDPattern = regexp.MustCompile(
|
||||
`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user