XF 2.0 Login/authenticate user without password

I'm creating a custom XenForo 2.0 add-on for remote authentication.

I'm trying to figure out how to authenticate a user using an email address alone.

We are using a basic HTTP Authorisation header for token based validation via main website that returns a valid email in a JSON response. I have set up this initial remote authentication and this part works OK.

Next steps following return of valid email:-

1. If the user email already exists (i.e. account exists) in XenForo then I want to authenticate the user immediately.
2. If they don't exist then I will redirect them to a screen that allows them to register (create username, etc).

I've been looking through the authentication classes XF\Authentication, as well as:

XF\Pub\Controller\Login
XF\Service\User\Login

I'm assuming this might be something similar to a Connected Registration / Login...

I'm very new to the XenForo environment so any pointers gratefully received...
 
I'm not 100% following how you're doing the token validation and, more specifically, how XF would access that token. But I don't really think you would be using XF\Authentication directly in most cases.

Are you trying to do this automatically when someone comes to the forum? If so, that's a fairly different approach. I can cover what I'd do if that's what you're after.

Are you wanting to disable normal registrations and create forum accounts on demand when someone logs into the forum using their main site details? That would generally involve overwriting the login service to check with your main site API to do the authentication and then determining if a user exists and taking action based on that.

Or do you really want to do this like a connected account, which is an optional alternative method of registering? If this gets exposed via OAuth, then you may be able to make a provider for the OAuth library for this. Otherwise, there may be a fair amount of custom stuff here. It will sort of work like the previous approach, though you'd be working through tokens and different ways of passing data back and forth (compared to silently using the email/password provided).
 
User follows link to forum with authentication token in query string:

https://FORUM_URL/authenticate?token=VLR99kInXPRLOhLEbtQ8MDOMMmuVnPWt

We pass back token to API endpoint with basic HTTP auth including API key as username (no password):

https://REMOTE_API/user/{token}/

API key, endpoint, etc, are currently hardcoded but will ultimately be set through options page.

Authorisation header value is a base64 encoding of 'username:password' prefixed with 'Basic'.

e.g. https://REMOTE_API/user/VLR99kInXPRLOhLEbtQ8MDOMMmuVnPWt/

If successful auth we get back a JSON response, as follows:

Code:
{
    email_address: "user@email.address"
}

If user@email.address already exists in XenForo we authenticate the user. If not we reroute user to page to register with no password option.

That's broadly speaking what's happening...

Oauth route would have been ideal but the developers creating the auth route did not have time to implement this.

Anyway this is most like Option 3 above:

Or do you really want to do this like a connected account, which is an optional alternative method of registering? If this gets exposed via OAuth, then you may be able to make a provider for the OAuth library for this. Otherwise, there may be a fair amount of custom stuff here. It will sort of work like the previous approach, though you'd be working through tokens and different ways of passing data back and forth (compared to silently using the email/password provided).

I guess this is like a simplified "Oauth lite" approach.

I was going to start digging through the ConnectedAccount related code. Any pointers appreciated!!!
 
Instead of working on this try to work on creating push notification api for xf serverside integration and implementing it in webview.

It could be best ever addon.

I am looking for it for xF2, can too pay for it but budget is low few $.

Try this developement, it is greater thing then what u are trying to do.
 
Instead of working on this try to work on creating push notification api for xf serverside integration and implementing it in webview.

It could be best ever addon.

I am looking for it for xF2, can too pay for it but budget is low few $.

Try this developement, it is greater thing then what u are trying to do.

Ha — I'm new to XenForo development so probably not the best person for the job.

I'm actually working on a requirement for my current employer... I'm their in-house Drupal developer but they have a need for this and I'm the only developer here!
 
Right I understand now -- the user is being specifically directed to a page. It's not something getting picked up automatically.

If you really want to do this like a connected account, then you really want a different identifier than an email address that could change, either in the source or in XF. A connected account would use this unique identifier (like a user ID) as a means of looking of whether there's an account of not. If there's not, then you could potentially look up the email address and take action (such as possibly automatically associating the accounts, though if you do that, you may want to force the user to confirm their current XF account password).

Note that another benefit of using a non-changeable ID for tracking associations is that, should the email change, you could reflect that automatically within XF as well if you wanted.

This is fairly connected account'y, though our core connected account framework is likely pretty tied to OAuth approaches so I don't think you can use it directly. That said, given that you're just developing something for your specific needs, you may be able to write a custom setup that is much simpler than the fairly generic connected account framework.

