feat(auth): implement user and device authentication

This commit is contained in:
QiuSW
2026-07-26 15:18:48 +08:00
parent c5d3b215ff
commit 49db5b8305
66 changed files with 6216 additions and 271 deletions
@@ -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}$`,
)