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