返回

WebSocket 认证: 修复 NextJS NGINX Akka-HTTP 连接错误

javascript

搞定 WebSocket 连接:NextJS -> NGINX -> Akka-HTTP 疑难杂症

问题来了:WebSocket 为啥连不上?

不少开发者在使用 NextJS 前端、NGINX 反向代理和 Akka-HTTP (Scala) 后端搭建 WebSocket 通信时,可能会碰到一个头疼的问题:浏览器控制台无情地抛出 WebSocket connection to 'wss://your.domain/ws' failed: 错误。

你的配置大概是这样的:

  • 后端: Scala + Akka-HTTP,跑在比如 localhost:8080
  • 前端: NextJS 应用,通过浏览器访问。
  • 中间: NGINX 作为反向代理,处理所有请求,并将 /ws 路径的请求转发给 Akka-HTTP 后端。

目标很明确:让 NextJS 应用能和 Akka-HTTP 服务建立一个安全的(wss) WebSocket 连接,实现实时通信,比如服务器推送更新。

你可能已经检查了 Akka-HTTP 端的代码,特别是 WebSocket 路由部分,还加上了身份验证逻辑,从日志看,后端似乎正确处理了请求头中的 token,权限验证也通过了。

// Akka-HTTP (Scala) 后端 WebSocket 相关代码片段
// ... (用户提供的原始代码) ...
val responseQueue: mutable.Queue[ApiResponse] = mutable.Queue()

private def webSocketFlow: Flow[Message, Message, _] = {
  Flow[Message].mapAsync(1) { _ =>
    if (responseQueue.nonEmpty) {
      system.log.info("Flushing responseQueue")
      val response = responseQueue.dequeue()
      val protobufMessage = ByteString(response.toByteArray)

      Future.successful(BinaryMessage(protobufMessage))
    } else {
      system.log.warn("Response queue empty")
      Future.successful(BinaryMessage(ByteString.empty))
    }
  }
}

private def websocketRoute: Route = {
  pathPrefix("ws") {
    pathEndOrSingleSlash {
      extractRequest { req =>
        // 这里尝试从 Sec-WebSocket-Protocol 头提取 token
        val tokenOpt = req.headers.collectFirst {
          case header if header.name() == "Sec-WebSocket-Protocol" =>
            OAuth2BearerToken(header.value())
        }

        system.log.info(s"Handling ws auth:${tokenOpt.toString}")

        extractUri { uri =>
          val callingURI = uri.toRelative.path.dropChars(1).toString
          system.log.info(s"===== $callingURI")
          // 认证逻辑
          oAuthAuthenticator(Credentials(tokenOpt), handleWebSocketMessages(webSocketFlow), callingURI).get
        }
      }
    }
  }
}

// ... (oAuthAuthenticator 逻辑基本同用户提供) ...

前端 NextJS 这边,你可能用了 useEffectuseState 来管理 token,并在 token 获取或变化后尝试建立 WebSocket 连接。

// NextJS 前端代码片段
// ... (用户提供的原始代码,稍作简化) ...
useEffect(() => {
    if (pageToken) {
        // 注意这里,尝试将 token 作为第二个参数传入 WebSocket 构造函数
        const ws = new WebSocket("/ws", pageToken); // <--- 问题可能出在这里

        ws.onopen = () => {
            console.log("Connected to WebSocket server");
        };

        ws.onmessage = (event) => {
            // ... 处理消息 ...
        };

        ws.onerror = (err) => {
            // 这里会看到连接失败的错误
            console.error("WebSocket error:", err);
        };

        return () => {
            if (ws) ws.close();
        };
    }
}, [pageToken]);

// ... (获取和管理 token 的逻辑,如 useSWR) ...

NGINX 的配置看起来也很标准,启用了 WebSocket 代理所需的 UpgradeConnection 头。

# NGINX 配置片段
location /ws {
        proxy_pass http://localhost:8080; # 指向 Akka-HTTP 服务
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        # 可能还需要配置超时等,后面会提
}

