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}'");

// Eloquent 自动防护
$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
// bootstrap/app.php
->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');
}

// 处理 webhook
}

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
// config/session.php
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; // 10MB

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
// routes/api.php
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 () {
// 每分钟60次
});

Route::middleware(['throttle:10,1,user'])->group(function () {
// 每用户每分钟10次
});

安全头设置

中间件设置

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 安全措施
  • 安全头设置
  • 安全审计日志

安全是一个持续的过程,需要不断学习和更新防护措施。