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.
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.
To follow in this project tutorial:
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.
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:
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:
1 {
2 "identifier": "marynoir",
3 "password": "marynoir"
4 }
The response body should be similar to the following:
1 {
2 "jwt": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwiaWF0IjoxNjYxOTQ0OTQ4LCJleHAiOjE2NjQ1MzY5NDh9.yeYcS8kA_TI9JqVn4Xnqu0lKiT4BUgnM7l8HFKJ56hc",
3 "user": {
4 "id": 1,
5 "username": "marynoir",
6 "email": "mary@gmail.com",
7 "provider": "local",
8 "confirmed": true,
9 "blocked": false,
10 "createdAt": "2022-08-31T11:22:24.613Z",
11 "updatedAt": "2022-08-31T11:22:24.613Z"
12 }
13 }
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.
.env
file, add the following environment variables:1 // .env
2
3 REFRESH_SECRET=strapisecret
4 REFRESH_TOKEN_EXPIRES=2d
5 JWT_SECRET_EXPIRES=360s
6 NODE_ENV=development
src/extensions
folder, create a folder named users-permissions
. Within this folder, create another folder called controllers/validation
. Inside this folder, create a file auth.js
. Add the following code snippets to the auth.js
file:
// ../users-permissions/controllers/validation/auth.js1 'use strict';
2 const { yup, validateYupSchema } = require('@strapi/utils');
3 const callbackBodySchema = yup.object().shape({
4 identifier: yup.string().required(),
5 password: yup.string().required(),
6 });
7 module.exports = {
8 validateCallbackBody: validateYupSchema(callbackBodySchema)
9 };
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.
users-permissions
folder, create a new folder called utils
. Within this folder, create a file called index.js
and add the following code snippets to the file:1 // ../users-permissions/utils/index.js
2
3 'use strict';
4 const getService = name => {
5 return strapi.plugin('users-permissions').service(name);
6 return
7 };
8 module.exports = {
9 getService,
10 };
users-permissions
folder, create a new file called strapi-server.js
. Add the following code snippets to the strapi-server.js
file:1 // ../users-permissions/strapi-server.js
2
3 const utils = require('@strapi/utils');
4 const { getService } = require('../users-permissions/utils');
5 const jwt = require('jsonwebtoken');
6 const _ = require('lodash');
7 const {
8 validateCallbackBody
9 } = require('../users-permissions/controllers/validation/auth');
10
11 const { setMaxListeners } = require('process');
12 const { sanitize } = utils;
13 const { ApplicationError, ValidationError } = utils.errors;
14 const sanitizeUser = (user, ctx) => {
15 const { auth } = ctx.state;
16 const userSchema = strapi.getModel('plugin::users-permissions.user');
17 return sanitize.contentAPI.output(user, userSchema, { auth });
18 };
19
20 module.exports = (plugin) => {
21 return plugin
22 }
@strapi/plugin-users-permissions/server/controllers/auth.js
.Add the following snippets within the module.export function:
1 // ../users-permissions/strapi-server.js
2
3 module.exports = (plugin) => {
4 plugin.controllers.auth.callback = async (ctx) => {
5 const provider = ctx.params.provider || 'local';
6 const params = ctx.request.body;
7 const store = strapi.store({ type: 'plugin', name: 'users-permissions' });
8 const grantSettings = await store.get({ key: 'grant' });
9 const grantProvider = provider === 'local' ? 'email' : provider;
10 if (!_.get(grantSettings, [grantProvider, 'enabled'])) {
11 throw new ApplicationError('This provider is disabled');
12 }
13 if (provider === 'local') {
14 await validateCallbackBody(params);
15 const { identifier } = params;
16 // Check if the user exists.
17 const user = await strapi.query('plugin::users-permissions.user').findOne({
18 where: {
19 provider,
20 $or: [{ email: identifier.toLowerCase() }, { username: identifier }],
21 },
22 });
23 if (!user) {
24 throw new ValidationError('Invalid identifier or password');
25 }
26 if (!user.password) {
27 throw new ValidationError('Invalid identifier or password');
28 }
29 const validPassword = await getService('user').validatePassword(
30 params.password,
31 user.password
32 );
33 if (!validPassword) {
34 throw new ValidationError('Invalid identifier or password');
35 } else {
36 ctx.send({
37 jwt: getService('jwt').issue({
38 id: user.id,
39 }),
40 user: await sanitizeUser(user, ctx),
41 });
42 }
43 const advancedSettings = await store.get({ key: 'advanced' });
44 const requiresConfirmation = _.get(advancedSettings, 'email_confirmation');
45 if (requiresConfirmation && user.confirmed !== true) {
46 throw new ApplicationError('Your account email is not confirmed');
47 }
48 if (user.blocked === true) {
49 throw new ApplicationError('Your account has been blocked by an administrator');
50 }
51 return ctx.send({
52 jwt: getService('jwt').issue({ id: user.id }),
53 user: await sanitizeUser(user, ctx),
54 });
55 }
56 // Connect the user with a third-party provider.
57 try {
58 const user = await getService('providers').connect(provider, ctx.query);
59 return ctx.send({
60 jwt: getService('jwt').issue({ id: user.id }),
61 user: await sanitizeUser(user, ctx),
62 });
63 } catch (error) {
64 throw new ApplicationError(error.message);
65 }
66 }
67 return plugin
68 }
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.
sanitizeUser
function in the strapi-server.js
file:1 // ../users-permissions/strapi-server.js
2 const sanitizeUser = (user, ctx) => {
3 ...
4 };
5
6 // issue a JWT
7 const issueJWT = (payload, jwtOptions = {}) => {
8 _.defaults(jwtOptions, strapi.config.get('plugin.users-permissions.jwt'));
9 return jwt.sign(
10 _.clone(payload.toJSON ? payload.toJSON() : payload),
11 strapi.config.get('plugin.users-permissions.jwtSecret'),
12 jwtOptions
13 );
14 }
15
16 // verify the refreshToken by using the REFRESH_SECRET from the .env
17 const verifyRefreshToken = (token) => {
18 return new Promise(function (resolve, reject) {
19 jwt.verify(token, process.env.REFRESH_SECRET, {}, function (
20 err,
21 tokenPayload = {}
22 ) {
23 if (err) {
24 return reject(new Error('Invalid token.'));
25 }
26 resolve(tokenPayload);
27 });
28 });
29 }
30
31 // issue a Refresh token
32 const issueRefreshToken = (payload, jwtOptions = {}) => {
33 _.defaults(jwtOptions, strapi.config.get('plugin.users-permissions.jwt'));
34 return jwt.sign(
35 _.clone(payload.toJSON ? payload.toJSON() : payload),
36 process.env.REFRESH_SECRET,
37 { expiresIn: process.env.REFRESH_TOKEN_EXPIRES }
38 );
39 }
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.
1 // ../users-permissions/strapi-server.js
2
3 if (!validPassword) {
4 throw new ValidationError('Invalid identifier or password');
5 } else {
6 ctx.cookies.set("refreshToken", issueRefreshToken({ id: user.id }), {
7 httpOnly: true,
8 secure: false,
9 signed: true,
10 overwrite: true,
11 });
12 ctx.send({
13 status: 'Authenticated',
14 jwt: issueJWT({ id: user.id }, { expiresIn: process.env.JWT_SECRET_EXPIRES }),
15 user: await sanitizeUser(user, ctx),
16 });
17 }
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 the refreshToken
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:
1 // ../users-permissions/strapi-server.js
2
3 plugin.controllers.auth.callback = async (ctx) => {
4 ......
5 }
6 plugin.controllers.auth['refreshToken'] = async (ctx) => {
7 const store = await strapi.store({ type: 'plugin', name: 'users-permissions' });
8 const { refreshToken } = ctx.request.body;
9 const refreshCookie = ctx.cookies.get("refreshToken")
10
11 if (!refreshCookie && !refreshToken) {
12 return ctx.badRequest("No Authorization");
13 }
14 if (!refreshCookie) {
15 if (refreshToken) {
16 refreshCookie = refreshToken
17 }
18 else {
19 return ctx.badRequest("No Authorization");
20 }
21 }
22 try {
23 const obj = await verifyRefreshToken(refreshCookie);
24 const user = await strapi.query('plugin::users-permissions.user').findOne({ where: { id: obj.id } });
25 if (!user) {
26 throw new ValidationError('Invalid identifier or password');
27 }
28 if (
29 _.get(await store.get({ key: 'advanced' }), 'email_confirmation') &&
30 user.confirmed !== true
31 ) {
32 throw new ApplicationError('Your account email is not confirmed');
33 }
34 if (user.blocked === true) {
35 throw new ApplicationError('Your account has been blocked by an administrator');
36 }
37 const refreshToken = issueRefreshToken({ id: user.id })
38 ctx.cookies.set("refreshToken", refreshToken, {
39 httpOnly: true,
40 secure: false,
41 signed: true,
42 overwrite: true,
43 });
44 ctx.send({
45 jwt: issueJWT({ id: obj.id }, { expiresIn: process.env.JWT_SECRET_EXPIRES }),
46 refreshToken: refreshToken,
47 });
48 }
49 catch (err) {
50 return ctx.badRequest(err.toString());
51 }
52 }
The snippets above get the refreshToken
from the cookies and saves it as refreshCookie
. If the refreshCookie
is not found, it returns an error of No Authorization
. If the refreshCookie
is found, it gets verified using the verifyRefreshToken()
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 the issueJWT()
and assigns it to the user.
1 // ../users-permissions/strapi-server.js
2
3 plugin.controllers.auth.callback = async (ctx) => {
4 ......
5 }
6 plugin.controllers.auth['refreshToken']= async (ctx) => {
7 ......
8 }
9 plugin.routes['content-api'].routes.push({
10 method: 'POST',
11 path: '/token/refresh',
12 handler: 'auth.refreshToken',
13 config: {
14 policies: [],
15 prefix: '',
16 }
17 });
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:
1 // config/plugins.js
2
3 module.exports = ({ env }) => ({
4 'users-permissions': {
5 enabled: true,
6 config: {
7 jwt: {
8 expiresIn: '15m',
9 },
10 },
11 },
12 });
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:
npm install -g @vue/cli
# OR
yarn global add @vue/cli
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:
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.
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:
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
1 //Login.vue
2
3 <template>
4 <div class="login">
5 <input
6 type="text"
7 v-model="identifier"
8 placeholder="Enter username/email"
9 />
10 <input
11 type="text"
12 v-model="password"
13 placeholder="Enter password"
14 />
15 <div>
16 <button @click="login">LOGIN</button>
17 </div>
18 </div>
19 </template>
20 <script>
21 import axios from "axios";
22 export default {
23 name: "login",
24 data() {
25 return {
26 identifier: "",
27 password: "",
28 };
29 },
30 methods: {
31 async login() {
32 try {
33 const data = {
34 identifier: this.identifier,
35 password: this.password,
36 };
37 const options = {
38 credentials: "include",
39 withCredentials: true,
40 };
41 const res = await axios.post(
42 "http://localhost:1337/api/auth/local",
43 data,
44 options
45 );
46 localStorage.setItem("token", res.data.jwt);
47 localStorage.setItem("user", JSON.stringify(res.data.user));
48 if (res.status === 200) {
49 this.$router.push("/dashboard");
50 }
51 } catch (err) {
52 console.log(err);
53 }
54 },
55 },
56 };
57 </script>
58 <style scoped>
59 .login {
60 display: flex;
61 flex-direction: column;
62 padding: 35px;
63 background: #e8e8e8;
64 }
65 input {
66 padding: 15px;
67 margin: 5px 0;
68 border-radius: 2px;
69 border: 1px solid white;
70 }
71 button {
72 background: #36865d;
73 color: white;
74 border: none;
75 padding: 15px 25px;
76 width: 100%;
77 margin-top: 5px;
78 font-weight: bolder;
79 }
80 button:hover {
81 background: #4cab7a;
82 }
83 </style>
In the Dashboard.vue
file, add the following contents:
1 <template>
2 <div>
3 <h1>User Dashboard</h1>
4 <ul>
5 <li><b>Username:</b> {{ getUser.username }}</li>
6 <li><b>Email:</b> {{ getUser.email }}</li>
7 <li><b>Is User Confirmed:</b> {{ getUser.confirmed }}</li>
8 <li><b>Is User Blocked: </b>{{ getUser.blocked }}</li>
9 <li><b>Provider:</b> {{ getUser.provider }}</li>
10 </ul>
11 </div>
12 </template>
13 <script>
14 export default {
15 name: "dashboard",
16 computed: {
17 getUser() {
18 let jwtPayload = JSON.parse(localStorage.getItem("user"));
19 return jwtPayload;
20 },
21 },
22 };
23 </script>
24 <style scoped>
25 ul {
26 list-style-type: none !important;
27 padding: 0;
28 }
29 </style>
In the views folder, create a LoginView.vue
file and copy the following content:
1 <template>
2 <div class="container">
3 <Login />
4 </div>
5 </template>
6 <script>
7 import Login from "@/components/Login.vue";
8 export default {
9 name: "LoginView",
10 components: {
11 Login,
12 },
13 };
14 </script>
15 <style scoped>
16 .container {
17 margin: 50px auto;
18 width: 400px;
19 }
20 </style>
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:
1 <template>
2 <div class="modal-overlay" @click="$emit('close-modal')">
3 <div class="modal" @click.stop>
4 <h6>Token Expired!</h6>
5 <p>You are unable to view your dashboard.</p>
6 <p>Do you want to refresh your token?</p>
7 <button id="no-button" @click="handleNoButton">No</button>
8 <button id="yes-button" @click="getRefreshToken">Yes</button>
9 </div>
10 </div>
11 </template>
12 <script>
13 import axios from "axios";
14 export default {
15 methods: {
16 handleNoButton() {
17 this.$router.push("/");
18 },
19 async getRefreshToken() {
20 try {
21 const data = {
22 refreshToken: localStorage.getItem("token"),
23 };
24 const options = {
25 "Access-Control-Allow-Credentials": true,
26 withCredentials: true,
27 };
28 const res = await axios.post(
29 "http://localhost:1337/api/token/refresh",
30 data,
31 options
32 );
33 localStorage.setItem("token", res.data.jwt);
34 this.$emit("close-modal");
35 } catch (err) {
36 console.log(err);
37 }
38 },
39 },
40 };
41 </script>
42 <style scoped>
43 .modal-overlay {
44 position: fixed;
45 top: 0;
46 bottom: 0;
47 left: 0;
48 right: 0;
49 display: flex;
50 justify-content: center;
51 background-color: #000000da;
52 }
53 .modal {
54 text-align: center;
55 background-color: white;
56 height: 200px;
57 width: 400px;
58 margin-top: 10%;
59 padding: 60px 0;
60 border-radius: 20px;
61 }
62 .close {
63 margin: 10% 0 0 16px;
64 cursor: pointer;
65 }
66 .close-img {
67 width: 25px;
68 }
69 .check {
70 width: 150px;
71 }
72 h6 {
73 font-weight: 500;
74 font-size: 28px;
75 margin: 20px 0;
76 }
77 p {
78 font-size: 16px;
79 }
80 button {
81 width: 100px;
82 height: 40px;
83 color: white;
84 font-size: 14px;
85 border-radius: 12px;
86 margin-top: 10px;
87 margin-right: 10px;
88 border: 1px solid #fff;
89 }
90 #yes-button {
91 background-color: #4cab7a;
92 }
93 #no-button {
94 background-color: #ba0000da;
95 }
96 </style>
Refactor the index.js
file to suit the changes done so far. It should be similar to the following:
1 import { createRouter, createWebHistory } from "vue-router";
2 import LoginView from "../views/LoginView.vue";
3 const routes = [
4 {
5 path: "/",
6 name: "login",
7 component: LoginView,
8 },
9 {
10 path: "/dashboard",
11 name: "dashboard",
12 // route level code-splitting
13 // this generates a separate chunk (about.[hash].js) for this route
14 // which is lazy-loaded when the route is visited.
15 component: () =>
16 import(/* webpackChunkName: "about" */ "../views/Dashboard.vue"),
17 },
18 ];
19 const router = createRouter({
20 history: createWebHistory(process.env.BASE_URL),
21 routes,
22 });
23 export default router;
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.
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.
Backend Developer 👩💻 | Technical Writer ✍️