LiveView 的认证系统:从登录到权限,我一开始以为有 `current_user` 就算完事了

前言

我第一次执行 mix phx.gen.auth 的时候,心情挺复杂。

一方面是爽,毕竟登录、注册、重置密码、邮件确认,一下给了一整套。另一方面也是真的虚,因为生成出来的文件不少,命名也挺正式,那个感觉特别像:

"这套代码一看就很权威,但我暂时不敢乱碰。"

然后我就犯了一个很典型的错误。

我以为认证系统已经"生成完了",那后面我只要在页面里拿 @current_user,再按角色藏几个按钮,这事就差不多了。

后来事实证明,这个想法属于典型的"刚进场时过于乐观"。

因为在 Phoenix 里,登录 、当前用户 、LiveView 路由守卫 、页面事件权限 、资源级别校验,压根不是一层东西。它们看起来都跟"用户身份"有关,但真正开始写项目时,这几层一旦搅在一起,人就会非常容易把自己骗过去。

最常见的自我安慰有三句:

  • 我都登录成功了,后面肯定能拿到当前用户
  • 这个按钮我已经不让普通用户看见了,应该没事
  • Router 都守住了,里面的 handle_event 总不至于还出问题吧

这三句我都想过。后面也都被现实教育过。

1. 我第一次看 phx.gen.auth,最大的感受不是方便,是"它其实只解决了一半问题"

phx.gen.auth 很值,这点我先摆明。

它帮我省掉了很多体力活,尤其是下面这些脏活累活:

  • 用户注册和登录流程
  • session token 的生成和校验
  • UserAuth 这种"把用户塞进请求上下文"的胶水代码
  • 登录后跳转、退出登录、记住我
  • 邮箱确认、重置密码这种边缘但又不能没有的流程

换句话说,它把"你是谁"这件事的基础设施搭起来了。

但问题也正好在这儿。

很多人第一次用它时,容易顺手把另一件事也脑补进去:既然认证都生成了,那权限大概也差不多了。

其实没有。

phx.gen.auth 解决的是 authentication,不是 authorization。

翻成人话就是:

  • 它负责确认"你是不是这个人"
  • 但它不负责回答"你这个人现在到底能干什么"

这俩东西表面看只差一个单词,落到项目里却差一整层边界。

举个很现实的例子:

  • 用户能不能登录
  • 登录后能不能进 /dashboard
  • 进了后台后能不能看 /admin/users
  • 能看到列表后能不能删别人创建的数据

这四个问题,一个比一个具体。前两个跟认证关系更近,后两个已经进入权限设计了。生成器不会替你决定角色模型,更不会替你决定"管理员能删什么,普通成员能改什么,资源所有者有没有例外"。

所以我后来对 phx.gen.auth 的心态就正常了很多:

它不是整套权限系统的终点,它只是起跑线。

2. 真正拧巴的点,在于 LiveView 不是"请求来一次就结束"

我一开始理解认证,总是下意识按传统 Web 思路来想:

  1. 用户提交登录表单
  2. 服务端验密码
  3. session/cookie 写进去
  4. 后面的页面请求自然就有当前用户了

这套在 Controller 世界里基本没毛病。

可 LiveView 一掺进来,事情就没那么直给了。因为 LiveView 不是那种"请求进来,响应出去,这事就结束"的模型。它是先经过一次 HTTP,再升级成一条会持续活着的连接。

这个差异看着不大,实际上正是认证系统最容易让人犯迷糊的地方。

2.1 Controller 里的守卫,守的是请求入口

phx.gen.auth 生成出来以后,UserAuth 里通常会有这些东西:

  • fetch_current_user
  • redirect_if_user_is_authenticated
  • require_authenticated_user

这些名字都挺直白,本质上就是给传统请求用的:

  • 先从 session token 里把用户捞出来
  • 把当前用户挂到连接上下文里
  • 该拦的页面提前拦掉

比如登录成功后,你去请求一个受保护的 Controller 页面,这条链就很顺。

2.2 LiveView 里的守卫,守的是 socket 挂载入口

但 LiveView 不一样。

