安全加固

This commit is contained in:
ryan
2026-06-08 19:50:33 +08:00
parent 0f65202659
commit d99bd5231a
8 changed files with 155 additions and 2 deletions
+6
View File
@@ -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)
}
+1 -1
View File
@@ -129,6 +129,6 @@ export class AuthService extends BaseService {
}
static async deleteExternalAccountBinding(id: string): Promise<void> {
return this.delete<void>(`/oauth/external-accounts/${encodeURIComponent(id)}/delete`);
return this.post<void>(`/oauth/external-accounts/${encodeURIComponent(id)}/delete`);
}
}
+1 -1
View File
@@ -74,7 +74,7 @@ export interface OAuthAuthorizeResponse {
}
export interface OAuthCallbackResult {
status: 'logged_in' | 'bound';
status: 'logged_in' | 'bound' | 'need_bind';
user?: User;
}
+64
View File
@@ -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) {
+21
View File
@@ -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()))
+3
View File
@@ -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 {
+22
View File
@@ -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)
+37
View File
@@ -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)))
}