I have a Symfony 7 project where I'm implementing custom authentication logic for the login route (/api/login). I have a login method in my controller that handles user login and generates a token based on the provided name. Additionally, I've created a custom AuthenticationListener class that implements AuthenticatorInterface to handle authentication for other routes.
Here's a summary of the relevant code:
<?php
namespace App\EventListener;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
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\Http\Authenticator\AuthenticatorInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
class AuthenticationListener implements AuthenticatorInterface
{
public function supports(Request $request): ?bool
{
$route = $request->attributes->get('_route');
return str_starts_with($route, 'api_');
}
#[AsEventListener(event: 'kernel.request')]
public function authenticate(RequestEvent|Request $event): Passport
{
$request = $event->getRequest();
// Check if the request is for the login route
if ($request->attributes->get('_route') === 'login') {
return new Passport();
}
if (!$request->headers->has('Authorization')) {
throw new UnauthorizedHttpException('Unauthorized', 'Authorization header is missing');
}
$key = $request->headers->get('Authorization');
if (!$key) {
throw new UnauthorizedHttpException('Unauthorized', 'Authorization header is missing');
}
$decryptedName = $this->decrypt($key);
if (!$decryptedName) {
throw new UnauthorizedHttpException('Unauthorized', 'Invalid or expired token');
}
// Create a UserBadge for other routes
$userBadge = new UserBadge($decryptedName, function ($username) {
return null;
});
return new Passport($userBadge);
}
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);
}
private function decrypt($string): string
{
// Decrypt the authorization token using your decryption method
$secret_key = $_ENV['APP_SECRET']; // Ensure you have APP_KEY set in your environment variables
$key = openssl_digest($secret_key, 'SHA256', TRUE);
$c = base64_decode($string);
$ivlen = openssl_cipher_iv_length($cipher = "AES-128-CBC");
$iv = substr($c, 0, $ivlen);
$hmac = substr($c, $ivlen, $sha2len = 32);
$ciphertext_raw = substr($c, $ivlen + $sha2len);
$original_plaintext = openssl_decrypt($ciphertext_raw, $cipher, $key, OPENSSL_RAW_DATA, $iv);
// dd($original_plaintext);
$calcmac = hash_hmac('sha256', $ciphertext_raw, $key, true);
if (hash_equals($hmac, $calcmac)) {
return $original_plaintext;
}
return ''; // Return an empty string if decryption fails
}
public function createToken(Passport $passport, string $firewallName): TokenInterface
{
throw new \LogicException('This method should not be called for stateless authentication.');
}
}
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);
}
Despite my efforts, I'm encountering challenges bypassing authentication for the login route and ensuring proper authentication for other routes. I've experimented with different configurations, but I keep receiving errors related to missing or invalid authorization headers.
Could someone provide guidance on how to correctly implement custom authentication logic for the login route while ensuring authentication for other routes remains intact? Any insights, code examples, or configuration suggestions would be greatly appreciated. Thank you!
security:
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
stateless: true
custom_authenticators:
- App\EventListener\AuthenticationListener
api_login:
pattern: ^/api/login
stateless: true
access_control:
- { path: '^/api/login', roles: PUBLIC_ACCESS }
- { path: '^/', roles: IS_AUTHENTICATED_FULLY }
update I have updated the security.yml and customauthenticator.php but I receive this error " "title": "An error occurred", "status": 401, "detail": "Full authentication is required to access this resource.", "class": "Symfony\Component\HttpKernel\Exception\HttpException",:
security:
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
stateless: true
custom_authenticators:
- App\Security\CustomAuthenticator
api_login:
pattern: ^/api/login
stateless: false # Change stateless to false for stateful authentication
access_control:
- { path: '^/api/login', roles: PUBLIC_ACCESS }
- { path: '^/', roles: [ROLE_USER, ROLE_COMPANY_ADMIN, ROLE_SUPER_ADMIN] }
<?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 $request->isMethod(Request::METHOD_POST)
&& $request->getRequestUri() === '/api/login';
}
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');
}
$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
{
// Decrypt the authorization token using your decryption method
$secret_key = $_ENV['APP_SECRET']; // Ensure you have APP_KEY set in your environment variables
$key = openssl_digest($secret_key, 'SHA256', TRUE);
$c = base64_decode($string);
$ivlen = openssl_cipher_iv_length($cipher = "AES-128-CBC");
$iv = substr($c, 0, $ivlen);
$hmac = substr($c, $ivlen, $sha2len = 32);
$ciphertext_raw = substr($c, $ivlen + $sha2len);
$original_plaintext = openssl_decrypt($ciphertext_raw, $cipher, $key, OPENSSL_RAW_DATA, $iv);
// dd($original_plaintext);
$calcmac = hash_hmac('sha256', $ciphertext_raw, $key, true);
if (hash_equals($hmac, $calcmac)) {
return $original_plaintext;
}
return ''; // Return an empty string if decryption fails
}
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.');
}
}
Since your login requires no authorization, your access control should allow public access:
# config/packages/security.yaml
security:
# ...
access_control:
- { path: '^/api/login', roles: PUBLIC_ACCESS }
- { path: '^/', roles: IS_AUTHENTICATED_FULLY } # As per Martin Komischke comment
Read more about allowing unsecured access
Also your Authenticator is somewhat mixed with an EventListener.
Try this:
<?php
namespace App\Security;
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\Http\Authenticator\AuthenticatorInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
class CustomAuthenticator implements AuthenticatorInterface
{
public function supports(Request $request): ?bool
{
return $request->headers->has('Authorization');
}
public function authenticate(Request $request): Passport
{
$key = $request->headers->get('Authorization');
$decryptedName = $this->decrypt($key);
if (!$decryptedName) {
throw new UnauthorizedHttpException('Unauthorized', 'Invalid or expired token');
}
// Create a UserBadge for other routes
$userBadge = new UserBadge($decryptedName, function ($username) {
return null;
});
return new Passport($userBadge);
}
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);
}
private function decrypt($string): string
{
// Decrypt the authorization token using your decryption method
$secret_key = $_ENV['APP_SECRET']; // Ensure you have APP_KEY set in your environment variables
$key = openssl_digest($secret_key, 'SHA256', TRUE);
$c = base64_decode($string);
$ivlen = openssl_cipher_iv_length($cipher = "AES-128-CBC");
$iv = substr($c, 0, $ivlen);
$hmac = substr($c, $ivlen, $sha2len = 32);
$ciphertext_raw = substr($c, $ivlen + $sha2len);
$original_plaintext = openssl_decrypt($ciphertext_raw, $cipher, $key, OPENSSL_RAW_DATA, $iv);
// dd($original_plaintext);
$calcmac = hash_hmac('sha256', $ciphertext_raw, $key, true);
if (hash_equals($hmac, $calcmac)) {
return $original_plaintext;
}
return ''; // Return an empty string if decryption fails
}
}
Please note the namespace and class name have changed, according to the documentation
Some changes:
supports
should not check for paths, that should be defined in your security.yaml
authenticate
had check that have been replaced by using the supports
correctly