
前言我第一次执行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 和 LiveViewon_mount守的不是同一扇门。我后来比较习惯的写法大概是这样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.3current_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就会收到。所以真正敏感的动作我后来都不敢只在模板里判断。像删除这种操作我更愿意把判断压回服务端逻辑里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里卡掉完全合理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 里把管理员页面单独收一组live_session :admin, on_mount: [ {MyAppWeb.UserAuth, :ensure_authenticated}, {MyAppWeb.UserAuth, :ensure_admin} ] do live /admin/users, Admin.UserLive.Index, :index end但到了“编辑某篇文章”“删除某个项目”“查看某个团队的账单”这种事我更不愿意只靠角色名硬判断。因为这些动作常常还带资源归属。这时候我更喜欢把规则收敛到上下文里比如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 真正容易出事的往往不是没登录而是边界看起来都有实际全靠默契。