You'll need to parse out the bits that are applicable to you, but \XF\Pub\Controller\Register::actionConnectedAccount is basically what your authenticate route is going to be doing. It handles detecting an associated user and logging in as them if needed. If there isn't, it also determines whether there's a user with that email and what to do in that case, what to do if a user hits this page while already logged in (adds an association), and the new user registration form. actionConnectedAccountAssociate and actionConnectedAccountRegister actually do the association and registration. Again, these are setup to be generic for what data is provided by arbitrary connected account providers, so you can likely simplify things.
 
You'll need to parse out the bits that are applicable to you, but \XF\Pub\Controller\Register::actionConnectedAccount is basically what your authenticate route is going to be doing. It handles detecting an associated user and logging in as them if needed. If there isn't, it also determines whether there's a user with that email and what to do in that case, what to do if a user hits this page while already logged in (adds an association), and the new user registration form. actionConnectedAccountAssociate and actionConnectedAccountRegister actually do the association and registration. Again, these are setup to be generic for what data is provided by arbitrary connected account providers, so you can likely simplify things.

Thank you — that was really useful!

So here's where I'm at... Warts and all.

This "works" as it stands but some things not ideal (e.g. use of session, curl).

Let me know you have any feedback/thoughts...

PHP:
namespace IPSE\Authenticate\Pub\Controller;

//use XF\ConnectedAccount\Provider\AbstractProvider;
//use XF\ConnectedAccount\ProviderData\AbstractProviderData;
use XF\Pub\Controller\AbstractController;
use XF\Mvc\ParameterBag;

class Register extends AbstractController {

  public function actionIndex(ParameterBag $params) {

    $redirect = $this->getDynamicRedirect();
    $visitor = \XF::visitor();


    if (!isset($params['auth_token'])) {
      return $this->error('Missing authentication {auth_token}.');
    }

    // Retrieve API and Username from Options.
    $api = $this->options()->registerIPSEAPI;
    $http_username = $this->options()->registerIPSEHttpUsername;
    $endpoint = '/user/' . $params['auth_token'] . '/';

    // Perform authentication...

    // @TODO use GuzzleHttp, e.g.
    //    $client = new \GuzzleHttp\Client();
    //    $res = $client->get($api . $endpoint, [
    //      'auth' => [$http_username, NULL],
    //    ]);

    // Curl Options
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, $api . $endpoint);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
    curl_setopt($ch, CURLOPT_USERPWD, "$http_username:");
    curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
    $output = curl_exec($ch);
    curl_close($ch);

    $json = json_decode($output);

    // ERROR {"error":"Token not found, or expired","code":404}
    if (isset($json->code) && $json->code == 404) {
      return $this->error('Error: ' . $json->error);
    }

    // ERROR {"detail":"Not authorized","status":401,"type":"rest.authorization.failed","title":"Authorization failed"}
    if (isset($json->status) && $json->status == 401) {
      return $this->error('Error: ' . $json->title);
    }

    if (isset($json->email_address)) {
      $email = $json->email_address;

      /** @var \XF\Session\Session $session */
      //      $session = \XF::app()['session.public'];
      $session = $this->session();
      $session->set('registerIPSE', [
        'auth_token' => $params['auth_token'],
        'email' => $email,
      ]);
      $session->save();
    }
    else {
      return $this->error('There was an error completing the Single Sign On.');
    }

    // @TODO retrieve this properly... Repository???
    $emailUser = $this->em()
      ->findOne('XF:User', ['email' => $email]);

