Search code examples
phpauthenticationsymfonydoctrine-orm

Symfony 7 Authentication Issue: "Invalid or Tampered Token: HMAC Validation Failed (401 Unauthorized)"


I'm encountering an authentication issue when attempting to implement custom authentication for the login process in Symfony. Despite configuring the security.yml file to allow access without authentication for certain routes, notably the login route, I'm consistently receiving the error message:

"Invalid or Tampered Token: HMAC Validation Failed (401 Unauthorized)"

Here's a snippet of my security.yml configuration:

    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

here is my customauthentication class :


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

and here is my usercontroller class :


    <?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);
    }
}

Despite specifying that the /login route should be accessible without authentication, I'm still encountering this error. It's worth mentioning that my logic of authentication has worked successfully in Laravel, but in Symfony, it seems to encounter this issue. In my Symfony application, I'm using a similar approach where I authenticate users by their name and utilize an app_secret stored in the .env file for decryption.

Could someone please provide guidance on what might be causing this error and how to resolve it? I'm puzzled by Symfony's authentication logic and would appreciate any insights or suggestions for troubleshooting. Thank you.


Solution

  • I managed to solve the problem. and here is the updated method of login of the userCotnroller:

        #[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'];
    
            // Check if a user with the provided name exists
            $user = $this->entityManager->getRepository(User::class)->findOneBy(['name' => $name]);
    
            if (!$user) {
                return new JsonResponse(['error' => 'User not found'], Response::HTTP_NOT_FOUND);
            }
    
            // Generate a token
            $token = $this->generateToken($name);
    
            // Return the token to the client
            return new JsonResponse(['token' => $token]);
        }
    
        // Generate a token based on the user's name
        private function generateToken(string $name): string
        {
            // You can use any method to generate a token. Here, I'm using a simple JWT for demonstration purposes.
            $payload = [
                'name' => $name,
                'exp' => time() + 3600 // Token expiration time (1 hour)
            ];
    
            // Retrieve the secret key from the APP_SECRET environment variable
            $secretKey = $_ENV['JWT_SECRET'];
    
            if (!$secretKey) {
                throw new \Exception('JWT_SECRET environment variable not found.');
            }
    
            // Replace 'your_secret_key' with your actual secret key
            return JWT::encode($payload, $secretKey, 'HS256');
        }
    

    and here is the CustomAuthenticaor:

    <?php
    
    namespace App\Security;
    
    
    use Firebase\JWT\JWT;
    use Firebase\JWT\Key;
    
    use Symfony\Component\HttpFoundation\JsonResponse;
    use Symfony\Component\HttpFoundation\Request;
    use Symfony\Component\HttpFoundation\Response;
    
    use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
    use Symfony\Component\Security\Core\Exception\AuthenticationException;
    
    use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
    
    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 Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
    
    use App\Entity\User;
    use App\Repository\UserRepository;
    use Doctrine\ORM\EntityManagerInterface;
    use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
    
    
    class CustomAuthenticator implements AuthenticatorInterface
    {
        private EntityManagerInterface $entityManager;
    
        private UserRepository $userRepository;
    
        public function __construct(EntityManagerInterface $entityManager, UserRepository $userRepository)
        {
            $this->entityManager = $entityManager;
            $this->userRepository = $userRepository;
        }
    
        public function supports(Request $request): ?bool
        {
            if ($request->attributes->get('_route') === 'login' && $request->isMethod('POST')) {
                return false; // Return false for the login route
            }
    
            // Return true for other routes where you want this authenticator to handle authentication
            return true;
        }
    
    
        public function authenticate(Request $request): SelfValidatingPassport
        {
            try {
                $authorizationHeader = $request->headers->get('Authorization');
    
                if (!$authorizationHeader || !preg_match('/^Bearer\s+(.*?)$/', $authorizationHeader, $matches)) {
                    throw new CustomUserMessageAuthenticationException('Invalid authorization header', ['error' => 'Bearer token not found']);
                }
    
                $jwtToken = $matches[1]; // Extract the JWT token from the Authorization header
    
                // Retrieve the secret key from the JWT_SECRET environment variable
                $secretKey = $_ENV['JWT_SECRET'] ?? null;
    
                if (!$secretKey) {
                    throw new \Exception('JWT_SECRET environment variable not found.');
                }
    
                // Decode the JWT token
                $decodedToken = JWT::decode($jwtToken, new Key($secretKey, 'HS256'));
    //dd($decodedToken);
                // Extract the user information from the decoded token
                $name = $decodedToken->name ?? null;
    //dd($name);
                if (!$name) {
                    throw new CustomUserMessageAuthenticationException('User not found in token');
                }
    
                // Load the user from the database
                $user = $this->entityManager->getRepository(User::class)->findOneBy(['name' => $name]);
    //dd($user);
                if (!$user) {
                    throw new CustomUserMessageAuthenticationException('User not found', ['name' => $name]);
                }
    
                $userBadge = new UserBadge($user->getUsername(), function ($username) use ($user) {
                    return $user;
                });
    
    // Create and return a SelfValidatingPassport with the UserBadge
                return new SelfValidatingPassport($userBadge);
            } catch (\Exception $e) {
                // Log the exception to PHP error log
                error_log('Authentication error: ' . $e->getMessage());
    
                // Rethrow the exception with a generic error message
                throw new CustomUserMessageAuthenticationException('Error decoding token');
            }
        }
    
    
        public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
        {
            return null;
        }
    
        public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
        {
            return new JsonResponse(['error' => $exception->getMessage()], Response::HTTP_UNAUTHORIZED);
        }
    
        public function createToken(Passport $passport, string $firewallName): TokenInterface
        {
    
    
            $user = $passport->getUser();
    
            // Retrieve the roles of the authenticated user using the UserRepository
            $roles = $this->userRepository->loadUserByRole($user->getName());
    //dd($roles);
            // Create the UsernamePasswordToken with the provided $firewallName and user roles
            return new UsernamePasswordToken($user, 'main', (array)$roles);
    
        }
    }