mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-04 07:06:36 +08:00
fix(cordis): fail-closed auth guards for user/message_gateway/admin (#1)
* autoresearch iter 23: fail-closed auth guarding for user/message_gateway Both plugins resolve contracts.AuthService in Apply to build their route middleware, but declared only DBService in Inject(). The kernel gates a plugin's Apply solely on declared deps, and cmd/app.go registers user before auth, so user mounted first, core.Inject failed, and loginMW silently degraded to a pass-through closure — leaving /api/v1/user change-password, profile and access-tokens unguarded. message_gateway was saved only by its later list position. Declare AuthService in Inject() for both, and pin the property with a reconcile-level test that mirrors production registration order and asserts the real auth middleware reaches the route table. * autoresearch iter 24: make auth middleware fallbacks fail closed user, message_gateway and admin each fell back to a c.Next() closure when contracts.AuthService could not be resolved, so a route would be served as if authenticated. For admin this is reachable at runtime: OnDispose calls service.ResetServices(), which nils the global the per-request guard reads, so requests still in flight during dispose bypass authorization entirely. Add ginutil.AuthUnavailable() and bind every fallback to it, with a test that drives each plugin's registered guard without an auth service present and asserts the request is aborted rather than passed through. * chore(autoresearch): log iter 23 (fail-open auth ordering, proven) * autoresearch iter 24 follow-up: let staticcheck infer the auth guard type * docs(autoresearch): log iters 24-25 and lessons 9-11 (declared-dep bug class, gate discipline)
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"Wavelet/core"
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/core/extpoints"
|
||||
"Wavelet/pkg/ginutil"
|
||||
"Wavelet/plugins/domain/admin/handler"
|
||||
"Wavelet/plugins/domain/admin/model"
|
||||
"Wavelet/plugins/domain/admin/service"
|
||||
@@ -142,6 +143,7 @@ func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
})
|
||||
|
||||
// 0a. Dynamic Auth Middlewares
|
||||
denyAuth := ginutil.AuthUnavailable()
|
||||
var loginMW gin.HandlerFunc = func(c *gin.Context) {
|
||||
if authSvc := service.GetAuthService(c.Request.Context()); authSvc != nil {
|
||||
if mw, ok := authSvc.RequireAuthMiddleware().(gin.HandlerFunc); ok {
|
||||
@@ -149,7 +151,7 @@ func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
return
|
||||
}
|
||||
}
|
||||
c.Next()
|
||||
denyAuth(c)
|
||||
}
|
||||
var adminMW gin.HandlerFunc = func(c *gin.Context) {
|
||||
if authSvc := service.GetAuthService(c.Request.Context()); authSvc != nil {
|
||||
@@ -158,7 +160,7 @@ func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
return
|
||||
}
|
||||
}
|
||||
c.Next()
|
||||
denyAuth(c)
|
||||
}
|
||||
|
||||
// 0b. Register migrations
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package domain_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"Wavelet/core"
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/core/extpoints"
|
||||
"Wavelet/plugins/domain/admin"
|
||||
"Wavelet/plugins/domain/message_gateway"
|
||||
"Wavelet/plugins/domain/user"
|
||||
)
|
||||
|
||||
// The kernel only gates a plugin's Apply on the services it DECLARES in Inject,
|
||||
// so a plugin that consumes contracts.AuthService without declaring it can be
|
||||
// mounted before auth exists. Those plugins fall back to a pass-through
|
||||
// middleware, which silently un-guards their routes.
|
||||
|
||||
func sentinelLogin(c *gin.Context) { c.Next() }
|
||||
func sentinelAdmin(c *gin.Context) { c.Next() }
|
||||
func sentinelNoToken(c *gin.Context) { c.Next() }
|
||||
|
||||
type stubAuthService struct{ contracts.AuthService }
|
||||
|
||||
func (stubAuthService) RequireAuthMiddleware() any { return gin.HandlerFunc(sentinelLogin) }
|
||||
func (stubAuthService) RequireAdminMiddleware() any { return gin.HandlerFunc(sentinelAdmin) }
|
||||
func (stubAuthService) DisallowTokenAuthMiddleware() any { return gin.HandlerFunc(sentinelNoToken) }
|
||||
|
||||
type stubDBService struct{ contracts.DBService }
|
||||
|
||||
// providerPlugin publishes a contract into the container at Apply time, the way
|
||||
// the real infra and domain plugins do.
|
||||
type providerPlugin struct {
|
||||
name string
|
||||
provide func(*core.Context) error
|
||||
}
|
||||
|
||||
func (p providerPlugin) Name() string { return p.name }
|
||||
|
||||
func (p providerPlugin) Apply(ctx *core.Context) error { return p.provide(ctx) }
|
||||
|
||||
func dbProvider() core.Plugin {
|
||||
return providerPlugin{name: "stub-database", provide: func(ctx *core.Context) error {
|
||||
core.Provide[contracts.DBService](ctx, stubDBService{})
|
||||
return nil
|
||||
}}
|
||||
}
|
||||
|
||||
func authProvider() core.Plugin {
|
||||
return providerPlugin{name: "stub-auth", provide: func(ctx *core.Context) error {
|
||||
core.Provide[contracts.AuthService](ctx, stubAuthService{})
|
||||
return nil
|
||||
}}
|
||||
}
|
||||
|
||||
func findRoute(routes []extpoints.RouteDefinition, method, path string) (extpoints.RouteDefinition, bool) {
|
||||
for _, rd := range routes {
|
||||
if rd.Method == method && rd.Path == path {
|
||||
return rd, true
|
||||
}
|
||||
}
|
||||
return extpoints.RouteDefinition{}, false
|
||||
}
|
||||
|
||||
// codePointer resolves the function code pointer of a registered handler so
|
||||
// identity can be compared without depending on gin internals.
|
||||
func codePointer(handler any) uintptr {
|
||||
switch fn := handler.(type) {
|
||||
case gin.HandlerFunc:
|
||||
return reflect.ValueOf(fn).Pointer()
|
||||
case func(*gin.Context):
|
||||
return reflect.ValueOf(fn).Pointer()
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func assertsMiddleware(t *testing.T, routes []extpoints.RouteDefinition, method, path string, want func(*gin.Context)) {
|
||||
t.Helper()
|
||||
|
||||
rd, ok := findRoute(routes, method, path)
|
||||
if !ok {
|
||||
t.Fatalf("route %s %s was never registered", method, path)
|
||||
}
|
||||
wantPtr := codePointer(gin.HandlerFunc(want))
|
||||
for _, h := range rd.Handlers {
|
||||
if codePointer(h) == wantPtr {
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, m := range rd.Middlewares {
|
||||
if codePointer(m) == wantPtr {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("route %s %s is not guarded by the auth middleware: handlers=%d middlewares=%d",
|
||||
method, path, len(rd.Handlers), len(rd.Middlewares))
|
||||
}
|
||||
|
||||
// TestRoutesMountedBeforeAuthServiceAreGuarded 回归:cmd/app.go 把 user/message_gateway
|
||||
// 注册在 auth 之前,若插件未在 Inject 中声明 contracts.AuthService,reconcile 会先
|
||||
// Apply 它们,导致鉴权中间件退化为透传闭包,路由完全不受保护。
|
||||
func TestRoutesMountedBeforeAuthServiceAreGuarded(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
ctx := core.NewContext(context.Background())
|
||||
|
||||
// Registration order mirrors cmd/app.go: the auth provider comes last.
|
||||
app := core.NewApp(
|
||||
core.WithContext(ctx),
|
||||
core.WithPlugins(
|
||||
dbProvider(),
|
||||
user.New(),
|
||||
message_gateway.New(),
|
||||
authProvider(),
|
||||
),
|
||||
)
|
||||
require.NoError(t, app.ApplyPlugins())
|
||||
|
||||
routes := ctx.Router().Routes()
|
||||
|
||||
assertsMiddleware(t, routes, "POST", "/api/v1/user/change-password", sentinelLogin)
|
||||
assertsMiddleware(t, routes, "PUT", "/api/v1/user/profile", sentinelLogin)
|
||||
assertsMiddleware(t, routes, "GET", "/api/v1/user/access-tokens", sentinelLogin)
|
||||
assertsMiddleware(t, routes, "GET", "/api/v1/user/access-tokens", sentinelNoToken)
|
||||
assertsMiddleware(t, routes, "GET", "/api/v1/message-gateway/channels", sentinelLogin)
|
||||
assertsMiddleware(t, routes, "GET", "/api/v1/admin/message-gateway/channels", sentinelAdmin)
|
||||
}
|
||||
|
||||
// TestAuthConsumersDeclareAuthDependency 是同一缺陷的架构面:声明依赖是内核排序的唯一
|
||||
// 依据,漏声明会让正确性取决于注册表顺序。
|
||||
func TestAuthConsumersDeclareAuthDependency(t *testing.T) {
|
||||
want := reflect.TypeFor[contracts.AuthService]()
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
deps []reflect.Type
|
||||
}{
|
||||
{"user", user.New().Inject()},
|
||||
{"message_gateway", message_gateway.New().Inject()},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Contains(t, tc.deps, want,
|
||||
"%s resolves contracts.AuthService in Apply, so it must declare it in Inject", tc.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// firstGuard returns the outermost middleware registered for the first route
|
||||
// matching method and path prefix — the auth guard the plugin resolved at Apply.
|
||||
func firstGuard(t *testing.T, routes []extpoints.RouteDefinition, method, prefix string) gin.HandlerFunc {
|
||||
t.Helper()
|
||||
|
||||
for _, rd := range routes {
|
||||
if rd.Method != method || !strings.HasPrefix(rd.Path, prefix) {
|
||||
continue
|
||||
}
|
||||
for _, candidate := range rd.Middlewares {
|
||||
if mw, ok := candidate.(gin.HandlerFunc); ok {
|
||||
return mw
|
||||
}
|
||||
}
|
||||
for _, candidate := range rd.Handlers {
|
||||
if mw, ok := candidate.(gin.HandlerFunc); ok {
|
||||
return mw
|
||||
}
|
||||
}
|
||||
t.Fatalf("route %s %s has no inspectable guard", method, rd.Path)
|
||||
}
|
||||
t.Fatalf("no route registered for %s %s*", method, prefix)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestAuthGuardFailsClosed 回归:鉴权服务无法解析时兜底必须是拒绝。旧实现兜底为
|
||||
// c.Next(),所以任何装配缺失——例如 admin 的 OnDispose 调用 service.ResetServices
|
||||
// 把全局 authService 置 nil——都会让路由以“已登录”的姿态直达业务处理函数。
|
||||
func TestAuthGuardFailsClosed(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
apply func(*core.Context) error
|
||||
method string
|
||||
prefix string
|
||||
}{
|
||||
{"user", user.New().Apply, http.MethodPost, "/api/v1/user/change-password"},
|
||||
{"message_gateway", message_gateway.New().Apply, http.MethodGet, "/api/v1/message-gateway"},
|
||||
{"admin", admin.New().Apply, http.MethodGet, "/api/v1/admin"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
require.NoError(t, tc.apply(ctx))
|
||||
|
||||
guard := firstGuard(t, ctx.Router().Routes(), tc.method, tc.prefix)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(tc.method, tc.prefix, nil)
|
||||
guard(c)
|
||||
|
||||
assert.True(t, c.IsAborted(),
|
||||
"%s guard must reject the request when contracts.AuthService is unavailable", tc.name)
|
||||
assert.NotEmpty(t, c.Errors,
|
||||
"%s guard must record why the request was rejected", tc.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"Wavelet/core"
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/core/extpoints"
|
||||
"Wavelet/pkg/ginutil"
|
||||
"Wavelet/pkg/util"
|
||||
"Wavelet/plugins/domain/message_gateway/handler"
|
||||
"Wavelet/plugins/domain/message_gateway/model"
|
||||
@@ -59,6 +60,10 @@ func (p *Plugin) Name() string {
|
||||
func (p *Plugin) Inject() []reflect.Type {
|
||||
return []reflect.Type{
|
||||
reflect.TypeFor[contracts.DBService](),
|
||||
// AuthService is captured as a middleware value in Apply, so it cannot
|
||||
// be late-bound with core.When like the other services below; the
|
||||
// kernel must mount auth first or the routes get a pass-through guard.
|
||||
reflect.TypeFor[contracts.AuthService](),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,8 +135,9 @@ func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
})
|
||||
|
||||
// 0. Resolve auth service for middleware (via IoC, not direct import)
|
||||
var loginMW gin.HandlerFunc = func(c *gin.Context) { c.Next() }
|
||||
var adminMW gin.HandlerFunc = func(c *gin.Context) { c.Next() }
|
||||
denyAuth := ginutil.AuthUnavailable()
|
||||
loginMW := denyAuth
|
||||
adminMW := denyAuth
|
||||
if authSvc, err := core.Inject[contracts.AuthService](ctx); err == nil && authSvc != nil {
|
||||
if mw, ok := authSvc.RequireAuthMiddleware().(gin.HandlerFunc); ok {
|
||||
loginMW = mw
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"Wavelet/core"
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/core/extpoints"
|
||||
"Wavelet/pkg/ginutil"
|
||||
"context"
|
||||
"embed"
|
||||
"reflect"
|
||||
@@ -56,6 +57,10 @@ func (p *Plugin) Name() string {
|
||||
func (p *Plugin) Inject() []reflect.Type {
|
||||
return []reflect.Type{
|
||||
reflect.TypeFor[contracts.DBService](),
|
||||
// Apply resolves AuthService to build the route auth middleware. The
|
||||
// kernel only gates Apply on declared deps, so leaving this out lets
|
||||
// user mount before auth and fall back to a pass-through middleware.
|
||||
reflect.TypeFor[contracts.AuthService](),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,8 +90,9 @@ func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
})
|
||||
|
||||
// 0.1 Resolve auth service for middleware (via IoC, not direct import)
|
||||
var loginMW gin.HandlerFunc = func(c *gin.Context) { c.Next() }
|
||||
var noTokenMW gin.HandlerFunc = func(c *gin.Context) { c.Next() }
|
||||
denyAuth := ginutil.AuthUnavailable()
|
||||
loginMW := denyAuth
|
||||
noTokenMW := denyAuth
|
||||
if authSvc, err := core.Inject[contracts.AuthService](ctx); err == nil && authSvc != nil {
|
||||
if mw, ok := authSvc.RequireAuthMiddleware().(gin.HandlerFunc); ok {
|
||||
loginMW = mw
|
||||
|
||||
Reference in New Issue
Block a user