diff --git a/backend/plugins/domain/auth/errs.go b/backend/plugins/domain/auth/errs.go index 3d63894e..08a4f0cb 100644 --- a/backend/plugins/domain/auth/errs.go +++ b/backend/plugins/domain/auth/errs.go @@ -30,11 +30,9 @@ const ( errExternalAccountBindingIncomplete = "外部帐号绑定信息不完整" errExternalAccountAlreadyBoundToAnother = "该外部帐号已被其他用户绑定" errExternalAccountBindingIDRequired = "外部帐号绑定记录 ID 不能为空" - errAdminRequired = "无权访问" - //nolint:gosec // error message, not hardcoded credentials - errTokenAdminRequired = "令牌无管理员权限" - errBannedAccount = "账号已被封禁" - errUnAuthorized = "未登录" + errInsufficientPermission = "权限不足" + errBannedAccount = "账号已被封禁" + errUnAuthorized = "未登录" ) // Service 层与鉴权中间件内部错误文案(保持与重构前逐字一致) diff --git a/backend/plugins/domain/auth/middleware.go b/backend/plugins/domain/auth/middleware.go index ea93a667..7f2d575a 100644 --- a/backend/plugins/domain/auth/middleware.go +++ b/backend/plugins/domain/auth/middleware.go @@ -162,15 +162,13 @@ func AdminRequired() gin.HandlerFunc { isTokenAuth, _ := ginutil.GetFromContext[bool](c, contracts.AuthTokenAuthKey) isTokenAdmin, _ := ginutil.GetFromContext[bool](c, contracts.AuthTokenAdminKey) - // 如果是通过 Token 鉴权,要求该 Token 具备管理员权限或者用户本身为管理员 + // Logged-in but lacking admin permission is 403, not 401/404. if isTokenAuth && !isTokenAdmin && !user.IsAdmin { - response.AbortNotFound(c, errTokenAdminRequired) + response.AbortForbidden(c, errInsufficientPermission) return } - - // 如果是通过 Session 鉴权,直接检查用户的 is_admin 属性 if !isTokenAuth && !user.IsAdmin { - response.AbortNotFound(c, errAdminRequired) + response.AbortForbidden(c, errInsufficientPermission) return } diff --git a/backend/plugins/domain/auth/plugin_test.go b/backend/plugins/domain/auth/plugin_test.go index 2c5b52b8..a4f81cd2 100644 --- a/backend/plugins/domain/auth/plugin_test.go +++ b/backend/plugins/domain/auth/plugin_test.go @@ -40,6 +40,7 @@ type testUser struct { ID uint64 `gorm:"primaryKey"` Username string IsActive bool + IsAdmin bool LastLoginAt time.Time } diff --git a/backend/plugins/domain/auth/service_test.go b/backend/plugins/domain/auth/service_test.go index 2431ec67..89ddef37 100644 --- a/backend/plugins/domain/auth/service_test.go +++ b/backend/plugins/domain/auth/service_test.go @@ -399,3 +399,30 @@ func TestAuthWhitelistMiddleware(t *testing.T) { engine.ServeHTTP(w2, req2) assert.Equal(t, http.StatusUnauthorized, w2.Code) } + +func TestAdminRequiredReturnsForbiddenForLoggedInNonAdmin(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupTestDB(t) + require.NoError(t, db.Create(&testUser{ID: 42, Username: "member", IsActive: true, IsAdmin: false}).Error) + + svc := newTestAuthService(t, db) + mw, ok := svc.RequireAdminMiddleware().(gin.HandlerFunc) + require.True(t, ok) + + engine := newSessionEngine() + engine.Use(func(c *gin.Context) { + session := sessions.Default(c) + session.Set(auth.UserIDKey, uint64(42)) + require.NoError(t, session.Save()) + c.Next() + }) + engine.Use(mw) + engine.GET("/admin-only", func(c *gin.Context) { + c.JSON(http.StatusOK, response.OK("ok")) + }) + + w := httptest.NewRecorder() + engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/admin-only", nil)) + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), "权限不足") +} diff --git a/backend/plugins/domain/cap/middleware.go b/backend/plugins/domain/cap/middleware.go index 643f9e06..aa9dc19a 100644 --- a/backend/plugins/domain/cap/middleware.go +++ b/backend/plugins/domain/cap/middleware.go @@ -17,19 +17,19 @@ func VerifyMiddleware(mgr *Manager, scope string) gin.HandlerFunc { return } if mgr == nil { - response.AbortUnauthorized(c, errCapTokenInvalidOrExpired) + response.AbortBadRequest(c, errCapTokenInvalidOrExpired) return } token := c.GetHeader("X-Cap-Token") if token == "" { - response.AbortUnauthorized(c, errCapTokenMissing) + response.AbortBadRequest(c, errCapTokenMissing) return } valid, err := mgr.VerifyToken(c.Request.Context(), token, scope) if err != nil || !valid { - response.AbortUnauthorized(c, errCapTokenInvalidOrExpired) + response.AbortBadRequest(c, errCapTokenInvalidOrExpired) return } diff --git a/backend/plugins/domain/cap/middleware_test.go b/backend/plugins/domain/cap/middleware_test.go new file mode 100644 index 00000000..0aaf1da1 --- /dev/null +++ b/backend/plugins/domain/cap/middleware_test.go @@ -0,0 +1,32 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package cap + +import ( + "Wavelet/pkg/response" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestVerifyMiddlewareMissingTokenIsBadRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + restore := InstallTestRuntimeSettings(RuntimeSettings{LoginEnabled: true}) + t.Cleanup(restore) + + engine := gin.New() + engine.Use(response.ErrorHandlerMiddleware()) + engine.POST("/register", VerifyMiddleware(GetDefaultManager(), "register"), func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodPost, "/register", nil) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Errorf("VerifyMiddleware() status = %d, want %d", rec.Code, http.StatusBadRequest) + } +} diff --git a/backend/plugins/domain/upload/plugin.go b/backend/plugins/domain/upload/plugin.go index 310eef97..16a06442 100644 --- a/backend/plugins/domain/upload/plugin.go +++ b/backend/plugins/domain/upload/plugin.go @@ -8,6 +8,7 @@ import ( "Wavelet/core" "Wavelet/core/contracts" "Wavelet/core/extpoints" + "Wavelet/pkg/ginutil" "Wavelet/plugins/domain/upload/filesrv" "Wavelet/plugins/domain/upload/handler" "Wavelet/plugins/domain/upload/shared" @@ -66,13 +67,25 @@ func (p *Plugin) Apply(ctx *core.Context) error { return nil }) - // 0. Resolve auth service for middleware - var authSvc contracts.AuthService - if err := core.Using[contracts.AuthService](ctx, func(svc contracts.AuthService) { authSvc = svc }); err != nil { - return err + denyAuth := ginutil.AuthUnavailable() + loginMW := func(c *gin.Context) { + if svc := shared.GetAuthService(c.Request.Context()); svc != nil { + if mw, ok := svc.RequireAuthMiddleware().(gin.HandlerFunc); ok && mw != nil { + mw(c) + return + } + } + denyAuth(c) + } + adminMW := func(c *gin.Context) { + if svc := shared.GetAuthService(c.Request.Context()); svc != nil { + if mw, ok := svc.RequireAdminMiddleware().(gin.HandlerFunc); ok && mw != nil { + mw(c) + return + } + } + denyAuth(c) } - loginMW := authSvc.RequireAuthMiddleware().(gin.HandlerFunc) - adminMW := authSvc.RequireAdminMiddleware().(gin.HandlerFunc) // 0a. Register migrations ctx.Migrations().Register("upload", uploadMigrations) diff --git a/frontend/components/auth/cap-widget.tsx b/frontend/components/auth/cap-widget.tsx index b48a63e3..7fc699e9 100644 --- a/frontend/components/auth/cap-widget.tsx +++ b/frontend/components/auth/cap-widget.tsx @@ -60,13 +60,13 @@ export function CapWidget({ } }; - // Auto-start on mount (only when autoStart is enabled) useEffect(() => { + solving.current = false; if (autoStart) { void solve(); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [autoStart, scope]); return (