本文目录

[[toc]]

断点续传

文件分片

文件本质上就是一串二进制内容,所以可以按照一定的规则切分,再逐个上传,每次保存哪些上传成功,哪些上传失败/还未上传即可。

大致流程如下:

  1. 将需要上传的文件分割为大小相同的数据块。
  2. 初始化上传任务,从后端获取本次任务的唯一 ID (也可以前端生成后发送后端),一般初始化还需要包括以下内容:
    • 完整文件 hash/CRC 值: 用于校验最终合并后的文件是否完整。
    • 文件分片数量或者所有分片 ID 合集: 让服务端知道有哪些分片会被上传。
  3. 通过串行/并行发送分片数据块,数据块包含以下内容:
    • 任务 ID: 所属任务唯一 ID。
    • 二进制内容: 文件在当前分片下的内容。
    • 序号/ID: 标记当前为哪个分片。
    • hash: 当前二进制内容的 hash 值,用于分片完整性校验。
  4. 服务端校验数据完整性,完整后合并为原文件。

断点续传

断点续传依赖于文件分片,未被分片的内容是无法续传的。

大致流程如下:

  1. 从服务端获取上传进度,还有哪些分片需要上传。
  2. 前端按照后端返回的未上传分片继续上传。
  3. 后端检测分片数量满足,从临时文件中读取文件合并,校验哈希值。

流式上传

在读取文件 buffer 的时候,边读边传,而不是读完再传。

需要注意的是 HTTP2 的流式传输可能会出现队头阻塞,推荐使用 HTTP3 实现。

其他注意点

  • 上传失败自动重传,重传次数限制
  • 上传过程中页面切走、断网、刷新导致异常中断,需要 recover
  • 切片的时候按文件大小切还是按数量切?策略如何确定?
  • web worker 后台上传
  • 秒传
    • 创建任务时,发送了文件 hash ,后端比较是否已经存在,存在则不需要重复上传,即是秒传。
  • 限制并发数避免浏览器/服务器占用过多连接数
  • 切片动态调整?
  • 定时任务清理文件碎片

示例代码

切片上传

const SIZE = 10 * 1024 * 1024 // 10MB

class fileUpload {
  // 生成文件切片
  createFileChunk(file, size = SIZE) {
    const fileChunkList = []
    let cur = 0
    while (cur < file.size) {
      fileChunkList.push({ file: file.slice(cur, cur + size) })
      cur += size
    }
    return fileChunkList
  }

  // 上传切片
  async uploadChunks() {
    const requestList = this.data
      .map(({ chunk, hash }) => {
        const formData = new FormData()
        formData.append('chunk', chunk)
        formData.append('hash', hash)
        formData.append('filename', this.container.file.name)
        return { formData }
      })
      .map(({ formData }) =>
        this.request({
          url: 'http://localhost:3000',
          data: formData,
        })
      )
    // 并发请求
    await Promise.all(requestList)
    //  合并切片
    await this.mergeRequest()
  }

  async mergeRequest() {
    await this.request({
      url: 'http://localhost:3000/merge',
      headers: {
        'content-type': 'application/json',
      },
      data: JSON.stringify({
        filename: this.container.file.name,
      }),
    })
  }

  async handleUpload() {
    if (!this.container.file) {
      return
    }
    const fileChunkList = this.createFileChunk(this.container.file)
    this.data = fileChunkList.map(({ file }, index) => ({
      chunk: file,
      hash: `${this.container.file.name}-${index}`,
    }))
    await this.uploadChunks()
  }
}

web-worker 计算 hash

// web-worker 代码

// 库 spark-md5,它可以根据文件内容计算出文件的 hash
globalThis.importScripts('/spark-md5.min.js')

// 生成文件 hash
globalThis.onmessage = (e) => {
  const { fileChunkList } = e.data
  const spark = new globalThis.SparkMD5.ArrayBuffer()
  let percentage = 0
  let count = 0

  const loadNext = (index) => {
    const reader = new FileReader()
    reader.readAsArrayBuffer(fileChunkList[index].file)
    reader.onload = (e) => {
      count++
      spark.append(e.target.result)

      if (count === fileChunkList.length) {
        globalThis.postMessage({
          percentage: 100,
          hash: spark.end()
        })
        globalThis.close()
      } else {
        percentage += 100 / fileChunkList.length
        globalThis.postMessage({
          percentage
        })
        // calculate recursively
        loadNext(count)
      }
    }
  }
  loadNext(0)
}
// 和主线程的通讯
// 主线程使用 postMessage 给 worker 线程传入所有切片 fileChunkList,
// 并监听 worker 线程发出的 postMessage 事件拿到文件 hash
function calculateHash(fileChunkList) {
  return new Promise((resolve) => {
    this.container.worker = new Worker('/hash.js')
    this.container.worker.postMessage({ fileChunkList })

    this.container.worker.onmessage = (e) => {
      const { percentage, hash } = e.data

      this.hashPercentage = percentage
      if (hash) {
        resolve(hash)
      }
    }
  })
}