令人费解的是,当你尝试移除身份验证逻辑——不在 NextJS WebSocket 构造函数里传 token,并且也移除 Akka-HTTP 后端对 Sec-WebSocket-Protocol 头的检查——连接竟然成功了!这说明 WebSocket 代理本身是通的,问题似乎就出在身份验证这环。

你可能会纳闷:很多资料不是说用 Sec-WebSocket-Protocol 来传 token 进行身份验证吗?怎么就不行了呢?

刨根问底:Sec-WebSocket-Protocol 不是这么用的

问题的关键在于对 Sec-WebSocket-Protocol 这个 HTTP 头(在 WebSocket 握手阶段使用)的理解。

Sec-WebSocket-Protocol 的真正用途是协商应用层子协议,而不是传递任意的认证凭证。

什么意思呢? ধরুন,你的 WebSocket 通信可能需要支持多种不同的消息格式或交互约定,比如一种是纯文本聊天,一种是基于 JSON-RPC 的调用。客户端可以在发起连接时,通过 Sec-WebSocket-Protocol 头告诉服务器:“我能支持 chat-v1json-rpc 这两种子协议”。服务器收到后,看看自己能支持哪种,然后选择一个(比如 chat-v1),在响应头里也带上 Sec-WebSocket-Protocol: chat-v1。这样,双方就知道接下来该用哪套“规矩”来沟通了。

浏览器端的 WebSocket API 设计严格遵循了这个规范。当你调用 new WebSocket(url, protocols) 时:

  1. 第二个参数 protocols 必须是一个字符串或者字符串数组,代表客户端提议的子协议名称列表。
  2. 浏览器会检查这些名称是否符合 RFC 6455 定义的格式(大致上是不能包含特殊分隔符,得是可见 ASCII 字符等)。
  3. 关键点:浏览器不允许你在这里塞入像 Bearer your-long-token 这样的自定义认证字符串。 如果你强行塞入,浏览器要么直接报错,要么在发送握手请求时忽略掉这个无效的 Sec-WebSocket-Protocol 值,或者发送了但服务器因为格式不对而拒绝,最终导致连接失败。

所以,你在 NextJS 代码里 new WebSocket("/ws", pageToken) 的尝试,本质上是误用了 Sec-WebSocket-Protocol。即使你的 Akka-HTTP 后端日志显示似乎收到了类似 Bearer xxx 的信息(这有点奇怪,可能是某些非标准实现或者日志记录方式的误导,或者 NGINX 没正确处理?但根源在浏览器端就不该这么用),浏览器在发起连接时就已经埋下了失败的种子。

这就是为什么去掉 token 传递(即 new WebSocket("/ws"))反而能成功——因为不再尝试设置那个非法的 Sec-WebSocket-Protocol 了。

那么,正确的 WebSocket 身份验证姿势是什么?

解决方案:WebSocket 身份验证的正确姿势

既然 Sec-WebSocket-Protocol 此路不通,我们需要换个思路来验证 WebSocket 连接的合法性。有几种常见且靠谱的方法:

方案一:升级握手时验证 Cookie (推荐)

