Laravel 安全最佳实践 是做什么的?
针对 Laravel 应用的全面安全指南,帮助保护您的应用免受常见漏洞攻击。
何时激活
- 设置 Laravel 的身份认证和授权机制(Sanctum、Passport、Jetstream、Breeze)
- 实现用户角色、权限和策略
- 配置生产环境的安全设置和环境变量
- 审查 Laravel 应用的安全漏洞
- 将 Laravel 应用部署到生产环境
- 编写安全的 Eloquent 查询和迁移
生产环境配置
关键生产设置
// config/app.php
'env' => env('APP_ENV', 'production'),
'debug' => (bool) env('APP_DEBUG', false), // 关键:生产环境绝不可设为 true
'key' => env('APP_KEY'), // 必须设置:php artisan key:generate
// config/session.php
'secure' => env('SESSION_SECURE_COOKIE', true),
'http_only' => true,
'same_site' => 'lax',
// 启动时验证 APP_KEY 是否已设置
// bootstrap/app.php 或服务提供者中
if (empty(config('app.key'))) {
throw new RuntimeException('APP_KEY 未设置。请执行:php artisan key:generate');
}
环境文件安全
# 绝对不要将 .env 提交到版本控制
# .gitignore 默认已包含 .env
# 使用带占位符的 .env.example 代替
DB_PASSWORD=
APP_KEY=
SANCTUM_TOKEN_PREFIX=
# 启动时验证必需的变量
// 在 AppServiceProvider::boot() 中
$requiredKeys = ['app.key', 'database.connections.mysql.database', 'database.connections.mysql.username'];
foreach ($requiredKeys as $key) {
if (empty(config($key))) {
throw new RuntimeException("缺少必需的配置键:{$key}");
}
}
HTTPS 强制
// AppServiceProvider::boot() 或中间件中
if (app()->environment('production')) {
URL::forceScheme('https');
request()->server->set('HTTPS', 'on');
}
// config/app.php 中配置受信代理(负载均衡器)
// 使用具体的 IP 范围——* 会信任所有代理,导致 X-Forwarded-* 可以被伪造
// AWS:'10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'
'trusted_proxies' => ['10.0.0.0/8', '172.16.0.0/12'],
// 通过中间件在生产环境强制 HTTPS
// app/Http/Middleware/ForceHttps.php
public function handle($request, Closure $next)
{
if (!$request->secure() && app()->environment('production')) {
return redirect()->secure($request->getRequestUri());
}
return $next($request);
}
身份认证
Sanctum(API 令牌认证)
// config/sanctum.php
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
env('APP_URL') ? ',' . parse_url(env('APP_URL'), PHP_URL_HOST) : ''
)));
'expiration' => 60 * 24, // 令牌过期时间(分钟),null 表示永不过期
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
// 发放带能力的令牌
$token = $user->createToken('api-token', ['read', 'write'])->plainTextToken;
// 在路由上验证能力
Route::middleware('auth:sanctum')->group(function () {
Route::get('/orders', function () {
// 用户必须拥有 'read' 能力
abort_unless(Auth::user()->tokenCan('read'), 403);
// ...
})->middleware('abilities:read');
Route::post('/orders', function () {
// 用户必须拥有 'write' 能力
abort_unless(Auth::user()->tokenCan('write'), 403);
// ...
})->middleware('abilities:write');
});
密码安全
// config/hashing.php
// 默认使用 bcrypt。Argon2id 更安全。
'bcrypt' => [
'rounds' => env('BCRYPT_ROUNDS', 12), // 增大轮数以增强哈希强度
],
'argon' => [
'memory' => 65536,
'threads' => 4,
'time' => 4,
],
// RegisterRequest 中的密码验证
public function rules(): array
{
return [
'password' => [
'required',
'confirmed',
Password::min(12)
->letters()
->mixedCase()
->numbers()
->symbols()
->uncompromised(), // 检查 haveibeenpwned 泄漏数据库
],
];
}
// 限制登录尝试次数
// App\Http\Controllers\Auth\AuthenticatedSessionController
protected function authenticated(Request $request, $user)
{
if ($user->wasRecentlyLockedOut()) {
// 通知用户可疑登录
$user->notify(new SuspiciousLoginNotification($request->ip()));
}
}
会话管理
// config/session.php
'driver' => env('SESSION_DRIVER', 'database'), // database/redis 优于 file
'lifetime' => env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
'encrypt' => env('SESSION_ENCRYPT', false),
// 登录时重新生成会话
// App\Http\Controllers\Auth\AuthenticatedSessionController
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$request->session()->regenerate(); // 关键:防止会话固定攻击
return redirect()->intended(RouteServiceProvider::HOME);
}
// 注销时使会话失效
public function destroy(Request $request): RedirectResponse
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
授权
门卫(Gates)
// App\Providers\AuthServiceProvider
use App\Models\Post;
use App\Models\User;
use Illuminate\Support\Facades\Gate;
public function boot(): void
{
Gate::define('update-post', function (User $user, Post $post): bool {
return $user->id === $post->user_id;
});
Gate::define('publish-post', function (User $user): bool {
return $user->role === 'editor' || $user->role === 'admin';
});
// 使用 before() 实现超级管理员覆盖
Gate::before(function (User $user, string $ability): ?bool {
if ($user->role === 'super-admin') {
return true; // 授予所有能力
}
return null; // 继续执行正常检查
});
}
// 在控制器中使用
public function update(Request $request, Post $post): RedirectResponse
{
Gate::authorize('update-post', $post);
// 或者:$this->authorize('update-post', $post);
// 或者:abort_unless(Auth::user()->can('update-post', $post), 403);
// ...
}
策略(Policies)
// App\Policies\PostPolicy
class PostPolicy
{
use HandlesAuthorization;
public function viewAny(?User $user): bool
{
return true; // 公开列表
}
public function view(?User $user, Post $post): bool
{
return $post->is_published || ($user && $user->id === $post->user_id);
}
public function create(User $user): bool
{
return $user->hasVerifiedEmail(); // 必须先验证邮箱
}
public function update(User $user, Post $post): bool
{
return $user->id === $post->user_id;
}
public function delete(User $user, Post $post): bool
{
return $user->id === $post->user_id && $post->created_at->diffInDays(now()) <= 30;
}
public function restore(User $user, Post $post): bool
{
return $user->role === 'admin';
}
public function forceDelete(User $user, Post $post): bool
{
return $user->role === 'super-admin';
}
}
// 在 AuthServiceProvider 中注册
protected $policies = [
Post::class => PostPolicy::class,
];
// 控制器中使用
public function show(Post $post): View
{
$this->authorize('view', $post);
return view('posts.show', compact('post'));
}
// Blade 模板中使用
@can('update', $post)
<a href="{{ route('posts.edit', $post) }}">编辑</a>
@endcan
@cannot('update', $post)
<span>您不能编辑这篇文章</span>
@endcannot
中间件授权
// 在路由中使用中间件
Route::put('/posts/{post}', [PostController::class, 'update'])
->middleware('can:update,post');
Route::get('/posts/create', [PostController::class, 'create'])
->middleware('can:create,App\Models\Post');
// 自定义授权中间件
// app/Http/Middleware/CheckRole.php
class CheckRole
{
public function handle(Request $request, Closure $next, string $role): mixed
{
if (!$request->user() || $request->user()->role !== $role) {
abort(403, '未经授权。此区域需要角色:' . $role);
}
return $next($request);
}
}
// 在 Kernel 中注册
protected $routeMiddleware = [
'role' => \App\Http\Middleware\CheckRole::class,
];
// 路由中使用
Route::middleware(['auth', 'role:admin'])->group(function () {
Route::get('/admin', [AdminController::class, 'index']);
});
Eloquent 安全
批量赋值保护
// 错误:$guarded = [] 允许所有列进行批量赋值
// 生产环境中绝对不要使用 $guarded = []
// 正确:白名单方式列出可填充属性
final class User extends Authenticatable
{
protected $fillable = [
'name',
'email',
'phone',
'avatar',
];
// 绝对不要将 'role'、'is_admin'、'is_verified' 加入此数组
}
// 正确:显式控制在请求中可填充的字段
public function store(StoreUserRequest $request): RedirectResponse
{
$user = User::create($request->safe()->only([
'name', 'email', 'phone', 'avatar'
]));
// $request->safe() 仅使用经过验证的数据
// 如果没有验证规则,$request->only() 本身并不安全
}
// 错误:直接使用请求数据创建用户
User::create($request->all()); // 易受批量赋值攻击!
// 更好:使用 DTO 进行创建
$user = User::create($request->validated()); // 仅使用已验证字段
SQL 注入防御
// 正确:Eloquent 自动参数化查询
User::where('email', $userInput)->first();
User::whereRaw('email = ?', [$userInput])->first();
// 正确:查询构建器也会参数化
DB::table('users')->where('email', $userInput)->first();
DB::select('SELECT * FROM users WHERE email = ?', [$userInput]);
// 错误:原始字符串插值
DB::select("SELECT * FROM users WHERE email = '{$userInput}'"); // 易受攻击!
User::whereRaw("email = '{$userInput}'")->first(); // 易受攻击!
// 错误:whereRaw/orderByRaw 使用未转义的输入
User::orderByRaw($userInput); // 易受攻击!
User::groupByRaw($userInput); // 易受攻击!
// 错误:使用拼接字符串的 DB::statement
DB::statement("INSERT INTO users (email) VALUES ('{$userInput}')"); // 易受攻击!
属性转换
final class User extends Authenticatable
{
protected $casts = [
'email_verified_at' => 'datetime',
'is_admin' => 'boolean', // 转换为布尔值可防止字符串注入
'settings' => 'array', // 自动 json_encode/json_decode
'metadata' => 'encrypted:array', // Laravel 11+ 加密转换
'password' => 'hashed', // Laravel 10+ 设置时自动哈希
];
}
模型安全
final class User extends Authenticatable
{
// 从 JSON/API 响应中隐藏敏感属性
protected $hidden = [
'password',
'remember_token',
'two_factor_secret',
'two_factor_recovery_codes',
];
// 仅附加安全的计算属性
protected $appends = ['full_name']; // 安全
// 绝不要附加敏感的计算数据
}
final class Post extends Model
{
// 全局作用域以过滤软删除记录
use SoftDeletes;
// 通过限制懒加载防止 N+1 问题(可选严格模式)
// AppServiceProvider::boot()
// Model::preventLazyLoading(!app()->isProduction());
}
CSRF 保护
默认保护
// Laravel 默认通过 VerifyCsrfToken 中间件启用 CSRF 保护
// app/Http/Kernel.php (protected $middlewareGroups['web'])
// 所有 POST/PUT/PATCH/DELETE 表单都必须包含 @csrf
<form method="POST" action="/posts">
@csrf
<input type="text" name="title">
<button type="submit">创建</button>
</form>
排除路由(谨慎操作)
// app/Http/Middleware/VerifyCsrfToken.php
class VerifyCsrfToken extends Middleware
{
// 仅排除已有外部 CSRF 保护的路由(如 webhook)
protected $except = [
'stripe/*', // Stripe webhook 使用自己的签名验证
// 避免直接写 'api/*'——有状态的 Sanctum 路由需要 CSRF。
// 仅排除特定的无状态 webhook/终端路由。
];
}
JavaScript 中的 CSRF
<meta name="csrf-token" content="{{ csrf_token() }}">
<script>
// Axios 示例(Laravel 默认包含 Axios)
axios.defaults.headers.common['X-CSRF-TOKEN'] = document.querySelector(
'meta[name="csrf-token"]'
).getAttribute('content');
// Fetch 示例
fetch('/posts', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
</script>
XSS 防御
Blade 模板安全
{{-- 安全:Blade 会自动转义 --}}
{{ $userInput }}
{{-- 危险:原始输出——绝不要用于用户输入 --}}
{!! $userInput !!}
{{-- 安全:仅对您控制的可信内容使用 {!! !!} --}}
{!! $trustedHtmlFromYourServer !!}
{{-- 正确:使用特定的转义指令 --}}
@js($data) {{-- 为 JavaScript 进行 JSON 编码 --}}
@json($data) {{-- 在模板中进行 JSON 编码 --}}
{{-- 错误:直接将用户输入放入原始 HTML --}}
<div>{!! $user->bio !!}</div> {{-- 如果用户提供简介,则存在漏洞 --}}
安全 HTML 处理
// 当必须允许某些 HTML 时,采用白名单方法
use HTMLPurifier; // 需要:composer require ezyang/htmlpurifier
public function sanitizeHtml(string $dirty): string
{
$config = \HTMLPurifier_Config::createDefault();
$config->set('HTML.Allowed', 'p,b,i,a[href],ul,ol,li,br');
$config->set('URI.AllowedSchemes', ['http', 'https', 'mailto']);
$purifier = new \HTMLPurifier($config);
return $purifier->purify($dirty);
}
// 在 Blade 中使用:
<div>{!! $sanitizedContent !!}</div> {{-- 安全,因为已通过净化 --}}
API 安全
API 速率限制
// app/Http/Kernel.php
'api' => [
'throttle:api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
// 自定义速率限制器
// App\Providers\RouteServiceProvider::boot()
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
// 基于端点的更严格限制
RateLimiter::for('auth', function (Request $request) {
return Limit::perMinute(5)->by($request->ip());
});
// 在路由中使用
Route::middleware('throttle:auth')->group(function () {
Route::post('/login', [AuthController::class, 'login']);
});
CORS(跨域资源共享)
// config/cors.php
return [
// 在生产环境中限制为特定来源
'allowed_origins' => env('CORS_ALLOWED_ORIGINS')
? explode(',', env('CORS_ALLOWED_ORIGINS'))
: ['https://yourdomain.com'],
'allowed_methods' => ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
'allowed_headers' => ['Content-Type', 'Authorization', 'X-Requested-With'],
'exposed_headers' => [],
'max_age' => 86400, // 缓存预检请求结果 24 小时
'supports_credentials' => true, // Sanctum 状态域需要
];
API 令牌轮换
// App\Http\Controllers\Api\AuthController
public function rotateToken(Request $request): JsonResponse
{
$request->user()->tokens()->delete(); // 撤销所有现有令牌
$token = $request->user()->createToken('api-token', ['read', 'write']);
return response()->json(['token' => $token->plainTextToken]);
}
// 在需要更高安全性的操作前强制重新认证
public function sensitiveAction(Request $request): JsonResponse
{
$request->validate(['password' => 'required|current_password']);
// 执行敏感操作...
}
部署安全
安全标头(Security Headers)
// app/Http/Middleware/SecurityHeaders.php
class SecurityHeaders
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('X-Frame-Options', 'DENY');
$response->headers->set('X-XSS-Protection', '1; mode=block');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
return $response;
}
}
// 在 Http Kernel 中注册
protected $middlewareGroups = [
'web' => [
// ...
\App\Http\Middleware\SecurityHeaders::class,
],
];
依赖审计
# 使用 Laravel 的安全扫描命令
php artisan shield:check # Laravel Shield 包
# composer 审计(Composer 2.4+)
composer audit
# npm 审计(前端依赖)
npm audit
# 安全建议:使用 Dependabot 或类似工具进行自动依赖更新
# .github/dependabot.yml
example:
package-ecosystem: "composer"
directory: "/"
schedule:
interval: "weekly"
数据库安全
// 使用预准备语句进行数据库操作(自动完成)
DB::statement('UPDATE users SET name = ? WHERE id = ?', [$name, $id]);
// 加密敏感列
use Illuminate\Database\Eloquent\Casts\AsEncryptedString;
final class User extends Authenticatable
{
protected $casts = [
'ssn' => AsEncryptedString::class, // Laravel 11+
'credit_card_last_four' => 'encrypted',
];
}
// 迁移权限:运行迁移的用户应有限权限
// 最好使用专用部署用户,而非 root
文件上传安全
// 验证上传的文件
public function rules(): array
{
return [
'avatar' => [
'required',
'file',
'image',
'mimes:jpeg,png,gif,webp',
'max:2048', // KB
'dimensions:min_width=100,min_height=100,max_width=1024,max_height=1024',
],
];
}
// 安全存储
public function storeAvatar(Request $request): string
{
// 使用保留原始扩展名的自定义存储路径
$path = $request->file('avatar')->storePubliclyAs(
'avatars/' . auth()->id(),
Str::random(40) . '.' . $request->file('avatar')->extension(),
's3'
);
return $path;
}
// 安全提供文件(防止路径遍历)
public function getPrivateFile(string $filename): StreamedResponse
{
// 验证文件名以防止路径遍历
abort_if(str_contains($filename, '..'), 404);
return Storage::disk('private')->download($filename);
}
监控与日志
安全事件记录
// config/logging.php
'channels' => [
'security' => [
'driver' => 'daily',
'path' => storage_path('logs/security.log'),
'level' => 'warning',
'days' => 14,
],
],
// 记录安全事件
Log::channel('security')->warning('Failed login attempt', [
'email' => $request->email,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
'attempted_at' => now(),
]);
// 数据更改审计追踪
// App\Models\User
protected static function booted(): void
{
static::updated(function (User $user) {
Log::channel('audit')->info('User updated', [
'admin_id' => auth()->id(),
'user_id' => $user->id,
'changes' => $user->getChanges(),
]);
});
}
入侵检测
// App\Http\Middleware\DetectSuspiciousActivity.php
class DetectSuspiciousActivity
{
protected array $patterns = [
'/\bUNION\b.*\bSELECT\b/i' => 'SQL 注入尝试',
'/<script\b[^>]*>/i' => 'XSS 尝试',
'/\bexec\(/i' => '命令执行尝试',
'/\beval\(/i' => '代码注入尝试',
];
public function handle(Request $request, Closure $next): Response
{
foreach ($this->patterns as $pattern => $description) {
foreach ($request->all() as $value) {
if (is_string($value) && preg_match($pattern, $value)) {
Log::channel('security')->warning('可疑活动检测到', [
'type' => $description,
'ip' => $request->ip(),
'url' => $request->fullUrl(),
'input' => $value,
]);
if (app()->environment('production')) {
abort(403, '检测到可疑活动');
}
}
}
}
return $next($request);
}
}
部署检查清单
-
APP_DEBUG设置为false -
APP_KEY已设置且非默认值 - HTTPS 已强制(通过中间件或服务器配置)
- 会话 Cookie 标记为
secure和http_only - CORS 配置限制为受信来源
- 数据库凭据使用强密码且具有有限权限
- 队列驱动使用
database或redis(而非sync) - 缓存驱动使用
redis或memcached(而非file) -
.env文件在生产环境中不可通过 Web 访问 - 存储目录(
storage、bootstrap/cache)具有正确的 Web 服务器写入权限 - 已配置安全标头
- API 速率限制已激活
- 失败登录尝试有限制
- 已配置文件上传验证
- 已设置依赖审计计划
- 日志配置为轮换且限制保留期限
- 备份已配置并定期测试
关键规则总结
- 绝不要在
$fillable中包含管理字段(is_admin、role) - 始终使用 Eloquent 或查询构建器——避免原始 SQL 查询
- 生产环境中始终启用 HTTPS
- 使用
@csrf指令保护所有表单 - 避免使用
{!! !!}输出用户输入 - 对敏感列使用
$hidden和属性转换 - 对 API 和登录尝试使用速率限制
- 保持依赖更新——使用
composer audit - 使用策略和门卫实施最小权限原则
- 记录安全事件但不记录敏感数据(密码、令牌等)