参考资料

前端鉴权

HTTP Basic Authentication ( 基本废弃 )

鉴权流程

---
title: HTTP Basic 鉴权流程
---
sequenceDiagram
  participant 客户端
  participant 服务器

  客户端 ->> 服务器: 请求受限数据
  服务器 ->> 客户端: HTTP/1.1 401 Unauthorized<br>WWW-Authenticate: Basic realm="baidu.com"

  客户端 ->> 客户端: 弹框询问用户
  客户端 ->> 服务器: 携带Base64认证头<br>Authorization: Basic Kitsid2FuZzp3YW5n==

  服务器 -->> 服务器: 身份校验

  alt 校验成功
    服务器 -->> 客户端: 校验成功,返回状态码 200 资源
  else 校验失败
    服务器 -->> 客户端: 校验失败,返回 403 Forbidden
  end

优点

简单,广泛支持

缺点

不安全

  • HTTP 未被加密,整个认证的账号密码是明文传输。
  • 即使加密了,也可以通过重放请求获取权限。
  • 难以防御中间人攻击。

cookie-session

鉴权流程

---
title: Cookie-Session 鉴权流程
---
sequenceDiagram
  participant 浏览器
  participant 服务端
  participant User
  participant Session

  浏览器 ->> 服务端: POST 账号密码
  服务端 ->> User: 校验账号密码
  User -->> 服务端: 校验成功
  服务端 ->> Session: 存 session
  服务端 -->> 浏览器: Set-Cookie: sessionId

  浏览器 ->> 服务端: 携带 cookie 请求接口
  服务端 ->> session: 查 session 是否存在并且有效
  session -->> 服务端: 校验成功
  服务端 ->> 服务端: 接口处理
  服务端 -->> 浏览器: 接口响应结果

存储方式

服务端只是给 cookie 一个 sessionId ,而 session 的具体内容要自己存一下

  • Redis(推荐):内存型数据库,以 key-value 的形式存,正合 sessionId-sessionData 的场景;且访问快。
  • 内存:直接放到变量里。一旦服务重启就没了
  • 数据库:普通数据库。性能不高。

token

鉴权流程

---
title: Token 鉴权流程
---
sequenceDiagram
  participant 浏览器
  participant 服务端
  participant User

  浏览器 ->> 服务端: POST 账号密码
  服务端 ->> User: 校验账号密码
  User -->> 服务端: 用户数据
  服务端 ->> 服务端: 生成 token
  服务端 -->> 浏览器: Set-Cookie: token

  浏览器 ->> 服务端: 携带 cookie 请求接口
  服务端 ->> 服务端: 校验 token
  服务端 ->> 服务端: 接口处理
  服务端 -->> 浏览器: 接口响应结果

问题

  • 容易被篡改,所以需要添加 token 签名,也就是 JWT 机制
  • 容易被盗用,所以有 refresh token

JWT

分为 3 部分: headerpayloadsignature

header 结构如下:

{
  "typ": "JWT",
  "alg": "使用的签名算法"
}

payload 结构基本为自定义内容,会将部分公开信息保存在 payload 减少重复查询

{
  "sub": "主题,一般是用户 ID",
  "name": "用户名",
  "role": "用户角色",
  // 过期时间
  "exp": 1627708800
}

signature 一般就是纯字符串

三个部分均由 base64 编码,再通过 . 拼接而成

refresh token

refresh token 机制分为两部分:

  • access token: 用来访问业务接口,由于有效期足够短,盗用风险小,也可以使请求方式更宽松灵活
  • refresh token: 用来获取 access token ,有效期可以长一些,通过独立服务和严格的请求方式增加安全性;由于不常验证,也可以如前面的 session 一样处理

完整流程如下:

---
title: JWT 鉴权流程时序图
---
sequenceDiagram
  participant 浏览器
  participant 业务服务
  participant 认证服务
  participant User

  rect rgb(222,227,232)
  Note over 浏览器, User: 初次登录
  浏览器 ->> 认证服务: POST 账号密码
  认证服务 ->> User: 校验账号密码
  User ->> 认证服务: 用户数据
  认证服务 ->> 认证服务: 生成 refresh token (长期有效)
  认证服务 ->> 认证服务: 生成 access token (短期有效)
  认证服务 -->> 浏览器: refresh token, access token
  end

  rect rgb(222,227,232)
  Note over 浏览器, User: access token 有效
  浏览器 ->> 业务服务: 请求接口( access token )
  业务服务 ->> 业务服务: 校验 access token (有效)
  业务服务 ->> 业务服务: 接口处理
  业务服务 -->> 浏览器: 接口响应
  end

  rect rgb(222,227,232)
  Note over 浏览器, User: access token 失效
  浏览器 ->> 业务服务: 请求接口( access token )
  业务服务 ->> 业务服务: 校验 access token (失效)
  业务服务 -->> 浏览器: 拒绝请求
  浏览器 ->> 认证服务: refresh token
  认证服务 ->> 认证服务: 校验 refresh token (有效)
  认证服务 -->> 浏览器: access token (新)
  浏览器 ->> 业务服务: 请求接口( access token (新))
  业务服务 ->> 业务服务: 校验 access token (新)
  业务服务 ->> 业务服务: 接口处理
  业务服务 -->> 浏览器: 接口响应
  end