这是最常用也相对安全的方法。

  • 原理:
    WebSocket 连接的建立始于一个标准的 HTTP GET 请求(包含特殊的 Upgrade: websocketConnection: Upgrade 头)。浏览器在发送这个初始的 HTTP 请求时,会自动带上目标域下的 Cookie。如果你的用户在访问网页时已经登录,并且登录状态通过 Cookie (比如 Session ID 或 JWT Token 存在 Cookie 里) 维护,那么这个 Cookie 会在 WebSocket 握手请求中一并发送给服务器。服务器可以在处理这个 HTTP 升级请求时,检查 Cookie 的有效性,验证用户身份。只有验证通过,才同意将连接升级为 WebSocket;否则,直接返回 HTTP 错误(如 401 Unauthorized 或 403 Forbidden)。

  • Akka-HTTP 实现:
    在你的 websocketRoute 中,你需要先提取 Cookie,然后进行验证,最后才调用 handleWebSocketMessages。可以使用 akka.http.scaladsl.server.directives.CookieDirectives 中的 cookieoptionalCookie 指令。

    import akka.http.scaladsl.server.directives.CookieDirectives._
    import akka.http.scaladsl.model.headers.HttpCookiePair
    
    // ...
    
    private def websocketRoute: Route = {
      pathPrefix("ws") {
        pathEndOrSingleSlash {
          // 1. 尝试提取名为 "auth_token" 的 Cookie
          optionalCookie("auth_token") {
            case Some(tokenCookie) =>
              val tokenValue = tokenCookie.value
              system.log.info(s"Found auth_token cookie: ${tokenValue.take(10)}...") // 注意日志脱敏
    
              // 2. 使用 Cookie 中的 token 进行认证
              //    你需要调整 oAuthAuthenticator 或创建一个新的认证逻辑来处理 Cookie token
              //    这里假设 oAuthAuthenticator 能处理一个 Option[String] 类型的 token
              //    注意:OAuth2BearerToken 可能不再适用,取决于你的 token 格式
              val credentials = if (tokenValue.nonEmpty) Credentials(Some(OAuth2BearerToken(tokenValue))) else Credentials.Missing // 调整为实际类型
    
              extractUri { uri =>
                val callingURI = uri.toRelative.path.dropChars(1).toString
                system.log.info(s"===== $callingURI")
                // 传入 token 进行验证,然后才处理 WebSocket 消息
                oAuthAuthenticator(credentials, handleWebSocketMessages(webSocketFlow), callingURI).getOrElse {
                   complete(StatusCodes.Unauthorized -> "Invalid or missing auth cookie") // 如果认证失败
                }
              }
    
            case None =>
              // 3. 如果没有找到 Cookie,拒绝连接
              system.log.warn("Auth token cookie missing.")
              complete(StatusCodes.Unauthorized -> "Auth token cookie required")
          }
        }
      }
    }
    
    // 你需要相应调整 oAuthAuthenticator 来适配从 Cookie 获取的 token
    // 例如,它可能不再需要处理 `Sec-WebSocket-Protocol`
    // private def oAuthAuthenticator(credentials: Credentials, protectedRoutes: Route, callingURI: String): Option[Route] = ...
    
  • NextJS 实现:
    确保你的登录流程会将认证 token (比如 JWT) 写入一个 HttpOnly, Secure (在 HTTPS 环境下), SameSite=StrictSameSite=Lax 的 Cookie 中。之后,在建立 WebSocket 连接时,你不需要WebSocket 构造函数里做任何特殊操作,浏览器会自动发送这个 Cookie。

    // NextJS: 登录成功后,确保服务器设置了正确的 Cookie
    // ... fetch login api ... then server responds with Set-Cookie header ...
    
    // 建立 WebSocket 连接时,无需传递 token
    useEffect(() => {
        // pageToken 可能仍然需要从某处获取,用于其他 API 调用,但不用传给 WebSocket 构造函数
        // 假设登录状态由 Cookie 维护
        const ws = new WebSocket("/ws"); // Просто! No second argument needed.
    
        ws.onopen = () => {
            console.log("Connected to WebSocket server (authenticated via cookie)");
        };
    
        // ... a többi WebSocket eseménykezelő ...
    }, []); // Maybe depends on login status rather than pageToken
    
    // 注意: 你获取 token 的 fetcher 和 useSWR 可能仍需存在,
    // 但它的主要目的或许是检查登录状态或获取用于普通 API 请求的 token,
    // 而不是直接提供给 WebSocket。
    
  • NGINX 配置:
    通常不需要特殊配置,NGINX 默认会转发 HTTP 请求头,包括 Cookie 头。

  • 安全建议:

    • 必须 使用 HttpOnly 属性的 Cookie 来存储敏感 token,防止客户端 JavaScript 读取,减少 XSS 攻击风险。
    • 必须 使用 Secure 属性,确保 Cookie 只在 HTTPS 连接下传输。
    • 设置 SameSite=StrictSameSite=Lax 来防御 CSRF 攻击。Strict 更安全,但可能影响某些跨站跳转场景;Lax 是个不错的折中。
    • Cookie 中的 Session ID 或 Token 本身应该设计得难以猜测,并有合理的过期时间。

