feat(auth): implement user and device authentication
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
package authcommon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AttemptLimiter interface {
|
||||
Allow(string) (bool, time.Duration)
|
||||
Reset(string)
|
||||
}
|
||||
|
||||
type attemptWindow struct {
|
||||
count int
|
||||
resetAt time.Time
|
||||
}
|
||||
|
||||
type InMemoryAttemptLimiter struct {
|
||||
mu sync.Mutex
|
||||
maxAttempts int
|
||||
window time.Duration
|
||||
maxKeys int
|
||||
now func() time.Time
|
||||
attempts map[string]attemptWindow
|
||||
}
|
||||
|
||||
func NewAttemptLimiter(
|
||||
maxAttempts int,
|
||||
window time.Duration,
|
||||
maxKeys int,
|
||||
) (*InMemoryAttemptLimiter, error) {
|
||||
return newAttemptLimiter(maxAttempts, window, maxKeys, time.Now)
|
||||
}
|
||||
|
||||
func newAttemptLimiter(
|
||||
maxAttempts int,
|
||||
window time.Duration,
|
||||
maxKeys int,
|
||||
now func() time.Time,
|
||||
) (*InMemoryAttemptLimiter, error) {
|
||||
if maxAttempts < 1 || window <= 0 || maxKeys < 1 || now == nil {
|
||||
return nil, errors.New("attempt limiter configuration is invalid")
|
||||
}
|
||||
return &InMemoryAttemptLimiter{
|
||||
maxAttempts: maxAttempts,
|
||||
window: window,
|
||||
maxKeys: maxKeys,
|
||||
now: now,
|
||||
attempts: make(map[string]attemptWindow),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (limiter *InMemoryAttemptLimiter) Allow(
|
||||
key string,
|
||||
) (bool, time.Duration) {
|
||||
now := limiter.now().UTC()
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
key = "unknown"
|
||||
}
|
||||
|
||||
limiter.mu.Lock()
|
||||
defer limiter.mu.Unlock()
|
||||
|
||||
current, found := limiter.attempts[key]
|
||||
if found && !current.resetAt.After(now) {
|
||||
delete(limiter.attempts, key)
|
||||
found = false
|
||||
}
|
||||
if !found {
|
||||
if len(limiter.attempts) >= limiter.maxKeys {
|
||||
limiter.removeExpired(now)
|
||||
}
|
||||
if len(limiter.attempts) >= limiter.maxKeys {
|
||||
return false, limiter.window
|
||||
}
|
||||
limiter.attempts[key] = attemptWindow{
|
||||
count: 1,
|
||||
resetAt: now.Add(limiter.window),
|
||||
}
|
||||
return true, 0
|
||||
}
|
||||
if current.count >= limiter.maxAttempts {
|
||||
return false, current.resetAt.Sub(now)
|
||||
}
|
||||
current.count++
|
||||
limiter.attempts[key] = current
|
||||
return true, 0
|
||||
}
|
||||
|
||||
func (limiter *InMemoryAttemptLimiter) Reset(key string) {
|
||||
limiter.mu.Lock()
|
||||
delete(limiter.attempts, strings.TrimSpace(key))
|
||||
limiter.mu.Unlock()
|
||||
}
|
||||
|
||||
func (limiter *InMemoryAttemptLimiter) removeExpired(now time.Time) {
|
||||
for key, current := range limiter.attempts {
|
||||
if !current.resetAt.After(now) {
|
||||
delete(limiter.attempts, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func LoginAttemptKey(scope, remoteAddress string) string {
|
||||
host, _, err := net.SplitHostPort(strings.TrimSpace(remoteAddress))
|
||||
if err != nil {
|
||||
host = strings.TrimSpace(remoteAddress)
|
||||
}
|
||||
if parsed := net.ParseIP(host); parsed != nil {
|
||||
host = parsed.String()
|
||||
}
|
||||
if host == "" {
|
||||
host = "unknown"
|
||||
}
|
||||
return scope + ":" + host
|
||||
}
|
||||
|
||||
func RetryAfterSeconds(wait time.Duration) int {
|
||||
seconds := int(math.Ceil(wait.Seconds()))
|
||||
if seconds < 1 {
|
||||
return 1
|
||||
}
|
||||
return seconds
|
||||
}
|
||||
|
||||
var _ AttemptLimiter = (*InMemoryAttemptLimiter)(nil)
|
||||
@@ -0,0 +1,75 @@
|
||||
package authcommon
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAttemptLimiterBlocksUntilWindowExpiresAndCanReset(t *testing.T) {
|
||||
now := time.Date(2026, 7, 26, 1, 2, 3, 0, time.UTC)
|
||||
limiter, err := newAttemptLimiter(
|
||||
2,
|
||||
time.Minute,
|
||||
10,
|
||||
func() time.Time { return now },
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("newAttemptLimiter() error = %v", err)
|
||||
}
|
||||
if allowed, _ := limiter.Allow("admin:127.0.0.1"); !allowed {
|
||||
t.Fatal("first attempt was blocked")
|
||||
}
|
||||
if allowed, _ := limiter.Allow("admin:127.0.0.1"); !allowed {
|
||||
t.Fatal("second attempt was blocked")
|
||||
}
|
||||
if allowed, wait := limiter.Allow("admin:127.0.0.1"); allowed ||
|
||||
wait != time.Minute {
|
||||
t.Fatalf("third attempt = %v, wait = %v", allowed, wait)
|
||||
}
|
||||
|
||||
limiter.Reset("admin:127.0.0.1")
|
||||
if allowed, _ := limiter.Allow("admin:127.0.0.1"); !allowed {
|
||||
t.Fatal("attempt after reset was blocked")
|
||||
}
|
||||
|
||||
now = now.Add(time.Minute)
|
||||
if allowed, _ := limiter.Allow("admin:127.0.0.1"); !allowed {
|
||||
t.Fatal("attempt after window expiry was blocked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttemptLimiterBoundsTrackedKeys(t *testing.T) {
|
||||
now := time.Date(2026, 7, 26, 1, 2, 3, 0, time.UTC)
|
||||
limiter, err := newAttemptLimiter(
|
||||
1,
|
||||
time.Minute,
|
||||
1,
|
||||
func() time.Time { return now },
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("newAttemptLimiter() error = %v", err)
|
||||
}
|
||||
if allowed, _ := limiter.Allow("one"); !allowed {
|
||||
t.Fatal("first key was blocked")
|
||||
}
|
||||
if allowed, wait := limiter.Allow("two"); allowed ||
|
||||
wait != time.Minute {
|
||||
t.Fatalf("second key = %v, wait = %v", allowed, wait)
|
||||
}
|
||||
now = now.Add(time.Minute)
|
||||
if allowed, _ := limiter.Allow("two"); !allowed {
|
||||
t.Fatal("expired key was not evicted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginAttemptKeyUsesRemoteAddressOnly(t *testing.T) {
|
||||
if got := LoginAttemptKey("buyer", "127.0.0.1:1234"); got != "buyer:127.0.0.1" {
|
||||
t.Fatalf("IPv4 key = %q", got)
|
||||
}
|
||||
if got := LoginAttemptKey("admin", "[2001:db8::1]:443"); got != "admin:2001:db8::1" {
|
||||
t.Fatalf("IPv6 key = %q", got)
|
||||
}
|
||||
if got := RetryAfterSeconds(time.Millisecond); got != 1 {
|
||||
t.Fatalf("RetryAfterSeconds() = %d", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package authcommon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
AdminSessionCookieName = "cmroubao_admin_session"
|
||||
CSRFCookieName = "cmroubao_admin_csrf"
|
||||
CSRFFormField = "csrf_token"
|
||||
CSRFHeader = "X-CSRF-Token"
|
||||
)
|
||||
|
||||
type principalContextKey struct{}
|
||||
|
||||
func WithPrincipal(
|
||||
ctx context.Context,
|
||||
principal domain.AuthPrincipal,
|
||||
) context.Context {
|
||||
return context.WithValue(ctx, principalContextKey{}, principal)
|
||||
}
|
||||
|
||||
func Principal(ctx context.Context) (domain.AuthPrincipal, bool) {
|
||||
principal, ok := ctx.Value(principalContextKey{}).(domain.AuthPrincipal)
|
||||
return principal, ok
|
||||
}
|
||||
|
||||
func ValidCSRFPair(cookieValue, presentedValue string) bool {
|
||||
cookieValue = strings.TrimSpace(cookieValue)
|
||||
presentedValue = strings.TrimSpace(presentedValue)
|
||||
if !ValidOpaqueValue(cookieValue) ||
|
||||
len(cookieValue) != len(presentedValue) {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare(
|
||||
[]byte(cookieValue),
|
||||
[]byte(presentedValue),
|
||||
) == 1
|
||||
}
|
||||
|
||||
func ValidOpaqueValue(value string) bool {
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(value)
|
||||
return err == nil && len(decoded) == 32
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package authcommon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
)
|
||||
|
||||
func TestPrincipalRoundTrip(t *testing.T) {
|
||||
want := domain.AuthPrincipal{
|
||||
UserID: "user-1",
|
||||
Username: "buyer",
|
||||
Role: domain.UserRoleBuyer,
|
||||
DeviceID: "device-1",
|
||||
}
|
||||
ctx := WithPrincipal(context.Background(), want)
|
||||
|
||||
got, ok := Principal(ctx)
|
||||
|
||||
if !ok || got != want {
|
||||
t.Fatalf("Principal() = %+v, %t", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidCSRFPairRequiresOneCanonical256BitValue(t *testing.T) {
|
||||
valid := "YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE"
|
||||
if !ValidCSRFPair(valid, valid) {
|
||||
t.Fatal("valid CSRF pair was rejected")
|
||||
}
|
||||
for _, candidate := range []string{
|
||||
"",
|
||||
"short",
|
||||
valid + "=",
|
||||
valid[:len(valid)-1] + "b",
|
||||
} {
|
||||
if ValidCSRFPair(valid, candidate) {
|
||||
t.Fatalf("invalid presented value %q was accepted", candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}$`,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"cmroubao/backend-api/internal/usecase"
|
||||
)
|
||||
|
||||
type AuthUsecaseAdapter struct {
|
||||
service *usecase.AuthService
|
||||
}
|
||||
|
||||
func NewAuthUsecaseAdapter(
|
||||
service *usecase.AuthService,
|
||||
) (*AuthUsecaseAdapter, error) {
|
||||
if service == nil {
|
||||
return nil, errors.New("admin auth use case is required")
|
||||
}
|
||||
return &AuthUsecaseAdapter{service: service}, nil
|
||||
}
|
||||
|
||||
func (adapter *AuthUsecaseAdapter) LoginAdmin(
|
||||
ctx context.Context,
|
||||
input AdminLoginInput,
|
||||
) (AdminLoginResult, error) {
|
||||
result, err := adapter.service.LoginAdmin(
|
||||
ctx,
|
||||
usecase.LoginAdminCommand{
|
||||
Username: input.Username,
|
||||
Password: input.Password,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return AdminLoginResult{}, mapAuthUsecaseError(err)
|
||||
}
|
||||
return AdminLoginResult{
|
||||
Token: result.Token,
|
||||
ExpiresAt: result.ExpiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (adapter *AuthUsecaseAdapter) LogoutAdmin(
|
||||
ctx context.Context,
|
||||
token string,
|
||||
) error {
|
||||
if err := adapter.service.LogoutAdmin(ctx, token); err != nil {
|
||||
return mapAuthUsecaseError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mapAuthUsecaseError(err error) error {
|
||||
var typed *usecase.Error
|
||||
if !errors.As(err, &typed) {
|
||||
return err
|
||||
}
|
||||
var public error
|
||||
switch typed.Kind {
|
||||
case usecase.ErrorKindUnauthorized,
|
||||
usecase.ErrorKindForbidden,
|
||||
usecase.ErrorKindInvalid:
|
||||
public = ErrInvalidCredentials
|
||||
case usecase.ErrorKindUnavailable:
|
||||
public = ErrUnavailable
|
||||
default:
|
||||
return err
|
||||
}
|
||||
return &adapterError{
|
||||
public: public,
|
||||
cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
var _ AdminSessionService = (*AuthUsecaseAdapter)(nil)
|
||||
@@ -0,0 +1,311 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cmroubao/backend-api/internal/transport/authcommon"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
AdminSessionCookieName = authcommon.AdminSessionCookieName
|
||||
maxLoginFormBytes = 16 << 10
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
ErrAccountDisabled = errors.New("account disabled")
|
||||
)
|
||||
|
||||
type AdminSessionService interface {
|
||||
LoginAdmin(context.Context, AdminLoginInput) (AdminLoginResult, error)
|
||||
LogoutAdmin(context.Context, string) error
|
||||
}
|
||||
|
||||
type AdminLoginInput struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
type AdminLoginResult struct {
|
||||
Token string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type AuthHandler struct {
|
||||
sessions AdminSessionService
|
||||
renderer *Renderer
|
||||
limiter authcommon.AttemptLimiter
|
||||
}
|
||||
|
||||
func NewAuthHandler(
|
||||
sessions AdminSessionService,
|
||||
renderer *Renderer,
|
||||
limiter authcommon.AttemptLimiter,
|
||||
) (*AuthHandler, error) {
|
||||
if sessions == nil {
|
||||
return nil, errors.New("admin session service is required")
|
||||
}
|
||||
if renderer == nil {
|
||||
return nil, errors.New("admin auth renderer is required")
|
||||
}
|
||||
if limiter == nil {
|
||||
return nil, errors.New("admin login limiter is required")
|
||||
}
|
||||
return &AuthHandler{
|
||||
sessions: sessions,
|
||||
renderer: renderer,
|
||||
limiter: limiter,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *AuthHandler) RegisterPublic(routes gin.IRoutes) {
|
||||
routes.GET("/login", SecurityHeaders(), h.LoginPage)
|
||||
routes.POST("/login", SecurityHeaders(), h.Login)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) RegisterProtected(routes gin.IRoutes) {
|
||||
routes.POST("/logout", SecurityHeaders(), h.Logout)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) LoginPage(ctx *gin.Context) {
|
||||
token, err := csrfToken(ctx)
|
||||
if err != nil {
|
||||
h.renderError(ctx, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.render(ctx, http.StatusOK, loginPage{
|
||||
Page: pageView{Title: "管理端登录"},
|
||||
CSRFToken: token,
|
||||
Next: safeNext(ctx.Query("next")),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Login(ctx *gin.Context) {
|
||||
previousSessionToken := ""
|
||||
if cookie, err := ctx.Request.Cookie(
|
||||
authcommon.AdminSessionCookieName,
|
||||
); err == nil {
|
||||
previousSessionToken = cookie.Value
|
||||
}
|
||||
ctx.Request.Body = http.MaxBytesReader(
|
||||
ctx.Writer,
|
||||
ctx.Request.Body,
|
||||
maxLoginFormBytes,
|
||||
)
|
||||
if err := ctx.Request.ParseForm(); err != nil {
|
||||
h.renderError(ctx, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
next := safeNext(ctx.PostForm("next"))
|
||||
page := loginPage{
|
||||
Page: pageView{Title: "管理端登录"},
|
||||
CSRFToken: strings.TrimSpace(
|
||||
ctx.PostForm(authcommon.CSRFFormField),
|
||||
),
|
||||
Next: next,
|
||||
Username: strings.TrimSpace(ctx.PostForm("username")),
|
||||
}
|
||||
if !validCSRF(ctx) {
|
||||
page.Message = "登录页面已失效,请刷新后重试。"
|
||||
h.render(ctx, http.StatusForbidden, page)
|
||||
return
|
||||
}
|
||||
password := ctx.PostForm("password")
|
||||
if page.Username == "" {
|
||||
page.UsernameError = "请输入账号。"
|
||||
}
|
||||
if password == "" {
|
||||
page.PasswordError = "请输入密码。"
|
||||
}
|
||||
if page.UsernameError != "" || page.PasswordError != "" {
|
||||
page.Message = "请检查并补全必填项。"
|
||||
h.render(ctx, http.StatusUnprocessableEntity, page)
|
||||
return
|
||||
}
|
||||
|
||||
attemptKey := authcommon.LoginAttemptKey(
|
||||
"admin",
|
||||
ctx.Request.RemoteAddr,
|
||||
)
|
||||
if allowed, wait := h.limiter.Allow(attemptKey); !allowed {
|
||||
ctx.Header(
|
||||
"Retry-After",
|
||||
strconv.Itoa(authcommon.RetryAfterSeconds(wait)),
|
||||
)
|
||||
page.Message = "登录尝试次数过多,请稍后再试。"
|
||||
h.render(ctx, http.StatusTooManyRequests, page)
|
||||
return
|
||||
}
|
||||
result, err := h.sessions.LoginAdmin(
|
||||
ctx.Request.Context(),
|
||||
AdminLoginInput{
|
||||
Username: page.Username,
|
||||
Password: password,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidCredentials),
|
||||
errors.Is(err, ErrAccountDisabled):
|
||||
page.Message = "账号或密码不正确。"
|
||||
h.render(ctx, http.StatusUnauthorized, page)
|
||||
case errors.Is(err, context.DeadlineExceeded),
|
||||
errors.Is(err, ErrUnavailable):
|
||||
page.Message = "登录服务暂时不可用,请稍后重试。"
|
||||
h.render(ctx, http.StatusServiceUnavailable, page)
|
||||
default:
|
||||
h.renderError(ctx, http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
h.limiter.Reset(attemptKey)
|
||||
|
||||
if previousSessionToken != "" &&
|
||||
previousSessionToken != result.Token {
|
||||
if err := h.sessions.LogoutAdmin(
|
||||
ctx.Request.Context(),
|
||||
previousSessionToken,
|
||||
); err != nil {
|
||||
_ = h.sessions.LogoutAdmin(
|
||||
ctx.Request.Context(),
|
||||
result.Token,
|
||||
)
|
||||
clearAdminSessionCookie(ctx)
|
||||
h.renderError(ctx, http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
}
|
||||
setAdminSessionCookie(ctx, result)
|
||||
if _, err := rotateCSRFToken(ctx); err != nil {
|
||||
_ = h.sessions.LogoutAdmin(ctx.Request.Context(), result.Token)
|
||||
clearAdminSessionCookie(ctx)
|
||||
h.renderError(ctx, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, next)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Logout(ctx *gin.Context) {
|
||||
if !validCSRF(ctx) {
|
||||
h.renderError(ctx, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
cookie, err := ctx.Request.Cookie(authcommon.AdminSessionCookieName)
|
||||
if err == nil && cookie.Value != "" {
|
||||
if err := h.sessions.LogoutAdmin(
|
||||
ctx.Request.Context(),
|
||||
cookie.Value,
|
||||
); err != nil &&
|
||||
!errors.Is(err, ErrNotFound) {
|
||||
h.renderError(ctx, http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
}
|
||||
clearAdminSessionCookie(ctx)
|
||||
_, _ = rotateCSRFToken(ctx)
|
||||
ctx.Redirect(http.StatusSeeOther, "/login")
|
||||
}
|
||||
|
||||
func setAdminSessionCookie(
|
||||
ctx *gin.Context,
|
||||
result AdminLoginResult,
|
||||
) {
|
||||
maxAge := int(time.Until(result.ExpiresAt).Seconds())
|
||||
if maxAge < 1 {
|
||||
maxAge = 1
|
||||
}
|
||||
http.SetCookie(ctx.Writer, &http.Cookie{
|
||||
Name: authcommon.AdminSessionCookieName,
|
||||
Value: result.Token,
|
||||
Path: "/",
|
||||
Expires: result.ExpiresAt.UTC(),
|
||||
MaxAge: maxAge,
|
||||
HttpOnly: true,
|
||||
Secure: ctx.Request.TLS != nil,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func clearAdminSessionCookie(ctx *gin.Context) {
|
||||
http.SetCookie(ctx.Writer, &http.Cookie{
|
||||
Name: authcommon.AdminSessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Expires: time.Unix(1, 0).UTC(),
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: ctx.Request.TLS != nil,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func safeNext(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "/tasks"
|
||||
}
|
||||
if strings.Contains(value, `\`) ||
|
||||
strings.HasPrefix(value, "//") {
|
||||
return "/tasks"
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil ||
|
||||
parsed.IsAbs() ||
|
||||
parsed.Host != "" ||
|
||||
parsed.Fragment != "" ||
|
||||
path.Clean(parsed.Path) != parsed.Path {
|
||||
return "/tasks"
|
||||
}
|
||||
if parsed.Path != "/tasks" &&
|
||||
!strings.HasPrefix(parsed.Path, "/tasks/") {
|
||||
return "/tasks"
|
||||
}
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
func (h *AuthHandler) render(
|
||||
ctx *gin.Context,
|
||||
status int,
|
||||
page loginPage,
|
||||
) {
|
||||
var output bytes.Buffer
|
||||
if err := h.renderer.Execute(&output, "login", page); err != nil {
|
||||
ctx.Data(
|
||||
http.StatusInternalServerError,
|
||||
formContentType,
|
||||
[]byte("页面暂时无法显示,请稍后重试。"),
|
||||
)
|
||||
return
|
||||
}
|
||||
ctx.Data(status, formContentType, output.Bytes())
|
||||
}
|
||||
|
||||
func (h *AuthHandler) renderError(ctx *gin.Context, status int) {
|
||||
token, _ := csrfToken(ctx)
|
||||
h.render(ctx, status, loginPage{
|
||||
Page: pageView{Title: "管理端登录"},
|
||||
CSRFToken: token,
|
||||
Next: "/tasks",
|
||||
Message: "操作失败,请刷新页面后重试。",
|
||||
})
|
||||
}
|
||||
|
||||
type loginPage struct {
|
||||
Page pageView
|
||||
CSRFToken string
|
||||
Next string
|
||||
Username string
|
||||
Message string
|
||||
UsernameError string
|
||||
PasswordError string
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmroubao/backend-api/internal/transport/authcommon"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestLoginPageIssuesCSRFAndRendersSafeNext(t *testing.T) {
|
||||
router, _ := newAuthTestRouter(t, &fakeAdminSessions{})
|
||||
|
||||
response := performRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodGet,
|
||||
"/login?next=%2Ftasks%2Fnew",
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body)
|
||||
}
|
||||
cookie := csrfCookie(t, response)
|
||||
body := response.Body.String()
|
||||
for _, expected := range []string{
|
||||
`name="csrf_token" value="` + cookie.Value + `"`,
|
||||
`name="next" value="/tasks/new"`,
|
||||
`autocomplete="username"`,
|
||||
`autocomplete="current-password"`,
|
||||
`data-password-toggle`,
|
||||
} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Fatalf("login body missing %q", expected)
|
||||
}
|
||||
}
|
||||
assertSecurityHeaders(t, response)
|
||||
}
|
||||
|
||||
func TestLoginRejectsInvalidCredentialsWithoutLeakingPassword(t *testing.T) {
|
||||
sessions := &fakeAdminSessions{loginErr: ErrInvalidCredentials}
|
||||
router, _ := newAuthTestRouter(t, sessions)
|
||||
csrf := loginCSRF(t, router)
|
||||
form := url.Values{
|
||||
"csrf_token": {csrf.Value},
|
||||
"username": {"admin"},
|
||||
"password": {"private-password-value"},
|
||||
"next": {"/tasks"},
|
||||
}
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/login",
|
||||
strings.NewReader(form.Encode()),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(csrf)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusUnauthorized ||
|
||||
!strings.Contains(response.Body.String(), "账号或密码不正确") {
|
||||
t.Fatalf("status/body = %d / %s", response.Code, response.Body)
|
||||
}
|
||||
if strings.Contains(response.Body.String(), "private-password-value") {
|
||||
t.Fatal("login response contains submitted password")
|
||||
}
|
||||
if sessions.loginInput.Username != "admin" ||
|
||||
sessions.loginInput.Password != "private-password-value" {
|
||||
t.Fatalf("login input = %+v", sessions.loginInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRateLimitBlocksBeforePasswordVerification(t *testing.T) {
|
||||
sessions := &fakeAdminSessions{loginErr: ErrInvalidCredentials}
|
||||
limiter, err := authcommon.NewAttemptLimiter(1, time.Minute, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAttemptLimiter() error = %v", err)
|
||||
}
|
||||
router, _ := newAuthTestRouterWithLimiter(t, sessions, limiter)
|
||||
csrf := loginCSRF(t, router)
|
||||
form := url.Values{
|
||||
"csrf_token": {csrf.Value},
|
||||
"username": {"admin"},
|
||||
"password": {"private-password-value"},
|
||||
"next": {"/tasks"},
|
||||
}
|
||||
requestLogin := func() *httptest.ResponseRecorder {
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/login",
|
||||
strings.NewReader(form.Encode()),
|
||||
)
|
||||
request.Header.Set(
|
||||
"Content-Type",
|
||||
"application/x-www-form-urlencoded",
|
||||
)
|
||||
request.AddCookie(csrf)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
if response := requestLogin(); response.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("first status = %d", response.Code)
|
||||
}
|
||||
response := requestLogin()
|
||||
if response.Code != http.StatusTooManyRequests ||
|
||||
response.Header().Get("Retry-After") == "" ||
|
||||
!strings.Contains(response.Body.String(), "尝试次数过多") ||
|
||||
sessions.loginCalls != 1 {
|
||||
t.Fatalf(
|
||||
"second status/retry/calls = %d / %q / %d",
|
||||
response.Code,
|
||||
response.Header().Get("Retry-After"),
|
||||
sessions.loginCalls,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRotatesSessionAndCSRFThenUsesSafeRedirect(t *testing.T) {
|
||||
expiresAt := time.Now().UTC().Add(8 * time.Hour)
|
||||
sessions := &fakeAdminSessions{
|
||||
loginResult: AdminLoginResult{
|
||||
Token: mustToken(t),
|
||||
ExpiresAt: expiresAt,
|
||||
},
|
||||
}
|
||||
router, _ := newAuthTestRouter(t, sessions)
|
||||
csrf := loginCSRF(t, router)
|
||||
form := url.Values{
|
||||
"csrf_token": {csrf.Value},
|
||||
"username": {"admin"},
|
||||
"password": {"valid-password"},
|
||||
"next": {"https://attacker.invalid/private"},
|
||||
}
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/login",
|
||||
strings.NewReader(form.Encode()),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(csrf)
|
||||
request.AddCookie(&http.Cookie{
|
||||
Name: AdminSessionCookieName,
|
||||
Value: mustToken(t),
|
||||
})
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther ||
|
||||
response.Header().Get("Location") != "/tasks" {
|
||||
t.Fatalf(
|
||||
"status/location = %d / %q",
|
||||
response.Code,
|
||||
response.Header().Get("Location"),
|
||||
)
|
||||
}
|
||||
var sessionCookie, rotatedCSRF *http.Cookie
|
||||
for _, cookie := range response.Result().Cookies() {
|
||||
switch cookie.Name {
|
||||
case AdminSessionCookieName:
|
||||
sessionCookie = cookie
|
||||
case csrfCookieName:
|
||||
rotatedCSRF = cookie
|
||||
}
|
||||
}
|
||||
if sessionCookie == nil ||
|
||||
sessionCookie.Value != sessions.loginResult.Token ||
|
||||
!sessionCookie.HttpOnly ||
|
||||
sessionCookie.SameSite != http.SameSiteLaxMode ||
|
||||
sessionCookie.Path != "/" ||
|
||||
sessionCookie.Secure {
|
||||
t.Fatalf("session cookie = %+v", sessionCookie)
|
||||
}
|
||||
if rotatedCSRF == nil || rotatedCSRF.Value == csrf.Value {
|
||||
t.Fatalf("CSRF was not rotated: %+v", rotatedCSRF)
|
||||
}
|
||||
if sessions.logoutToken == "" ||
|
||||
sessions.logoutToken == sessions.loginResult.Token {
|
||||
t.Fatalf(
|
||||
"previous session was not revoked: %q",
|
||||
sessions.logoutToken,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSessionCookieIsSecureForTLSRequest(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.GET("/secure-cookie", func(ctx *gin.Context) {
|
||||
setAdminSessionCookie(ctx, AdminLoginResult{
|
||||
Token: mustToken(t),
|
||||
ExpiresAt: time.Now().UTC().Add(time.Hour),
|
||||
})
|
||||
ctx.Status(http.StatusNoContent)
|
||||
})
|
||||
request := httptest.NewRequest(http.MethodGet, "/secure-cookie", nil)
|
||||
request.TLS = &tls.ConnectionState{}
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
var sessionCookie *http.Cookie
|
||||
for _, cookie := range response.Result().Cookies() {
|
||||
if cookie.Name == AdminSessionCookieName {
|
||||
sessionCookie = cookie
|
||||
break
|
||||
}
|
||||
}
|
||||
if sessionCookie == nil || !sessionCookie.Secure {
|
||||
t.Fatalf("TLS session cookie = %+v", sessionCookie)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginFailsClosedWhenPreviousSessionCannotBeRevoked(
|
||||
t *testing.T,
|
||||
) {
|
||||
newToken := mustToken(t)
|
||||
oldToken := mustToken(t)
|
||||
for oldToken == newToken {
|
||||
oldToken = mustToken(t)
|
||||
}
|
||||
sessions := &fakeAdminSessions{
|
||||
loginResult: AdminLoginResult{
|
||||
Token: newToken,
|
||||
ExpiresAt: time.Now().UTC().Add(8 * time.Hour),
|
||||
},
|
||||
logoutErr: ErrUnavailable,
|
||||
}
|
||||
router, _ := newAuthTestRouter(t, sessions)
|
||||
csrf := loginCSRF(t, router)
|
||||
form := url.Values{
|
||||
"csrf_token": {csrf.Value},
|
||||
"username": {"admin"},
|
||||
"password": {"valid-password"},
|
||||
"next": {"/tasks"},
|
||||
}
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/login",
|
||||
strings.NewReader(form.Encode()),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(csrf)
|
||||
request.AddCookie(&http.Cookie{
|
||||
Name: AdminSessionCookieName,
|
||||
Value: oldToken,
|
||||
})
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusServiceUnavailable ||
|
||||
sessions.logoutToken != newToken {
|
||||
t.Fatalf(
|
||||
"status/last revoked token = %d / %q",
|
||||
response.Code,
|
||||
sessions.logoutToken,
|
||||
)
|
||||
}
|
||||
for _, cookie := range response.Result().Cookies() {
|
||||
if cookie.Name == AdminSessionCookieName &&
|
||||
cookie.Value == newToken {
|
||||
t.Fatal("new session cookie was returned after revoke failure")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeNextRejectsExternalAndAmbiguousPaths(t *testing.T) {
|
||||
tests := []string{
|
||||
"https://attacker.invalid/tasks",
|
||||
"//attacker.invalid/tasks",
|
||||
`/tasks\redirect`,
|
||||
"/tasks/../admin",
|
||||
"/healthz",
|
||||
"tasks",
|
||||
}
|
||||
for _, candidate := range tests {
|
||||
if actual := safeNext(candidate); actual != "/tasks" {
|
||||
t.Fatalf("safeNext(%q) = %q", candidate, actual)
|
||||
}
|
||||
}
|
||||
if actual := safeNext("/tasks/item?id=1"); actual != "/tasks/item?id=1" {
|
||||
t.Fatalf("safeNext(valid) = %q", actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutRequiresCSRFAndRevokesSession(t *testing.T) {
|
||||
sessions := &fakeAdminSessions{}
|
||||
router, _ := newAuthTestRouter(t, sessions)
|
||||
csrf := loginCSRF(t, router)
|
||||
sessionValue := mustToken(t)
|
||||
form := url.Values{"csrf_token": {csrf.Value}}
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/logout",
|
||||
strings.NewReader(form.Encode()),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(csrf)
|
||||
request.AddCookie(&http.Cookie{
|
||||
Name: AdminSessionCookieName,
|
||||
Value: sessionValue,
|
||||
})
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther ||
|
||||
response.Header().Get("Location") != "/login" ||
|
||||
sessions.logoutToken != sessionValue {
|
||||
t.Fatalf(
|
||||
"status/location/token = %d / %q / %q",
|
||||
response.Code,
|
||||
response.Header().Get("Location"),
|
||||
sessions.logoutToken,
|
||||
)
|
||||
}
|
||||
foundCleared := false
|
||||
for _, cookie := range response.Result().Cookies() {
|
||||
if cookie.Name == AdminSessionCookieName && cookie.MaxAge < 0 {
|
||||
foundCleared = true
|
||||
}
|
||||
}
|
||||
if !foundCleared {
|
||||
t.Fatal("logout did not clear the admin session cookie")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutRejectsWrongCSRFWithoutRevoking(t *testing.T) {
|
||||
sessions := &fakeAdminSessions{}
|
||||
router, _ := newAuthTestRouter(t, sessions)
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/logout",
|
||||
strings.NewReader("csrf_token=wrong"),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{
|
||||
Name: csrfCookieName,
|
||||
Value: mustToken(t),
|
||||
})
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusForbidden || sessions.logoutToken != "" {
|
||||
t.Fatalf(
|
||||
"status/logout token = %d / %q",
|
||||
response.Code,
|
||||
sessions.logoutToken,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeAdminSessions struct {
|
||||
loginInput AdminLoginInput
|
||||
loginResult AdminLoginResult
|
||||
loginErr error
|
||||
loginCalls int
|
||||
logoutToken string
|
||||
logoutErr error
|
||||
}
|
||||
|
||||
func (service *fakeAdminSessions) LoginAdmin(
|
||||
_ context.Context,
|
||||
input AdminLoginInput,
|
||||
) (AdminLoginResult, error) {
|
||||
service.loginCalls++
|
||||
service.loginInput = input
|
||||
return service.loginResult, service.loginErr
|
||||
}
|
||||
|
||||
func (service *fakeAdminSessions) LogoutAdmin(
|
||||
_ context.Context,
|
||||
token string,
|
||||
) error {
|
||||
service.logoutToken = token
|
||||
return service.logoutErr
|
||||
}
|
||||
|
||||
func newAuthTestRouter(
|
||||
t *testing.T,
|
||||
sessions AdminSessionService,
|
||||
) (http.Handler, *AuthHandler) {
|
||||
t.Helper()
|
||||
limiter, err := authcommon.NewAttemptLimiter(
|
||||
100,
|
||||
time.Minute,
|
||||
100,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAttemptLimiter() error = %v", err)
|
||||
}
|
||||
return newAuthTestRouterWithLimiter(t, sessions, limiter)
|
||||
}
|
||||
|
||||
func newAuthTestRouterWithLimiter(
|
||||
t *testing.T,
|
||||
sessions AdminSessionService,
|
||||
limiter authcommon.AttemptLimiter,
|
||||
) (http.Handler, *AuthHandler) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
renderer, err := NewRenderer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewRenderer() error = %v", err)
|
||||
}
|
||||
handler, err := NewAuthHandler(sessions, renderer, limiter)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuthHandler() error = %v", err)
|
||||
}
|
||||
router := gin.New()
|
||||
handler.RegisterPublic(router)
|
||||
handler.RegisterProtected(router)
|
||||
return router, handler
|
||||
}
|
||||
|
||||
func loginCSRF(t *testing.T, router http.Handler) *http.Cookie {
|
||||
t.Helper()
|
||||
response := performRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodGet,
|
||||
"/login",
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
return csrfCookie(t, response)
|
||||
}
|
||||
|
||||
var _ AdminSessionService = (*fakeAdminSessions)(nil)
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
@@ -14,6 +13,8 @@ import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"cmroubao/backend-api/internal/transport/authcommon"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -24,8 +25,8 @@ const (
|
||||
maxTitleBytes = 2048
|
||||
maxSKUBytes = 512
|
||||
maxDescriptionBytes = 8192
|
||||
csrfCookieName = "cmroubao_admin_csrf"
|
||||
csrfFormField = "csrf_token"
|
||||
csrfCookieName = authcommon.CSRFCookieName
|
||||
csrfFormField = authcommon.CSRFFormField
|
||||
formContentType = "text/html; charset=utf-8"
|
||||
cssContentType = "text/css; charset=utf-8"
|
||||
javascriptContentType = "text/javascript; charset=utf-8"
|
||||
@@ -50,8 +51,16 @@ func NewHandler(service Service, renderer *Renderer) (*Handler, error) {
|
||||
}
|
||||
|
||||
func (h *Handler) Register(routes gin.IRoutes) {
|
||||
h.RegisterStatic(routes)
|
||||
h.RegisterProtected(routes)
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterStatic(routes gin.IRoutes) {
|
||||
routes.GET("/static/admin.css", SecurityHeaders(), h.Stylesheet)
|
||||
routes.GET("/static/admin.js", SecurityHeaders(), h.Script)
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterProtected(routes gin.IRoutes) {
|
||||
routes.GET("/tasks", SecurityHeaders(), h.ListTasks)
|
||||
routes.GET("/tasks/new", SecurityHeaders(), h.NewTask)
|
||||
routes.POST("/tasks", SecurityHeaders(), h.CreateTask)
|
||||
@@ -97,6 +106,11 @@ func (h *Handler) serveStatic(
|
||||
}
|
||||
|
||||
func (h *Handler) ListTasks(ctx *gin.Context) {
|
||||
token, err := csrfToken(ctx)
|
||||
if err != nil {
|
||||
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
||||
return
|
||||
}
|
||||
input := ListTasksInput{
|
||||
Query: strings.TrimSpace(ctx.Query("q")),
|
||||
Status: strings.TrimSpace(ctx.Query("status")),
|
||||
@@ -127,6 +141,7 @@ func (h *Handler) ListTasks(ctx *gin.Context) {
|
||||
Page: pageView{
|
||||
Title: "采购任务",
|
||||
TasksCurrent: true,
|
||||
CSRFToken: token,
|
||||
},
|
||||
Query: input.Query,
|
||||
Status: input.Status,
|
||||
@@ -138,7 +153,7 @@ func (h *Handler) ListTasks(ctx *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *Handler) NewTask(ctx *gin.Context) {
|
||||
token, err := h.csrfToken(ctx)
|
||||
token, err := csrfToken(ctx)
|
||||
if err != nil {
|
||||
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
||||
return
|
||||
@@ -167,7 +182,7 @@ func (h *Handler) CreateTask(ctx *gin.Context) {
|
||||
if ctx.Request.MultipartForm != nil {
|
||||
defer ctx.Request.MultipartForm.RemoveAll()
|
||||
}
|
||||
if !h.validCSRF(ctx) {
|
||||
if !validCSRF(ctx) {
|
||||
h.renderError(
|
||||
ctx,
|
||||
http.StatusForbidden,
|
||||
@@ -239,7 +254,7 @@ func (h *Handler) TaskDetail(ctx *gin.Context) {
|
||||
h.renderServiceError(ctx, err, "无法加载任务详情,请稍后重试。")
|
||||
return
|
||||
}
|
||||
token, tokenErr := h.csrfToken(ctx)
|
||||
token, tokenErr := csrfToken(ctx)
|
||||
if tokenErr != nil {
|
||||
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
||||
return
|
||||
@@ -253,6 +268,7 @@ func (h *Handler) TaskDetail(ctx *gin.Context) {
|
||||
Page: pageView{
|
||||
Title: "任务详情",
|
||||
TasksCurrent: true,
|
||||
CSRFToken: token,
|
||||
},
|
||||
Task: taskDetailViewFrom(task),
|
||||
CSRFToken: token,
|
||||
@@ -263,7 +279,7 @@ func (h *Handler) TaskDetail(ctx *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *Handler) CancelTask(ctx *gin.Context) {
|
||||
if !h.validCSRF(ctx) {
|
||||
if !validCSRF(ctx) {
|
||||
h.renderError(
|
||||
ctx,
|
||||
http.StatusForbidden,
|
||||
@@ -328,7 +344,7 @@ func (h *Handler) uploadReference(
|
||||
}
|
||||
|
||||
func (h *Handler) createPageFromRequest(ctx *gin.Context) newTaskPageView {
|
||||
token, err := h.csrfToken(ctx)
|
||||
token, err := csrfToken(ctx)
|
||||
if err != nil {
|
||||
token = ""
|
||||
}
|
||||
@@ -338,6 +354,7 @@ func (h *Handler) createPageFromRequest(ctx *gin.Context) newTaskPageView {
|
||||
Page: pageView{
|
||||
Title: "新建采购任务",
|
||||
NewCurrent: true,
|
||||
CSRFToken: token,
|
||||
},
|
||||
CSRFToken: token,
|
||||
UploadKey: strings.TrimSpace(ctx.PostForm("upload_key")),
|
||||
@@ -438,19 +455,35 @@ func validBudget(value string) bool {
|
||||
return units > 0 || fraction > 0
|
||||
}
|
||||
|
||||
func (h *Handler) csrfToken(ctx *gin.Context) (string, error) {
|
||||
if cookie, err := ctx.Request.Cookie(csrfCookieName); err == nil &&
|
||||
validToken(cookie.Value) {
|
||||
return cookie.Value, nil
|
||||
func csrfToken(ctx *gin.Context) (string, error) {
|
||||
cookies := csrfCookies(ctx.Request)
|
||||
for index := len(cookies) - 1; index >= 0; index-- {
|
||||
if validToken(cookies[index].Value) {
|
||||
return cookies[index].Value, nil
|
||||
}
|
||||
}
|
||||
return rotateCSRFToken(ctx)
|
||||
}
|
||||
|
||||
func rotateCSRFToken(ctx *gin.Context) (string, error) {
|
||||
token, err := newToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
http.SetCookie(ctx.Writer, &http.Cookie{
|
||||
Name: csrfCookieName,
|
||||
Value: token,
|
||||
Name: authcommon.CSRFCookieName,
|
||||
Value: "",
|
||||
Path: "/tasks",
|
||||
Expires: time.Unix(1, 0).UTC(),
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: ctx.Request.TLS != nil,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
http.SetCookie(ctx.Writer, &http.Cookie{
|
||||
Name: authcommon.CSRFCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
MaxAge: 3600,
|
||||
HttpOnly: true,
|
||||
Secure: ctx.Request.TLS != nil,
|
||||
@@ -459,24 +492,28 @@ func (h *Handler) csrfToken(ctx *gin.Context) (string, error) {
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (h *Handler) validCSRF(ctx *gin.Context) bool {
|
||||
cookie, err := ctx.Request.Cookie(csrfCookieName)
|
||||
if err != nil || !validToken(cookie.Value) {
|
||||
return false
|
||||
func validCSRF(ctx *gin.Context) bool {
|
||||
presented := ctx.PostForm(authcommon.CSRFFormField)
|
||||
for _, cookie := range csrfCookies(ctx.Request) {
|
||||
if authcommon.ValidCSRFPair(cookie.Value, presented) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
formToken := strings.TrimSpace(ctx.PostForm(csrfFormField))
|
||||
if len(cookie.Value) != len(formToken) {
|
||||
return false
|
||||
return false
|
||||
}
|
||||
|
||||
func csrfCookies(request *http.Request) []*http.Cookie {
|
||||
result := make([]*http.Cookie, 0, 2)
|
||||
for _, cookie := range request.Cookies() {
|
||||
if cookie.Name == authcommon.CSRFCookieName {
|
||||
result = append(result, cookie)
|
||||
}
|
||||
}
|
||||
return subtle.ConstantTimeCompare(
|
||||
[]byte(cookie.Value),
|
||||
[]byte(formToken),
|
||||
) == 1
|
||||
return result
|
||||
}
|
||||
|
||||
func validToken(value string) bool {
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(value)
|
||||
return err == nil && len(decoded) == 32
|
||||
return authcommon.ValidOpaqueValue(value)
|
||||
}
|
||||
|
||||
func newToken() (string, error) {
|
||||
@@ -500,6 +537,7 @@ func newTaskPage(token string) (newTaskPageView, error) {
|
||||
Page: pageView{
|
||||
Title: "新建采购任务",
|
||||
NewCurrent: true,
|
||||
CSRFToken: token,
|
||||
},
|
||||
CSRFToken: token,
|
||||
UploadKey: uploadKey,
|
||||
@@ -561,9 +599,11 @@ func (h *Handler) renderError(
|
||||
title string,
|
||||
message string,
|
||||
) {
|
||||
token, _ := csrfToken(ctx)
|
||||
h.render(ctx, status, "error", errorPage{
|
||||
Page: pageView{
|
||||
Title: title,
|
||||
Title: title,
|
||||
CSRFToken: token,
|
||||
},
|
||||
Heading: title,
|
||||
Message: message,
|
||||
@@ -622,6 +662,7 @@ type pageView struct {
|
||||
Title string
|
||||
TasksCurrent bool
|
||||
NewCurrent bool
|
||||
CSRFToken string
|
||||
}
|
||||
|
||||
type statusOption struct {
|
||||
|
||||
@@ -112,7 +112,7 @@ func TestNewTaskIssuesReusableStrictCSRFCookie(t *testing.T) {
|
||||
cookie := csrfCookie(t, response)
|
||||
if !cookie.HttpOnly ||
|
||||
cookie.SameSite != http.SameSiteStrictMode ||
|
||||
cookie.Path != "/tasks" {
|
||||
cookie.Path != "/" {
|
||||
t.Fatalf("CSRF cookie = %+v", cookie)
|
||||
}
|
||||
body := response.Body.String()
|
||||
@@ -128,6 +128,7 @@ func TestNewTaskIssuesReusableStrictCSRFCookie(t *testing.T) {
|
||||
`name="quantity"`,
|
||||
`name="max_budget"`,
|
||||
`name="image"`,
|
||||
`action="/logout"`,
|
||||
"最高总预算",
|
||||
} {
|
||||
if !strings.Contains(body, required) {
|
||||
@@ -141,6 +142,39 @@ func TestNewTaskIssuesReusableStrictCSRFCookie(t *testing.T) {
|
||||
assertSecurityHeaders(t, response)
|
||||
}
|
||||
|
||||
func TestNewTaskPrefersRootCSRFCookieDuringLegacyPathMigration(
|
||||
t *testing.T,
|
||||
) {
|
||||
router := newTestRouter(t, &fakeService{})
|
||||
legacy := mustToken(t)
|
||||
root := mustToken(t)
|
||||
for root == legacy {
|
||||
root = mustToken(t)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/tasks/new", nil)
|
||||
request.AddCookie(&http.Cookie{
|
||||
Name: csrfCookieName,
|
||||
Value: legacy,
|
||||
Path: "/tasks",
|
||||
})
|
||||
request.AddCookie(&http.Cookie{
|
||||
Name: csrfCookieName,
|
||||
Value: root,
|
||||
Path: "/",
|
||||
})
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusOK ||
|
||||
!strings.Contains(
|
||||
response.Body.String(),
|
||||
`name="csrf_token" value="`+root+`"`,
|
||||
) {
|
||||
t.Fatalf("status/body = %d / %s", response.Code, response.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTaskRejectsCSRFBeforeCallingService(t *testing.T) {
|
||||
service := &fakeService{}
|
||||
router := newTestRouter(t, service)
|
||||
@@ -622,7 +656,9 @@ func csrfCookie(
|
||||
) *http.Cookie {
|
||||
t.Helper()
|
||||
for _, cookie := range response.Result().Cookies() {
|
||||
if cookie.Name == csrfCookieName {
|
||||
if cookie.Name == csrfCookieName &&
|
||||
cookie.Path == "/" &&
|
||||
cookie.Value != "" {
|
||||
return cookie
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +144,27 @@ a {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.logout-form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
min-height: 44px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
background: var(--surface);
|
||||
color: var(--text-muted);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.logout-button:hover,
|
||||
.logout-button:focus-visible {
|
||||
border-color: var(--text-muted);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.page {
|
||||
width: min(calc(100% - 32px), 1180px);
|
||||
margin: 0 auto;
|
||||
@@ -705,6 +726,65 @@ tbody tr:last-child td {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.login-body {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
background: #eef1f0;
|
||||
}
|
||||
|
||||
.login-page {
|
||||
width: min(calc(100% - 28px), 430px);
|
||||
margin: auto;
|
||||
padding: 32px 0;
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 18px;
|
||||
color: var(--ink);
|
||||
font-weight: 800;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
padding: 28px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.login-panel h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-panel form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.password-field {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.password-field input {
|
||||
border-radius: 5px 0 0 5px;
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
min-width: 64px;
|
||||
border-left: 0;
|
||||
border-radius: 0 5px 5px 0;
|
||||
}
|
||||
|
||||
.login-submit {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.page {
|
||||
width: min(calc(100% - 20px), 1180px);
|
||||
@@ -797,6 +877,11 @@ tbody tr:last-child td {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
padding-inline: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.title-row,
|
||||
.detail-title {
|
||||
align-items: stretch;
|
||||
|
||||
@@ -9,6 +9,19 @@
|
||||
if (summary) summary.focus();
|
||||
}
|
||||
|
||||
const passwordToggle = document.querySelector("[data-password-toggle]");
|
||||
if (passwordToggle) {
|
||||
const password = document.getElementById(
|
||||
passwordToggle.getAttribute("aria-controls"),
|
||||
);
|
||||
passwordToggle.addEventListener("click", () => {
|
||||
const showing = password.type === "text";
|
||||
password.type = showing ? "password" : "text";
|
||||
passwordToggle.textContent = showing ? "显示" : "隐藏";
|
||||
passwordToggle.setAttribute("aria-pressed", String(!showing));
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-loading-form]").forEach((form) => {
|
||||
form.addEventListener("submit", () => {
|
||||
form.setAttribute("aria-busy", "true");
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
{{define "login"}}
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<title>{{.Page.Title}} - 采购任务管理</title>
|
||||
{{template "document-head" .}}
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<main id="main-content" class="login-page">
|
||||
<a class="login-brand" href="/login" aria-label="采购任务管理登录">
|
||||
<span class="brand-mark" aria-hidden="true">采</span>
|
||||
<span>采购任务管理</span>
|
||||
</a>
|
||||
<section class="login-panel" aria-labelledby="login-title">
|
||||
<h1 id="login-title">管理端登录</h1>
|
||||
<p class="subtitle">使用采购管理员账号继续</p>
|
||||
|
||||
{{if .Message}}
|
||||
<div class="notice notice-error" role="alert" tabindex="-1" data-error-summary>
|
||||
{{.Message}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form method="post" action="/login" novalidate data-loading-form>
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="next" value="{{.Next}}">
|
||||
<div class="field">
|
||||
<label for="username">账号</label>
|
||||
<input id="username" name="username" value="{{.Username}}"
|
||||
autocomplete="username" autofocus required
|
||||
aria-describedby="username-error"
|
||||
{{if .UsernameError}}aria-invalid="true" data-error-field{{end}}>
|
||||
<p id="username-error" class="field-error">{{.UsernameError}}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="login-password">密码</label>
|
||||
<div class="password-field">
|
||||
<input id="login-password" name="password" type="password"
|
||||
autocomplete="current-password" required
|
||||
aria-describedby="password-error"
|
||||
{{if .PasswordError}}aria-invalid="true" data-error-field{{end}}>
|
||||
<button class="button password-toggle" type="button"
|
||||
aria-controls="login-password" aria-pressed="false"
|
||||
data-password-toggle>显示</button>
|
||||
</div>
|
||||
<p id="password-error" class="field-error">{{.PasswordError}}</p>
|
||||
</div>
|
||||
<button class="button primary login-submit" type="submit"
|
||||
data-loading-label="正在登录…">登录</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -18,5 +18,11 @@
|
||||
<a href="/tasks" {{if .Page.TasksCurrent}}aria-current="page"{{end}}>任务列表</a>
|
||||
<a href="/tasks/new" {{if .Page.NewCurrent}}aria-current="page"{{end}}>新建任务</a>
|
||||
</nav>
|
||||
{{if .Page.CSRFToken}}
|
||||
<form class="logout-form" method="post" action="/logout">
|
||||
<input type="hidden" name="csrf_token" value="{{.Page.CSRFToken}}">
|
||||
<button class="logout-button" type="submit">退出</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</header>
|
||||
{{end}}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
"cmroubao/backend-api/internal/transport/authcommon"
|
||||
"cmroubao/backend-api/internal/usecase"
|
||||
)
|
||||
|
||||
@@ -104,6 +105,7 @@ func (adapter *UsecaseAdapter) CreateTask(
|
||||
}
|
||||
result, err := adapter.tasks.Create(ctx, usecase.CreateTaskCommand{
|
||||
CreatorSubject: localAdminSubject,
|
||||
ActorUserID: actorUserID(ctx),
|
||||
IdempotencyKey: input.IdempotencyKey,
|
||||
Title: input.Title,
|
||||
Description: input.Description,
|
||||
@@ -118,12 +120,21 @@ func (adapter *UsecaseAdapter) CreateTask(
|
||||
return taskFromPurchase(result.Task), nil
|
||||
}
|
||||
|
||||
func actorUserID(ctx context.Context) string {
|
||||
principal, ok := authcommon.Principal(ctx)
|
||||
if !ok || principal.Role != domain.UserRoleAdmin {
|
||||
return ""
|
||||
}
|
||||
return principal.UserID
|
||||
}
|
||||
|
||||
func (adapter *UsecaseAdapter) CancelPending(
|
||||
ctx context.Context,
|
||||
input CancelPendingInput,
|
||||
) (Task, error) {
|
||||
task, err := adapter.tasks.Cancel(ctx, usecase.CancelTaskCommand{
|
||||
CreatorSubject: localAdminSubject,
|
||||
ActorUserID: actorUserID(ctx),
|
||||
TaskID: input.TaskID,
|
||||
Reason: "管理员取消",
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user