WebSocket 认证: 修复 NextJS NGINX Akka-HTTP 连接错误
2025-03-26 09:42:33
搞定 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 这边,你可能用了 useEffect
和 useState
来管理 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 代理所需的 Upgrade
和 Connection
头。
# 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-v1
和 json-rpc
这两种子协议”。服务器收到后,看看自己能支持哪种,然后选择一个(比如 chat-v1
),在响应头里也带上 Sec-WebSocket-Protocol: chat-v1
。这样,双方就知道接下来该用哪套“规矩”来沟通了。
浏览器端的 WebSocket
API 设计严格遵循了这个规范。当你调用 new WebSocket(url, protocols)
时:
- 第二个参数
protocols
必须是一个字符串或者字符串数组,代表客户端提议的子协议名称列表。 - 浏览器会检查这些名称是否符合 RFC 6455 定义的格式(大致上是不能包含特殊分隔符,得是可见 ASCII 字符等)。
- 关键点:浏览器不允许你在这里塞入像
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: websocket
和Connection: 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
中的cookie
或optionalCookie
指令。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=Strict
或SameSite=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=Strict
或SameSite=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 实现:
使用parameter
或parameterMap
指令来提取查询参数。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 方式。
- 极不推荐! 将 Token 放在 URL 中有严重的安全风险:
方案三:连接建立后,通过首条消息进行认证
这种方法是先允许建立连接,然后在连接成功后的第一条消息里进行认证。
-
原理:
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_timeout
和 proxy_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 连接失败的问题!