Symfony 7自定义认证报错:HMAC验证失败(401未授权)
Symfony自定义认证HMAC验证失败问题
在Symfony中实现自定义登录认证流程时,已在security.yml配置/api/login允许匿名访问,但调用该接口时持续收到"Invalid or Tampered Token: HMAC Validation Failed (401 Unauthorized)"错误。该逻辑在Laravel中可正常运行,认证方式为通过用户名加密后生成令牌,使用.env的APP_SECRET解密验证。
security.yml配置片段
security: # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords password_hashers: Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider providers: users_in_memory: { memory: null } firewalls: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false main: lazy: true stateless: true custom_authenticators: - App\Security\CustomAuthenticator provider: users_in_memory # https://symfony.com/doc/current/security.html#the-firewall # https://symfony.com/doc/current/security/impersonating_user.html # switch_user: true # Easy way to control access for large sections of your site # Note: Only the *first* access control that matches will be used access_control: - { path: ^/api/login, roles: IS_AUTHENTICATED_ANONYMOUSLY } - { path: '^/', roles: IS_AUTHENTICATED_FULLY } when@test: security: password_hashers: # By default, password hashers are resource intensive and take time. This is # important to generate secure password hashes. In tests however, secure hashes # are not important, waste resources and increase test times. The following # reduces the work factor to the lowest possible values. Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: algorithm: auto cost: 4 # Lowest possible value for bcrypt time_cost: 3 # Lowest possible value for argon memory_cost: 10 # Lowest possible value for argon
CustomAuthenticator类代码
<?php namespace App\Security; use Doctrine\ORM\EntityManagerInterface; use Exception; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Passport; use App\Entity\User; // Assuming your User entity is in the App\Entity namespace class CustomAuthenticator implements AuthenticatorInterface { private $entityManager; public function __construct(EntityManagerInterface $entityManager) { $this->entityManager = $entityManager; } public function supports(Request $request): ?bool { return true; } public function authenticate(Request $request): Passport { $requestData = json_decode($request->getContent(), true); if (!isset($requestData['name'])) { throw new UnauthorizedHttpException('Invalid credentials', 'Required field "name" is missing'); } $name = $requestData['name']; try { $decryptedName = $this->decrypt($name); } catch (Exception $e) { throw new UnauthorizedHttpException('Invalid credentials', 'Invalid or tampered token'.$e->getMessage()); } $user = $this->loadUser($decryptedName); $userBadge = new UserBadge($decryptedName, function ($name) { return $this->loadUser($name); }); return new Passport($userBadge); } private function loadUser(string $name): ?UserInterface { return $this->entityManager->getRepository(User::class)->findOneBy(['name' => $name]); } private function decrypt($string): string { // Retrieve the secret key $secretKey = $_ENV['APP_SECRET']; if (!$secretKey) { throw new \RuntimeException('APP_SECRET environment variable is not set.'); } // Derive the encryption key from the secret key $key = openssl_digest($secretKey, 'SHA256', TRUE); // Extract the IV, HMAC, and ciphertext from the encoded string $ciphertext = base64_decode($string); $ivLength = openssl_cipher_iv_length($cipher = "AES-128-CBC"); $iv = substr($ciphertext, 0, $ivLength); $hmac = substr($ciphertext, $ivLength, $sha2len = 32); $ciphertextRaw = substr($ciphertext, $ivLength + $sha2len); // Ensure the IV is exactly 16 bytes long $iv = str_pad($iv, $ivLength, "\0"); // Verify the HMAC to ensure integrity $calculatedHmac = hash_hmac('sha256', $ciphertextRaw, $key, true); if (!hash_equals($hmac, $calculatedHmac)) { throw new \RuntimeException('HMAC validation failed.'); } // Decrypt the ciphertext to obtain the original plaintext $originalPlaintext = openssl_decrypt($ciphertextRaw, $cipher, $key, OPENSSL_RAW_DATA, $iv); if ($originalPlaintext === false) { throw new \RuntimeException('Decryption failed.'); } return $originalPlaintext; } public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response { return new Response('Authenticated Successfully', Response::HTTP_OK); } public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response { return new Response('Unauthorized', Response::HTTP_UNAUTHORIZED); } public function createToken(Passport $passport, string $firewallName): TokenInterface { throw new \LogicException('This method should not be called for stateless authentication.'); } }
UserController类代码
<?php namespace App\Controller; use App\Entity\Company; use App\Entity\User; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Validator\Validator\ValidatorInterface; class UserController extends AbstractController { private $entityManager; public function __construct(EntityManagerInterface $entityManager) { $this->entityManager = $entityManager; } #[Route('/api/login', name: 'login', methods: ['POST'])] public function login(Request $request): JsonResponse { // Retrieve the request data $requestData = json_decode($request->getContent(), true); // Ensure the required fields are provided if (!isset($requestData['name'])) { return new JsonResponse(['error' => 'Name field is required'], Response::HTTP_BAD_REQUEST); } $name = $requestData['name']; // Encrypt the name $encryptedName = $this->encrypt($name); // Generate a token based on the encrypted name $token = $this->generateToken($encryptedName); // Return the token to the client return new JsonResponse(['token' => $token]); } private function encrypt(string $name): string { $secretKey = $_ENV['APP_SECRET']; $key = openssl_digest($secretKey, 'SHA256', TRUE); $ivlen = openssl_cipher_iv_length($cipher = "AES-128-CBC"); $iv = openssl_random_pseudo_bytes($ivlen); $ciphertextRaw = openssl_encrypt($name, $cipher, $key, OPENSSL_RAW_DATA, $iv); $hmac = hash_hmac('sha256', $ciphertextRaw, $key, true); $output = base64_encode($iv . $hmac . $ciphertextRaw); return $output; } private function generateToken(string $encryptedName): string { // Generate a token based on the encrypted name return hash('sha256', $encryptedName); } #[Route('/api/users', name: 'user_index', methods: ['GET'])] public function index(): Response { $currentUser = $this->getUser(); if (!$currentUser) { return $this->json(['error' => 'User not authenticated'], Response::HTTP_UNAUTHORIZED); } $userRepository = $this->entityManager->getRepository(User::class); if (in_array('ROLE_SUPER_ADMIN', $currentUser->getRoles())) { $users = $userRepository->findAll(); } elseif (in_array('ROLE_COMPANY_ADMIN', $currentUser->getRoles())) { $users = $userRepository->findBy(['company' => $currentUser->getCompany()]); } else { $users = [$currentUser]; } return $this->json($users); } #[Route('/api/users/{id}', name: 'user_show', methods: ['GET'])] public function show(User $user): Response { $currentUser = $this->getUser(); if (!$currentUser) { return $this->json(['error' => 'User not authenticated'], Response::HTTP_UNAUTHORIZED); } if (!in_array('ROLE_SUPER_ADMIN', $currentUser->getRoles()) && ($user->getCompany() !== $currentUser->getCompany() || !in_array('ROLE_COMPANY_ADMIN', $currentUser->getRoles()))) { return $this->json(['error' => 'Access denied'], Response::HTTP_FORBIDDEN); } return $this->json($user); } #[Route('/api/users', name: 'user_new', methods: ['POST'])] public function new(Request $request, ValidatorInterface $validator): Response { $currentUser = $this->getUser(); if (!$currentUser) { return $this->json(['error' => 'User not authenticated'], Response::HTTP_UNAUTHORIZED); } if (!in_array('ROLE_SUPER_ADMIN', $currentUser->getRoles()) && !in_array('ROLE_COMPANY_ADMIN', $currentUser->getRoles())) { return $this->json(['error' => 'Access denied'], Response::HTTP_FORBIDDEN); } // Check if Content-Type is application/json if ($request->headers->get('Content-Type') !== 'application/json') { return $this->json(['error' => 'Request must be JSON'], Response::HTTP_UNSUPPORTED_MEDIA_TYPE); } // Decode JSON content $data = json_decode($request->getContent(), true); // Check if decoding was successful if ($data === null && json_last_error() !== JSON_ERROR_NONE) { return $this->json(['error' => 'Invalid JSON'], Response::HTTP_BAD_REQUEST); } // Check if required fields are present if (!isset($data['name']) || !isset($data['role'])) { return $this->json(['error' => 'Name and role are required'], Response::HTTP_BAD_REQUEST); } // Create new user $user = new User(); $user->setName($data['name']); $user->setRole($data['role']); // Validate the user entity $errors = $validator->validate($user); // Check if there are any validation errors if (count($errors) > 0) { $errorMessages = []; foreach ($errors as $error) { $errorMessages[] = $error->getMessage(); } return $this->json(['errors' => $errorMessages], Response::HTTP_BAD_REQUEST); } // If the role is super admin, set company_id to null if ($data['role'] === 'ROLE_SUPER_ADMIN') { $user->setCompany(null); } else { // Check if company_id is provided and set it if present if (isset($data['company_id'])) { // Fetch company entity based on the provided company_id $company = $this->entityManager->getRepository(Company::class)->find($data['company_id']); if (!$company) { return $this->json(['error' => 'Invalid company_id'], Response::HTTP_BAD_REQUEST); } // Set the company for the user $user->setCompany($company); } } // Persist user and associated entities to the database $this->entityManager->persist($user); $this->entityManager->flush(); // Return created user return $this->json($user, Response::HTTP_CREATED); } #[Route('/api/users/{id}', name: 'user_delete', methods: ['DELETE'])] public function delete(User $user): Response { $currentUser = $this->getUser(); if (!$currentUser) { return $this->json(['error' => 'User not authenticated'], Response::HTTP_UNAUTHORIZED); } if (!in_array('ROLE_SUPER_ADMIN', $currentUser->getRoles())) { return $this->json(['error' => 'Access denied'], Response::HTTP_FORBIDDEN); } // Delete the user $this->entityManager->remove($user); $this->entityManager->flush(); return $this->json(['message' => 'User deleted'], Response::HTTP_OK); } }
问题分析与解决方法
核心问题1:自定义认证器拦截了登录请求
CustomAuthenticator的supports方法返回true,意味着它会拦截所有请求,包括/api/login。即使access_control配置了允许匿名访问,认证器仍会先执行认证逻辑,导致登录请求触发HMAC验证错误。
解决方法:修改supports方法,只拦截需要认证的接口(除了/api/login):
public function supports(Request $request): ?bool { return !$request->matches('/api/login'); }
核心问题2:登录流程的令牌传递逻辑错误
登录接口返回的是encryptedName的SHA256哈希,但认证器期望接收的是原始的encryptedName(包含IV、HMAC、密文的base64字符串),而非哈希后的令牌。这导致解密时无法正确解析IV和HMAC,验证失败。
解决方法:
- 登录接口直接返回
encryptedName作为令牌,无需二次哈希:
#[Route('/api/login', name: 'login', methods: ['POST'])] public function login(Request $request): JsonResponse { $requestData = json_decode($request->getContent(), true); if (!isset($requestData['name'])) { return new JsonResponse(['error' => 'Name field is required'], Response::HTTP_BAD_REQUEST); } $name = $requestData['name']; $token = $this->encrypt($name); // 直接返回加密后的字符串作为令牌 return new JsonResponse(['token' => $token]); }
- 认证器中修改获取令牌的方式,从请求头(如
Authorization: Bearer <token>)或请求参数中获取令牌,而非name字段(登录时传的是明文用户名,后续接口传令牌):
public function authenticate(Request $request): Passport { // 从Authorization头获取令牌 $authorizationHeader = $request->headers->get('Authorization'); if (!$authorizationHeader || !str_starts_with($authorizationHeader, 'Bearer ')) { throw new UnauthorizedHttpException('Invalid credentials', 'Token missing or invalid'); } $token = substr($authorizationHeader, 7); try { $decryptedName = $this->decrypt($token); } catch (Exception $e) { throw new UnauthorizedHttpException('Invalid credentials', 'Invalid or tampered token: ' . $e->getMessage()); } $user = $this->loadUser($decryptedName); if (!$user) { throw new UnauthorizedHttpException('Invalid credentials', 'User not found'); } $userBadge = new UserBadge($decryptedName, function ($name) { return $this->loadUser($name); }); return new Passport($userBadge); }
额外优化点
- 在Symfony中建议使用依赖注入获取
相关产品推荐
相关产品推荐

