Постановка задачи
Вам необходимо реализовать безопасную систему аутентификации, которая поддерживает несколько клиентов (web, mobile, API), защищает от распространённых атак и обеспечивает хороший пользовательский опыт.
Архитектура аутентификации
┌─────────────────────────────────────────────────────────────────────────┐
│ Client Applications │
├─────────────────┬─────────────────┬─────────────────┬──────────────────┤
│ Web (SPA) │ Mobile App │ 3rd Party API │ Internal API │
│ HTTP-Only │ Secure Token │ OAuth 2.0 │ API Key + │
│ Cookies │ Storage │ Client Creds │ mTLS │
└────────┬────────┴────────┬────────┴────────┬────────┴────────┬─────────┘
│ │ │ │
└─────────────────┴────────┬────────┴─────────────────┘
│
┌─────────▼─────────┐
│ API Gateway │
│ Rate Limiting │
│ Token Validation│
└─────────┬─────────┘
│
┌─────────▼─────────┐
│ Auth Service │
│ JWT Issuer │
│ Session Mgmt │
└─────────┬─────────┘
│
┌──────────────────┼──────────────────┐
│ │ │
┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
│ User DB │ │ Redis │ │ OAuth │
│ (hashed) │ │ (sessions) │ │ Providers │
└─────────────┘ └─────────────┘ └─────────────┘
Стратегия 1: JWT + HTTP-Only Cookies (Web-приложения)
Реализация на backend (Laravel)
// config/sanctum.php
'expiration' => 60, // Срок действия токена истекает через 60 минут
// app/Http/Controllers/AuthController.php
class AuthController extends Controller
{
public function login(Request $request)
{
$credentials = $request->validate([
'email' => 'required|email',
'password' => 'required',
]);
if (!Auth::attempt($credentials)) {
// Используйте обобщённое сообщение, чтобы предотвратить перечисление пользователей
throw ValidationException::withMessages([
'email' => ['The provided credentials are incorrect.'],
]);
}
$user = Auth::user();
// Ограничивайте частоту неудачных попыток
RateLimiter::hit('login:' . $request->ip(), 300);
// Создать токен
$token = $user->createToken('auth_token')->plainTextToken;
// Установить HTTP-only cookie для web-клиентов
$cookie = cookie(
'auth_token',
$token,
60, // минут
'/',
null,
true, // secure (только HTTPS)
true, // httpOnly
false,
'Strict' // sameSite
);
return response()->json([
'user' => $user,
'message' => 'Login successful'
])->withCookie($cookie);
}
public function logout(Request $request)
{
// Отозвать текущий токен
$request->user()->currentAccessToken()->delete();
// Очистить cookie
$cookie = cookie()->forget('auth_token');
return response()->json(['message' => 'Logged out'])
->withCookie($cookie);
}
public function refresh(Request $request)
{
$user = $request->user();
// Отозвать старый токен
$user->currentAccessToken()->delete();
// Выдать новый токен
$token = $user->createToken('auth_token')->plainTextToken;
$cookie = cookie('auth_token', $token, 60, '/', null, true, true, false, 'Strict');
return response()->json(['message' => 'Token refreshed'])
->withCookie($cookie);
}
}
Middleware для валидации токена
// app/Http/Middleware/ValidateToken.php
class ValidateToken
{
public function handle($request, Closure $next)
{
// Сначала проверьте cookie, затем заголовок Authorization
$token = $request->cookie('auth_token')
?? $request->bearerToken();
if (!$token) {
return response()->json(['error' => 'Unauthorized'], 401);
}
$accessToken = PersonalAccessToken::findToken($token);
if (!$accessToken || $accessToken->expires_at < now()) {
return response()->json(['error' => 'Token expired'], 401);
}
// Установить аутентифицированного пользователя
Auth::setUser($accessToken->tokenable);
return $next($request);
}
}
Реализация на frontend (Vue.js/React)
// api/auth.js
import axios from 'axios';
const api = axios.create({
baseURL: '/api',
withCredentials: true, // Важно: отправляйте cookies вместе с запросами
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
}
});
// Добавьте CSRF-токен для Laravel
api.interceptors.request.use(config => {
const token = document.querySelector('meta[name="csrf-token"]')?.content;
if (token) {
config.headers['X-CSRF-TOKEN'] = token;
}
return config;
});
// Обработать истечение срока действия токена
api.interceptors.response.use(
response => response,
async error => {
if (error.response?.status === 401) {
// Попробовать обновить токен
try {
await api.post('/auth/refresh');
return api.request(error.config);
} catch (refreshError) {
// Перенаправить на страницу входа
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
export const auth = {
login: (email, password) => api.post('/auth/login', { email, password }),
logout: () => api.post('/auth/logout'),
getUser: () => api.get('/auth/user'),
};
Стратегия 2: OAuth 2.0 / Social Login
Конфигурация Laravel Socialite
// config/services.php
'google' => [
'client_id' => env('GOOGLE_CLIENT_ID'),
'client_secret' => env('GOOGLE_CLIENT_SECRET'),
'redirect' => env('GOOGLE_REDIRECT_URL'),
],
'apple' => [
'client_id' => env('APPLE_CLIENT_ID'),
'client_secret' => env('APPLE_CLIENT_SECRET'),
'redirect' => env('APPLE_REDIRECT_URL'),
],
// app/Http/Controllers/SocialAuthController.php
class SocialAuthController extends Controller
{
public function redirect($provider)
{
// Провалидировать provider
if (!in_array($provider, ['google', 'apple', 'facebook'])) {
abort(404);
}
return Socialite::driver($provider)
->stateless()
->redirect();
}
public function callback($provider)
{
try {
$socialUser = Socialite::driver($provider)->stateless()->user();
} catch (Exception $e) {
return redirect('/login')->with('error', 'Authentication failed');
}
// Найти или создать пользователя
$user = User::updateOrCreate(
['email' => $socialUser->getEmail()],
[
'name' => $socialUser->getName(),
'provider' => $provider,
'provider_id' => $socialUser->getId(),
'avatar' => $socialUser->getAvatar(),
]
);
// Создать токен
$token = $user->createToken('social_auth')->plainTextToken;
// Перенаправить на frontend с токеном
return redirect(env('FRONTEND_URL') . '/auth/callback?token=' . $token);
}
}
Стратегия 3: аутентификация по API Key (server-to-server)
Реализация API Key
// app/Models/ApiKey.php
class ApiKey extends Model
{
protected $fillable = ['name', 'key', 'permissions', 'rate_limit', 'expires_at'];
protected $casts = ['permissions' => 'array'];
public static function generate($name, $permissions = [])
{
$key = Str::random(32);
return self::create([
'name' => $name,
'key' => hash('sha256', $key),
'permissions' => $permissions,
'rate_limit' => 1000,
]);
}
public static function findByKey($key)
{
return self::where('key', hash('sha256', $key))->first();
}
}
// app/Http/Middleware/ValidateApiKey.php
class ValidateApiKey
{
public function handle($request, Closure $next, ...$permissions)
{
$key = $request->header('X-API-Key');
if (!$key) {
return response()->json(['error' => 'API key required'], 401);
}
$apiKey = ApiKey::findByKey($key);
if (!$apiKey) {
return response()->json(['error' => 'Invalid API key'], 401);
}
if ($apiKey->expires_at && $apiKey->expires_at < now()) {
return response()->json(['error' => 'API key expired'], 401);
}
// Проверить permissions
foreach ($permissions as $permission) {
if (!in_array($permission, $apiKey->permissions ?? [])) {
return response()->json(['error' => 'Insufficient permissions'], 403);
}
}
// Rate limiting
$rateLimitKey = 'api_key:' . $apiKey->id;
if (RateLimiter::tooManyAttempts($rateLimitKey, $apiKey->rate_limit)) {
return response()->json(['error' => 'Rate limit exceeded'], 429);
}
RateLimiter::hit($rateLimitKey, 3600);
$request->merge(['api_key' => $apiKey]);
return $next($request);
}
}
Безопасность паролей
Безопасное хеширование паролей
// Всегда используйте bcrypt или argon2
$hashedPassword = Hash::make($password);
// Проверить
if (Hash::check($plainPassword, $hashedPassword)) {
// Пароль совпадает
}
// Проверить, нужен ли rehash (обновление алгоритма)
if (Hash::needsRehash($hashedPassword)) {
$user->password = Hash::make($plainPassword);
$user->save();
}
Правила валидации пароля
// app/Rules/SecurePassword.php
class SecurePassword implements Rule
{
public function passes($attribute, $value)
{
return strlen($value) >= 12
&& preg_match('/[A-Z]/', $value)
&& preg_match('/[a-z]/', $value)
&& preg_match('/[0-9]/', $value)
&& preg_match('/[^A-Za-z0-9]/', $value)
&& !$this->isCommonPassword($value);
}
private function isCommonPassword($password)
{
$common = ['password123', '123456789', 'qwerty123'];
return in_array(strtolower($password), $common);
}
}
Многофакторная аутентификация (MFA)
Реализация TOTP
// Используется pragmarx/google2fa
use PragmaRX\Google2FA\Google2FA;
class MfaController extends Controller
{
public function enable(Request $request)
{
$google2fa = new Google2FA();
// Сгенерировать secret
$secret = $google2fa->generateSecretKey();
// Временно сохранить в session
session(['mfa_secret' => $secret]);
// Сгенерировать URL QR-кода
$qrCodeUrl = $google2fa->getQRCodeUrl(
config('app.name'),
$request->user()->email,
$secret
);
return response()->json([
'secret' => $secret,
'qr_code_url' => $qrCodeUrl,
]);
}
public function verify(Request $request)
{
$google2fa = new Google2FA();
$secret = session('mfa_secret');
if (!$google2fa->verifyKey($secret, $request->code)) {
return response()->json(['error' => 'Invalid code'], 422);
}
// Сохранить MFA пользователю
$request->user()->update([
'mfa_secret' => encrypt($secret),
'mfa_enabled' => true,
]);
// Сгенерировать backup codes
$backupCodes = collect(range(1, 10))->map(fn() => Str::random(8));
$request->user()->backupCodes()->createMany(
$backupCodes->map(fn($code) => ['code' => Hash::make($code)])
);
session()->forget('mfa_secret');
return response()->json([
'message' => 'MFA enabled',
'backup_codes' => $backupCodes,
]);
}
}
Безопасность сессий
Конфигурация сессий Redis
// config/session.php
'driver' => 'redis',
'lifetime' => 120,
'expire_on_close' => false,
'encrypt' => true,
'secure' => true,
'http_only' => true,
'same_site' => 'lax',
Инвалидация сессий при событиях безопасности
// Инвалидировать все сессии при смене пароля
class PasswordController extends Controller
{
public function update(Request $request)
{
$user = $request->user();
$user->password = Hash::make($request->new_password);
$user->save();
// Отозвать все токены
$user->tokens()->delete();
// Инвалидировать все сессии
DB::table('sessions')
->where('user_id', $user->id)
->delete();
// Создать новую сессию для текущего запроса
Auth::login($user);
}
}
Rate Limiting
// app/Http/Kernel.php
protected $middlewareGroups = [
'api' => [
'throttle:api',
],
];
// app/Providers/RouteServiceProvider.php
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
RateLimiter::for('login', function (Request $request) {
return [
Limit::perMinute(5)->by($request->ip()),
Limit::perMinute(10)->by($request->input('email')),
];
});
Заголовки безопасности
// app/Http/Middleware/SecurityHeaders.php
class SecurityHeaders
{
public function handle($request, Closure $next)
{
$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('Content-Security-Policy',
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'");
$response->headers->set('Strict-Transport-Security',
'max-age=31536000; includeSubDomains');
return $response;
}
}
Чек-лист безопасности
Аутентификация
- [ ] Используйте HTTPS везде
- [ ] Храните пароли с bcrypt/argon2
- [ ] Реализуйте блокировку аккаунта после неудачных попыток
- [ ] Используйте cookies с HTTP-only, Secure, SameSite
- [ ] Реализуйте CSRF protection
- [ ] Добавьте поддержку MFA
- [ ] Генерируйте безопасные session ID
- [ ] Инвалидируйте сессии при logout и смене пароля
Безопасность API
- [ ] Валидируйте и санитизируйте все входные данные
- [ ] Используйте parameterized queries (предотвращение SQL injection)
- [ ] Реализуйте rate limiting
- [ ] Используйте API keys для server-to-server
- [ ] Логируйте события аутентификации
- [ ] Устанавливайте корректные CORS headers
Безопасность токенов
- [ ] Короткий срок жизни токена (15–60 минут)
- [ ] Реализуйте механизм refresh-токенов
- [ ] Храните refresh-токены безопасно
- [ ] Отзывайте токены при logout
- [ ] Не храните токены в localStorage (используйте HTTP-only cookies)
Подход Senior к аутентификации
Senior-инженеры рассматривают аутентификацию и контроль доступа как критически важные системы, а не как дополнения. Они проектируют с безопасными настройками по умолчанию, операционной наблюдаемостью и простым восстановлением.
Ключевые принципы
Сначала Threat Modeling: определите, что вы защищаете и кто может атаковать. Выявите точки входа, такие как endpoints логина, сценарии сброса пароля и интеграции со сторонними сервисами. Это определяет, насколько строгими должны быть ваши механизмы аутентификации.
Многоуровневая безопасность: безопасность многослойна — физические, сетевые, платформенные, прикладные и данные-контроли должны работать совместно. Когда весь стек согласован, аутентификация становится предсказуемой границей, а не хрупким краем.
Мониторинг состояния auth: системы аутентификации должны предоставлять метрики: долю успешных логинов, долю неуспешных логинов, долю ошибок MFA и ошибки refresh-токенов. Настраивайте алерты на всплески, которые могут указывать на атаки или сбои. Наблюдаемость критична, потому что проблемы с auth для пользователей часто выглядят как общий outage системы.
Распространённые ошибки, которых следует избегать
- Смешивание логики аутентификации и авторизации в одном шаге
- Долгоживущие токены без ротации
- Слабые сценарии сброса пароля
- Отсутствие audit logs для чувствительных действий
- Слишком широкие permissions, которые становятся постоянными
Балансируйте безопасность и UX
Слишком строгая аутентификация может отпугнуть пользователей, тогда как слишком слабая — провоцирует злоупотребления. Давайте понятные сообщения об ошибках, избегайте ненужной повторной аутентификации и проводите пользователей через безопасные сценарии без раздражения. Хороший UX — это функция безопасности: пользователи с большей вероятностью будут следовать безопасным практикам, когда система понятна и отзывчива.
Это доверие трудно восстановить, если оно утрачено. Защитите его.
Связанные статьи Wiki