Rolling your own Patreon Drupal 7 authorization system.
Abdolah Pouriliaee
Scalable, sustainable teams and technology | Some nice people call me a leader.
So there are times when you can't really rely on third party softwares to do what you need them to do.
My case study herein is Patreon and Drupal 7 as that is what my DMGE is built upon.
If you're a fan of Composer, it means you like being able to Google a function or feature and using three quick strings of strung text, install a new and funky thing. Composer manages the dependencies, theoretically checks for redundancies, and generally makes managing your code easier as it keeps all those third party bits out of your repo (thus you don't have swaths of change due to versioning).
When I developed my DMGE platform, I wanted to use Patreon as the primary method of log in and so I wrote some code that integrated the existing Drupal Patreon module to provide a simple log in link on the block form.
It was soon discovered there were some issues with the Patreon module. Specifically, it didn't even have a user authorization url coded, which I did, and made the original author aware of. However, the primary issue was with the IDs being returned by Patreon, and I could not discover if it was the software or Patreon; after a few tests, I shrugged and rewrote the code to use the users email address instead of the ID. This was problematic because I was hacking core and I didn't want to simply dupe the module for two line changes. Truth be, I didn't like having so much code installed for such a simple facet of my site.
I don't use the Patreon-PHP library or the existing Drupal modules available any longer, as they make a lot of assumptions about how things should or should not work.
In addition to this, the modules required Composer integration and came with reams of packages worth of code that simply wasn't necessary for our needs.
So let's see some code!
Note: This code assume use of the Patreon v2 API, and assumes that you've created fields to store the relevant information. The code assumes you have fields for a users id, refresh token, and access token as denoted below in the field info definitions. However, that part of the work is outside the scope of this article.
Important Note: There are strings of capitalized constants such as DMGE_PATREON_OAUTH_CALLBACK_URL littered in the code. These are some hard coded values and won't be published in the article.
The hook_menu is one of the first pieces and it should look like this:
function dmge_patreon_menu() {
$items[DMGE_PATREON_OAUTH_CALLBACK_URL] = array(
'title' => 'Patreon OAuth',
// Allow all requests to this URL so that OAuth may work correctly.
'access callback' => TRUE,
'page callback' => 'dmge_patreon_oauth_callback',
'type' => MENU_CALLBACK,
);
The callback url, whatever you decide it should be for your needs, is the url that you register with the Patreon API as your callback. Patreon will trigger the process by accessing the url with the appropriate codes and subsequently allowing you to login.
The following is our process, and is quite literally just fired from a menu callback that the Patreon API treats as a hook ('page callback' => 'dmge_patreon_oauth_callback').
/**
* Callback to obtain a valid Oauth token.
*/
function dmge_patreon_oauth_callback() {
if ($_GET['code']) {
$codes = dmge_patreon_get_codes();
$query = array(
'code' => $_GET['code'],
'grant_type' => 'authorization_code',
'client_id' => $codes['client_id'],
'client_secret' => $codes['client_secret'],
'redirect_uri' => DMGE_PATREON_OAUTH_REDIRECT,
);
$data = array(
'method' => 'POST',
'headers' => array('Content-Type' => 'application/x-www-form-urlencoded'),
'data' => http_build_query($query),
);
$response = drupal_http_request(DMGE_PATREON_URL . '/api/oauth2/token', $data);
if (!empty($response->data)) {
$data = json_decode($response->data);
if (empty($data->access_token)) {
drupal_set_message('Failed to connect to Patreon');
if ($user->uid == 1) {
drupal_goto(DMGE_PATREON_ADMIN_URL);
}
drupal_exit();
}
$access_token = $data->access_token;
$refresh_token = $data->refresh_token;
$query = array(
'include' => 'memberships',
'fields[user]' => 'about,created,email,first_name,full_name,image_url,last_name,social_connections,thumb_url,url,vanity',
'fields[member]' => 'currently_entitled_amount_cents,lifetime_support_cents,last_charge_status,patron_status,last_charge_date,pledge_relationship_start',
);
$data = array(
'headers' => array('Authorization: OAuth' => $access_token),
);
// The line that reaches out to touch Patreon.
$response = drupal_http_request(url(DMGE_PATREON_URL . '/api/oauth2/v2/identity', array('query' => $query)), $data);
if (!empty($response->data)) {
$data = json_decode($response->data);
if (user_is_logged_in()) {
global $user;
if ($user->uid == 1) {
drupal_set_message('Patreon connects successfully');
drupal_goto(DMGE_PATREON_ADMIN_URL);
drupal_exit();
return;
}
}
$account = new EntityFieldQuery();
$account = $account->entityCondition('entity_type', 'user')
->addMetaData('account', user_load(1))
->fieldCondition('dmge_patreon_id', 'value', $data->data->id, '=')
->execute();
if (!empty($account)) {
$account = array_shift(array_shift($account))->uid;
$account = user_load($account);
if (!empty($account)) {
dmge_patreon_finalize_login($account);
}
}
// This searches for users by email and sets their new dmge patreon settings.
if (empty($account)) {
$account = user_load_by_mail($data->data->attributes->email);
if (!empty($account)) {
$account->dmge_patreon_id[LANGUAGE_NONE][0]['value'] = $data->data->id;
$account->dmge_patreon_access_token[LANGUAGE_NONE][0]['value'] = $access_token;
$account->dmge_patreon_refresh_token[LANGUAGE_NONE][0]['value'] = $refresh_token;
dmge_patreon_finalize_login($account);
}
}
if (empty($account)) {
$attr = $data->data->attributes;
$account = array(
'name' => $attr->full_name,
'mail' => $attr->email,
'signature_format' => 'full_html',
'status' => 1,
'language' => 'en',
'init' => 'Email',
'roles' => array(
DRUPAL_AUTHENTICATED_RID => 'authenticated user',
),
'dmge_patreon_id' => array(
LANGUAGE_NONE => array(
0 => array(
'value' => $data->data->id,
),
),
),
'dmge_patreon_access_token' => array(
LANGUAGE_NONE => array(
0 => array(
'value' => $access_token,
),
),
),
'dmge_patreon_refresh_token' => array(
LANGUAGE_NONE => array(
0 => array(
'value' => $refresh_token,
),
),
),
);
$account = user_save('', $account);
if ($account) {
dmge_patreon_finalize_login($account);
}
}
return;
}
}
drupal_set_message(t('You have to Allow Patreon to create an account. An account is required to use the engine.'), 'warning');
drupal_goto('<front>');
}
}
So what just happened up there? A number of things, let's step through it, shall we?
if ($_GET['code']) {
$codes = dmge_patreon_get_codes();
$query = array(
'code' => $_GET['code'],
'grant_type' => 'authorization_code',
'client_id' => $codes['client_id'],
'client_secret' => $codes['client_secret'],
'redirect_uri' => DMGE_PATREON_OAUTH_REDIRECT,
);
$data = array(
'method' => 'POST',
'headers' => array('Content-Type' => 'application/x-www-form-urlencoded'),
'data' => http_build_query($query),
);
$response = drupal_http_request(DMGE_PATREON_URL . '/api/oauth2/token', $data);
Here, we are looking for our auth code as delivered by Patreon; we grab that and then we grab our client id and client (secret) key.
We assemble that into a bit of data to be POSTed back to Patreon, along with a header that says we're submitting form data.
Then we hand it over to Drupal's own drupal_http_request function, and wait for the response, which if the user clicked Allow, will be their user details. If the user clicked Deny, the logic skips down to the bottom, delivering a warning message dictating the Patreon requirement, while showing them the home page.
if (!empty($response->data)) {
$data = json_decode($response->data);
The chunk above decodes our incoming data.
$account = new EntityFieldQuery();
$account = $account->entityCondition('entity_type', 'user')
->addMetaData('account', user_load(1))
->fieldCondition('dmge_patreon_id', 'value', $data->data->id, '=')
->execute();
if (!empty($account)) {
$account = array_shift(array_shift($account))->uid;
$account = user_load($account);
if (!empty($account)) {
dmge_patreon_finalize_login($account);
}
}
We use an entity field query to find our user as efq handles joining the tables and searching the appropriate columns. No muss, no fuss.
The returned array of elements is indexed by uid and a basic set of properties; we grab the uid and load the user object to pass into our login finalization function.
But wait, what is this?
// This searches for users by email and sets their new dmge patreon settings.
if (empty($account)) {
$account = user_load_by_mail($data->data->attributes->email);
if (!empty($account)) {
$account->dmge_patreon_id[LANGUAGE_NONE][0]['value'] = $data->data->id;
$account->dmge_patreon_access_token[LANGUAGE_NONE][0]['value'] = $access_token;
$account->dmge_patreon_refresh_token[LANGUAGE_NONE][0]['value'] = $refresh_token;
dmge_patreon_finalize_login($account);
}
}
If we didn't find an account, we should be generating an account; however, if you recall from above, there was a time when I was registering users by their email addresses. If I haven't found a user by their Patreon ID, then I should be able to find them by their email and assign them the appropriate Patreon credentials with the newly written module. Then in the future, I can identify them by the Patreon ID, if they ever change their email address.
Using the email vs the Patreon ID is problematic because if the user changes their email address on the platform or Patreon, instead of logging them into the platform, the platform would generate a new account to match the Patreon email address.
So the account generation comes up next:
if (empty($account)) {
$attr = $data->data->attributes;
$account = array(
'name' => $attr->full_name,
'mail' => $attr->email,
'signature_format' => 'full_html',
'status' => 1,
'language' => 'en',
'init' => 'Email',
'roles' => array(
DRUPAL_AUTHENTICATED_RID => 'authenticated user',
),
'dmge_patreon_id' => array(
LANGUAGE_NONE => array(
0 => array(
'value' => $data->data->id,
),
),
),
'dmge_patreon_access_token' => array(
LANGUAGE_NONE => array(
0 => array(
'value' => $access_token,
),
),
),
'dmge_patreon_refresh_token' => array(
LANGUAGE_NONE => array(
0 => array(
'value' => $refresh_token,
),
),
),
);
$account = user_save('', $account);
if ($account) {
dmge_patreon_finalize_login($account);
}
}
Patreon provides us with a nice array of data about our user and we'll dutifully plug those bits into place. We set some defaults that we can assume, and if I were working on a multi lingual site, I'd assign language as well (denoted above between status and init type).
The last few lines saves the user account, and remember that when saving a new account, the first parameter should be left empty to denote a new account.
We then pass the account to the finalization process. Let's look at that now.
/**
* If we have an account (or uid) make with the finalization and goto profile.
*/
function dmge_patreon_finalize_login($account) {
if (empty($account)) {
drupal_set_message(t('Something went wrong.'), 'error');
}
global $user;
$user = $account;
user_login_finalize();
drupal_goto('user');
drupal_exit();
}
Yes, I comment my own work. Don't you?
This section is very simple and to the point; if we have no account, return an error; else we call global $user object and assign the account object we were passed. Firing login_finalize sets the session information and saves the session to the database; lastly, we redirect to the users profile and kick out of the code with drupal_exit().
So this provides us with the login mechanism, but how do we give the user access to it?
For that, we need a login link. To generate a login link, we need a few pieces of information.
/**
* Authorize an account via Patreon.
*/
function dmge_patreon_authorise_account($client_id = NULL) {
$codes = dmge_patreon_get_codes();
$query = array(
'response_type' => 'code',
'client_id' => $codes['client_id'],
'redirect_uri' => DMGE_PATREON_OAUTH_REDIRECT,
'scope' => 'identity identity[email]',
);
$href = url(DMGE_PATREON_URL . '/oauth2/authorize', array('query' => $query));
return $href;
}
Our get codes function gets our client id and secret key, then produces a query string that we use to start the process.
At this point, the only thing left is to decide where to put the link url that a user can click it.
On my DMGE, I have a singular function that is fired by a form_FORMID_alter for each user login form type.
You may use a l() function in a template to expose it, or some other process.
Once you've made that decision, you effectively have the material needed for a functioning Patreon log in! Huzzah!
If you have questions, comments or criticisms, feel free to leave them below.
If you liked reading this, let me know by hitting the like button.
If you're a tech recruiter, feel free to reach out through the usual channels. I'm currently available for hire.
If you wish to see and test the code you see above, you can see it in the wild at https://www.dmge.net
All the best!