mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-11 01:36:37 +08:00
安全加固
This commit is contained in:
@@ -109,6 +109,12 @@ export function LoginPage() {
|
|||||||
setIsProcessingCallback(true)
|
setIsProcessingCallback(true)
|
||||||
try {
|
try {
|
||||||
const result = await services.auth.handleCallback({ state, code })
|
const result = await services.auth.handleCallback({ state, code })
|
||||||
|
if (result.status === "need_bind") {
|
||||||
|
toast.info("您的第三方账号未绑定本地账号,系统已关闭注册。请登录已有本地账号进行绑定。")
|
||||||
|
setIsProcessingCallback(false)
|
||||||
|
router.replace('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (result.user) {
|
if (result.user) {
|
||||||
setUser(result.user)
|
setUser(result.user)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,6 +129,6 @@ export class AuthService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async deleteExternalAccountBinding(id: string): Promise<void> {
|
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 {
|
export interface OAuthCallbackResult {
|
||||||
status: 'logged_in' | 'bound';
|
status: 'logged_in' | 'bound' | 'need_bind';
|
||||||
user?: User;
|
user?: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -722,6 +722,70 @@ func TestCallbackLoginAndUserInfo(t *testing.T) {
|
|||||||
if collisionResp.Data.User.Username != "test_oauth_user-1" {
|
if collisionResp.Data.User.Username != "test_oauth_user-1" {
|
||||||
t.Errorf("expected collision renamed username, got %s", collisionResp.Data.User.Username)
|
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) {
|
func TestCallbackBind(t *testing.T) {
|
||||||
|
|||||||
@@ -481,6 +481,27 @@ func Callback(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
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)
|
username, uniqueErr := uniqueUsername(ctx, userInfo.Username)
|
||||||
if uniqueErr != nil {
|
if uniqueErr != nil {
|
||||||
c.JSON(http.StatusInternalServerError, util.Err(uniqueErr.Error()))
|
c.JSON(http.StatusInternalServerError, util.Err(uniqueErr.Error()))
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ import (
|
|||||||
// @Failure 500 {object} util.ResponseAny "服务内部错误"
|
// @Failure 500 {object} util.ResponseAny "服务内部错误"
|
||||||
// @Router /f/{id} [get]
|
// @Router /f/{id} [get]
|
||||||
func ServeFileByID(c *gin.Context) {
|
func ServeFileByID(c *gin.Context) {
|
||||||
|
c.Header("X-Content-Type-Options", "nosniff")
|
||||||
|
c.Header("Content-Security-Policy", "sandbox")
|
||||||
|
|
||||||
idStr := c.Param("id")
|
idStr := c.Param("id")
|
||||||
uploadID, err := strconv.ParseUint(idStr, 10, 64)
|
uploadID, err := strconv.ParseUint(idStr, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -68,6 +68,12 @@ type batchDownloadRequest struct {
|
|||||||
// @Failure 500 {object} util.ResponseAny "内部错误"
|
// @Failure 500 {object} util.ResponseAny "内部错误"
|
||||||
// @Router /api/v1/upload [post]
|
// @Router /api/v1/upload [post]
|
||||||
func UploadFile(c *gin.Context) {
|
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)
|
currUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
@@ -130,6 +136,19 @@ func UploadFile(c *gin.Context) {
|
|||||||
mimeType = header.Header.Get("Content-Type")
|
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 且大小一致的可用文件
|
// 6. 秒传匹配校验:校验数据库中是否存在相同 Hash 且大小一致的可用文件
|
||||||
var existing model.Upload
|
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
|
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 "服务内部错误"
|
// @Failure 500 {object} util.ResponseAny "服务内部错误"
|
||||||
// @Router /api/v1/upload/download/{id} [get]
|
// @Router /api/v1/upload/download/{id} [get]
|
||||||
func DownloadFile(c *gin.Context) {
|
func DownloadFile(c *gin.Context) {
|
||||||
|
c.Header("X-Content-Type-Options", "nosniff")
|
||||||
|
c.Header("Content-Security-Policy", "sandbox")
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
idStr := c.Param("id")
|
idStr := c.Param("id")
|
||||||
uploadID, err := strconv.ParseUint(idStr, 10, 64)
|
uploadID, err := strconv.ParseUint(idStr, 10, 64)
|
||||||
|
|||||||
@@ -251,6 +251,43 @@ func Login(c *gin.Context) {
|
|||||||
return
|
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)))
|
c.JSON(http.StatusOK, util.OK(oauth.BuildBasicUserInfo(&user, needChangePassword)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user