首页
/ 用 Supabase Realtime 授权频道构建可访问控制的实时聊天室(Next.js 实战)

用 Supabase Realtime 授权频道构建可访问控制的实时聊天室(Next.js 实战)

2026-09-06 18:54:32作者:明树来

导读

本文基于 examples/realtime/nextjs-authorization-demo 这一完整示例工程,讲解如何利用 Supabase 的 Realtime Authorization(授权频道 / Private Channel)能力,结合 Postgres 行级安全(RLS)策略,构建一个「用户可建房间、邀请他人、收发实时消息」的聊天系统。读完本文,你将掌握 Realtime 的 config.private 频道开关、在 realtime.messages 表上编写授权策略、用触发器自动同步用户档案,以及 Broadcast 与 Presence 扩展在受控频道中的组合用法。

一、示例工程要解决什么问题

默认情况下,Supabase Realtime 的 Broadcast 与 Presence 扩展对“谁能加入某个频道”不做业务层面的限制——只要能通过 WebSocket 连接,客户端理论上可以订阅任意 channel 名称。而这套授权频道机制允许开发者把频道的准入控制交还给数据库:只有通过了 RLS 策略校验的用户,才能订阅并读写指定频道

SupaSecureSlack 这个演示应用的目标正是:

  • 用户登录后可以创建房间(room);
  • 用户可以邀请其他已注册用户进入房间;
  • 房间内用户通过 Broadcast 发送临时消息,通过 Presence 感知谁在线;
  • 每个房间的授权人数由 public schema 业务表与自动生成的 realtime schema 表上的 RLS 策略共同约束。

换言之,这是一个把「应用层权限校验」下沉到「数据库策略层」的实时应用样板。若用户未被授权订阅某房间,客户端连接会被服务端拒绝,界面上会呈现无访问权限的状态(详见下文配图)。

技术要点:本示例使用的核心机制是 Realtime 私有(private)频道。频道级鉴权依赖于两个要素:客户端在建频道时显式声明 private: true,以及数据库侧在 realtime.messages 表上设置的授权策略。

二、本地运行步骤

工程的启动方式与普通 Next.js + Supabase 项目一致:

  1. 复制环境变量文件:cp .env.example .env.local
  2. 在 Supabase 控制台新建一个项目;
  3. 参照本文「四、数据库初始化」编写建表与 RLS 策略 SQL;
  4. 在项目 API 设置页复制项目的 URLanon 公钥,填入 .env.local
  5. 安装依赖并启动:npm installnpm run dev

环境方面,package.json 声明 Node 版本要求 >=20.0.0,使用的依赖包括 @supabase/ssr@supabase/supabase-js^2)、Next.js 与 Tailwind CSS。登录流程走邮箱密码认证,注册与登录表单在 app/login/page.tsx 中实现;会话刷新中间件逻辑见 proxy.tsutils/supabase/proxy.ts

三、数据模型设计

示例共涉及三张业务表:

表名 职责 关键点
public.profiles 用户档案 通过数据库触发器在新用户注册时自动写入
public.rooms 全部房间 topic 字段唯一,同时充当房间名/频道名
public.rooms_users 用户与房间的关联 授权判断的核心依据,可理解为房间成员表

需要注意一个巧妙的设计:房间名(rooms.topic)直接作为 Realtime 频道的名称。这样客户端订阅频道时,服务端即可用频道名(realtime.topic())反查 rooms_users,判定当前用户是否是该房间的合法成员。这一约定贯穿整条授权链路,也是后续所有 RLS 策略的逻辑基础。

创建三张业务表

CREATE TABLE public.rooms (
    id bigint GENERATED BY default AS IDENTITY PRIMARY KEY,
    topic text NOT NULL UNIQUE
);
ALTER TABLE public.rooms ENABLE ROW LEVEL SECURITY;

CREATE TABLE public.profiles (
  id uuid NOT NULL REFERENCES auth.users ON DELETE CASCADE,
  email text NOT NULL,

  PRIMARY KEY (id)
);
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;

