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/3handle_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 真正容易出事的,往往不是没登录,而是边界看起来都有,实际全靠默契。

相关推荐
在因斯坦1 小时前
摸摸鱼之前端自己研究 Jenkins 自动化部署
前端
snpgroupcn2 小时前
SAP实施风险管控注意事项:四大风险域与防范策略
前端·数据库·人工智能
阿成学长_Cain2 小时前
Linux talk 命令详解:经典终端实时聊天工具,无需图形界面即可对话
linux·运维·前端
lichenyang4532 小时前
从零开始写 HarmonyOS Web 容器:先让 ArkWeb 加载本地 H5
前端
kyriewen2 小时前
一个看不见的撇号,让Claude Code追踪了中国开发者三个月
前端·ai编程·claude
莫生灬灬3 小时前
DY联系人聊天Web面板支持获取联系人/聊天记录/发送消息
前端·chrome·websocket·c#
郑州光合科技余经理3 小时前
海外外卖平台源码改造实战——多语言核心代码实现
java·开发语言·前端·后端·mysql·架构·php
一tiao咸鱼3 小时前
前端转 agent # 02 - FastAPI 框架入门与原理
前端·python
wanghowie3 小时前
LangGraph4j 落地(二)— ReAct 多步工具与 trace 扩展
前端·react.js·前端框架