From d99bd5231ad809500af0f47e1e137a1667dd5df8 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 8 Jun 2026 19:50:33 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=89=E5=85=A8=E5=8A=A0=E5=9B=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/auth/login-page.tsx | 6 ++ frontend/lib/services/auth/auth.service.ts | 2 +- frontend/lib/services/auth/types.ts | 2 +- internal/apps/oauth/oauth_test.go | 64 ++++++++++++++++++++++ internal/apps/oauth/sources.go | 21 +++++++ internal/apps/upload/file_server.go | 3 + internal/apps/upload/routers.go | 22 ++++++++ internal/apps/user/controllers.go | 37 +++++++++++++ 8 files changed, 155 insertions(+), 2 deletions(-) diff --git a/frontend/components/auth/login-page.tsx b/frontend/components/auth/login-page.tsx index a8114a51..b4cb0dd9 100644 --- a/frontend/components/auth/login-page.tsx +++ b/frontend/components/auth/login-page.tsx @@ -109,6 +109,12 @@ export function LoginPage() { setIsProcessingCallback(true) try { const result = await services.auth.handleCallback({ state, code }) + if (result.status === "need_bind") { + toast.info("您的第三方账号未绑定本地账号,系统已关闭注册。请登录已有本地账号进行绑定。") + setIsProcessingCallback(false) + router.replace('/login') + return + } if (result.user) { setUser(result.user) } diff --git a/frontend/lib/services/auth/auth.service.ts b/frontend/lib/services/auth/auth.service.ts index 681a1986..8e1575d6 100644 --- a/frontend/lib/services/auth/auth.service.ts +++ b/frontend/lib/services/auth/auth.service.ts @@ -129,6 +129,6 @@ export class AuthService extends BaseService { } static async deleteExternalAccountBinding(id: string): Promise { - return this.delete(`/oauth/external-accounts/${encodeURIComponent(id)}/delete`); + return this.post(`/oauth/external-accounts/${encodeURIComponent(id)}/delete`); } } diff --git a/frontend/lib/services/auth/types.ts b/frontend/lib/services/auth/types.ts index b5bbd96f..271b35ae 100644 --- a/frontend/lib/services/auth/types.ts +++ b/frontend/lib/services/auth/types.ts @@ -74,7 +74,7 @@ export interface OAuthAuthorizeResponse { } export interface OAuthCallbackResult { - status: 'logged_in' | 'bound'; + status: 'logged_in' | 'bound' | 'need_bind'; user?: User; } diff --git a/internal/apps/oauth/oauth_test.go b/internal/apps/oauth/oauth_test.go index 1bf35865..aed6a1d9 100644 --- a/internal/apps/oauth/oauth_test.go +++ b/internal/apps/oauth/oauth_test.go @@ -722,6 +722,70 @@ func TestCallbackLoginAndUserInfo(t *testing.T) { if collisionResp.Data.User.Username != "test_oauth_user-1" { t.Errorf("expected collision renamed username, got %s", collisionResp.Data.User.Username) } + + t.Run("OIDC login when registration disabled - need bind", func(t *testing.T) { + // Disable registration in database + dbConn.Create(&model.SystemConfig{ + Key: model.ConfigKeyRegistrationEnabled, + Value: "false", + }) + defer func() { + dbConn.Where("key = ?", model.ConfigKeyRegistrationEnabled).Delete(&model.SystemConfig{}) + }() + + state4 := uuid.NewString() + payloadValue4, _ := encodeOAuthStatePayload(oauthStatePayload{ + SourceName: testSourceName, + Purpose: OAuthPurposeLogin, + }) + mockRedis.Set(context.Background(), db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state4)), payloadValue4, OAuthStateCacheKeyExpiration) + + httpMock4 := &http.Client{ + Transport: &mockRoundTripper{ + roundTripFunc: func(req *http.Request) (*http.Response, error) { + if req.Method == http.MethodGet && strings.Contains(req.URL.String(), "/.well-known/openid-configuration") { + return oidcDiscoveryResponse(), nil + } + if req.Method == http.MethodGet && req.URL.String() == testJWKSURL { + return jwksResponse(), nil + } + if req.Method == http.MethodPost && req.URL.String() == testTokenURL { + idToken := generateMockIDToken(testIssuerURL, "77777", testClientID, state4, "need_bind_user", "needbind@linux.do", "Need Bind User") + body := fmt.Sprintf(`{"access_token":"mock_access_token","token_type":"Bearer","expires_in":3600,"id_token":"%s"}`, idToken) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + } + return nil, fmt.Errorf("unexpected request") + }, + }, + } + util.SetHTTPClient(httpMock4) + router4 := setupTestRouter(dbConn, mockRedis, httpMock4) + + reqBody4 := fmt.Sprintf(`{"state":"%s","code":"test_auth_code"}`, state4) + w4 := performRequest(router4, http.MethodPost, "/api/v1/oauth/callback", []byte(reqBody4), map[string]string{ + "Content-Type": "application/json", + }, nil) + + if w4.Code != http.StatusOK { + t.Fatalf("callback failed: %d, body: %s", w4.Code, w4.Body.String()) + } + + var needBindResp struct { + Data OAuthCallbackResult `json:"data"` + } + _ = json.Unmarshal(w4.Body.Bytes(), &needBindResp) + + if needBindResp.Data.Status != "need_bind" { + t.Errorf("expected status 'need_bind', got %s", needBindResp.Data.Status) + } + if needBindResp.Data.User != nil { + t.Errorf("expected User to be nil, got %+v", needBindResp.Data.User) + } + }) } func TestCallbackBind(t *testing.T) { diff --git a/internal/apps/oauth/sources.go b/internal/apps/oauth/sources.go index 6b14bec0..e6e78dee 100644 --- a/internal/apps/oauth/sources.go +++ b/internal/apps/oauth/sources.go @@ -481,6 +481,27 @@ func Callback(c *gin.Context) { return } case errors.Is(err, gorm.ErrRecordNotFound): + // 检查系统是否允许注册 + registrationEnabled, regErr := model.GetBoolByKey(ctx, model.ConfigKeyRegistrationEnabled) + if regErr != nil { + registrationEnabled = true // 默认允许注册 + } + + if !registrationEnabled { + // 如果不允许注册,临时记录到 session 并向前端返回 "need_bind" 状态 + session := sessions.Default(c) + session.Set("pending_oauth_source_id", source.ID) + session.Set("pending_oauth_external_id", userInfo.Sub) + session.Set("pending_oauth_external_username", userInfo.Username) + session.Set("pending_oauth_email", userInfo.Email) + if err := session.Save(); err != nil { + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + c.JSON(http.StatusOK, util.OK(buildCallbackResult(nil, "need_bind"))) + return + } + username, uniqueErr := uniqueUsername(ctx, userInfo.Username) if uniqueErr != nil { c.JSON(http.StatusInternalServerError, util.Err(uniqueErr.Error())) diff --git a/internal/apps/upload/file_server.go b/internal/apps/upload/file_server.go index 218d0358..3b6007b9 100644 --- a/internal/apps/upload/file_server.go +++ b/internal/apps/upload/file_server.go @@ -41,6 +41,9 @@ import ( // @Failure 500 {object} util.ResponseAny "服务内部错误" // @Router /f/{id} [get] func ServeFileByID(c *gin.Context) { + c.Header("X-Content-Type-Options", "nosniff") + c.Header("Content-Security-Policy", "sandbox") + idStr := c.Param("id") uploadID, err := strconv.ParseUint(idStr, 10, 64) if err != nil { diff --git a/internal/apps/upload/routers.go b/internal/apps/upload/routers.go index 979ac820..eec15bc3 100644 --- a/internal/apps/upload/routers.go +++ b/internal/apps/upload/routers.go @@ -68,6 +68,12 @@ type batchDownloadRequest struct { // @Failure 500 {object} util.ResponseAny "内部错误" // @Router /api/v1/upload [post] func UploadFile(c *gin.Context) { + c.Header("X-Content-Type-Options", "nosniff") + c.Header("Content-Security-Policy", "sandbox") + + // 限制请求体大小以防止 DoS + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxUploadSize) + currUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey) ctx := c.Request.Context() @@ -130,6 +136,19 @@ func UploadFile(c *gin.Context) { mimeType = header.Header.Get("Content-Type") } + // 校验真实 MIME Type 是否与常见图片扩展名匹配,防止 Polyglot / HTML 注入攻击 + isImageExt := false + for _, imgExt := range []string{"jpg", "jpeg", "png", "webp", "gif"} { + if ext == imgExt { + isImageExt = true + break + } + } + if isImageExt && !strings.HasPrefix(mimeType, "image/") { + c.JSON(http.StatusOK, util.Err("文件内容与扩展名不匹配,可能包含安全风险")) + return + } + // 6. 秒传匹配校验:校验数据库中是否存在相同 Hash 且大小一致的可用文件 var existing model.Upload err = db.DB(ctx).Where("hash = ? AND file_size = ? AND status IN (?, ?)", fileHash, size, model.UploadStatusPending, model.UploadStatusUsed).First(&existing).Error @@ -255,6 +274,9 @@ func UploadFile(c *gin.Context) { // @Failure 500 {object} util.ResponseAny "服务内部错误" // @Router /api/v1/upload/download/{id} [get] func DownloadFile(c *gin.Context) { + c.Header("X-Content-Type-Options", "nosniff") + c.Header("Content-Security-Policy", "sandbox") + ctx := c.Request.Context() idStr := c.Param("id") uploadID, err := strconv.ParseUint(idStr, 10, 64) diff --git a/internal/apps/user/controllers.go b/internal/apps/user/controllers.go index e4c4129c..766d1cd8 100644 --- a/internal/apps/user/controllers.go +++ b/internal/apps/user/controllers.go @@ -251,6 +251,43 @@ func Login(c *gin.Context) { return } + // 检查是否有未完成的 OAuth/OIDC 绑定 + pendingSourceID := session.Get("pending_oauth_source_id") + pendingExternalID := session.Get("pending_oauth_external_id") + pendingExternalUsername := session.Get("pending_oauth_external_username") + pendingEmail := session.Get("pending_oauth_email") + + if pendingSourceID != nil && pendingExternalID != nil { + var sourceID uint64 + switch v := pendingSourceID.(type) { + case uint64: + sourceID = v + case int: + sourceID = uint64(v) + case float64: + sourceID = uint64(v) + } + externalID, _ := pendingExternalID.(string) + externalUsername, _ := pendingExternalUsername.(string) + email, _ := pendingEmail.(string) + + if sourceID != 0 && externalID != "" { + _ = model.BindExternalAccount(&model.ExternalAccount{ + AuthSourceID: sourceID, + UserID: user.ID, + ExternalID: externalID, + ExternalUsername: externalUsername, + Email: email, + }) + } + // 清除 pending 信息 + session.Delete("pending_oauth_source_id") + session.Delete("pending_oauth_external_id") + session.Delete("pending_oauth_external_username") + session.Delete("pending_oauth_email") + _ = session.Save() + } + c.JSON(http.StatusOK, util.OK(oauth.BuildBasicUserInfo(&user, needChangePassword))) }