CREATE TABLE public.rooms_users (
  user_id uuid REFERENCES auth.users (id),
  room_topic text REFERENCES public.rooms (topic),
  created_at timestamptz DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE public.rooms_users ENABLE ROW LEVEL SECURITY;

三个 ALTER TABLE ... ENABLE ROW LEVEL SECURITY 意味着这些表默认不允许任何角色直读直写,必须依赖随后建立的策略放行。

四、数据库初始化:为实时频道编写授权策略

Realtime 授权在服务端的落地点是自动生成的 realtime.messages。客户端尝试订阅/写入频道时,Supabase Realtime 服务器会以当前登录用户的身份,在该表上执行 RLS 策略判断;只有策略放行,订阅才会成功。

示例文档作者特别强调:以下所有策略仅服务于本 Demo 教学场景,生产环境务必按自身业务与安全要求裁剪。

4.1 public 表的读写策略

为了让已登录用户能够浏览房间列表、看到成员并邀请成员,需要放开三张业务表的相应权限:

CREATE POLICY "authenticated can view all profiles"
ON "public"."profiles"
AS PERMISSIVE FOR SELECT
TO authenticated
USING (true);

CREATE POLICY "supabase_auth_admin can insert profile"
ON "public"."profiles"
AS PERMISSIVE FOR INSERT
TO supabase_auth_admin
WITH CHECK (true);

CREATE POLICY "authenticated can read rooms"
ON "public"."rooms"
AS PERMISSIVE FOR SELECT
TO authenticated
USING (TRUE);

CREATE POLICY "authenticated can add rooms"
ON "public"."rooms"
AS PERMISSIVE FOR INSERT
TO authenticated
WITH CHECK (TRUE);

CREATE POLICY "authenticated can read rooms_users"
ON "public"."rooms_users"
AS PERMISSIVE FOR SELECT
TO authenticated
USING (TRUE);

CREATE POLICY "authenticated can add rooms_users"
ON "public"."rooms_users"
AS PERMISSIVE FOR INSERT
TO authenticated
WITH CHECK (TRUE);

这些策略的含义是:authenticated 角色可以读取所有 profilesroomsrooms_users,可以创建 rooms 并向 rooms_users 追加关联;而 profiles 的插入仅交给 supabase_auth_admin(配合后文的触发器,避免普通用户伪造档案)。

4.2 realtime.messages 上的频道授权策略(核心)

接下来是对频道准入控制起决定作用的策略。它们的判定逻辑是:当前请求的频道名称(realtime.topic())是否在 rooms_users 中与当前用户(auth.uid())存在一条关联记录,且该扩展属于 broadcast 或 presence

CREATE POLICY "authenticated can read broadcast and presence state"
ON "realtime"."messages"
AS PERMISSIVE FOR SELECT
TO authenticated
USING (
  EXISTS (
    SELECT 1
    FROM public.rooms_users
    WHERE user_id = (select auth.uid())
    AND room_topic = realtime.topic()
    AND realtime.messages.extension in ('broadcast', 'presence')
  )
);

CREATE POLICY "authenticated can send broadcast and track presence"
ON "realtime"."messages"
AS PERMISSIVE FOR INSERT
TO authenticated
WITH CHECK (
  EXISTS (
    SELECT 1
    FROM public.rooms_users
    WHERE user_id = (select auth.uid())
    AND room_topic = realtime.topic()
    AND realtime.messages.extension in ('broadcast', 'presence')
  )
);

其中几个辅助函数/伪列值得展开说明:

  • realtime.topic():返回当前请求对应的频道名(即前文约定的 rooms.topic)。它把 WebSocket 层的频道名与数据库关系数据衔接起来;
  • realtime.messages.extension:标识消息来自哪个 Realtime 扩展,此处限定在 'broadcast''presence',从而只授权这两种能力;
  • auth.uid():当前已认证用户 ID。

SELECT 策略决定“能否订阅收听该频道”,INSERT 策略(WITH CHECK)决定“能否向该频道发送广播/上报 Presence 状态”。两者必须同时存在,聊天收发才会打通。

4.3 触发器:新用户自动写入档案

Realtime 授权需要知道当前登录用户在 profiles/rooms_users 中的身份,因此每当 auth.users 新增记录时,都要自动在 profiles 补一条档案:

CREATE OR REPLACE FUNCTION insert_user() RETURNS TRIGGER AS
$$
  BEGIN
    INSERT INTO public.profiles (id, email) VALUES (NEW.id, NEW.email); RETURN NEW;
  END;
$$ LANGUAGE plpgsql
   SECURITY DEFINER
   SET search_path = public;

CREATE OR REPLACE TRIGGER "on_new_auth_create_profile"
AFTER INSERT ON auth.users FOR EACH ROW
EXECUTE FUNCTION insert_user();

GRANT EXECUTE ON FUNCTION insert_user () TO supabase_auth_admin;
GRANT INSERT ON TABLE public.profiles TO supabase_auth_admin;

触发器函数声明为 SECURITY DEFINER 并固定 search_path = public,是为了让触发器能以函数属主权限完成插入;最后的两个 GRANT 则显式授权 supabase_auth_admin 调用函数并向 profiles 插入数据,与 4.1 中 profiles 的 INSERT 策略相互呼应。

五、前端:创建私有频道并接入授权

RLS 策略就绪后,真正的门槛在客户端。从 app/protected/page.tsx 可以看到两个关键约束。

5.1 版本与频道配置要求

  • 确保实际引入的 @supabase/realtime-jsv2.44.0 或更高版本(README 明确提示该版本起才支持私有频道配置);
  • 创建频道时必须声明 private: true,让服务端对该频道执行授权校验:
const channel = supabase.channel('room-1', {
  config: { private: true },
})

在本 Demo 中,频道名并非写死的 room-1,而是取自用户点选的房间 topic,同时开启广播回显自身消息的能力:

let newChannel = supabase.channel(selectedRoom, {
  config: {
    broadcast: { self: true },
    private: true, // This line will tell the server that you want to use a private channel for this connection
  },
})

broadcast: { self: true } 的作用是让发送者本人也能收到自己发出的消息事件,这样自己的气泡可以和其他人的气泡一样通过统一的事件回调渲染出来。

5.2 登录态与频道的绑定

授权校验需要携带当前用户的身份。代码在登录后手动把 access token 注入 Realtime 客户端,再创建主频道监听全局广播(如新房间创建事件)并拉取房间列表:

const token = (await supabase.auth.getSession()).data.session?.access_token!
supabase.realtime.setAuth(token)
let main = supabase
  .channel('supaslack')
  .on('broadcast', { event: 'new_room' }, () => getChannels())
  .subscribe()

5.3 订阅结果的四种状态处理

订阅回调中根据 status 区分不同结果,这是观察授权是否生效的最直观位置:

.subscribe((status, err) => {
  setLoading(false)

  if (status == 'SUBSCRIBED') {
    setChannel(newChannel)
    newChannel.track({ email: user?.email })
    setError(null)
  }
  if (status == 'CLOSED') {
    setChannel(null)
  }
  if (status == 'CHANNEL_ERROR') {
    setError(err?.message || null)
  }
})
  • SUBSCRIBED:RLS 校验通过,订阅成功,随即调用 track({ email }) 上报自己的 Presence 信息;
  • CLOSED:连接被关闭(含切换房间时主动 unsubscribe 的场景);
  • CHANNEL_ERROR:通常意味着该用户未通过频道授权,界面上会渲染「You do not have access to this room」的错误提示,输入框同时因 channel 为空而禁用。

5.4 收发消息与 Presence 渲染

频道建立后,消息与在线成员的处理都挂在同一个频道对象上:

newChannel
  .on('broadcast', { event: 'message' }, ({ payload: payload }) =>
    addMessage(payload.user_id == user?.id, false, payload.message)
  )
  .on('presence', { event: 'join' }, ({ newPresences }) => {
    newPresences.map(({ email }) => users.add(email))
    setUsers(new Set(users))
  })
  .on('presence', { event: 'leave' }, ({ leftPresences }) => {
    leftPresences.map(({ email }) => users.delete(email))
    setUsers(new Set(users))
  })

发消息时判断是否为 /invite 指令,否则走普通广播:

if (message.startsWith('/invite')) {
  const email = message.replace('/invite ', '')
  addUserToChannel(email)
} else {
  channel?.send({
    type: 'broadcast',
    event: 'message',
    payload: { message, user_id: user?.id },
  })
}

Presence 事件里携带的 email 来自订阅成功后 track({ email }) 上报的字段——也就是说,房间右侧的「Users in Room」在线列表,正是 Presence 状态被广播到同频道成员的结果。

5.5 邀请加入(/invite)背后的授权写入

/invite <email> 的本质是向 rooms_users 写入一条关联记录。由于 4.1 已为 rooms_users 配置了 INSERT 策略,登录用户可以直接插入关联行,从而把某用户“授权”进某房间:

const addUserToChannel = async (email: string) => {
  const user = await supabase.from('profiles').select('id').eq('email', email)
  if (!user.data?.length) {
    addMessage(true, true, `User ${email} not found`)
  } else {
    const room = await supabase.from('rooms').select('topic').eq('topic', selectedRoom)

    await supabase
      .from('rooms_users')
      .upsert({ user_id: user.data?.[0].id, room_topic: room.data?.[0].topic })
    addMessage(true, true, `Added ${email} to channel ${selectedRoom}`)
  }
}

被邀请用户下次打开该房间时,Realtime 服务端会以该用户身份执行 4.2 中的 EXISTS 查询——此时 rooms_users 已有其关联记录,订阅即被放行。

创建房间的流程与之类似(见 components/create-room-modal.tsx):先向 rooms 插入新 topic,再把创建者自己写入 rooms_users(保证房主天然有权限),最后通过主频道 supaslack 广播 new_room 事件通知所有在线客户端刷新房间列表。

六、两种访问结果对照

下面两张截图直观呈现了授权策略的作用效果。

两个用户均成功接入房间,可以看到消息列表与在线成员(chat_success.png)

未授权用户的 RLS 校验被拒绝,界面提示 You do not have access to this room(chat_unauthorized.png)

对未授权用户而言,即便他知道房间名也无法完成订阅:realtime.messages 上的 SELECT 策略让服务端直接拒绝,客户端状态落入 CHANNEL_ERROR 分支,呈现无权限提示。这也是「授权下沉到数据库」相比纯客户端鉴权的核心价值——访问规则无法被绕过。

在输入框输入 /invite 指令邀请成员,系统给出 Added xx to channel xx 的系统消息(invite.png)

/invite 的斜杠命令与系统消息气泡是本地实时合成的,用于反馈邀请结果;新成员收到的是之后重新进入房间时被策略放行的订阅能力,而非消息历史。

七、要点回顾与安全提醒

从该示例可提炼出四条可直接迁移到生产项目的经验:

  1. 频道名即业务主键:让 rooms.topic 直接充当频道名,授权策略才能用 realtime.topic() 完成“请求的频道 ↔ 房间成员表”的关联判断;
  2. 两把锁缺一不可realtime.messages 上的 SELECT 策略管“能否订阅收听”,INSERT 策略管“能否广播/上报 Presence”,写策略必须显式过滤 extension in ('broadcast', 'presence')
  3. 客户端必须显式声明私有频道:忘记设置 config.private: true,或 @supabase/realtime-js 版本低于 v2.44.0,授权流程不会生效;
  4. 策略按需收紧:Demo 中「authenticated 可读所有 rooms/profiles/rooms_users」是教学便利之举。若房间私密性要求较高,应将 SELECT 策略也改为基于 rooms_users 的 EXISTS 子查询,避免未授权成员枚举房间列表;SECURITY DEFINER 触发器函数务必锁定 search_path,并最小化对 supabase_auth_admin 的授权范围。

如需继续阅读本工程其余源码,可参考认证回调 app/auth/callback/route.ts、中间件 proxy.ts 与客户端初始化 utils/supabase/client.ts

登录后查看全文
热门项目推荐
相关项目推荐