你在 Router 里哪怕已经写了 pipe_through [:browser, :require_authenticated_user],也不等于后续所有 LiveView 页面都自动安全了。原因很简单:LiveView 后面那条长连接,有它自己的挂载过程。

所以真正让我脑子转过来的,不是某个 API,而是下面这个事实:

HTTP plug 和 LiveView on_mount,守的不是同一扇门。

我后来比较习惯的写法,大概是这样:

elixir 复制代码
scope "/", MyAppWeb do
  pipe_through [:browser]

  live_session :public,
    on_mount: [{MyAppWeb.UserAuth, :mount_current_user}] do
    live "/", PageLive, :home
  end

  live_session :require_authenticated_user,
    on_mount: [{MyAppWeb.UserAuth, :ensure_authenticated}] do
    live "/dashboard", DashboardLive, :index
    live "/settings", SettingsLive, :edit
  end
end

这段代码对我最大的意义,不是"写法标准",而是它强迫我承认了一件事:

  • plug 负责 HTTP 请求阶段
  • on_mount 负责 LiveView 连接阶段

你要是少掉后者,就很容易出现一种诡异状态:浏览器里明明有登录态,但 LiveView 页面拿不到你以为会自动存在的当前用户。

这也是我最早踩的第一个坑。

2.3 current_user 也好,current_scope 也好,重点从来不是变量名

这里顺手提一句。

Phoenix 不同版本、不同项目模板里,当前用户这层 assign 的命名有时会是 current_user,有时会进一步包装成 current_scope。名字不完全一致,但核心问题没变:

你得明确,当前 LiveView 在挂载时,到底有没有把身份上下文接进来。

别被变量名带跑,重点是"身份上下文有没有进入 socket"。

3. current_user 这件事,我踩过三个特别像"理所当然"的坑

如果只让我挑三个最容易把人绕进去的点,我会选下面这三个。因为它们都很像常识,结果都特别容易在项目里变成幻觉。

3.1 以为登录了,LiveView 里自然就有当前用户

这个误会非常常见,尤其是刚从 Controller 思维切过来的时候。

我那时的真实想法是:用户都已经登录成功了,session 也写了,那 LiveView 页面里出现 @current_user 不是理所当然吗?

结果不是。

LiveView 想拿这个身份,要么在 live_session 里通过 on_mount 接进来,要么你自己在挂载流程里明确处理。否则页面能开,事件能绑,但某些需要用户上下文的地方就会开始像踩棉花。

所以我后来挺认同一个很土但很有效的检查方式:

凡是要区分"游客可看"和"登录后可看"的 LiveView 页面,我先看 Router 里的 live_session,不先看模板。

因为真正决定这页有没有身份上下文的,往往是 Router 那层,不是页面里某个 if @current_user do。

3.2 以为把按钮藏起来,就算做了权限控制

这坑我现在回头看,多少有点喜剧效果。

因为"隐藏按钮"这件事确实很有迷惑性。它看起来像权限控制,因为用户眼睛里已经看不到"删除""编辑""封禁"这些按钮了,界面一下变得很文明。

但 UI 文明不等于服务端安全。

你把按钮藏了,只是说明你不鼓励 用户点;不代表用户不能点。

LiveView 的事件本质上还是服务端事件。只要事件名和参数能构造出来,handle_event/3 就会收到。所以真正敏感的动作,我后来都不敢只在模板里判断。

像删除这种操作,我更愿意把判断压回服务端逻辑里:

elixir 复制代码
def handle_event("delete", %{"id" => id}, socket) do
  project = Projects.get_project!(id)
  user = Accounts.get_user!(socket.assigns.current_user.id)

  with :ok <- Projects.authorize(:delete, user, project),
       {:ok, _project} <- Projects.delete_project(project) do
    {:noreply, stream_delete(socket, :projects, project)}
  else
    {:error, :forbidden} ->
      {:noreply, put_flash(socket, :error, "这一下真不能删")}
  end
end

这段代码里我最看重的,其实不是 put_flash,而是两件事:

  • 权限判断发生在真正执行动作之前
  • 敏感动作别只信页面上的 assign,必要时重新取一次用户/资源

