mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
安全加固
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ export interface OAuthAuthorizeResponse {
|
||||
}
|
||||
|
||||
export interface OAuthCallbackResult {
|
||||
status: 'logged_in' | 'bound';
|
||||
status: 'logged_in' | 'bound' | 'need_bind';
|
||||
user?: User;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user