skip to Main Content

PROBLEM: It’s easily possible to log in via username (and password) or to log in via email (and password) but how to enable both for an optimal user experience?

APPROACHES: I found multiple answers on Stackoverflow* and various tutorials, none worked for my Symfony version (6.3.3) or I was just to stupid to make them work (professional web developer).

  • Create my own Extension of the AbstractAuthenticator. Failed because: a) Requires a lot of logic to be duplicated, modified. b) Feels like a security risk to play around with the complex login logic functionality too much. c) I failed to get that one important constructor service to work in my extension.
  • Alternatively, I could have extended some internal Symfony classes, but they were final, so extending them was not a valid option.
  • It’s possible to do manual logins, but that just moves the above problem somewhere else (constructing "passwords", "badges", "tokens", etc.).

SOLUTION: I actually found an elegant workaround. The reason why I’m still asking this question is
a) to share my solution with others and
b) maybe there’s something wrong with it that I’m not seeing it and there’s still a far better way of doing it?

*Similar questions, but with old versions of Symfony (and they didn’t work for me):

2

Answers


  1. Chosen as BEST ANSWER

    So, my solution is:

    Instead of doing the following:

    1. login form
    2. redirects to login route, where the Symfony login magic happens
    3. redirects to login controller, where I can edit the result (redirect if successful, error message if not successful)

    I included another action between step 1 and step 2:

    1. login form:
    <form action="{{ path('pre_security_login') }}" method="post" class="d-flex">
        <input type="text" name="email_or_username" required="required" placeholder="email or username">
        <input type="password" name="_password" required="required" placeholder="password">
        <input type="submit" name="login" value="Login">
        <input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
        <input type="hidden" name="_target_path" value="{{ app.request.get('redirect_to') }}">
    </form>
    

    1.5 redirects to my own Controller Action, not touching the Symfony login logic: Controller/AuthenticationController.php

    ...
    class AuthenticationController extends BaseController
    ...
        #[Route("/pre_login", name: "pre_security_login")]
        public function PreAuthenticationAction(Request $request, UserRepository $userRepository): Response
        {
            if ($this->getUser()) {
                return $this->redirectToRoute(HomeController::ROUTE_HOME);
            }
    
            $usernameOrEmail = $request->request->get('email_or_username');
            if (str_contains($usernameOrEmail, '@')) {
                $email = $usernameOrEmail;
                $username = $userRepository->findOneBy(['email ' => $email ])?->getUsername() ?? '';
            } else {
                $username = $usernameOrEmail;
            }
    
            $password = $request->request->get('_password');
            $token = $request->request->get('_csrf_token');
    
            return $this->redirectToRoute('security_login', [
                '_username' => $username,
                '_password' => $password,
                '_csrf_token' => $token,
            ]);
        }
    
    1. Now, the real login happens, with the Symfony logic. Note that this only works with the associated configuration (I'm using PHP, the default is YAML): config/packages/security.php
            'firewalls' => [
    ...
                'main' => [
    ...
                    'form_login' => [
                        'check_path' => 'security_login',
                        'login_path' => 'security_login',
                        'form_only' => false, // <-- important, as it's no longer a form
                        'post_only' => false, // <-- important, as it's now a get request
                        'enable_csrf' => true,
                        'default_target_path' => 'home',
                    ],
                ],
            ],
    ...
    

    Step 3 remains like before:

    class AuthenticationController extends BaseController
    ...
        #[Route("/login", name: "security_login")]
        public function loginAction(AuthenticationUtils $authenticationUtils): Response
        {
            if ($this->getUser()) {
                return $this->redirectToRoute('home');
            }
    
            // get the login error if there is one
            $error = $authenticationUtils->getLastAuthenticationError();
            // last username entered by the user
            $lastUsername = $authenticationUtils->getLastUsername();
    
            return $this->render('authentication/login.html.twig', ['last_username' => $lastUsername, 'error' => $error]);
        }
    
    

  2. if you are using symfony maker auth, just edit the authenticate method in the se src/Security/

        class CustomAuthenticator extends AbstractLoginFormAuthenticator
        {
            use TargetPathTrait;
            public function __construct(private UserRepository, $userRepository){}
            public const LOGIN_ROUTE = 'app_login';
        
            public function authenticate(Request $request): Passport
            {
                $user_identificator = $request->request->get('user_identificator', '');
                $password = $request->request->get('password', '');
                // check if user_identifactor contains @ eg email
                if (str_contains($user_identificator, '@')) {
                    // login with email@
                    $user = $this-userRepository->findOneBy(['email'=> $user_identificator]);
                } else{
                    // login with usename
                     $user = $this-userRepository->findOneBy(['uname'=> $user_identificator]);
                }
                if($user == null){
                    // handle no user found ?
                }
                $request->getSession()->set(Security::LAST_USERNAME, $user_identificator);
                return new Passport(
                    new UserBadge($user->getEmail()),
                    new PasswordCredentials($request->request->get('password', '')),
                    [
                        new CsrfTokenBadge('authenticate', $request->request->get('_csrf_token')),            ]
                );
            }
    

    then you need to change the template for the login form in templates/security/login.html.twig

    change the the default input for the email to be

    <input type="text" value="{{ last_username }}" name="user_identificator" id="user_identificator" class="form-control" required autofocus>
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search