From b7e5e811d142e08ababa422242a64c2597e15261 Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 2 Sep 2026 18:26:22 +0800 Subject: [PATCH] fix(auth): register CAP scope, 400 on captcha, 403 for permission Navigating from login reused a send_email_code token on register. Captcha failure used 401 so the client stored /register as the post-login target and never left the page. Permission denials now return 403, and the API client no longer wipes the session on 401. --- backend/plugins/domain/auth/errs.go | 8 ++-- backend/plugins/domain/auth/middleware.go | 8 ++-- backend/plugins/domain/auth/plugin_test.go | 1 + backend/plugins/domain/auth/service_test.go | 27 ++++++++++++ backend/plugins/domain/cap/middleware.go | 6 +-- backend/plugins/domain/cap/middleware_test.go | 32 ++++++++++++++ backend/plugins/domain/upload/plugin.go | 25 ++++++++--- frontend/components/auth/cap-widget.tsx | 4 +- frontend/components/auth/register-form.tsx | 18 ++++---- frontend/lib/services/core/api-client.ts | 42 +++++++++---------- frontend/lib/utils.ts | 10 +++++ 11 files changed, 129 insertions(+), 52 deletions(-) create mode 100644 backend/plugins/domain/cap/middleware_test.go 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 (
diff --git a/frontend/components/auth/register-form.tsx b/frontend/components/auth/register-form.tsx index 93f002d7..3c115129 100644 --- a/frontend/components/auth/register-form.tsx +++ b/frontend/components/auth/register-form.tsx @@ -82,14 +82,9 @@ export function RegisterForm() { ); const capAutoSolve = configBool(publicConfigQuery.data?.cap_auto_solve, true); - const [capScope, setCapScope] = useState<'send_email_code' | 'register'>( - 'send_email_code', - ); - - // 监听 emailRegisterEnabled 改变初始 scope - useEffect(() => { - setCapScope(emailRegisterEnabled ? 'send_email_code' : 'register'); - }, [emailRegisterEnabled]); + const [capAfterEmailCode, setCapAfterEmailCode] = useState(false); + const capScope: 'send_email_code' | 'register' = + emailRegisterEnabled && !capAfterEmailCode ? 'send_email_code' : 'register'; const capTokenRef = useRef(null); const [capReady, setCapReady] = useState(false); @@ -131,6 +126,9 @@ export function RegisterForm() { }, onSuccess: (user) => { setUser(user); + if (typeof window !== 'undefined') { + sessionStorage.removeItem('redirect_after_login'); + } router.replace(redirectTarget); toast.success(t('success')); }, @@ -162,7 +160,7 @@ export function RegisterForm() { setRegisterCooldown(60); toast.success(t('codeSent')); if (capEnabled) { - setCapScope('register'); + setCapAfterEmailCode(true); capTokenRef.current = null; setCapReady(false); setCapResetKey((key) => key + 1); @@ -365,7 +363,7 @@ export function RegisterForm() { {capEnabled && ( Promise.reject(error), ); -/** - * 直接启动登录流程 - * @param currentPath - 当前路径,用于登录成功后重定向回来 - */ -function initiateLogin(currentPath: string): Promise { - if (currentPath.startsWith('/login') || currentPath.startsWith('/callback')) { - return Promise.reject(new UnauthorizedError()); - } - - if (typeof window !== 'undefined') { - sessionStorage.setItem('redirect_after_login', currentPath); - const loginUrl = new URL('/login', window.location.origin); - loginUrl.searchParams.set('callbackUrl', currentPath); - window.location.href = loginUrl.toString(); - } - - return new Promise(() => {}); +function isPublicAuthRequest(url?: string): boolean { + if (!url) return false; + return ( + url.includes('/user/login') || + url.includes('/user/register') || + url.includes('/user/send-email-code') || + url.includes('/cap/') || + url.includes('/oauth/login') || + url.includes('/oauth/callback') || + url.includes('/oauth/sources') + ); } /** @@ -158,16 +152,22 @@ apiClient.interceptors.response.use( return Promise.reject(cancelError); } - /* 401 未授权错误 */ + /* 401:未登录。公开认证接口只把错误交给页面;已登录态下的误报不得清 cookie 跳登录页。 */ if (error.response?.status === 401) { - return initiateLogin(window.location.pathname + window.location.search); + const message = error.response.data?.error_msg || '未登录'; + if (!isPublicAuthRequest(error.config?.url)) { + toast.error(message, { id: 'unauthorized-error' }); + } + return Promise.reject(new UnauthorizedError(message)); } - /* 403 权限不足错误 */ + /* 403:已登录但权限不足,留在当前页。 */ if (error.response?.status === 403) { + const message = error.response.data?.error_msg || '权限不足'; + toast.error(message, { id: 'forbidden-error' }); return Promise.reject( new ForbiddenError( - error.response.data?.error_msg || '权限不足,请过盾后重试', + message, error.response.data?.error_code, error.response.data?.details, ), diff --git a/frontend/lib/utils.ts b/frontend/lib/utils.ts index f27f5caa..c38cfb5f 100644 --- a/frontend/lib/utils.ts +++ b/frontend/lib/utils.ts @@ -149,6 +149,16 @@ export function safeRedirectTarget( ) { return fallback; } + + if ( + pathPart === '/login' || + pathPart === '/register' || + pathPart === '/callback' || + pathPart.startsWith('/login/') || + pathPart.startsWith('/register/') + ) { + return fallback; + } } catch { return fallback; }