phpauthenticationsymfonyauthorizationcustom-authentication

Symfony 7: How to Bypass Authentication for Login Route?


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

Solution

  • 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: