You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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,验证失败。

解决方法:

  1. 登录接口直接返回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]);
}
  1. 认证器中修改获取令牌的方式,从请求头(如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中建议使用依赖注入获取
相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.30 05:10:44