方案二:升级握手时验证查询参数

这种方式是在 WebSocket 的 URL 里直接带上 token。

  • 原理:
    客户端在构造 WebSocket URL 时,将 token 作为查询参数附加上去,例如 wss://your.domain/ws?token=YOUR_TOKEN。服务器在处理 HTTP 升级请求时,从 URL 中提取这个 token 进行验证。

  • Akka-HTTP 实现:
    使用 parameterparameterMap 指令来提取查询参数。

    import akka.http.scaladsl.server.directives.ParameterDirectives._
    
    // ...
    
    private def websocketRoute: Route = {
      pathPrefix("ws") {
        pathEndOrSingleSlash {
          // 1. 尝试提取名为 "token" 的查询参数
          parameter("token".?) { // 使用 ? 表示可选参数,根据你的要求调整
            case Some(tokenValue) =>
              system.log.info(s"Found token query parameter: ${tokenValue.take(10)}...")
    
              // 2. 使用查询参数 token 进行认证
              val credentials = Credentials(Some(OAuth2BearerToken(tokenValue))) // 调整凭证类型
    
              extractUri { uri =>
                 // ... (认证逻辑同上, 使用 credentials) ...
                 oAuthAuthenticator(credentials, handleWebSocketMessages(webSocketFlow), "ws").getOrElse {
                   complete(StatusCodes.Unauthorized -> "Invalid or missing token parameter")
                 }
              }
    
            case None =>
              // 3. 如果没有找到 token 参数,拒绝连接
              system.log.warn("Token query parameter missing.")
              complete(StatusCodes.Unauthorized -> "Token query parameter required")
          }
        }
      }
    }
    
  • NextJS 实现:
    在构造 WebSocket URL 时拼接上 token。

    useEffect(() => {
        if (pageToken) {
            // 将 token 拼接到 URL 的查询参数中
            const wsUrl = `/ws?token=${encodeURIComponent(pageToken)}`;
            const ws = new WebSocket(wsUrl);
    
            // ... WebSocket 事件处理 ...
        }
    }, [pageToken]);
    
  • NGINX 配置:
    通常不需要特殊配置。

  • 安全建议:

    • 极不推荐! 将 Token 放在 URL 中有严重的安全风险:
      • URL 可能会被记录在浏览器历史记录、服务器日志(包括 NGINX 和 Akka-HTTP)、网络设备日志中。
      • 如果 WebSocket 连接页面是通过 HTTP 访问的(即使 WebSocket 本身是 wss),Token 可能通过 Referer 头泄露给第三方网站。
    • 只有在实在没有其他办法,并且 Token 是非常短效、一次性或者作用范围极小的情况下,才可以勉强考虑,但一定要清楚其中的风险。 强烈建议优先选择 Cookie 方式。

方案三:连接建立后,通过首条消息进行认证

