Livewire `[Locked]` 属性全解析:锁定公共属性、防止客户端篡改的完整实战指南 Livewire#[Locked]属性全解析锁定公共属性、防止客户端篡改的完整实战指南【免费下载链接】livewireA full-stack framework for Laravel that takes the pain out of building dynamic UIs.项目地址: https://gitcode.com/gh_mirrors/li/livewire#[Locked]是 Livewire 提供的 PHP 属性Attribute用于锁定组件中的公共属性使其只能在后端 PHP 代码中修改前端通过wire:model、DevTools 或伪造请求等方式发起的任何修改都会被拦截并抛出异常。它适用于存储模型 ID、授权敏感数据等安全边界场景。读完本文你将掌握#[Locked]的基本用法、底层实现原理、与 Eloquent 模型自动保护的区别以及为什么 Livewire 不默认锁定所有属性。什么是#[Locked]属性Livewire 组件的公共属性public property在默认情况下前后端都可以自由修改——例如通过wire:model指令在前端双向绑定。但如果某个属性承载着敏感信息比如一条帖子的 ID、用户的角色标识这种自由就会变成安全隐患用户完全可以通过浏览器 DevTools 修改前端状态或直接篡改请求 payload 来改变属性值。#[Locked]属性的作用就是为这类属性加上客户端只读的锁前端客户端无法修改通过wire:model、Alpine.js 或手工伪造请求去改它都会失败后端PHP 代码仍可修改在你的组件方法如mount()、action 方法中正常赋值不受影响。从源码看#[Locked]定义在 src/Attributes/Locked.php它继承自Livewire\Features\SupportLockedProperties\BaseLocked而后者继承了Livewire\Features\SupportAttributes\Attribute见 BaseLocked.php。整个锁定逻辑由独立的特性模块SupportLockedProperties负责见 src/Features/SupportLockedProperties。基本用法第一步导入属性类使用任何 Livewire 属性前都必须先导入对应的类。#[Locked]需要use Livewire\Attributes\Locked;第二步在公共属性上添加属性下面的show-post组件把Post模型的 ID 存在公共属性$postId中并用#[Locked]锁定它防止用户在删除操作前篡改 ID?php // resources/views/components/post/⚡show.blade.php use Livewire\Attributes\Locked; use Livewire\Component; use App\Models\Post; new class extends Component { #[Locked] // 锁定该属性前端无法修改 public $postId; public function mount($id) { $this-postId $id; } public function delete() { Post::find($this-postId)-delete(); return redirect(/posts); } };注意这是 Livewire 4 的「单文件组件」语法组件写在resources/views/components/post/⚡show.blade.php中。加上#[Locked]之后$postId就永远不会被客户端篡改。如果用户试图通过浏览器 DevTools 修改该属性或伪造网络请求改变它的值Livewire 会抛出异常并阻止操作执行。一个简单的实践示例将上面的思路套用到计数器组件上可以直观地看到锁定效果?php use Livewire\Attributes\Locked; use Livewire\Component; new class extends Component { #[Locked] public $count 1; public function increment() { $this-count; // 后端修改是允许的 } };此时通过wire:model去改$count会失败但调用increment()方法后端修改则可以正常累加。锁定属性引发的异常与错误响应当客户端尝试更新一个被锁定的属性时底层会抛出CannotUpdateLockedPropertyException。这个异常定义在 CannotUpdateLockedPropertyException.php其错误信息为Cannot update locked property: [属性名]它在调试模式与生产模式下的行为不同调试模式app.debug true返回false交由 Laravel 渲染完整的错误页面便于开发者定位问题生产模式app.debug false返回response(, 419)——一个空的 HTTP 419 响应避免向攻击者泄露内部细节。这正是 UnitTest.php 中test_updating_locked_property_returns_419_in_production测试所验证的行为在app.debug关闭时伪造携带updates的请求会得到 419 状态码。底层原理#[Locked]是如何拦截更新的要理解#[Locked]为什么有效需要看一遍它从属性注册到拦截更新的完整调用链。1. 属性通过反射被发现并注册Livewire 通过Livewire\Features\SupportAttributes\AttributeCollection::fromComponent()使用 PHP 反射扫描组件的类属性、方法与类本身凡是继承自Livewire\Features\SupportAttributes\Attribute的属性都会被收集见 AttributeCollection.php。每个被发现的属性会调用__boot()绑定到组件上并标记其级别。属性的级别定义在 AttributeLevel.phpenum AttributeLevel { case ROOT; // 类级别 case PROPERTY; // 属性级别 case METHOD; // 方法级别 }#[Locked]是属性级别PROPERTY的属性。组件通过HandlesAttributestrait 暴露getAttributes()方法见 HandlesAttributes.php来访问这些收集到的属性。2. 组件 Hook 在每次更新时触发属性回调SupportAttributes是一个ComponentHook见 SupportAttributes.php它把属性生命周期与组件生命周期对齐。其中update($propertyName, $fullPath, $newValue)是关键function update($propertyName, $fullPath, $newValue) { $callbacks $this-getLivewireAttributes() -filter(fn ($attr) $attr-getLevel() AttributeLevel::PROPERTY) // 即使是深层更新如 foo.bar也会调用根属性上的 update 钩子 -filter(fn ($attr) str($fullPath)-startsWith($attr-getName() . .) || $fullPath $attr-getName()) -map(function ($attribute) use ($fullPath, $newValue) { if (method_exists($attribute, update)) { return $attribute-update($fullPath, $newValue); } }); ... }它只筛选属性级别且路径匹配的属性$fullPath $attr-getName()或以其为前缀然后调用属性自身的update()方法。注意这里的匹配规则即使是foo.count这样的深层更新只要根属性foo被锁定也会命中拦截。3.BaseLocked::update()直接抛出异常BaseLocked.php 的核心逻辑只有几行class BaseLocked extends LivewireAttribute { public function update() { throw new CannotUpdateLockedPropertyException($this-getName()); } }也就是说任何针对被锁定属性的客户端更新请求都会在属性真正赋值之前被拦截并抛出异常。4. 完整请求处理链路从请求入口看HandleComponents.php 的update()方法接收快照snapshot、更新updates与调用calls然后对每一个更新路径调用updateProperty()public function updateProperty($component, $path, $value, $context) { $segments explode(., $path); $maxDepth config(livewire.payload.max_nesting_depth); if ($maxDepth ! null count($segments) $maxDepth) { throw new MaxNestingDepthExceededException($path, $maxDepth); } $property array_shift($segments); $finish trigger(update, $component, $path, $value); // ...随后校验公共属性存在性再通过 Synth 递归赋值 }trigger(update, ...)会触发所有注册的ComponentHook::update()其中就包括SupportAttributes::update()。由于BaseLocked::update()直接抛异常赋值永远不会真正发生——这正是#[Locked]的拦截点。这也解释了为什么生产模式下会返回 419异常在赋值前抛出Laravel 将其渲染为 HTTP 419会话过期语义以避免泄露细节。深层更新的锁定行为#[Locked]不仅锁定属性本身的直接赋值也锁定对它的深层更新。上面的匹配逻辑str($fullPath)-startsWith($attr-getName() . .)意味着如果锁定的是$foo数组或对象那么foo.count、foo.bar.baz这类深层路径的更新同样会被拦截。这一点由 UnitTest.php 中的测试验证function test_cant_deeply_update_locked_property() { $this-expectException(CannotUpdateLockedPropertyException::class); $this-expectExceptionMessage(Cannot update locked property: [foo]); Livewire::test(new class extends TestComponent { #[BaseLocked] public $foo [count 1]; function increment() { $this-foo[count]; } }) -assertSetStrict(foo.count, 1) -set(foo.count, 2); // 深层更新被拦截 }另一个测试test_can_update_locked_property_with_similar_name则验证了名称相似但不同的属性如count与count2不会互相误伤——匹配是基于完整路径的精确前缀判断。什么场景该用#[Locked]官方文档docs/attribute-locked.md、docs/locked.md建议在以下场景使用#[Locked]存储不应被用户改变的模型 ID例如上面的删除帖子示例防止用户把postId改成其他帖子后越权删除在整个组件生命周期中保留授权敏感数据例如记录当前用户是否有权限执行某操作的标志位保护任何充当安全边界的公共属性只要该属性被前端修改会带来风险就应该考虑锁定。提醒模型属性默认就是安全的如果你把 Eloquent 模型本身而不是模型 ID存进公共属性Livewire 会自动保证其 ID 不会被篡改——不需要显式加#[Locked]?php // resources/views/components/post/⚡show.blade.php use Livewire\Component; use App\Models\Post; new class extends Component { public Post $post; // 已自动受到保护 public function mount($id) { $this-post Post::find($id); } };对大多数场景而言直接存模型是比#[Locked]更优的做法。为什么不用protected属性一个自然的疑问是敏感数据为什么不用protected属性而要绕一圈用#[Locked]关键在于 Livewire 的持久化机制Livewire 只会在请求之间持久化公共属性。protected属性对静态、硬编码的值没有问题但任何需要在运行时存储的数据都必须放在公共属性里才能跨请求保留。于是#[Locked]的价值就体现出来了它同时给你公共属性的持久化能力和防止客户端篡改的保护——两头的好处都要这正是它存在的意义。为什么 Livewire 不默认锁定所有属性你可能会想既然锁定这么安全为什么 Livewire 不默认锁定所有公共属性只在wire:model绑定到时才允许修改官方文档给出的答案是如果默认锁定Livewire 就必须解析你所有的 Blade 模板来判断某个属性是否被wire:model或类似 API 修改。这会带来额外的技术与性能开销更关键的是无法可靠检测属性是否被 Alpine.js 或其他自定义 JavaScript 修改——前端代码的动态性使得静态分析不可行。因此Livewire 的设计取向是默认让公共属性自由可变把锁定这个权力交给开发者按需使用#[Locked]。测试与验证用 Livewire 测试框架确认锁定行为仓库自带的单元测试src/Features/SupportLockedProperties/UnitTest.php覆盖了#[Locked]的典型行为也是你在自己项目中编写测试时的最佳参考测试方法验证的行为test_cant_update_locked_property直接set(count, 2)抛Cannot update locked property: [count]test_cant_deeply_update_locked_property深层更新set(foo.count, 2)同样被拦截test_cant_update_uninitialized_typed_locked_property_with_an_array未初始化的类型化锁定属性public \stdClass $group被set时同样拦截test_can_update_locked_property_with_similar_name名称相似但不同的属性count2不受影响可正常更新test_it_can_updates_form_with_locked_properties表单对象Form中的锁定属性与普通属性可以共存未锁定的form.foo正常更新test_updating_locked_property_returns_419_in_productionapp.debug false时伪造更新请求返回 HTTP 419最后一个测试还揭示了#[Locked]的另一种应用位置Form 对象表单类中的属性同样可以加#[Locked]。比如在Livewire\Form子类中锁定id字段见 UnitTest 中的SomeForm防止用户通过wire:model篡改表单的 ID 字段。注意此时更新路径带前缀如form.foo拦截机制同样生效——因为SupportFormObjects会把表单更新转发到属性更新链路见 SupportFormObjects.php。总结要点结论作用锁定公共属性禁止前端wire:model、DevTools、伪造请求修改后端修改不受影响组件 PHP 代码内可正常赋值异常CannotUpdateLockedPropertyException生产环境渲染为 419深层更新锁定根属性即可拦截所有前缀匹配的深层路径模型属性存 Eloquent 模型时 ID 自动受保护无需#[Locked]与protected区别公共属性才跨请求持久化#[Locked]兼顾持久化与防篡改为什么不默认锁定解析全部模板开销大且无法静态检测 Alpine/JS 修改可用位置组件公共属性、Form 对象属性#[Locked]是 Livewire 安全模型中的一块重要拼图它用最少的代码把该持久化与不该被改这两个需求同时满足。当你在组件里存放模型 ID、权限标志或其他安全敏感数据时别忘了给它们加上这把锁。【免费下载链接】livewireA full-stack framework for Laravel that takes the pain out of building dynamic UIs.项目地址: https://gitcode.com/gh_mirrors/li/livewire创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考