单点登录

---
title: 单点登录鉴权时序图
---
sequenceDiagram
  participant User as 浏览器
  participant AppA as 应用 A
  participant AppB as 应用 B
  participant SSO as SSO服务

  rect rgb(232,244,232)
  Note over User,SSO: 应用 A 引发登录

  %% 登录失效
  User ->> AppA: 访问应用 A
  AppA ->> AppA: 凭证失效,需要登录
  AppA -->> SSO: 重定向到 SSO

  %% SSO 登录
  User ->> SSO: 访问 SSO ,无 cookie
  SSO ->> SSO: 校验 cookie 失败
  SSO -->> User: 需要登录
  User ->> SSO: 输入账号密码
  SSO ->> SSO: 校验账号密码
  SSO -->> User: URL 带 code 重定向到应用 A callback<br>种 SSO 域 cookie

  %% SSO 同步权限状态到 AppA
  User ->> AppA: 带 cookie 访问应用 A callback
  AppA ->> SSO: 校验 code
  SSO ->> SSO: 校验 code 成功
  SSO -->> AppA: 换取 ticket
  AppA -->> User: 重定向到应用 A 原页面,种 A 域 cookie ( ticket )

  %% User 重新访问 AppA
  User ->> AppA: 访问应用 A callback , 带 cookie ( ticket )
  AppA ->> SSO: 校验 ticket
  SSO ->> SSO: 校验 ticket 成功
  SSO -->> AppA: 凭证有效
  AppA ->> AppA: 处理业务
  AppA ->> User: 业务响应
  end

  rect rgb(232,232,244)
  Note over User,SSO: 应用 B 无需登录
  %% 应用 B 还未同步 ticket
  User ->> AppB: 访问应用 B
  AppB ->> AppB: 凭证失效,需要登录
  AppB -->> User: 重定向到 SSO

  %% 从 SSO 同步登录状态
  User ->> SSO: 访问 SSO ,带 SSO 域 cookie
  SSO ->> SSO: 校验 cookie 成功
  SSO -->> User: URL 带 code 重定向到应用 B callback ,种 SSO 域 cookie
  User ->> AppB: 访问应用 B callback ,带 code
  AppB ->> SSO: 校验 code
  SSO ->> SSO: 校验 code 成功
  SSO -->> AppB: 换取 ticket
  AppB -->> User: 重定向到应用 B 原页面,种 B 域 cookie ( ticket )

  %% User 重新访问 AppB
  User ->> AppB: 访问系统 B ,带 B 域 cookie ( ticket )
  AppB ->> SSO: 校验 ticket
  SSO ->> SSO: 校验 ticket 通过
  SSO -->> AppB: 凭证有效
  AppB ->> AppB: 处理业务
  AppB -->> User: 业务响应
  end

OAuth

---
title: OAuth 鉴权时序图
---
sequenceDiagram
  participant User as 浏览器
  participant Client as OAuth 客户端
  participant AuthServer as OAuth 服务端
  participant ResourceServer as 认证服务器

  User ->> Client: 访问需要授权的资源
  Client -->> User: 重定向到授权页面

  User ->> AuthServer: 携带 client_id/redirect_uri/scope
  AuthServer ->> User: 要求登录并授权
  User ->> AuthServer: 输入凭证并授权
  AuthServer -->> User: 重定向到 redirect_uri 携带 code

  User ->> Client: 携带授权 code 访问
  Client ->> AuthServer: 发送 code + client_secret 换取 access_token
  AuthServer -->> Client: 返回 access_token

  Client ->> ResourceServer: 携带 access_token 请求资源
  ResourceServer ->> AuthServer: 验证 token 有效性
  AuthServer -->> ResourceServer: 返回验证结果

  alt 验证成功
    ResourceServer -->> Client: 返回请求的资源
    Client -->> User: 显示授权资源
  else 验证失败
    ResourceServer -->> Client: 返回 401 Unauthorized
    Client -->> User: 显示错误信息
  end

  Note over Client,AuthServer: access_token 需要安全存储<br>并设置合理有效期