Author: Mary Okosun
Every user-centric backend service, like Strapi, depends on authentication and user management because different users may have varying roles and permissions. Strapi is an open-source and headless content management system (CMS) that gives developers the freedom to use their favourite tools and frameworks during development.
Why Authenticate Your Strapi Application?
By allowing only authenticated users (or processes) to access their protected resources, authentication enables organizations to maintain the security of their networks. Authentication is the process of validating that a person or entity is, in fact, who or what it claims to be.
Prerequisites
To follow in this project tutorial:
- You need to download and install Node.js (versions 16.x are recommended by Strapi).
- You need to have npm (version 6 only) or yarn to run the CLI installation scripts.
- You need to have a basic knowledge of JavaScript and Vue.js.
Goals
This tutorial gives an approach on how to add a user as a content-type, how to test an authenticated user and why you need to authenticate a user. You will learn how to implement a refresh token for an authenticated user.
You will also learn how to create a mini-app using Vue.js, a JavaScript framework, and Strapi that showcases how an authenticated user can have access to the dashboard by creating a refresh token for this user.
At the end of this tutorial, you should know how to create a refresh token feature in your Strapi application.
Backend Implementation
You will be using Strapi for the backend implementation. This would be done in multiple steps:
1. Scaffolding a Strapi Project
You will be running the Strapi project locally and using the Strapi CLI (Command Line Interface) installation scripts as it is the fastest way to get Strapi running locally. A new Strapi instance, strapi-refresh-token-backend, will be created in a specified directory on your machine.
npx create-strapi-app strapi-refresh-token-backend --quickstart
#OR
yarn create-strapi-app strapi-refresh-token-backend --quickstart
In the directory you specified, the code snippets above will create a new Strapi project. It should automatically open http://localhost:1337/admin/auth/register-admin
on your browser. If not, you can start the admin panel on your browser by executing the following command in your terminal.
npm run develop
# OR
yarn run develop
To register as the system's new admin, a new window would open.
You can complete the form and press the submit button. You will then be redirected to the admin panel.
2. Create a New User
On the Strapi admin dashboard, navigate to the content manager and in the user collection type, you will create a new entry for a user.
Fill in the following details on the form:
- username: marynoir
- email: mary@gmail.com
- password: *****************
Click on the Save button to save your new user.
3. Testing an Authenticated User in an API Client.
Here, you will test your new user on Postman. You can use an API client of your choice; however, for this tutorial, Postman will be used to test the API endpoints. Navigate to your Postman client and send a POST request to http://localhost:1337/api/auth/local
.
The response body should be similar to the following:
{
"identifier": "marynoir",
"password": "marynoir"
}
- Identifier: This can either be the users’ email or username.
- Password: This is the password of the user
The response body should be similar to the following:
{
"jwt": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwiaWF0IjoxNjYxOTQ0OTQ4LCJleHAiOjE2NjQ1MzY5NDh9.yeYcS8kA_TI9JqVn4Xnqu0lKiT4BUgnM7l8HFKJ56hc",
"user": {
"id": 1,
"username": "marynoir",
"email": "mary@gmail.com",
"provider": "local",
"confirmed": true,
"blocked": false,
"createdAt": "2022-08-31T11:22:24.613Z",
"updatedAt": "2022-08-31T11:22:24.613Z"
}
}
4. Introduction to the Refresh Token Feature
From the response gotten above, you can see the jwt
in the response body. A jwt token may be used for making permission-restricted API requests. In this tutorial, the jwt token will be used to give the user access to the application and when the token is expired, the users’ access gets restricted.
The user will be mandated to request for another jwt token using the refresh token feature in order to have access to the application again. You will be creating a refresh token by configuring some folders and files in the Strapi directory.
-
Step 1: In the
.env
file, add the following environment variables:
// .env
REFRESH_SECRET=strapisecret
REFRESH_TOKEN_EXPIRES=2d
JWT_SECRET_EXPIRES=360s
NODE_ENV=development
-
Step 2: In the
src/extensions
folder, create a folder namedusers-permissions
. Within this folder, create another folder calledcontrollers/validation
. Inside this folder, create a fileauth.js
. Add the following code snippets to theauth.js
file: // ../users-permissions/controllers/validation/auth.js
'use strict';
const { yup, validateYupSchema } = require('@strapi/utils');
const callbackBodySchema = yup.object().shape({
identifier: yup.string().required(),
password: yup.string().required(),
});
module.exports = {
validateCallbackBody: validateYupSchema(callbackBodySchema)
};
The code above creates a schema, callbackBodySchema
, that requires an identifier and password for the login authentication. This authentication is similar to the Strapi login system.
-
Step 3: In the
users-permissions
folder, create a new folder calledutils
. Within this folder, create a file calledindex.js
and add the following code snippets to the file:
// ../users-permissions/utils/index.js
'use strict';
const getService = name => {
return strapi.plugin('users-permissions').service(name);
return
};
module.exports = {
getService,
};
-
Step 4: In the
users-permissions
folder, create a new file calledstrapi-server.js
. Add the following code snippets to thestrapi-server.js
file:
// ../users-permissions/strapi-server.js
const utils = require('@strapi/utils');
const { getService } = require('../users-permissions/utils');
const jwt = require('jsonwebtoken');
const _ = require('lodash');
const {
validateCallbackBody
} = require('../users-permissions/controllers/validation/auth');
const { setMaxListeners } = require('process');
const { sanitize } = utils;
const { ApplicationError, ValidationError } = utils.errors;
const sanitizeUser = (user, ctx) => {
const { auth } = ctx.state;
const userSchema = strapi.getModel('plugin::users-permissions.user');
return sanitize.contentAPI.output(user, userSchema, { auth });
};
module.exports = (plugin) => {
return plugin
}
-
Step 5: In the next step, you will be using the existing login procedure from Strapi, which can be found in the node-modules folder under
@strapi/plugin-users-permissions/server/controllers/auth.js
.
Add the following snippets within the module.export function:
// ../users-permissions/strapi-server.js
module.exports = (plugin) => {
plugin.controllers.auth.callback = async (ctx) => {
const provider = ctx.params.provider || 'local';
const params = ctx.request.body;
const store = strapi.store({ type: 'plugin', name: 'users-permissions' });
const grantSettings = await store.get({ key: 'grant' });
const grantProvider = provider === 'local' ? 'email' : provider;
if (!_.get(grantSettings, [grantProvider, 'enabled'])) {
throw new ApplicationError('This provider is disabled');
}
if (provider === 'local') {
await validateCallbackBody(params);
const { identifier } = params;
// Check if the user exists.
const user = await strapi.query('plugin::users-permissions.user').findOne({
where: {
provider,
$or: [{ email: identifier.toLowerCase() }, { username: identifier }],
},
});
if (!user) {
throw new ValidationError('Invalid identifier or password');
}
if (!user.password) {
throw new ValidationError('Invalid identifier or password');
}
const validPassword = await getService('user').validatePassword(
params.password,
user.password
);
if (!validPassword) {
throw new ValidationError('Invalid identifier or password');
} else {
ctx.send({
jwt: getService('jwt').issue({
id: user.id,
}),
user: await sanitizeUser(user, ctx),
});
}
const advancedSettings = await store.get({ key: 'advanced' });
const requiresConfirmation = _.get(advancedSettings, 'email_confirmation');
if (requiresConfirmation && user.confirmed !== true) {
throw new ApplicationError('Your account email is not confirmed');
}
if (user.blocked === true) {
throw new ApplicationError('Your account has been blocked by an administrator');
}
return ctx.send({
jwt: getService('jwt').issue({ id: user.id }),
user: await sanitizeUser(user, ctx),
});
}
// Connect the user with a third-party provider.
try {
const user = await getService('providers').connect(provider, ctx.query);
return ctx.send({
jwt: getService('jwt').issue({ id: user.id }),
user: await sanitizeUser(user, ctx),
});
} catch (error) {
throw new ApplicationError(error.message);
}
}
return plugin
}
The code above checks if a Strapi provider such as Google or Auth0 is used for login authentication. In this tutorial, you are not using an external provider, so the provider variable would be local.
If the provider is local, it would confirm that the user exists using the identifier field. If the user exists, it would check if the password in the request body is the same as the password used in registration. If the password matches, the user get logged in.
-
Step 6: You would need to have a refresh cookie that will be used alongside the jwt token in order to generate a new refresh token. You need to add the following code snippets just below the
sanitizeUser
function in thestrapi-server.js
file:
// ../users-permissions/strapi-server.js
const sanitizeUser = (user, ctx) => {
...
};
// issue a JWT
const issueJWT = (payload, jwtOptions = {}) => {
_.defaults(jwtOptions, strapi.config.get('plugin.users-permissions.jwt'));
return jwt.sign(
_.clone(payload.toJSON ? payload.toJSON() : payload),
strapi.config.get('plugin.users-permissions.jwtSecret'),
jwtOptions
);
}
// verify the refreshToken by using the REFRESH_SECRET from the .env
const verifyRefreshToken = (token) => {
return new Promise(function (resolve, reject) {
jwt.verify(token, process.env.REFRESH_SECRET, {}, function (
err,
tokenPayload = {}
) {
if (err) {
return reject(new Error('Invalid token.'));
}
resolve(tokenPayload);
});
});
}
// issue a Refresh token
const issueRefreshToken = (payload, jwtOptions = {}) => {
_.defaults(jwtOptions, strapi.config.get('plugin.users-permissions.jwt'));
return jwt.sign(
_.clone(payload.toJSON ? payload.toJSON() : payload),
process.env.REFRESH_SECRET,
{ expiresIn: process.env.REFRESH_TOKEN_EXPIRES }
);
}
In Line 7-14, the function issueJWT
creates a new jwt token which will be used when requesting for a refresh token.
In Line 17-29, the verifyRefreshToken
function is used to verify that the refresh token passed in the request body while requesting for a new jwt is actually valid. It uses the jwt.verify()
function to verify that the token is valid with the REFRESH_SECRETin the .env file. If this is valid, it returns a new token for the user, else it returns an error
Invalid token`.
In Line 32-39, the issueRefreshToken
function is used to create a new refresh token that will be stored in the cookie.
Now that you can create a refresh token, you need to be able to store this refresh token in the cookies. Replace the content of the isValidPassword
check with the following code snippets. The snippets sets the refresh token with the name refreshToken as the cookie name if the password is valid.
`js
// ../users-permissions/strapi-server.js
if (!validPassword) {
throw new ValidationError('Invalid identifier or password');
} else {
ctx.cookies.set("refreshToken", issueRefreshToken({ id: user.id }), {
httpOnly: true,
secure: false,
signed: true,
overwrite: true,
});
ctx.send({
status: 'Authenticated',
jwt: issueJWT({ id: user.id }, { expiresIn: process.env.JWT_SECRET_EXPIRES }),
user: await sanitizeUser(user, ctx),
});
}
`
Step 7: Let's test what you have done so far. At this stage, you have been able to refactor a login system for our application. If a registered user logs in, the user should have a jwt token and also a refresh token saved in the cookies. You can send a POST request to the login api route
http://localhost:1337/api/auth/local
and see therefreshToken
saved in the cookies.
-
Step 8: The next step is to create a function that would take in the refresh token and issue a new jwt for the user. Add the code snippets below the
plugin.controllers.auth.callback
function:
`js
// ../users-permissions/strapi-server.jsplugin.controllers.auth.callback = async (ctx) => { ...... } plugin.controllers.auth['refreshToken'] = async (ctx) => { const store = await strapi.store({ type: 'plugin', name: 'users-permissions' }); const { refreshToken } = ctx.request.body; const refreshCookie = ctx.cookies.get("refreshToken") if (!refreshCookie && !refreshToken) { return ctx.badRequest("No Authorization"); } if (!refreshCookie) { if (refreshToken) { refreshCookie = refreshToken } else { return ctx.badRequest("No Authorization"); } } try { const obj = await verifyRefreshToken(refreshCookie); const user = await strapi.query('plugin::users-permissions.user').findOne({ where: { id: obj.id } }); if (!user) { throw new ValidationError('Invalid identifier or password'); } if ( _.get(await store.get({ key: 'advanced' }), 'email_confirmation') && user.confirmed !== true ) { throw new ApplicationError('Your account email is not confirmed'); } if (user.blocked === true) { throw new ApplicationError('Your account has been blocked by an administrator'); } const refreshToken = issueRefreshToken({ id: user.id }) ctx.cookies.set("refreshToken", refreshToken, { httpOnly: true, secure: false, signed: true, overwrite: true, }); ctx.send({ jwt: issueJWT({ id: obj.id }, { expiresIn: process.env.JWT_SECRET_EXPIRES }), refreshToken: refreshToken, }); } catch (err) { return ctx.badRequest(err.toString()); } }
`
The snippets above get therefreshToken
from the cookies and saves it asrefreshCookie
. If therefreshCookie
is not found, it returns an error ofNo Authorization
. If therefreshCookie
is found, it gets verified using theverifyRefreshToken()
created earlier. Checks such as if the user exist, if the users’ email is not confirmed and if the users’ account has been blocked are made. It creates a new jwt using theissueJWT()
and assigns it to the user. Step 9: The final step is to create an api route to refresh our jwt and request for a new token. Add the following code before the return plugin statement:
`js
// ../users-permissions/strapi-server.js
plugin.controllers.auth.callback = async (ctx) => {
......
}
plugin.controllers.auth['refreshToken']= async (ctx) => {
......
}
plugin.routes['content-api'].routes.push({
method: 'POST',
path: '/token/refresh',
handler: 'auth.refreshToken',
config: {
policies: [],
prefix: '',
}
});
`
Let us test what you have done so far. You can send a POST request to the refresh token api route http://localhost:1337/api/token/refresh and see the new jwt and refreshToken
in the response body.
By default, Strapi gives a validation token (jwt) valid for 30 days. For the purpose of this project, you would manually configure the expiration date so that our application can be tested faster. Create a plugins.js
file in the config
folder and add the following code snippets:
`js
// config/plugins.js
module.exports = ({ env }) => ({
'users-permissions': {
enabled: true,
config: {
jwt: {
expiresIn: '15m',
},
},
},
});
`
Frontend Implementation
You have built the backend services and the refresh token feature configured; the next step is to create the frontend application to consume the Strapi APIs with Vue.js. The frontend application will be a mini-app that has two (2) screens: the login and dashboard interface. A registered user can log in and be directed to the dashboard screen. When the token of such user expires, the user will be prompted to request for another token. If the user does not request for a new token, they will be logged off the application.
According to the documentation, Vue.js is a JavaScript framework for building user interfaces. It builds on top of standard HTML, CSS, and JavaScript, and provides a declarative and component-based programming model that helps you efficiently develop user interfaces, be it simple or complex.
To create a new Vue.js project, follow these steps to get started:
- Navigate to a directory and install the Vue.js package using the command:
bash npm install -g @vue/cli # OR yarn global add @vue/cli
- Create a new project using the command:
bash vue create strapi-refresh-token-frontend
You will be prompted to pick a preset. Select "manually select features" to pick the features we need. You would select Vuex
, Router
, and Lint/Formatter
. Vuex is a state management library for Vue applications; Router allows to change the URL without reloading the page and Lint/Formatter properly formats the codes.
After successfully creating your project, navigate to the folder directory and run the application:
bash
cd strapi-refresh-token-frontend
npm run serve
#OR
yarn run serve
The URL http://localhost:8080/
should open your Vue.js application in your browser.
Dependency Installation
You need to install some dependencies such as axios
. Axios is the package dependency that will be used to make the call to the Strapi backend APIs:
bash
npm install axios
Firstly, delete the HelloWorld.vue
file in the components folder, the HomeView.vue
, and AboutView.vue
files in the views folder as these files are redundant in this project.
Create two new files Login.vue
and Dashboard.vue
in the components folder and copy the following contents into the login.vue
`html
//Login.vue
<template>
<div class="login">
<input
type="text"
v-model="identifier"
placeholder="Enter username/email"
/>
<input
type="text"
v-model="password"
placeholder="Enter password"
/>
<div>
<button @click="login">LOGIN</button>
</div>
</div>
</template>
<script>
import axios from "axios";
export default {
name: "login",
data() {
return {
identifier: "",
password: "",
};
},
methods: {
async login() {
try {
const data = {
identifier: this.identifier,
password: this.password,
};
const options = {
credentials: "include",
withCredentials: true,
};
const res = await axios.post(
"http://localhost:1337/api/auth/local",
data,
options
);
localStorage.setItem("token", res.data.jwt);
localStorage.setItem("user", JSON.stringify(res.data.user));
if (res.status === 200) {
this.$router.push("/dashboard");
}
} catch (err) {
console.log(err);
}
},
},
};
</script>
<style scoped>
.login {
display: flex;
flex-direction: column;
padding: 35px;
background: #e8e8e8;
}
input {
padding: 15px;
margin: 5px 0;
border-radius: 2px;
border: 1px solid white;
}
button {
background: #36865d;
color: white;
border: none;
padding: 15px 25px;
width: 100%;
margin-top: 5px;
font-weight: bolder;
}
button:hover {
background: #4cab7a;
}
</style>
`
html
In the `Dashboard.vue` file, add the following contents:
`
User Dashboard
- Username: {{ getUser.username }}
- Email: {{ getUser.email }}
- Is User Confirmed: {{ getUser.confirmed }}
- Is User Blocked: {{ getUser.blocked }}
- Provider: {{ getUser.provider }}
<br> export default {<br> name: "dashboard",<br> computed: {<br> getUser() {<br> let jwtPayload = JSON.parse(localStorage.getItem("user"));<br> return jwtPayload;<br> },<br> },<br> };<br>
<br> ul {<br> list-style-type: none !important;<br> padding: 0;<br> }<br>
`
In the views folder, create a `LoginView.vue` file and copy the following content:
`
html<br> import Login from "@/components/Login.vue";<br> export default {<br> name: "LoginView",<br> components: {<br> Login,<br> },<br> };<br>
<br> .container {<br> margin: 50px auto;<br> width: 400px;<br> }<br>
`
A modal will be built that will pop up when the token is expired and requests the user to choose if the application should refresh the token or not. In the components folder, create a `Modal.vue` file and copy the following code snippets:
`
htmlToken Expired!
You are unable to view your dashboard.
Do you want to refresh your token?
No
Yes
<br> import axios from "axios";<br> export default {<br> methods: {<br> handleNoButton() {<br> this.$router.push("/");<br> },<br> async getRefreshToken() {<br> try {<br> const data = {<br> refreshToken: localStorage.getItem("token"),<br> };<br> const options = {<br> "Access-Control-Allow-Credentials": true,<br> withCredentials: true,<br> };<br> const res = await axios.post(<br> "<a href="http://localhost:1337/api/token/refresh">http://localhost:1337/api/token/refresh</a>",<br> data,<br> options<br> );<br> localStorage.setItem("token", res.data.jwt);<br> this.$emit("close-modal");<br> } catch (err) {<br> console.log(err);<br> }<br> },<br> },<br> };<br>
<br> .modal-overlay {<br> position: fixed;<br> top: 0;<br> bottom: 0;<br> left: 0;<br> right: 0;<br> display: flex;<br> justify-content: center;<br> background-color: #000000da;<br> }<br> .modal {<br> text-align: center;<br> background-color: white;<br> height: 200px;<br> width: 400px;<br> margin-top: 10%;<br> padding: 60px 0;<br> border-radius: 20px;<br> }<br> .close {<br> margin: 10% 0 0 16px;<br> cursor: pointer;<br> }<br> .close-img {<br> width: 25px;<br> }<br> .check {<br> width: 150px;<br> }<br> h6 {<br> font-weight: 500;<br> font-size: 28px;<br> margin: 20px 0;<br> }<br> p {<br> font-size: 16px;<br> }<br> button {<br> width: 100px;<br> height: 40px;<br> color: white;<br> font-size: 14px;<br> border-radius: 12px;<br> margin-top: 10px;<br> margin-right: 10px;<br> border: 1px solid #fff;<br> }<br> #yes-button {<br> background-color: #4cab7a;<br> }<br> #no-button {<br> background-color: #ba0000da;<br> }<br>
`
Refactor the `index.js` file to suit the changes done so far. It should be similar to the following:
`
jsimport { createRouter, createWebHistory } from "vue-router";
import LoginView from "../views/LoginView.vue";
const routes = [
{
path: "/",
name: "login",
component: LoginView,
},
{
path: "/dashboard",
name: "dashboard",
// route level code-splitting
// this generates a separate chunk (about.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () =>
import(/* webpackChunkName: "about" */ "../views/Dashboard.vue"),
},
];
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes,
});
export default router;
`
Testing the Application
Now you can test the frontend implementation of the application. Go to http://localhost:8080/
and you should see the login page
Enter the username and password that was created for the user on the Strapi backend entry. If the details are correct, you would be routed to the dashboard page.
When the token expires, the modal pops up.
If you click on the No button, the app routes you back to the login page so you can log in again to regenerate a new token. If you click on the Yes button, the app makes an API call to the refresh token API and automatically reissues a new jwt and refresh token. This allows you to continue browsing the application without having to log in each time.
To ascertain that this really works, you can check the refreshToken
stored in the cookie and the token stored in the localstorage
. With each click on the Yes button, a new refreshToken is generated.
Conclusion
In this tutorial, you learned how to add and authenticate a user using jwt. A demonstration on how to implement a refresh token for an authenticated user using the jwt from a user login activity was done.
You can download the source code for the frontend and backend implementation from Github.