1 背景
在文件下載場景中,用戶點擊一個鏈接,就把文件下載到本地。這其中有幾個安全問題:
1.1 請求方式問題
獲取文件一般是GET請求,而頁面請求有同步和異步兩種方式:
- 同步請求:由瀏覽器直接發起,此時請求的下載鏈接就相當於是一個靜態資源,一個a鏈接或者iframe,它佔用的是瀏覽器的內存;
- 異步請求:一般通過ajax發起,請求出去後必須等待數據返回之後,把數據加載到內存中,再在內存中對數據進行處理,它佔用的是前端服務的內存。
我們知道,當文件比較小的時候,下載時間會比較短,內存使用量較小,採用同步或者異步請求,都不是大問題。但是當文件特別大的時候,如果是異步請求,就會需要等待很久,而且內存太大;而同步請求可以通過瀏覽器自身的斷點續傳功能,直到下載完成。
所以在文件下載這個場景中,我們一般採用的是瀏覽器內置的同步GET請求。此時,下載鏈接已經固定,不能對其進行修改,增減參數操作。
1.2 登錄態問題
當這個下載鏈接所請求的接口,如果沒有登錄態校驗,則以爲着用戶可以在不登錄的情況下,直接下載文件,這會導致安全漏洞;這意味着文件下載接口必須存在登錄態校驗,瀏覽器的請求,必須帶上登錄態信息。一般情況下,這種登錄態可通過cookie,session,token來實現。
1.3 用戶身份和權限問題
當1.1問題解決後,假設B用戶是登錄態,拿着A用戶的鏈接來進行下載,這就存在用戶身份安全漏洞;這意味着文件下載接口必須能帶上用戶身份信息,且能帶上一些業務信息,以便於身份和權限校驗。
綜合以上三點,我們可以得出:
- 這個接口的url是提前生成好的,在到達頁面之前就已經帶上所需要的所有參數和信息,而不能經由前端再進行修改;
- 這個接口必須能完成登錄態、用戶身份、權限校驗功能;
- 這個接口必須支持大文件的處理,不能直接把文件一次性讀進內存,然後再從內存中一次性吐給前端,這樣會把內存打掛;
2 接口設計
在這個設計中,我們默認所要下載的文件已經生成好,並存放在一個文件存儲服務中的,只需要通過一個路徑或者服務url就能獲取到文件。
這裏我們看到請求會進行一個流式轉發,也就是把請求直接進行轉發到File System,讓File System直接去進行響應;而不是自己去從File System讀文件到內存,然後再把內存的文件流進行返回。也就相當於Nginx的反向代理。
3 代碼實現
我們用go實現了這個功能,這裏展示一部分核心代碼:
package api
import (
"fmt"
"github.com/gin-gonic/gin"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"log"
"some/project/service"
)
type DownloadRequest struct {
UserID uint64 `form:"userId"` // 用戶ID,必傳
BusinessID uint64 `form:"BusinessID"` // 業務ID,必傳,用戶獲取業務信息,進行業務權限校驗
}
// Download 處理下載請求
func Download(c *gin.Context) {
req := DownloadRequest{}
if err := c.ShouldBindQuery(&req); err != nil {
AbortWithWriteErrorResponse(c, err)
return
}
// 此處的ctx.UserID是經過上游登錄態校驗後,把用戶身份信息塞入到ctx中的,與url上的UserID進行校驗
if req.UserID != ctx.UserID {
AbortWithWriteErrorResponse(c, errors.New("invalid user"))
return
}
// 參數校驗
err := validateDownloadRequest(req)
if err != nil {
AbortWithWriteErrorResponse(c, err)
return
}
// 通過業務參數,生成存在file system中的路徑或url
downloadUrl, err := service.GetDownloadUrl(c, req)
log.Logger.Debug("GetDownloadUrl downloadUrl:%s, err:%+v", downloadUrl, err)
if err != nil {
AbortWithWriteErrorResponse(c, err)
return
}
if downloadUrl == "" {
AbortWithWriteErrorResponse(c, errors.New("GenerateDownloadUrlFailed"))
return
}
// 開始準備流量轉發
target, _ := url.Parse(downloadUrl)
log.Logger.Debug("Download target: %+v", target)
// 生成反向代理器
proxy := httputil.ReverseProxy{
// 對request做修改
Director: func(request *http.Request) {
request.URL = target
// 通過實踐發現,光有上面這步有時候還不夠,需要下面兩步再強制設置一下Host
request.Host = target.Host
request.Header.Set("Host", target.Host)
// 爲了避免file system返回304導致頁面不進行下載動作,這裏刪除這2個header,強制file system返回200
request.Header.Del("If-Modified-Since")
request.Header.Del("If-None-Match")
log.Logger.Debug("proxy Director request: %+v", request)
},
// 對response做修改
ModifyResponse: func(response *http.Response) error {
filePath := response.Request.URL.Path
filePathList := strings.Split(filePath, "/")
fileName := filePathList[len(filePathList)-1]
response.Header.Set("Content-Disposition", fmt.Sprintf("attachment;filename=%s", fileName))
log.Logger.Debug("proxy ModifyResponse response: %+v", response)
return nil
}
}
// 執行轉發
proxy.ServeHTTP(c.Writer, c.Request)
c.Abort()
}
// validateDownloadRequest 參數校驗
func validateDownloadRequest(req DownloadRequest) error {
return nil
}
這裏我們默認是通過url的方式向文件系統請求文件。
核心方法是 httputil.ReverseProxy 和 proxy.ServerHTTP。特別需要注意的是:爲了適應文件下載場景,對 request 和 response 做的相應修改。