后面这句很关键。因为 LiveView 的 socket 可能活得比你想象中久,尤其是后台页面。一旦角色、团队关系、资源归属在别处发生变化,你页面上那个旧 assign 不一定永远新鲜。

3.3 以为 Router 守住了,页面里的事件层就可以躺平

这是第三个特别容易自我安慰的点。

很多时候我们在 Router 里做了这一层:

  • 游客区
  • 登录用户区
  • 管理员区

写完以后会很自然地产生一种安全感:都分区了,那后面的页面逻辑应该差不多了吧。

其实只能说,入口分区做对了,但资源级权限还没结束。

举个很现实的例子:

  • 两个用户都登录了
  • 他们都能进入 /projects
  • 但并不意味着他们都能编辑 /projects/42

这时候 Router 只能说明"这俩人都允许进入项目模块",不能直接说明"这俩人对同一条项目数据拥有一样的能力"。

所以我后来把权限拆成两层来看,脑子就清楚很多:

  • 页面入口权限:你能不能进这类页面
  • 资源操作权限:你能不能对这条具体数据做这件事

前者适合放 Router 和 on_mount。

后者别偷懒,老老实实放业务层、上下文层,或者最起码放在 handle_event/3、handle_params/3 这种真正用到资源的地方。

4. 从"能登录"到"真有权限",中间差的不是一张 role 表,而是一层判断边界

很多项目做到这里,就会自然冒出一句:

"行,那我给 users 表加个 role 字段不就完了?"

这当然比没有强,但如果只停在这一步,后面大概率还是会继续乱。

因为权限设计真正难的,往往不是有没有 role 字段,而是你到底按什么维度判断。

我后来比较认同的顺序是这样的:

4.1 先分清你到底在控制什么

常见的权限判断,至少有三类:

  • 登录态:是不是已登录
  • 角色:是不是管理员、运营、普通成员
  • 资源关系:这条数据是不是他自己创建的,是不是属于他所在团队

如果这三类东西全混在模板里写 if,项目很快就会有一股"哪儿都能判断一点,哪儿都说不完整"的味道。

4.2 角色判断适合拦入口,资源判断适合拦动作

比如管理员后台这种页面,入口就很明确,直接在 on_mount 里卡掉,完全合理:

elixir 复制代码
def on_mount(:ensure_admin, _params, _session, socket) do
  user = socket.assigns.current_user

  if user && user.role == :admin do
    {:cont, socket}
  else
    {:halt, socket |> put_flash(:error, "这页不是谁都能进") |> redirect(to: ~p"/")}
  end
end

然后 Router 里把管理员页面单独收一组:

elixir 复制代码
live_session :admin,
  on_mount: [
    {MyAppWeb.UserAuth, :ensure_authenticated},
    {MyAppWeb.UserAuth, :ensure_admin}
  ] do
  live "/admin/users", Admin.UserLive.Index, :index
end

但到了"编辑某篇文章""删除某个项目""查看某个团队的账单"这种事,我更不愿意只靠角色名硬判断。因为这些动作常常还带资源归属。

这时候我更喜欢把规则收敛到上下文里,比如:

elixir 复制代码
def authorize(:edit, user, project) do
  cond do
    user.role == :admin -> :ok
    project.user_id == user.id -> :ok
    true -> {:error, :forbidden}
  end
end

这类函数最大的价值,不是优雅,而是权限规则终于有了固定落点。后面无论是 Controller、LiveView,还是别的入口,判断逻辑都不用东一块西一块复制。

4.3 真敏感的动作,别把希望全寄托在长连接上的旧状态

这点是我后面越写越有感觉的一件事。

LiveView 很舒服,舒服到人容易忘了它本质上是个长期存活的服务端进程。页面挂着半小时、一小时,真的很正常。

也正因为如此,有些"当时是对的"身份信息,过一阵子不一定还对:

  • 用户刚被降权
  • 团队成员关系刚被移除
  • 资源归属刚被别人改了

