前言
我第一次执行 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 思路来想:
- 用户提交登录表单
- 服务端验密码
- session/cookie 写进去
- 后面的页面请求自然就有当前用户了
这套在 Controller 世界里基本没毛病。
可 LiveView 一掺进来,事情就没那么直给了。因为 LiveView 不是那种"请求进来,响应出去,这事就结束"的模型。它是先经过一次 HTTP,再升级成一条会持续活着的连接。
这个差异看着不大,实际上正是认证系统最容易让人犯迷糊的地方。
2.1 Controller 里的守卫,守的是请求入口
phx.gen.auth 生成出来以后,UserAuth 里通常会有这些东西:
fetch_current_userredirect_if_user_is_authenticatedrequire_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 真正容易出事的,往往不是没登录,而是边界看起来都有,实际全靠默契。