这种方法是先允许建立连接,然后在连接成功后的第一条消息里进行认证。

  • 原理:
    WebSocket 连接先建立起来(不进行预认证)。客户端连接成功后 (onopen 事件触发),立刻发送一条包含认证信息(如 Token)的特定格式消息给服务器。服务器收到第一条消息时,进行验证。如果验证通过,就将该连接标记为“已认证”,正常处理后续消息;如果验证失败,服务器主动关闭连接。

  • Akka-HTTP 实现:
    这需要更复杂的 Flow 设计,通常需要一个有状态的 Flow 来处理“待认证”和“已认证”两种状态。可以使用 scan 结合状态机,或者自定义 GraphStage

    // 这是一个简化的概念示例,实际实现可能更复杂
    
    import akka.stream.scaladsl._
    import akka.actor.typed.ActorSystem
    import scala.concurrent.Future
    import akka.http.scaladsl.model.ws.{Message, TextMessage, BinaryMessage}
    
    sealed trait WsAuthState
    case object PendingAuth extends WsAuthState
    case class Authenticated(userId: String) extends WsAuthState // 假设认证后得到用户ID
    case object AuthFailed extends WsAuthState
    
    def authenticatedWebSocketFlow(implicit system: ActorSystem[_]): Flow[Message, Message, _] = {
      implicit val ec = system.executionContext
    
      val authenticator: Flow[Message, Either[String, (Message, String)], _] = Flow[Message]
        .prefixAndTail(1) // 取出第一条消息,剩下的作为尾流
        .flatMapConcat { case (firstMessages, restOfMessages) =>
          if (firstMessages.isEmpty) {
            Source.single(Left("Auth failed: No initial message received.")) // 没有收到首条消息
          } else {
            firstMessages.head match {
              case tm: TextMessage => // 假设用 TextMessage 发送 JSON 认证信息
                val eventualAuthResult = tm.toStrict(1.second).map { strictMsg =>
                  val jsonString = strictMsg.text
                  // TODO: 解析 jsonString,提取 token,调用你的认证服务
                  val isValid = performAuthBasedOnMessage(jsonString) // 这是一个你需要实现的认证函数
                  if (isValid) Right("user_id_example") // 认证成功,返回用户信息或标识
                  else Left("Auth failed: Invalid token in message.")
                }
                Source.future(eventualAuthResult).flatMapConcat {
                  case Right(userId) =>
                     // 认证成功,将 userId 和后续消息流合并输出
                    Source.single(Right(firstMessages.head -> userId)) // 可以考虑不把认证消息本身往下传
                      .concat(restOfMessages.map(msg => Right(msg -> userId)))
                  case Left(error) => Source.single(Left(error)) // 认证失败
                }
              case bm: BinaryMessage =>
                  bm.dataStream.runWith(Sink.ignore) // 忽略非文本的首次消息
                 Source.single(Left("Auth failed: Expected text message for auth."))
            }
          }
        }
    
      Flow[Message]
        .via(authenticator) // 先经过认证处理
        .statefulMapConcat { () => // 使用 statefulMapConcat 管理认证状态
          var state: WsAuthState = PendingAuth
    
          {
            case Left(error) => // 认证失败
              system.log.error(error)
              state = AuthFailed
              // 可以发送一个错误消息给客户端然后关闭,或者直接让流结束
              List(TextMessage(s"Auth Error: $error")) // 示例:发送错误信息
            case Right((message, userId)) if state == PendingAuth => // 首次消息认证成功
              system.log.info(s"User $userId authenticated via message.")
              state = Authenticated(userId)
              // TODO: 处理认证成功后的第一条消息(如果需要),或者忽略认证消息本身
              // List(handleAuthenticatedMessage(message, userId))
              List.empty // 假设忽略认证消息
            case Right((message, userId)) if state.isInstanceOf[Authenticated] => // 已认证状态下的后续消息
              // TODO: 正常处理来自已认证用户的消息
              List(handleAuthenticatedMessage(message, Authenticated(userId))) // 调用你的业务处理逻辑
            case _ => // 其他无效状态或消息,忽略
              List.empty
          }
        }
        .takeWhile(_ => state != AuthFailed, inclusive = true) // 如果认证失败,最多再发送一条错误消息后就关闭流
    }
    
    // 模拟的认证函数和消息处理函数
    def performAuthBasedOnMessage(msg: String): Boolean = { /* 解析并验证 token */ true }
    def handleAuthenticatedMessage(msg: Message, state: Authenticated): Message = {
       // 你的正常业务逻辑,比如处理来自已认证用户的指令,或者从 responseQueue 推送数据
       // 这个示例只简单回显
       msg match {
          case tm: TextMessage => TextMessage(s"Server received from ${state.userId}: ${tm.getStrictText}")
          case bm: BinaryMessage => bm // 如果是二进制消息,原样返回或处理
       }
    }
    
    // 在你的 route 中使用这个 flow
    // private def websocketRoute: Route = path("ws") { handleWebSocketMessages(authenticatedWebSocketFlow) }
    

    注意: 上面的 Akka-HTTP 代码是概念性的,特别是状态管理和错误处理部分需要根据实际需求细化。使用 prefixAndTail 或自定义 GraphStage 是实现此类逻辑的关键。

  • NextJS 实现:
    onopen 回调中立即发送认证消息。

    useEffect(() => {
        if (pageToken) { // 假设 pageToken 仍需获取
            const ws = new WebSocket("/ws");
    
            ws.onopen = () => {
                console.log("WebSocket connected, sending auth message...");
                // 发送认证消息,格式需要与服务器约定,比如 JSON
                const authPayload = {
                    type: "auth",
                    token: pageToken
                };
                ws.send(JSON.stringify(authPayload));
            };
    
            ws.onmessage = (event) => {
                // 可能需要处理服务器确认认证成功的消息,或者直接开始处理业务消息
                console.log("Message from server:", event.data);
                // ...
            };
    
            ws.onerror = (err) => {
                console.error("WebSocket error:", err);
            };
    
            ws.onclose = (event) => {
                console.log("WebSocket closed:", event.code, event.reason);
                // 如果因认证失败被关闭,event.reason 可能会有提示
            };
    
            return () => {
                if (ws) ws.close();
            };
        }
    }, [pageToken]);
    
  • NGINX 配置:
    无需特殊配置。

  • 安全建议:

    • 连接建立初期有一个短暂的“未认证”窗口,需要实施速率限制等措施,防止恶意用户通过大量建立连接来消耗服务器资源。
    • 服务器必须严格执行“先认证后处理”的逻辑,不能在认证成功前处理任何业务数据。
    • 客户端在认证成功前不应发送敏感信息。
  • 进阶技巧:

    • 定义清晰的消息协议,明确认证消息的格式和服务器响应。
    • 考虑认证过程中的超时处理。
    • 认证成功后,可以启动心跳机制 (ping/pong) 来维持连接活跃并检测断线。