这时候你如果还完全信任 socket.assigns.current_user 里那份老数据,风险就会慢慢出来。

所以我现在对敏感操作的态度挺简单:

展示层可以大胆用 assign,执行层最好重新确认。

这不是杞人忧天,是长连接系统里很朴素的自保。

5. 我后来比较认同的一套拆法,不花哨,但不容易把自己绕晕

写到后面,我自己对 LiveView 认证和权限这块,基本形成了一套比较土、但我觉得挺稳的拆法。

5.1 让 phx.gen.auth 继续管它擅长的基础设施

登录、登出、session token、邮箱确认、密码重置,这些别手痒乱重写。

官方生成器不是不能改,但它已经把那些最容易漏边角的流程兜起来了。只要业务没有特别离谱的定制需求,我一般愿意先在它上面长,而不是从零造一套"更懂我业务"的登录系统。那种自信,通常在第二周就开始漏风。

5.2 用 live_session 切页面边界,用 on_mount 切身份边界

我现在很少把一堆 LiveView 路由胡乱堆在一起。

更顺手的方式是按页面区域拆:

  • 公共页面一组
  • 登录用户页面一组
  • 管理员页面一组

这样 Router 本身就像一份边界说明书。以后回来看项目,不用先进代码文件,光看路由分组,基本就知道哪些页面共享同一套身份规则。

5.3 用业务函数做最终权限判断,别让模板承担过重职责

模板当然可以做条件显示,这对体验很重要。

但我现在更愿意把模板理解成"礼貌层",不是"裁决层"。

  • 模板负责别把不该出现的按钮到处乱放
  • 业务层负责真的做决定

这个分工一旦稳住,后面就不太容易出现"页面上藏了,接口却没藏;Router 卡了,事件却没卡"的错位。

5.4 如果你发现自己到处在写 if user.role == ...,多半已经开始散了

这个是我给自己留的一个报警器。

角色判断偶尔写一两处没问题,但如果很多 LiveView、很多组件、很多 handle_event 里都在重复类似逻辑,那大概率说明权限规则已经到处外溢了。

这时候与其继续补 if,不如停一下,把"谁能做什么"收成几个明确的 policy 函数。哪怕先土一点,都比遍地开花强。

总结

我后来越来越觉得,LiveView 的认证系统难的不是"怎么让用户登录",而是怎么把登录态、页面入口、事件处理、资源权限这几层边界分开。

phx.gen.auth 负责把"你是谁"这件事铺平,但从"能进系统"到"能动哪些数据",中间还差一层你必须自己说清楚的规则。current_user 很重要,但它最多是起点,不是免死金牌。

如果只用一句话收这篇,那就是:

认证解决的是身份确认,权限解决的是动作边界;LiveView 真正容易出事的,往往不是没登录,而是边界看起来都有,实际全靠默契。

相关推荐
deli00733 分钟前
32K 上下文塞满后,AI 最先忘掉什么?120 轮对话实测
前端
deli0072 小时前
手机拍灯为什么有一条条黑纹?PWM 240Hz 实测
前端
wangruofeng6 小时前
AI Models 1.1.0 发布,查大模型价格、对比模型和找 API 地址
前端·aigc·ai编程
wangruofeng6 小时前
给前端开发者的 100 个设计参考网站
前端·设计
星栈9 小时前
视频即代码,Remotion 能做什么、不能做什么
前端·音视频开发
广州华水科技10 小时前
单北斗GNSS位移监测系统的应用与技术解析
前端
用户479492835691510 小时前
从一句话或代码仓到可交互 HTML:开源 Agent 画图技能 Archify v3.2 实测与选型边界
前端·后端·面试
风骏时光牛马12 小时前
生成一个AI多模态标题,仅输出一条标题,无额外文字、问句、备选、配图、文案等任何附加内容,不需要其他的任务服务 只要标题。
前端
wangruofeng13 小时前
从 GIF 到 AVIF:网页图片格式演进与 2026 年选型指南
前端·架构
清晨Coding14 小时前
为什么用 React Developer Tools 查看组件树这么乱?
前端·react.js