Laravel 13 安全最佳实践完全指南
安全是 Web 应用的基石。本文将深入探讨 Laravel 13 中各种安全最佳实践和防护措施。
输入验证
表单请求验证
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57
| <?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rules\Password;
class StoreUserRequest extends FormRequest { public function authorize(): bool { return true; }
public function rules(): array { return [ 'name' => ['required', 'string', 'max:255'], 'email' => ['required', 'email', 'unique:users,email'], 'password' => [ 'required', 'confirmed', Password::min(8) ->letters() ->mixedCase() ->numbers() ->symbols() ->uncompromised(), ], 'avatar' => ['nullable', 'image', 'max:2048'], 'website' => ['nullable', 'url', 'max:255'], ]; }
public function messages(): array { return [ 'name.required' => '姓名不能为空', 'email.unique' => '该邮箱已被注册', 'password.uncompromised' => '该密码已被泄露,请更换', ]; }
protected function prepareForValidation(): void { $this->merge([ 'email' => strtolower($this->email), ]); }
protected function passedValidation(): void { $this->replace([ 'password' => bcrypt($this->password), ]); } }
|
自定义验证规则
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| <?php
namespace App\Rules;
use Closure; use Illuminate\Contracts\Validation\ValidationRule;
class SecurePassword implements ValidationRule { public function validate(string $attribute, mixed $value, Closure $fail): void { if (strlen($value) < 12) { $fail('密码长度至少12位'); }
if (!preg_match('/[A-Z]/', $value)) { $fail('密码必须包含大写字母'); }
if (!preg_match('/[a-z]/', $value)) { $fail('密码必须包含小写字母'); }
if (!preg_match('/[0-9]/', $value)) { $fail('密码必须包含数字'); }
if (!preg_match('/[^A-Za-z0-9]/', $value)) { $fail('密码必须包含特殊字符'); }
if ($this->isCommonPassword($value)) { $fail('密码过于简单,请更换'); } }
protected function isCommonPassword(string $password): bool { $common = ['password', '123456', 'qwerty', 'abc123']; return in_array(strtolower($password), $common); } }
|
SQL 注入防护
参数绑定
1 2 3 4 5 6 7 8 9 10 11
| $users = DB::select('SELECT * FROM users WHERE email = ?', [$email]);
$users = DB::select("SELECT * FROM users WHERE email = '{$email}'");
$users = User::where('email', $email)->get();
$users = User::whereRaw('email = ?', [$email])->get();
|
安全的原始查询
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| use Illuminate\Support\Facades\DB;
class UserRepository { public function search(string $term): Collection { return DB::table('users') ->whereRaw('MATCH(name, email) AGAINST(? IN BOOLEAN MODE)', [$term]) ->get(); }
public function getByDateRange(string $start, string $end): Collection { return DB::table('users') ->whereBetween('created_at', [ DB::raw("CAST(? AS DATETIME)"), DB::raw("CAST(? AS DATETIME)"), ]) ->setBindings([$start, $end]) ->get(); } }
|
XSS 防护
Blade 自动转义
1 2 3 4 5 6 7 8
| {{-- 自动转义 --}} <p>{{ $user->name }}</p>
{{-- 原始输出(确保已净化)--}} <p>{!! $trustedHtml !!}</p>
{{-- 使用 Purifier --}} <p>{!! Purifier::clean($userInput) !!}</p>
|
HTML 净化
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| <?php
namespace App\Services;
use HTMLPurifier; use HTMLPurifier_Config;
class HtmlSanitizer { protected HTMLPurifier $purifier;
public function __construct() { $config = HTMLPurifier_Config::createDefault(); $config->set('HTML.Allowed', 'p,b,i,u,a[href],img[src|alt],ul,ol,li'); $config->set('URI.DisableExternalResources', true); $config->set('HTML.SafeIframe', true); $config->set('URI.SafeIframeRegexp', '%^(https?:)?//(www\.youtube\.com/embed/)%');
$this->purifier = new HTMLPurifier($config); }
public function clean(string $html): string { return $this->purifier->purify($html); } }
|
CSRF 防护
Blade 表单
1 2 3 4 5 6 7 8 9 10
| <form method="POST" action="{{ route('users.store') }}"> @csrf {{-- 表单字段 --}} </form>
<form method="DELETE" action="{{ route('users.destroy', $user) }}"> @method('DELETE') @csrf <button type="submit">删除</button> </form>
|
API CSRF 排除
1 2 3 4 5 6 7
| ->withMiddleware(function (Middleware $middleware) { $middleware->validateCsrfTokens(except: [ 'api/*', 'webhooks/*', ]); })
|
手动验证 CSRF
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| use Illuminate\Http\Request;
public function webhook(Request $request) { if (!$this->verifyWebhookSignature($request)) { abort(403, 'Invalid signature'); }
}
protected function verifyWebhookSignature(Request $request): bool { $signature = $request->header('X-Webhook-Signature'); $payload = $request->getContent(); $secret = config('services.webhook.secret');
$expected = hash_hmac('sha256', $payload, $secret);
return hash_equals($expected, $signature); }
|
认证安全
安全密码处理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
| <?php
namespace App\Services;
use App\Models\User; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str;
class AuthService { public function register(array $data): User { return User::create([ 'name' => $data['name'], 'email' => $data['email'], 'password' => Hash::make($data['password']), 'remember_token' => Str::random(60), ]); }
public function login(string $email, string $password): ?User { $user = User::where('email', $email)->first();
if (!$user || !Hash::check($password, $user->password)) { return null; }
if ($user->is_locked) { return null; }
$this->recordLoginAttempt($user, true);
return $user; }
public function logout(User $user): void { $user->tokens()->delete(); $user->update(['last_logout' => now()]); }
protected function recordLoginAttempt(User $user, bool $successful): void { LoginAttempt::create([ 'user_id' => $user->id, 'ip' => request()->ip(), 'user_agent' => request()->userAgent(), 'successful' => $successful, ]); } }
|
登录限制
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
| <?php
namespace App\Services;
use Illuminate\Cache\RateLimiter; use Illuminate\Http\Request;
class LoginRateLimiter { protected int $maxAttempts = 5; protected int $decayMinutes = 15;
public function __construct( protected RateLimiter $limiter ) {}
public function tooManyAttempts(Request $request): bool { return $this->limiter->tooManyAttempts( $this->throttleKey($request), $this->maxAttempts ); }
public function increment(Request $request): void { $this->limiter->hit( $this->throttleKey($request), $this->decayMinutes * 60 ); }
public function clear(Request $request): void { $this->limiter->clear($this->throttleKey($request)); }
public function availableIn(Request $request): int { return $this->limiter->availableIn($this->throttleKey($request)); }
protected function throttleKey(Request $request): string { return 'login:' . mb_strtolower($request->input('email')) . '|' . $request->ip(); } }
|
会话安全
安全配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| return [ 'driver' => env('SESSION_DRIVER', 'redis'), 'lifetime' => env('SESSION_LIFETIME', 120), 'expire_on_close' => true, 'encrypt' => true, 'files' => storage_path('framework/sessions'), 'connection' => env('SESSION_CONNECTION'), 'table' => 'sessions', 'store' => env('SESSION_STORE'), 'lottery' => [2, 100], 'cookie' => env('SESSION_COOKIE', Str::slug(env('APP_NAME', 'laravel'), '_').'_session'), 'path' => '/', 'domain' => env('SESSION_DOMAIN'), 'secure' => env('SESSION_SECURE_COOKIE'), 'http_only' => true, 'same_site' => 'strict', ];
|
会话管理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52
| <?php
namespace App\Services;
use Illuminate\Support\Facades\Session; use Illuminate\Support\Facades\Auth;
class SessionManager { public function regenerate(): void { Session::regenerate(); Session::regenerateToken(); }
public function invalidateOtherSessions(): void { $user = Auth::user(); DB::table('sessions') ->where('user_id', $user->id) ->where('id', '!=', Session::getId()) ->delete(); }
public function getActiveSessions(): Collection { return DB::table('sessions') ->where('user_id', Auth::id()) ->orderBy('last_activity', 'desc') ->get(); }
public function isSuspiciousLogin(): bool { $currentIp = request()->ip(); $lastIp = Session::get('last_login_ip');
if ($lastIp && $lastIp !== $currentIp) { return true; }
$currentAgent = request()->userAgent(); $lastAgent = Session::get('last_user_agent');
if ($lastAgent && $lastAgent !== $currentAgent) { return true; }
return false; } }
|
文件上传安全
安全文件上传
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81
| <?php
namespace App\Services;
use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str;
class SecureFileUpload { protected array $allowedMimes = [ 'image/jpeg', 'image/png', 'image/gif', 'application/pdf', ];
protected int $maxSize = 10485760;
public function upload(UploadedFile $file, string $directory = 'uploads'): string { $this->validate($file);
$filename = $this->generateSafeFilename($file); $path = $file->storeAs($directory, $filename, 'local');
$this->scanForMalware($path);
return $path; }
protected function validate(UploadedFile $file): void { if (!in_array($file->getMimeType(), $this->allowedMimes)) { throw new \Exception('不允许的文件类型'); }
if ($file->getSize() > $this->maxSize) { throw new \Exception('文件大小超出限制'); }
$this->validateImageContent($file); }
protected function validateImageContent(UploadedFile $file): void { if (str_starts_with($file->getMimeType(), 'image/')) { $imageInfo = getimagesize($file->path()); if ($imageInfo === false) { throw new \Exception('无效的图片文件'); } } }
protected function generateSafeFilename(UploadedFile $file): string { $extension = $this->getSafeExtension($file); return Str::random(40) . '.' . $extension; }
protected function getSafeExtension(UploadedFile $file): string { $extension = $file->guessExtension(); $safeExtensions = [ 'jpeg' => 'jpg', 'png' => 'png', 'gif' => 'gif', 'pdf' => 'pdf', ];
return $safeExtensions[$extension] ?? 'bin'; }
protected function scanForMalware(string $path): void { } }
|
API 安全
API 认证
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable { use HasApiTokens;
public function createApiToken(string $name, array $abilities = ['*']): string { return $this->createToken($name, $abilities)->plainTextToken; }
public function currentAccessToken(): ?PersonalAccessToken { return $this->tokens()->where('id', $this->currentAccessToken()->id)->first(); } }
class AuthController extends Controller { public function login(Request $request) { $request->validate([ 'email' => 'required|email', 'password' => 'required', ]);
if (!Auth::attempt($request->only('email', 'password'))) { return response()->json([ 'message' => '认证失败', ], 401); }
$user = Auth::user(); $token = $user->createApiToken('api-token', ['read', 'write']);
return response()->json([ 'token' => $token, 'user' => new UserResource($user), ]); } }
|
API 限流
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () { Route::get('/users', [UserController::class, 'index']); Route::post('/users', [UserController::class, 'store']); });
Route::middleware(['throttle:60,1'])->group(function () { });
Route::middleware(['throttle:10,1,user'])->group(function () { });
|
安全头设置
中间件设置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| <?php
namespace App\Http\Middleware;
use Closure; use Illuminate\Http\Request;
class SecurityHeaders { public function handle(Request $request, Closure $next) { $response = $next($request);
$response->headers->set('X-Content-Type-Options', 'nosniff'); $response->headers->set('X-Frame-Options', 'SAMEORIGIN'); $response->headers->set('X-XSS-Protection', '1; mode=block'); $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); $response->headers->set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
if ($request->isSecure()) { $response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); }
$csp = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self';"; $response->headers->set('Content-Security-Policy', $csp);
return $response; } }
|
安全审计
审计日志
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| <?php
namespace App\Services;
use App\Models\AuditLog; use Illuminate\Support\Facades\Auth;
class AuditService { public function log(string $action, array $details = []): void { AuditLog::create([ 'user_id' => Auth::id(), 'action' => $action, 'model_type' => $details['model_type'] ?? null, 'model_id' => $details['model_id'] ?? null, 'old_values' => $details['old_values'] ?? null, 'new_values' => $details['new_values'] ?? null, 'ip_address' => request()->ip(), 'user_agent' => request()->userAgent(), 'url' => request()->fullUrl(), 'method' => request()->method(), ]); }
public function logModelChange($model, string $action): void { $this->log($action, [ 'model_type' => get_class($model), 'model_id' => $model->id, 'old_values' => $action === 'created' ? null : $model->getOriginal(), 'new_values' => $model->getAttributes(), ]); } }
|
总结
Laravel 13 的安全最佳实践包括:
- 完善的输入验证
- SQL 注入防护
- XSS 攻击防护
- CSRF 保护
- 安全认证机制
- 会话安全管理
- 文件上传安全
- API 安全措施
- 安全头设置
- 安全审计日志
安全是一个持续的过程,需要不断学习和更新防护措施。