综合来看,对于大多数 Web 应用场景,方案一(基于 Cookie 的认证)是最推荐的方式,它安全、标准,且对客户端代码侵入性最小。

NGINX 配置注意事项

你的基础 NGINX 配置已经包含了代理 WebSocket 的关键部分:

location /ws {
    proxy_pass http://localhost:8080;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection 'upgrade';
    proxy_set_header Host $host;

    # 新增:设置合理的超时时间,防止连接因不活跃而被断开
    # 默认可能是 60s,对于需要长时间保持的 WebSocket 可能不够
    proxy_read_timeout 3600s; # 例如,设置为 1 小时
    proxy_send_timeout 3600s; # 同上

    # 如果你的 Akka-HTTP 服务需要知道原始请求协议是 https
    proxy_set_header X-Forwarded-Proto $scheme;
    # 可能还需要 X-Forwarded-For 等,根据后端需要添加
    # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

重点是 proxy_read_timeoutproxy_send_timeout。WebSocket 连接通常是长连接,如果长时间没有数据传输,NGINX 可能会根据默认的超时设置断开连接。将它们设置到一个较大的值(比如几小时 3600s,或者如果你有心跳机制,可以设置为略大于心跳间隔的值),或者如果 NGINX 版本支持且场景允许,可以设置为 off 来禁用超时(但这有资源耗尽风险,慎用)。

Akka-HTTP 代码调整示例 (以方案一:Cookie 认证为例)

基于方案一,你的 Akka-HTTP 代码可能需要这样调整:

import akka.http.scaladsl.server.directives.CookieDirectives._
import akka.http.scaladsl.model.headers.HttpCookiePair
import akka.http.scaladsl.model.StatusCodes

// ... other imports ...

// 调整 websocketRoute
private def websocketRoute: Route = {
  pathPrefix("ws") {
    pathEndOrSingleSlash {
      // 1. 提取名为 "session_token" (或其他你使用的名称) 的 cookie
      optionalCookie("session_token") { // 假设你的 token 存在这个 cookie 里
        case Some(tokenCookie) =>
          val token = tokenCookie.value
          system.log.info("WebSocket attempt with session token cookie.")

          // 2. 准备 Credentials (可能需要调整 Credential 类型)
          // 这里假设你的 oAuthAuthenticator 可以接受 Option[OAuth2BearerToken]
          // 如果你的 cookie 不是 Bearer token 格式,需要相应调整
          val credentials = Credentials(Some(OAuth2BearerToken(token))) // 使用 cookie 值

          extractUri { uri =>
            val callingURI = "ws" // 对于 /ws 路径
            // 3. 调用认证逻辑
            oAuthAuthenticator(credentials, handleWebSocketMessages(webSocketFlow), callingURI).getOrElse {
              // 认证失败,拒绝升级请求
              complete(StatusCodes.Unauthorized -> "Invalid or expired session token.")
            }
          }

        case None =>
          // 没找到 cookie,拒绝
          system.log.warn("WebSocket attempt without session token cookie.")
          complete(StatusCodes.Unauthorized -> "Session token cookie required.")
      }
    }
  }
}

// 调整 oAuthAuthenticator 的入口 (如果需要)
// 它现在期望从 Cookie 获取 token,而不是 Sec-WebSocket-Protocol
// 你可能需要修改 Credentials case class 或者处理逻辑来反映这一点
private def oAuthAuthenticator(credentials: Credentials, protectedRoutes: Route, callingURI: String): Option[Route] =
  credentials match {
    // Case 匹配可能需要调整,取决于你怎么包装 Cookie Token
    case Credentials(Some(OAuth2BearerToken(token))) => // 假设仍用 BearerToken 包装
      system.log.info("Credentials provided via Cookie.")
      // ... 原来的认证和权限检查逻辑 ...
      // 注意: p.verify 可能需要调整,它之前处理的是 header 来的 token
      val user = loggedInUsers.find(user => p.verify(user.oAuthToken.access_token)) // 确认这里的 token 比较逻辑是否正确
      // ... (后续逻辑不变) ...
    case _ =>
      system.log.error(s"No valid credentials found in cookie.")
      Option(complete(ApiResponse().withStatusResponse(HydraStatusCodes.MISSING_CREDENTIALS.getStatusResponse)))
  }

// webSocketFlow 部分不需要改动,它处理连接建立后的消息

NextJS 代码调整示例 (以方案一:Cookie 认证为例)

你的 NextJS 代码将变得更简单,不再需要在 WebSocket 构造函数里操心 token。

import { useEffect, useState } from 'react';
import { useRouter } from 'next/router';
import useSWR from 'swr';
// 假设你的 fetchWithErrors 和 errorToast 已经定义好
// 假设你的登录 API 会在成功后设置好 HttpOnly Cookie

function MyWebSocketComponent() {
    const router = useRouter();
    const [isLoggedIn, setIsLoggedIn] = useState(false); // 用一个状态表示登录与否

    // SWR 用于定期检查会话/Cookie 是否有效 (可选,取决于你的需求)
    // 这个 fetcher 可能只是访问一个需要认证的端点,看是否返回 200 OK
    // 或者它返回用户信息,如果 401 则认为未登录
    const fetcher = (url) => fetchWithErrors(url, {}, (error) => {
        if (error.status === 401) {
            setIsLoggedIn(false);
            // 可能需要清除一些本地状态,但 Cookie 是 HttpOnly 的,JS 无法清除
            router.push("/login"); // 重定向到登录页
        } else {
            errorToast("Session check error:" + error.message);
            // 这里可以考虑也设置 isLoggedIn 为 false
        }
    }).then(data => {
        // 如果请求成功,说明 Cookie 有效
        setIsLoggedIn(true);
        return data; // 返回可能的用户信息等
    });

    const { data, mutate } = useSWR(
        "/api/check-session", // 一个检查登录状态的 API 端点
        fetcher,
        {
            refreshInterval: 60000, // 例如每分钟检查一次
            revalidateOnFocus: true,
            shouldRetryOnError: false
        }
    );

    useEffect(() => {
        // 首次加载时触发检查
        mutate();
    }, [mutate]);

    useEffect(() => {
        let ws = null;
        // 只有在确认登录状态后才建立 WebSocket 连接
        if (isLoggedIn) {
            console.log("User is logged in, attempting WebSocket connection...");
            // 直接连接,浏览器会自动带上 Cookie
            ws = new WebSocket("/ws"); // 注意:这里不再需要第二个参数

            ws.onopen = () => {
                console.log("WebSocket connected successfully (authenticated via cookie)");
            };

            ws.onmessage = (event) => {
                try {
                    if (event.data instanceof Blob && event.data.size === 0) {
                        // Akka-HTTP 示例中会发送空 BinaryMessage,这里可以忽略
                        console.log("Received empty message, likely queue empty signal.");
                        return;
                    }
                    if (event.data instanceof ArrayBuffer) { // Protobuf 是 ArrayBuffer
                         const buffer = new Uint8Array(event.data);
                         // 假设 ApiResponse.deserializeBinary 存在
                         // const decodedMessage = ApiResponse.deserializeBinary(buffer);
                         // console.log("Decoded Protobuf message:", decodedMessage);
                         console.log("Received binary message, length:", buffer.length);
                    } else {
                        console.log("Received text message:", event.data);
                    }
                } catch (err) {
                    console.error("Failed to process WebSocket message:", err);
                }
            };

            ws.onerror = (err) => {
                // 这个错误现在不应该再是 'failed: ' 了,除非有其他网络或服务器问题
                console.error("WebSocket error:", err);
                // 如果连接错误,可能需要重新检查登录状态或提示用户
                setIsLoggedIn(false); // 假设连接错误意味着会话失效
            };

            ws.onclose = (event) => {
                console.log("WebSocket closed:", event.code, event.reason);
                 // 根据关闭代码和原因可以判断是否需要重新登录或重试
                 // 如果是因为认证失败 (比如 Cookie 过期) 被服务器关闭,需要处理
                 if (event.code === 1008 || event.code === 1011) { // Policy Violation or Server Error often used for auth failure
                    setIsLoggedIn(false);
                    router.push('/login');
                 }
            };
        }

        // Cleanup 函数:当组件卸载或 isLoggedIn 变为 false 时关闭连接
        return () => {
            if (ws) {
                console.log("Closing WebSocket connection.");
                ws.close();
            }
        };
    }, [isLoggedIn, router]); // 依赖于登录状态

    return (
        <div>
            {/* Your component UI */}
            <p>WebSocket Status: {isLoggedIn ? (ws && ws.readyState === WebSocket.OPEN ? 'Connected' : 'Connecting/Closed') : 'Not Logged In'}</p>
        </div>
    );
}

export default MyWebSocketComponent;

// 注意: 你需要确保你的登录API (/api/login 或类似) 在成功时通过 Set-Cookie header
// 设置了一个 HttpOnly, Secure, SameSite 的 cookie。
// /api/check-session 端点应该是一个需要认证的 API,用于验证 cookie 是否有效。
// 之前的 /api/gettoken 可能就不再需要了,除非你还需要 token 用于其他非 WebSocket 的场景。

这样调整后,身份验证的担子就交给了浏览器和服务器之间标准的 Cookie 机制,WebSocket 连接本身的代码变得干净多了。

希望这些分析和解决方案能帮你彻底解决 WebSocket 连接失败的问题!