@@ -0,0 +1,252 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/model"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/service"
|
||||
)
|
||||
|
||||
type LoginLimiter interface {
|
||||
Login(context.Context, string, string) (bool, error)
|
||||
}
|
||||
type IdentityOptions struct {
|
||||
Auth *service.AuthService
|
||||
Accounts *service.AccountService
|
||||
Limiter LoginLimiter
|
||||
DevelopmentLogin bool
|
||||
SecureCookie bool
|
||||
}
|
||||
|
||||
const sessionCookie = "veyra_admin_session"
|
||||
|
||||
func bindIdentity(r *gin.Engine, options IdentityOptions) {
|
||||
r.SetTrustedProxies(nil)
|
||||
adminAuth := func(c *gin.Context) { authenticate(c, options, "admin") }
|
||||
appAuth := func(c *gin.Context) { authenticate(c, options, "app") }
|
||||
login := func(audience string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !options.DevelopmentLogin {
|
||||
fail(c, apperror.ErrUnavailable)
|
||||
return
|
||||
}
|
||||
if audience == "admin" && !sameOrigin(c.Request) {
|
||||
fail(c, apperror.ErrForbidden)
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
LoginName string `json:"login_name"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if !decode(c, &input) {
|
||||
return
|
||||
}
|
||||
if len(input.LoginName) > 64 || len(input.Password) > 72 {
|
||||
fail(c, apperror.ErrUnauthenticated)
|
||||
return
|
||||
}
|
||||
if options.Auth == nil || options.Limiter == nil {
|
||||
fail(c, apperror.ErrUnavailable)
|
||||
return
|
||||
}
|
||||
allowed, err := options.Limiter.Login(c.Request.Context(), c.ClientIP(), strings.ToLower(strings.TrimSpace(input.LoginName)))
|
||||
if err != nil {
|
||||
fail(c, apperror.ErrUnavailable)
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
c.Header("Retry-After", "900")
|
||||
failure(c, http.StatusTooManyRequests, "RATE_LIMITED", "登录尝试过于频繁,请稍后再试。", true)
|
||||
return
|
||||
}
|
||||
result, err := options.Auth.Login(c.Request.Context(), input.LoginName, input.Password, audience)
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
if audience == "admin" {
|
||||
http.SetCookie(c.Writer, &http.Cookie{Name: sessionCookie, Value: result.Token, Path: "/api/admin", HttpOnly: true, Secure: options.SecureCookie, SameSite: http.SameSiteStrictMode, MaxAge: 8 * 60 * 60, Expires: result.ExpiresAt})
|
||||
success(c, http.StatusOK, gin.H{"user": result.User, "expires_at": result.ExpiresAt})
|
||||
} else {
|
||||
success(c, http.StatusOK, gin.H{"user": result.User, "access_token": result.Token, "expires_at": result.ExpiresAt})
|
||||
}
|
||||
}
|
||||
}
|
||||
r.POST("/api/admin/v1/auth/login", login("admin"))
|
||||
r.POST("/api/v1/auth/login", login("app"))
|
||||
logout := func(audience string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if audience == "admin" && !sameOrigin(c.Request) {
|
||||
fail(c, apperror.ErrForbidden)
|
||||
return
|
||||
}
|
||||
token := requestToken(c.Request, audience)
|
||||
if err := options.Auth.Logout(c.Request.Context(), token, audience); err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
if audience == "admin" {
|
||||
http.SetCookie(c.Writer, &http.Cookie{Name: sessionCookie, Value: "", Path: "/api/admin", HttpOnly: true, Secure: options.SecureCookie, SameSite: http.SameSiteStrictMode, MaxAge: -1})
|
||||
}
|
||||
success(c, http.StatusOK, gin.H{"revoked": true})
|
||||
}
|
||||
}
|
||||
r.POST("/api/admin/v1/auth/logout", adminAuth, logout("admin"))
|
||||
r.POST("/api/v1/auth/logout", appAuth, logout("app"))
|
||||
r.GET("/api/admin/v1/me", adminAuth, func(c *gin.Context) { success(c, http.StatusOK, currentUser(c)) })
|
||||
r.GET("/api/v1/me", appAuth, func(c *gin.Context) { success(c, http.StatusOK, currentUser(c)) })
|
||||
r.GET("/api/admin/v1/users", adminAuth, func(c *gin.Context) {
|
||||
users, err := options.Auth.ListUsers(c.Request.Context())
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
success(c, http.StatusOK, users)
|
||||
})
|
||||
r.GET("/api/v1/accounts", appAuth, func(c *gin.Context) {
|
||||
if options.Accounts == nil {
|
||||
fail(c, apperror.ErrUnavailable)
|
||||
return
|
||||
}
|
||||
list, err := options.Accounts.List(c.Request.Context(), currentUser(c).ID)
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
success(c, http.StatusOK, list)
|
||||
})
|
||||
r.POST("/api/v1/accounts", appAuth, func(c *gin.Context) {
|
||||
var input struct {
|
||||
Mode string `json:"mode"`
|
||||
Name string `json:"name"`
|
||||
InitialCash *string `json:"initial_cash"`
|
||||
}
|
||||
if !decode(c, &input) {
|
||||
return
|
||||
}
|
||||
if options.Accounts == nil {
|
||||
fail(c, apperror.ErrUnavailable)
|
||||
return
|
||||
}
|
||||
account, err := options.Accounts.Create(c.Request.Context(), currentUser(c).ID, input.Mode, input.Name, input.InitialCash)
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
success(c, http.StatusCreated, account)
|
||||
})
|
||||
r.GET("/api/v1/accounts/:id/summary", appAuth, func(c *gin.Context) {
|
||||
if options.Accounts == nil {
|
||||
fail(c, apperror.ErrUnavailable)
|
||||
return
|
||||
}
|
||||
account, err := options.Accounts.Get(c.Request.Context(), currentUser(c).ID, c.Param("id"))
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
success(c, http.StatusOK, account)
|
||||
})
|
||||
}
|
||||
|
||||
func authenticate(c *gin.Context, options IdentityOptions, audience string) {
|
||||
if options.Auth == nil {
|
||||
fail(c, apperror.ErrUnavailable)
|
||||
return
|
||||
}
|
||||
token := requestToken(c.Request, audience)
|
||||
if token == "" {
|
||||
fail(c, apperror.ErrUnauthenticated)
|
||||
return
|
||||
}
|
||||
user, err := options.Auth.Authenticate(c.Request.Context(), token, audience)
|
||||
if err != nil {
|
||||
fail(c, err)
|
||||
return
|
||||
}
|
||||
c.Set("user", user)
|
||||
c.Next()
|
||||
}
|
||||
|
||||
func requestToken(r *http.Request, audience string) string {
|
||||
if audience == "admin" {
|
||||
cookie, err := r.Cookie(sessionCookie)
|
||||
if err == nil {
|
||||
return cookie.Value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
scheme, token, ok := strings.Cut(r.Header.Get("Authorization"), " ")
|
||||
if !ok || !strings.EqualFold(scheme, "Bearer") {
|
||||
return ""
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func currentUser(c *gin.Context) model.User { return c.MustGet("user").(model.User) }
|
||||
|
||||
// The embedded console and Vite development proxy both call APIs on their own origin.
|
||||
func sameOrigin(r *http.Request) bool {
|
||||
origin, err := url.Parse(r.Header.Get("Origin"))
|
||||
if err != nil || origin.Host == "" || origin.User != nil || origin.Path != "" || origin.RawQuery != "" || origin.Fragment != "" {
|
||||
return false
|
||||
}
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
return origin.Scheme == scheme && origin.Host == r.Host
|
||||
}
|
||||
|
||||
func decode(c *gin.Context, value any) bool {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10)
|
||||
if !strings.HasPrefix(strings.ToLower(c.GetHeader("Content-Type")), "application/json") {
|
||||
failure(c, http.StatusUnsupportedMediaType, "INVALID_CONTENT_TYPE", "请使用 application/json。", false)
|
||||
return false
|
||||
}
|
||||
decoder := json.NewDecoder(c.Request.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(value); err != nil {
|
||||
fail(c, apperror.ErrInvalidInput)
|
||||
return false
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
fail(c, apperror.ErrInvalidInput)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func success(c *gin.Context, status int, data any) {
|
||||
c.JSON(status, gin.H{"data": data, "meta": gin.H{"request_id": c.Writer.Header().Get("X-Request-ID"), "server_time": time.Now().UTC()}})
|
||||
}
|
||||
func failure(c *gin.Context, status int, code, message string, retryable bool) {
|
||||
c.AbortWithStatusJSON(status, gin.H{"error": gin.H{"code": code, "message": message, "retryable": retryable}, "meta": gin.H{"request_id": c.Writer.Header().Get("X-Request-ID")}})
|
||||
}
|
||||
func fail(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, apperror.ErrInvalidInput):
|
||||
failure(c, http.StatusUnprocessableEntity, "INVALID_INPUT", "请检查输入内容;资金最多两位小数,密码为 12–72 字节。", false)
|
||||
case errors.Is(err, apperror.ErrUnauthenticated):
|
||||
failure(c, http.StatusUnauthorized, "UNAUTHENTICATED", "登录信息无效或会话已过期。", false)
|
||||
case errors.Is(err, apperror.ErrForbidden):
|
||||
failure(c, http.StatusForbidden, "FORBIDDEN", "无权执行此操作。", false)
|
||||
case errors.Is(err, apperror.ErrConflict):
|
||||
failure(c, http.StatusConflict, "CONFLICT", "该登录名或账户类型已存在。", false)
|
||||
case errors.Is(err, apperror.ErrNotFound):
|
||||
failure(c, http.StatusNotFound, "NOT_FOUND", "记录不存在。", false)
|
||||
default:
|
||||
failure(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "服务暂不可用;请检查依赖连接和数据库迁移。", true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/service"
|
||||
)
|
||||
|
||||
func TestAuthenticationAndCSRFBoundaries(t *testing.T) {
|
||||
auth, err := service.NewAuthService(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
probe := func(context.Context) error { return nil }
|
||||
router := NewRouter(service.NewHealthService(probe, probe), slog.New(slog.NewTextHandler(io.Discard, nil)), IdentityOptions{Auth: auth, DevelopmentLogin: true})
|
||||
for _, tc := range []struct {
|
||||
method, path, body, origin string
|
||||
status int
|
||||
}{
|
||||
{http.MethodGet, "/api/admin/v1/users", "", "", 401},
|
||||
{http.MethodGet, "/api/v1/accounts", "", "", 401},
|
||||
{http.MethodPost, "/api/admin/v1/auth/login", `{"login_name":"admin","password":"test-password"}`, "http://evil.example", 403},
|
||||
{http.MethodPost, "/api/admin/v1/auth/login", `{"login_name":"admin","password":"test-password"}`, "", 403},
|
||||
{http.MethodPost, "/api/admin/v1/auth/login", `{"login_name":"admin","password":"test-password","role":"ADMIN"}`, "http://example.com", 422},
|
||||
{http.MethodPost, "/api/v1/auth/login", `{} {}`, "", 422},
|
||||
} {
|
||||
req := httptest.NewRequest(tc.method, "http://example.com"+tc.path, strings.NewReader(tc.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Origin", tc.origin)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != tc.status {
|
||||
t.Fatalf("%s %s got %d want %d", tc.method, tc.path, rec.Code, tc.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevelopmentLoginDisabledByDefault(t *testing.T) {
|
||||
probe := func(context.Context) error { return nil }
|
||||
router := NewRouter(service.NewHealthService(probe, probe), slog.New(slog.NewTextHandler(io.Discard, nil)), IdentityOptions{})
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{}`)))
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatal("development login open outside explicitly enabled environment")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Package handler adapts application services to the HTTP protocol.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/adminui"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/service"
|
||||
)
|
||||
|
||||
func NewRouter(health *service.HealthService, logger *slog.Logger, identity IdentityOptions) http.Handler {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
requestID := uuid.NewString()
|
||||
c.Header("X-Request-ID", requestID)
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("Cache-Control", "no-store")
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
logger.Error("request panic", "request_id", requestID)
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": gin.H{"code": "INTERNAL_ERROR", "message": "Internal server error", "request_id": requestID}})
|
||||
}
|
||||
logger.Info("http request", "method", c.Request.Method, "route", c.FullPath(), "status", c.Writer.Status(), "duration_ms", time.Since(start).Milliseconds(), "request_id", requestID)
|
||||
}()
|
||||
c.Next()
|
||||
})
|
||||
r.GET("/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "alive"}) })
|
||||
check := func(c *gin.Context) {
|
||||
h := health.Check(c.Request.Context())
|
||||
status := http.StatusOK
|
||||
if h.Status != "ready" {
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
c.JSON(status, h)
|
||||
}
|
||||
r.GET("/readyz", check)
|
||||
r.GET("/api/v1/system/health", check)
|
||||
bindIdentity(r, identity)
|
||||
console := adminui.Handler()
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
if !strings.HasPrefix(c.Request.URL.Path, "/api/") && c.Request.URL.Path != "/api" {
|
||||
console.ServeHTTP(c.Writer, c.Request)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"code": "NOT_FOUND", "message": "Endpoint not found"}})
|
||||
})
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/service"
|
||||
)
|
||||
|
||||
func TestLivenessAndReadinessAreIndependent(t *testing.T) {
|
||||
secretError := "password=secret-internal-detail"
|
||||
health := service.NewHealthService(nil, func(context.Context) error { return errors.New(secretError) })
|
||||
router := NewRouter(health, slog.New(slog.NewTextHandler(io.Discard, nil)), IdentityOptions{})
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
status int
|
||||
}{{"/healthz", http.StatusOK}, {"/readyz", http.StatusServiceUnavailable}, {"/api/v1/system/health", http.StatusServiceUnavailable}} {
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tc.path, nil))
|
||||
if rec.Code != tc.status {
|
||||
t.Fatalf("%s: got %d", tc.path, rec.Code)
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), secretError) {
|
||||
t.Fatal("health endpoint leaked internal error")
|
||||
}
|
||||
if rec.Header().Get("X-Request-ID") == "" {
|
||||
t.Fatal("missing trace ID")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadyWhenBothDependenciesRespond(t *testing.T) {
|
||||
probe := func(context.Context) error { return nil }
|
||||
router := NewRouter(service.NewHealthService(probe, probe), slog.New(slog.NewTextHandler(io.Discard, nil)), IdentityOptions{})
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddedConsoleAndAPIBoundary(t *testing.T) {
|
||||
probe := func(context.Context) error { return nil }
|
||||
router := NewRouter(service.NewHealthService(probe, probe), slog.New(slog.NewTextHandler(io.Discard, nil)), IdentityOptions{})
|
||||
for _, path := range []string{"/", "/research/candidates"} {
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if rec.Code == http.StatusServiceUnavailable {
|
||||
t.Skip("build management console before testing embedded assets")
|
||||
}
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "<html") {
|
||||
t.Fatalf("embedded SPA %s: status=%d", path, rec.Code)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"/api/v1/missing", "/assets/missing.js", "/.keep"} {
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "<html") {
|
||||
t.Fatalf("missing route %s must not serve the SPA: %d", path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user