mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-14 02:46:37 +08:00
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.
This commit is contained in:
@@ -30,11 +30,9 @@ const (
|
|||||||
errExternalAccountBindingIncomplete = "外部帐号绑定信息不完整"
|
errExternalAccountBindingIncomplete = "外部帐号绑定信息不完整"
|
||||||
errExternalAccountAlreadyBoundToAnother = "该外部帐号已被其他用户绑定"
|
errExternalAccountAlreadyBoundToAnother = "该外部帐号已被其他用户绑定"
|
||||||
errExternalAccountBindingIDRequired = "外部帐号绑定记录 ID 不能为空"
|
errExternalAccountBindingIDRequired = "外部帐号绑定记录 ID 不能为空"
|
||||||
errAdminRequired = "无权访问"
|
errInsufficientPermission = "权限不足"
|
||||||
//nolint:gosec // error message, not hardcoded credentials
|
errBannedAccount = "账号已被封禁"
|
||||||
errTokenAdminRequired = "令牌无管理员权限"
|
errUnAuthorized = "未登录"
|
||||||
errBannedAccount = "账号已被封禁"
|
|
||||||
errUnAuthorized = "未登录"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Service 层与鉴权中间件内部错误文案(保持与重构前逐字一致)
|
// Service 层与鉴权中间件内部错误文案(保持与重构前逐字一致)
|
||||||
|
|||||||
@@ -162,15 +162,13 @@ func AdminRequired() gin.HandlerFunc {
|
|||||||
isTokenAuth, _ := ginutil.GetFromContext[bool](c, contracts.AuthTokenAuthKey)
|
isTokenAuth, _ := ginutil.GetFromContext[bool](c, contracts.AuthTokenAuthKey)
|
||||||
isTokenAdmin, _ := ginutil.GetFromContext[bool](c, contracts.AuthTokenAdminKey)
|
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 {
|
if isTokenAuth && !isTokenAdmin && !user.IsAdmin {
|
||||||
response.AbortNotFound(c, errTokenAdminRequired)
|
response.AbortForbidden(c, errInsufficientPermission)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果是通过 Session 鉴权,直接检查用户的 is_admin 属性
|
|
||||||
if !isTokenAuth && !user.IsAdmin {
|
if !isTokenAuth && !user.IsAdmin {
|
||||||
response.AbortNotFound(c, errAdminRequired)
|
response.AbortForbidden(c, errInsufficientPermission)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ type testUser struct {
|
|||||||
ID uint64 `gorm:"primaryKey"`
|
ID uint64 `gorm:"primaryKey"`
|
||||||
Username string
|
Username string
|
||||||
IsActive bool
|
IsActive bool
|
||||||
|
IsAdmin bool
|
||||||
LastLoginAt time.Time
|
LastLoginAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -399,3 +399,30 @@ func TestAuthWhitelistMiddleware(t *testing.T) {
|
|||||||
engine.ServeHTTP(w2, req2)
|
engine.ServeHTTP(w2, req2)
|
||||||
assert.Equal(t, http.StatusUnauthorized, w2.Code)
|
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(), "权限不足")
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,19 +17,19 @@ func VerifyMiddleware(mgr *Manager, scope string) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if mgr == nil {
|
if mgr == nil {
|
||||||
response.AbortUnauthorized(c, errCapTokenInvalidOrExpired)
|
response.AbortBadRequest(c, errCapTokenInvalidOrExpired)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token := c.GetHeader("X-Cap-Token")
|
token := c.GetHeader("X-Cap-Token")
|
||||||
if token == "" {
|
if token == "" {
|
||||||
response.AbortUnauthorized(c, errCapTokenMissing)
|
response.AbortBadRequest(c, errCapTokenMissing)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
valid, err := mgr.VerifyToken(c.Request.Context(), token, scope)
|
valid, err := mgr.VerifyToken(c.Request.Context(), token, scope)
|
||||||
if err != nil || !valid {
|
if err != nil || !valid {
|
||||||
response.AbortUnauthorized(c, errCapTokenInvalidOrExpired)
|
response.AbortBadRequest(c, errCapTokenInvalidOrExpired)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"Wavelet/core"
|
"Wavelet/core"
|
||||||
"Wavelet/core/contracts"
|
"Wavelet/core/contracts"
|
||||||
"Wavelet/core/extpoints"
|
"Wavelet/core/extpoints"
|
||||||
|
"Wavelet/pkg/ginutil"
|
||||||
"Wavelet/plugins/domain/upload/filesrv"
|
"Wavelet/plugins/domain/upload/filesrv"
|
||||||
"Wavelet/plugins/domain/upload/handler"
|
"Wavelet/plugins/domain/upload/handler"
|
||||||
"Wavelet/plugins/domain/upload/shared"
|
"Wavelet/plugins/domain/upload/shared"
|
||||||
@@ -66,13 +67,25 @@ func (p *Plugin) Apply(ctx *core.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// 0. Resolve auth service for middleware
|
denyAuth := ginutil.AuthUnavailable()
|
||||||
var authSvc contracts.AuthService
|
loginMW := func(c *gin.Context) {
|
||||||
if err := core.Using[contracts.AuthService](ctx, func(svc contracts.AuthService) { authSvc = svc }); err != nil {
|
if svc := shared.GetAuthService(c.Request.Context()); svc != nil {
|
||||||
return err
|
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
|
// 0a. Register migrations
|
||||||
ctx.Migrations().Register("upload", uploadMigrations)
|
ctx.Migrations().Register("upload", uploadMigrations)
|
||||||
|
|||||||
@@ -60,13 +60,13 @@ export function CapWidget({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto-start on mount (only when autoStart is enabled)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
solving.current = false;
|
||||||
if (autoStart) {
|
if (autoStart) {
|
||||||
void solve();
|
void solve();
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, [autoStart, scope]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex items-center gap-2 rounded-lg border border-border/60 bg-muted/30 px-3 py-2 text-sm'>
|
<div className='flex items-center gap-2 rounded-lg border border-border/60 bg-muted/30 px-3 py-2 text-sm'>
|
||||||
|
|||||||
@@ -82,14 +82,9 @@ export function RegisterForm() {
|
|||||||
);
|
);
|
||||||
const capAutoSolve = configBool(publicConfigQuery.data?.cap_auto_solve, true);
|
const capAutoSolve = configBool(publicConfigQuery.data?.cap_auto_solve, true);
|
||||||
|
|
||||||
const [capScope, setCapScope] = useState<'send_email_code' | 'register'>(
|
const [capAfterEmailCode, setCapAfterEmailCode] = useState(false);
|
||||||
'send_email_code',
|
const capScope: 'send_email_code' | 'register' =
|
||||||
);
|
emailRegisterEnabled && !capAfterEmailCode ? 'send_email_code' : 'register';
|
||||||
|
|
||||||
// 监听 emailRegisterEnabled 改变初始 scope
|
|
||||||
useEffect(() => {
|
|
||||||
setCapScope(emailRegisterEnabled ? 'send_email_code' : 'register');
|
|
||||||
}, [emailRegisterEnabled]);
|
|
||||||
|
|
||||||
const capTokenRef = useRef<string | null>(null);
|
const capTokenRef = useRef<string | null>(null);
|
||||||
const [capReady, setCapReady] = useState(false);
|
const [capReady, setCapReady] = useState(false);
|
||||||
@@ -131,6 +126,9 @@ export function RegisterForm() {
|
|||||||
},
|
},
|
||||||
onSuccess: (user) => {
|
onSuccess: (user) => {
|
||||||
setUser(user);
|
setUser(user);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
sessionStorage.removeItem('redirect_after_login');
|
||||||
|
}
|
||||||
router.replace(redirectTarget);
|
router.replace(redirectTarget);
|
||||||
toast.success(t('success'));
|
toast.success(t('success'));
|
||||||
},
|
},
|
||||||
@@ -162,7 +160,7 @@ export function RegisterForm() {
|
|||||||
setRegisterCooldown(60);
|
setRegisterCooldown(60);
|
||||||
toast.success(t('codeSent'));
|
toast.success(t('codeSent'));
|
||||||
if (capEnabled) {
|
if (capEnabled) {
|
||||||
setCapScope('register');
|
setCapAfterEmailCode(true);
|
||||||
capTokenRef.current = null;
|
capTokenRef.current = null;
|
||||||
setCapReady(false);
|
setCapReady(false);
|
||||||
setCapResetKey((key) => key + 1);
|
setCapResetKey((key) => key + 1);
|
||||||
@@ -365,7 +363,7 @@ export function RegisterForm() {
|
|||||||
|
|
||||||
{capEnabled && (
|
{capEnabled && (
|
||||||
<CapWidget
|
<CapWidget
|
||||||
key={capResetKey}
|
key={`${capResetKey}-${capScope}`}
|
||||||
scope={capScope}
|
scope={capScope}
|
||||||
onToken={handleCapToken}
|
onToken={handleCapToken}
|
||||||
onError={handleCapError}
|
onError={handleCapError}
|
||||||
|
|||||||
@@ -106,23 +106,17 @@ apiClient.interceptors.request.use(
|
|||||||
(error: unknown) => Promise.reject(error),
|
(error: unknown) => Promise.reject(error),
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
function isPublicAuthRequest(url?: string): boolean {
|
||||||
* 直接启动登录流程
|
if (!url) return false;
|
||||||
* @param currentPath - 当前路径,用于登录成功后重定向回来
|
return (
|
||||||
*/
|
url.includes('/user/login') ||
|
||||||
function initiateLogin(currentPath: string): Promise<never> {
|
url.includes('/user/register') ||
|
||||||
if (currentPath.startsWith('/login') || currentPath.startsWith('/callback')) {
|
url.includes('/user/send-email-code') ||
|
||||||
return Promise.reject(new UnauthorizedError());
|
url.includes('/cap/') ||
|
||||||
}
|
url.includes('/oauth/login') ||
|
||||||
|
url.includes('/oauth/callback') ||
|
||||||
if (typeof window !== 'undefined') {
|
url.includes('/oauth/sources')
|
||||||
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<never>(() => {});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -158,16 +152,22 @@ apiClient.interceptors.response.use(
|
|||||||
return Promise.reject(cancelError);
|
return Promise.reject(cancelError);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 401 未授权错误 */
|
/* 401:未登录。公开认证接口只把错误交给页面;已登录态下的误报不得清 cookie 跳登录页。 */
|
||||||
if (error.response?.status === 401) {
|
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) {
|
if (error.response?.status === 403) {
|
||||||
|
const message = error.response.data?.error_msg || '权限不足';
|
||||||
|
toast.error(message, { id: 'forbidden-error' });
|
||||||
return Promise.reject(
|
return Promise.reject(
|
||||||
new ForbiddenError(
|
new ForbiddenError(
|
||||||
error.response.data?.error_msg || '权限不足,请过盾后重试',
|
message,
|
||||||
error.response.data?.error_code,
|
error.response.data?.error_code,
|
||||||
error.response.data?.details,
|
error.response.data?.details,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -149,6 +149,16 @@ export function safeRedirectTarget(
|
|||||||
) {
|
) {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
pathPart === '/login' ||
|
||||||
|
pathPart === '/register' ||
|
||||||
|
pathPart === '/callback' ||
|
||||||
|
pathPart.startsWith('/login/') ||
|
||||||
|
pathPart.startsWith('/register/')
|
||||||
|
) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user