Authentication is one of the most important functions in any application. This authentication process has to be secure enough such that data being transmitted should not be compromised. Authentication dictates what users are able to see and do when they log in. Everyone knows how important it is to have a secure authentication method and how it works. I’ll skip that part! Let’s dive right into implementing authentication with Drupal and Gatsby.
On Drupal end will be using the simple OAuth module. Head over to my blog Alexa Account Linking and Custom Skill Model to find step by step instructions on how to configure this module.
Now that we have our OAuth setup ready let's make the required changes on the Gatsby site. I believe that you already have a setup ready to write some code as the blog proceeds.
Before starting to write code we should have in mind what our authentication functionality should be able to perform. Let’s break it down first in small parts:
- User should be able to log in
- User should be able to logout
- Store access token on client-side
- Accessing resource using the access token
- Use the refresh token to get valid access token when the previous access token expires
Let’s create a service which will help us check the following things throughout the project:
- Whether the user is authenticated or not
- If the user is authenticated but access token is expired, then automatically generate new access token so that user experience shouldn’t break.
Some developers prefer to place their helper functions under the ‘services’ folder while the others keep them under ‘utils’. Hence the location of the file doesn’t actually matter, it’s merely individual preference. I am going to create this file under src/services/auth.js
import { navigate } from 'gatsby';
const token_url = `${process.env.GATSBY_DRUPAL_ROOT}/oauth/token`;
const loginUrl = `${process.env.GATSBY_DRUPAL_ROOT}/user/login?_format=json`;
/* This check is to ensure that this code gets executed in browser because
* If we run this code without this check your gatsby develop will fail as it won't be able
* to access localStorage on build time
*/
export const isBrowser = typeof window !== 'undefined';
// Helper function to get the current status of the user
export const isLoggedIn = async () => {
// Check if code is executing in browser or not
if (typeof window === 'undefined') {
return Promise.resolve(false);
}
// Check if we already have access token in localStorage
const token = localStorage.getItem('access-token') !== null ? JSON.parse(localStorage.getItem('access-token')) : null;
// If not, return false as the user is not loggedIn.
if (token === null) {
return Promise.resolve(false);
}
// Check if access token is still valid
if (token !== null && token.expirationDate > Math.floor(Date.now() / 1000)) {
return Promise.resolve(token);
}
// If not, use refresh token and generate new token
if (token !== null) {
const formData = new FormData();
formData.append('client_id', process.env.GATSBY_CLIENT_ID);
formData.append('client_secret', process.env.GATSBY_CLIENT_SECRET);
formData.append('grant_type', 'refresh_token');
formData.append('scope', process.env.GATSBY_CLIENT_SCOPE);
formData.append('refresh_token', token.refresh_token);
const response = await fetch(token_url, {
method: 'post',
headers: new Headers({
Accept: 'application/json',
}),
body: formData,
});
if (response.ok) {
const result = await response.json();
const token = await saveToken(result);
return Promise.resolve(token)
}
// If refresh token is also expired
return navigate('/user/login', {state: {message: "your session has been timed out, please login"}});
}
};
/**
* Login the user.
*
* Save the token in local storage.
*/
export const handleLogin = async (username, password) => {
const drupallogIn = await drupalLogIn(username, password);
if (drupallogIn !== undefined && drupallogIn) {
return fetchSaveOauthToken(username, password);
}
return false;
};
/**
* Log the current user out.
*
* Deletes the token from local storage.
*/
export const handleLogout = async () => {
const drupallogout = await drupalLogout();
localStorage.removeItem('access-token');
navigate('/user/login');
};
/**
* Get an OAuth token from Drupal.
*
* Exchange a username and password for an OAuth token.
* @param username
* @param password
* @returns {Promise}
* Returns a promise that resolves with the new token returned from Drupal.
*/
export const fetchOauthToken = async (username, password) => {
const formData = new FormData();
formData.append('client_id', process.env.GATSBY_CLIENT_ID);
formData.append('client_secret', process.env.GATSBY_CLIENT_SECRET);
formData.append('grant_type', 'password');
formData.append('scope', process.env.GATSBY_CLIENT_SCOPE);
formData.append('username', username);
formData.append('password', password);
const response = await fetch(token_url, {
method: 'post',
headers: new Headers({
Accept: 'application/json',
}),
body: formData,
});
if (response.ok) {
const json = await response.json();
if (json.error) {
throw new Error(json.error.message);
}
return json;
}
};
/**
* Helper function to fetch and store tokens in local storage.
**/
const fetchSaveOauthToken = async (username, password) => {
const response = await fetchOauthToken(username, password);
if (response) {
return saveToken(response);
}
};
/**
* Helper function to store token into local storage
**/
const saveToken = (json) => {
const token = { ...json };
token.date = Math.floor(Date.now() / 1000);
token.expirationDate = token.date + token.expires_in;
localStorage.setItem('access-token', JSON.stringify(token));
return token;
};
/**
* Login request to Drupal.
*
* Exchange username and password.
* @param username
* @param password
* @returns {Promise}
* Returns a promise that resolves to JSON response from Drupal.
*/
const drupalLogIn = async (username, password) => {
const response = await fetch(loginUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: username,
pass: password,
}),
});
if (response.ok) {
const json = await response.json();
if (json.error) {
throw new Error(json.error.message);
}
return json;
}
};
/**
* Logout request to Drupal.
*
* Logs the user out on Drupal end.
*/
const drupalLogout = async () => {
const oauthToken = await isLoggedIn();
const logoutoken = oauthToken.access_token;
if (logoutoken) {
const res = await fetch(`${process.env.GATSBY_DRUPAL_ROOT}/user/logout?_format=json`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${logoutoken}`,
},
});
if (res.ok) {
return true;
}
}
};
Adding a UI for users to log in
Let's create a new SignIn form component that displays a form users can fill out with a username and password to log in.
import React, { useState } from 'react';
import { handleLogin } from '../../Services/auth';
import Layout from '../Layout';
import { navigate } from '@reach/router';
const SignIn = () => {
const [processing, setProcessing] = useState(false);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState(null);
const handleSubmit = (event) => {
event.preventDefault();
setProcessing(true);
if (!username && !password) {
setProcessing(false);
setError("Incorrect username or password, please try again.");
} else {
handleLogin(username, password).then((res) => {
if (res !== undefined && res) {
localStorage.setItem('username', JSON.stringify(username));
setProcessing(false);
navigate("/", { state: { message: 'You are now logged in' } });
} else {
setProcessing(false);
setError("User name and password don't exist");
}
});
}
};
return (
<Layout>
<div className="login-page-wrapper">
<h3 className="title-28 text-center">Login Form</h3>
{error && <div className="form-error"><p>{error}</p></div>}
<form noValidate className="login" id="logIn">
<fieldset>
<div className="form-element">
<label>Username</label>
<input
className="form-input"
name="username"
type="text"
placeholder="Username"
value={username}
onChange={(event) => setUsername(event.target.value)}
/>
</div>
<div className="form-element">
<label>Password</label>
<input
className="form-input"
name="password"
type="password"
id="passwordSignin"
value={password}
placeholder="Password"
onChange={(event) => setPassword(event.target.value)}
/>
</div>
{processing ? (
<div className="text-center">Loading...</div>
) : (
<button
onClick={handleSubmit}
className="button-black w-full"
type="submit"
>
Login
</button>
)}
</fieldset>
</form>
</div>
</Layout>
);
};
export default SignIn;
That’s all! We have our authentication functionality ready on the site. Now let’s take a look at each function and what it does.
SaveToken(): this function is used to store the token in local storage.
fetchOauthtoken(): This function takes a username and password as parameters, and uses them to make a request to the Drupal /oauth/token endpoint attempting to retrieve a new OAuth token.
fetchSaveOauthToken(): it generates new access token by making use on fetchOauthtoken function. Then stores this token in local storage to access various resources.
drupalLogin(): This function gets invoked when a user tries to log in into the site. It makes a request to /user/login REST resource with username and password. Which returns a user object if the passed credentials are correct else it will return an error message.
handleLogin(): It makes a call to the drupalLogin function to verify that the user exists and provided credentials are right. If a user exists, using fetchSaveOauthToken() generates new access token and stores it in local storage so it can be used for subsequent requests.
isLoggedIn(): This function verifies whether the user accessing the site is authenticated or not. Considering the various scenarios mentioned below it will consider that the user is logged in to the site which means that token is available in local storage.
Scenario 1: When the token is present and is not expired, this function will assume that the current user is an authenticated user.
Scenario 2: When the token is present but is an expired one. In this case, the function will make use of refresh token and makes a request to the OAuth server to regenerate a new access token.
Scenario 3. When the token is present but both access and refresh tokens are expired, in this case, the function will redirect the user to the login page. Since there is no other way to get access tokens from the OAuth server if both the tokens are expired.
Scenario 4: If a token is not present in the local storage, the function will return false as the user is not logged into the site.
drupalLogout(): Verifies if a user is logged in or not, if true then it makes a request to Drupal site using the access token to logout out from the site.
handleLogout(): This function makes use of drupalLogout() function and if it is successful then removes the token stored in local storage.
We have a login page and if we try to log in it works perfectly but do you think there is any problem? If yes, then you are right. If you open the Chrome developer console, go to the Network tab, and inspect your OAuth/token request you will be able to see the client ID and client secret in the request header which is not right this can cause a security issue within your site.
How do we resolve this?
Let’s take a look at the options we have in our hand.
- Netlify function
- Overriding OAuth controller
Netlify function
Write simple functions that automatically become APIs. Netlify deploys the functions you write as full API endpoints and will even run them automatically in response to events (like a form submission or a user login). Functions receive request context or event data and return data back to your frontend.
In our case, we get the necessary information i.e. username and password, pass it to the netlify function. This will pre-define our sensitive data in the netlify function which is client ID and client secret and make authentication requests from it to Drupal.
By doing this, only the response will be visible to the user but what is being sent inside the header of the request won’t be available.
One downside to this is that you have to pay as per your usage to use netlify functions.
Let us know if you would like to hear more about netlify dev setup and netlify function from us. We will get back to you with another post talking about it.
Overriding OAuth controller
When you hit a request to /oauth/token endpoint to get an access token this simple_oauth/src/Controller/Oauth2Token.php controller is being executed.
This controller is responsible for generating a token for you based on the headers you sent in the request.
So we thought, what if we override this controller in a custom module and define our client ID and client secret here and when a request comes in and attach these both to the request. The requirement of the Oauth controller to generate a token is being satisfied and our problem of client secret and ID being exposed gets resolved. In addition, we also save some money for our client.
To override the OAuth controller we will have to let Drupal know that when /oauth/token route is requested rather than using a controller provided by a simple OAuth module use the controller from our custom module.
We will have to alter the route so that we can inform Drupal.
Add a YAML file my_module.services.yml into the module's folder with the following content
services:
my_module.route_subscriber:
class: Drupal\my_module\Routing\RouteSubscriber
tags:
- { name: event_subscriber }
Now we have to create a Routsubscriber class under my_module/src/Routing where we will notify Drupal to use our custom controller when oauth2_token.token route is requested.
get('oauth2_token.token')) {
$route->setDefaults(array(
'_controller' => 'Drupal\my_module\Controller\OauthTokenGenerator::token',
));
}
}
}
The overridden controller will look like as below
grantManager = $grant_manager;
$this->state = $state;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('plugin.manager.oauth2_grant.processor'),
$container->get('state')
);
}
/**
* Processes POST requests to /login/oauth/token.
*/
public function token(ServerRequestInterface $request) {
// Extract the grant type from the request body.
$body = $request->getParsedBody();
// Add client id and client secret to the request body.
$body['client_id'] = $this->state->get('client_id');
$body['client_secret'] = $this->state->get('client_secret');
$newrequest = $request->withParsedBody($body);
$grant_type_id = !empty($body['grant_type']) ? $body['grant_type'] : 'implicit';
$client_drupal_entity = NULL;
$consumer_storage = $this->entityTypeManager()->getStorage('consumer');
$client_drupal_entities = $consumer_storage
->loadByProperties([
'uuid' => $this->state->get('client_id'),
]);
if (empty($client_drupal_entities)) {
return OAuthServerException::invalidClient()
->generateHttpResponse(new Response());
}
$client_drupal_entity = reset($client_drupal_entities);
// Get the auth server object from that uses the League library.
try {
// Respond to the incoming request and fill in the response.
$auth_server = $this->grantManager->getAuthorizationServer($grant_type_id, $client_drupal_entity);
$response = $this->handleToken($newrequest, $auth_server);
}
catch (OAuthServerException $exception) {
watchdog_exception('simple_oauth', $exception);
$response = $exception->generateHttpResponse(new Response());
}
return $response;
}
/**
* Handles the token processing.
*
* @param \Psr\Http\Message\ServerRequestInterface $psr7_request
* The psr request.
* @param \League\OAuth2\Server\AuthorizationServer $auth_server
* The authorization server.
*
* @return \Psr\Http\Message\ResponseInterface
* The response.
*
* @throws \League\OAuth2\Server\Exception\OAuthServerException
*/
protected function handleToken(ServerRequestInterface $psr7_request, AuthorizationServer $auth_server) {
// Instantiate a new PSR-7 response object so the library can fill it.
return $auth_server->respondToAccessTokenRequest($psr7_request, new Response());
}
}
After this, we can remove the following lines from IsLoggedIn and fetchOauthToken function from auth.js file.
formData.append('client_id',
process.env.GATSBY_CLIENT_ID);
formData.append('client_secret',
process.env.GATSBY_CLIENT_SECRET);
Please do let us know if you have any questions or a better approach to achieve this in the comment section.