    // User exists in DB
    if (isset($emailUser) && isset($emailUser->user_id)) {

      // User is already logged in and email matches
      if ($visitor->user_id && $emailUser->user_id == $visitor->user_id) {
        return $this->redirect($redirect);
      }

      if ($visitor->user_id && $emailUser->user_id != $visitor->user_id) {
        // Automatically log them out

        /** @var \XF\ControllerPlugin\Login $loginPlugin */
        $loginPlugin = $this->plugin('XF:Login');
        $loginPlugin->logoutVisitor();

        // Optionally redirect to homepage
        // return $this->redirect($redirect, '');

      }

      // Log them in
      /** @var \XF\ControllerPlugin\Login $loginPlugin */
      $loginPlugin = $this->plugin('XF:Login');
      $loginPlugin->triggerIfTfaConfirmationRequired(
        $emailUser,
        $this->buildLink('login/two-step', NULL, [
          '_xfRedirect' => $redirect,
          'remember' => 1,
        ])
      );
      $loginPlugin->completeLogin($emailUser, TRUE);

      return $this->redirect($redirect, '');
    }
    // User does not exist so we need to register them
    else {

      // @TODO pass date of birth as well...

      $viewParams = [

        'params' => $params,
        'userData' => [
          'email' => $email,
          // 'dob' => $dob,
          // 'location' => $location,
        ],
        'redirect' => $redirect,
      ];
      return $this->getRegisterResponse($viewParams);
    }


  }


  protected function getRegisterResponse(array $viewParams) {
    return $this->view('IPSE\Authenticate:View', 'register_ipse_account', $viewParams);
  }


  /**
   * @param \XF\Mvc\ParameterBag $params
   *
   * @return \XF\Mvc\Reply\Error|\XF\Mvc\Reply\Redirect
   */
  public function actionRegister(ParameterBag $params) {

    $session = $this->session();
    $registerIPSE = $session->get('registerIPSE');

    if (isset($params['auth_token'])) {
      if ($params['auth_token'] != $registerIPSE['auth_token']) {
        $session->remove('registerIPSE');
        $session->save();
        return $this->error('Registration failed.');
      }
    }

    $this->assertRegistrationActive();
    $this->assertPostOnly();

    $redirect = $this->getDynamicRedirect(NULL, FALSE);

    $visitor = \XF::visitor();
    if ($visitor->user_id) {
      return $this->redirect($redirect);
    }

    $input = $this->getRegistrationInput();

    $registration = $this->setupRegistration($input);
    $registration->checkForSpam();

    if (!$registration->validate($errors)) {
      return $this->error($errors);
    }

    $user = $registration->save();

    $this->finalizeRegistration($user);

    return $this->redirect($this->buildLink('register/complete'));

  }

  /**
   * @throws \XF\Mvc\Reply\Exception
   */
  protected function assertRegistrationActive() {
    if (!$this->options()->registrationSetup['enabled']) {
      throw $this->exception(
        $this->error(\XF::phrase('new_registrations_currently_not_being_accepted'))
      );
    }

    // prevent discouraged IP addresses from registering
    if ($this->options()->preventDiscouragedRegistration && $this->isDiscouraged()) {
      throw $this->exception(
        $this->error(\XF::phrase('new_registrations_currently_not_being_accepted'))
      );
    }
  }

  /**
   * @param \IPSE\Authenticate\Pub\Controller\AbstractProviderData $providerData
   *
   * @return array|mixed|string
   */
  protected function getRegistrationInput() {
    $input = $this->request->filter([
      'username' => 'str',
      'email' => 'str',
      'timezone' => 'str',
      'location' => 'str',
      'dob_day' => 'uint',
      'dob_month' => 'uint',
      'dob_year' => 'uint',
      'custom_fields' => 'array',
    ]);

    $session = $this->session();
    $registerIPSE = $session->get('registerIPSE');

    $input['email'] = $registerIPSE['email'];

    //    if ($providerData->email) {
    //      $input['email'] = $providerData->email;
    //    }
    //    if ($providerData->location) {
    //      $input['location'] = $providerData->location;
    //    }
    //    if ($providerData->dob) {
    //      $dob = $providerData->dob;
    //      $input['dob_day'] = $dob['dob_day'];
    //      $input['dob_month'] = $dob['dob_month'];
    //      $input['dob_year'] = $dob['dob_year'];
    //    }

    return $input;
  }

  /**
   * @param array $input
   * @param \XF\ConnectedAccount\ProviderData\AbstractProviderData $providerData
   *
   * @return \XF\Service\User\Registration
   */
  protected function setupRegistration(array $input) {
    /** @var \XF\Service\User\Registration $registration */
    $registration = $this->service('XF:User\Registration');
    $registration->setFromInput($input);
    $registration->setNoPassword();
    $registration->skipEmailConfirmation();
    return $registration;
  }

  /**
   * @param \XF\Entity\User $user
   */
  protected function finalizeRegistration(\XF\Entity\User $user) {
    $this->session()->changeUser($user);
    \XF::setVisitor($user);

    /** @var \XF\ControllerPlugin\Login $loginPlugin */
    $loginPlugin = $this->plugin('XF:Login');
    $loginPlugin->createVisitorRememberKey();
  }

  /**
   * @param $action
   */
  public
  function assertViewingPermissions($action) {
  }


}
 
Last edited:
Top Bottom