Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Add refresh token support to Auth flow #2704

Closed
wants to merge 9 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
62 changes: 61 additions & 1 deletion docs/3.x.x/guides/authentication.md
Expand Up @@ -54,6 +54,7 @@ axios
console.log('Well done!');
console.log('User profile', response.data.user);
console.log('User token', response.data.jwt);
console.log('User refresh token', response.data.refreshToken);
})
.catch(error => {
// Handle error.
Expand Down Expand Up @@ -83,6 +84,7 @@ axios
console.log('Well done!');
console.log('User profile', response.data.user);
console.log('User token', response.data.jwt);
console.log('User refresh token', response.data.refreshToken);
})
.catch(error => {
// Handle error.
Expand Down Expand Up @@ -117,9 +119,67 @@ Response payload:
```json
{
"user": {},
"jwt": ""
"jwt": "",
"refreshToken": ""
}
```
## Refresh tokens
The refresh token is used to generate a new jwt access token. Once the access token expires, the user would have to authenticate again to obtain an access token. With refresh token, this step can be skipped and with a request to `/auth/token` API get a new jwt access token that allows the user to continue accessing the resources.

### Get a new access token with refresh token
- The `refreshToken` variable is the `data.refreshToken` received when login in or registering.

```js
import axios from 'axios';

const refreshToken = 'YOUR_TOKEN_HERE';

// Request API.
axios
.post('http://localhost:1337/auth/token', {
identifier: 'user@strapi.io',
refreshToken: `${refreshToken}`
})
.then(response => {
// Handle success.
console.log('Well done!');
console.log('User profile', response.data.user);
console.log('User token', response.data.jwt);
})
.catch(error => {
// Handle error.
console.log('An error occurred:', error);
});
```
### Revoke a refresh token
- The `token` variable is the `data.jwt` received when login in or registering.
- The `refreshToken` variable is one of the refresh tokens in `data.user.tokens` which received when login/register or one of the refresh tokens in `data.tokens` which is received in the response of `/user/me` api.

```js
import axios from 'axios';

const refreshToken = 'YOUR_TOKEN_HERE';

// Request API.
axios
.post('http://localhost:1337/auth/token/revoke', {
headers: {
Authorization: `Bearer ${token}`
},
data: {
refreshToken: `${refreshToken}`
}
})
.then(response => {
// Handle success.
console.log('Well done!');
})
.catch(error => {
// Handle error.
console.log('An error occurred:', error);
});
```


## Forgotten password

Expand Down
30 changes: 29 additions & 1 deletion packages/strapi-plugin-users-permissions/config/routes.json
Expand Up @@ -285,6 +285,34 @@
}
}
},
{
"method": "POST",
"path": "/auth/token",
"handler": "Auth.token",
"config": {
"policies": ["plugins.users-permissions.ratelimit"],
"prefix": "",
"description": "Generate a new jwt using refresh token",
"tag": {
"plugin": "users-permissions",
"name": "User"
}
}
},
{
"method": "POST",
"path": "/auth/token/revoke",
"handler": "Auth.revoke",
"config": {
"policies": ["plugins.users-permissions.ratelimit"],
"prefix": "",
"description": "Revoke a refresh token",
"tag": {
"plugin": "users-permissions",
"name": "User"
}
}
},
{
"method": "GET",
"path": "/users",
Expand Down Expand Up @@ -370,4 +398,4 @@
}
}
]
}
}
70 changes: 70 additions & 0 deletions packages/strapi-plugin-users-permissions/controllers/Auth.js
Expand Up @@ -81,6 +81,7 @@ module.exports = {
} else {
ctx.send({
jwt: strapi.plugins['users-permissions'].services.jwt.issue(_.pick(user.toJSON ? user.toJSON() : user, ['_id', 'id'])),
refreshToken: await strapi.plugins['users-permissions'].services.refreshtoken.issue(_.pick(user.toJSON ? user.toJSON() : user, ['_id', 'id']), ctx.request.header['user-agent']),
user: _.omit(user.toJSON ? user.toJSON() : user, ['password', 'resetPasswordToken'])
});
}
Expand All @@ -103,6 +104,7 @@ module.exports = {

ctx.send({
jwt: strapi.plugins['users-permissions'].services.jwt.issue(_.pick(user, ['_id', 'id'])),
refreshToken: await strapi.plugins['users-permissions'].services.refreshtoken.issue(_.pick(user, ['_id', 'id']), ctx.request.header['user-agent']),
user: _.omit(user.toJSON ? user.toJSON() : user, ['password', 'resetPasswordToken'])
});
}
Expand Down Expand Up @@ -131,6 +133,7 @@ module.exports = {

ctx.send({
jwt: strapi.plugins['users-permissions'].services.jwt.issue(_.pick(user.toJSON ? user.toJSON() : user, ['_id', 'id'])),
refreshToken: await strapi.plugins['users-permissions'].services.refreshtoken.issue(_.pick(user.toJSON ? user.toJSON() : user, ['_id', 'id']), ctx.request.header['user-agent']),
user: _.omit(user.toJSON ? user.toJSON() : user, ['password', 'resetPasswordToken'])
});
} else if (params.password && params.passwordConfirmation && params.password !== params.passwordConfirmation) {
Expand Down Expand Up @@ -333,6 +336,7 @@ module.exports = {

ctx.send({
jwt: !settings.email_confirmation ? jwt : undefined,
refreshToken: await strapi.plugins['users-permissions'].services.refreshtoken.issue(_.pick(user.toJSON ? user.toJSON() : user, ['_id', 'id']), ctx.request.header['user-agent']),
user: _.omit(user.toJSON ? user.toJSON() : user, ['password', 'resetPasswordToken'])
});
} catch(err) {
Expand Down Expand Up @@ -362,5 +366,71 @@ module.exports = {
}).get();

ctx.redirect(settings.email_confirmation_redirection || '/');
},

token: async (ctx) => {
const query = {};

const params = ctx.request.body;

// Check if the provided identifier is an email or not.
const isEmail = emailRegExp.test(params.identifier);

// Set the identifier to the appropriate query field.
if (isEmail) {
query.email = params.identifier.toLowerCase();
} else {
query.username = params.identifier;
}

// Check if the user exists.
const user = await strapi.query('user', 'users-permissions').findOne(query);

// User not found.
if (!user) {
return ctx.badRequest(null, ctx.request.admin ? [{ messages: [{ id: 'Auth.form.error.user.not-exist' }] }] : 'This email does not exist.');
}

let tokenMatch = false;
_.map(user.tokens, function (t) {
if (t.token == params.refreshToken) {
tokenMatch = true;
}
});
// Invalid refresh token
if (!tokenMatch) {
return ctx.badRequest(null, 'Invalid refresh token');
}

ctx.send({
jwt: strapi.plugins['users-permissions'].services.jwt.issue(_.pick(user, ['_id', 'id'])),
user: _.omit(user.toJSON ? user.toJSON() : user, ['password', 'resetPasswordToken'])
});
},

revoke: async (ctx) => {
const user = ctx.state.user;
const params = ctx.request.body;

if (!user) {
return ctx.badRequest(null, [{ messages: [{ id: 'No authorization header was found' }] }]);
}

let tokenMatch = false;
_.map(user.tokens, function (t) {
if (t.token == params.refreshToken) {
tokenMatch = true;
params.id = (t._id || t.id);
}
});
// Invalid refresh token
if (!tokenMatch) {
return ctx.badRequest(null, 'Invalid refresh token');
}

const data = await strapi.plugins['users-permissions'].services.refreshtoken.revoke(params);

// Send 200 `ok`
ctx.send(data);
}
};
54 changes: 54 additions & 0 deletions packages/strapi-plugin-users-permissions/models/RefreshToken.js
@@ -0,0 +1,54 @@
'use strict';

/**
* Lifecycle callbacks for the `RefreshToken` model.
*/

module.exports = {
// Before saving a value.
// Fired before an `insert` or `update` query.
// beforeSave: async (model) => {},

// After saving a value.
// Fired after an `insert` or `update` query.
// afterSave: async (model, result) => {},

// Before fetching all values.
// Fired before a `fetchAll` operation.
// beforeFetchAll: async (model) => {},

// After fetching all values.
// Fired after a `fetchAll` operation.
// afterFetchAll: async (model, results) => {},

// Fired before a `fetch` operation.
// beforeFetch: async (model) => {},

// After fetching a value.
// Fired after a `fetch` operation.
// afterFetch: async (model, result) => {},

// Before creating a value.
// Fired before `insert` query.
// beforeCreate: async (model) => {},

// After creating a value.
// Fired after `insert` query.
// afterCreate: async (model, result) => {},

// Before updating a value.
// Fired before an `update` query.
// beforeUpdate: async (model) => {},

// After updating a value.
// Fired after an `update` query.
// afterUpdate: async (model, result) => {},

// Before destroying a value.
// Fired before a `delete` query.
// beforeDestroy: async (model) => {},

// After destroying a value.
// Fired after a `delete` query.
// afterDestroy: async (model, result) => {}
};
@@ -0,0 +1,25 @@
{
"connection": "default",
"collectionName": "users-permissions_refresh_token",
"info": {
"name": "refreshToken",
"description": ""
},
"attributes": {
"user": {
"model": "user",
"via": "tokens",
"plugin": "users-permissions"
},
"token": {
"type": "string",
"required": true,
"configurable": false
},
"agent": {
"type": "string",
"required": true,
"configurable": false
}
}
}
Expand Up @@ -48,6 +48,11 @@
"via": "users",
"plugin": "users-permissions",
"configurable": false
},
"tokens": {
"collection": "refreshtoken",
"via": "user",
"plugin": "users-permissions"
}
},
"collectionName": "users-permissions_user"
Expand Down
1 change: 1 addition & 0 deletions packages/strapi-plugin-users-permissions/package.json
Expand Up @@ -28,6 +28,7 @@
"koa": "^2.1.0",
"koa2-ratelimit": "^0.6.1",
"purest": "^2.0.1",
"rand-token": "^0.4.0",
"request": "^2.83.0",
"strapi-utils": "3.0.0-alpha.25.2",
"uuid": "^3.1.0"
Expand Down
21 changes: 21 additions & 0 deletions packages/strapi-plugin-users-permissions/services/RefreshToken.js
@@ -0,0 +1,21 @@
'use strict';

/**
* RefreshToken.js service
*
* @description: A set of functions similar to controller's actions to avoid code duplication.
*/
const randtoken = require('rand-token');

module.exports = {
revoke: async params => {
params.model = 'refreshtoken';
return strapi.query('refreshToken', 'users-permissions').delete(params);
},

issue: async (user, useragent) => {
let token = randtoken.uid(255);
nyzm marked this conversation as resolved.
Show resolved Hide resolved
await strapi.query('refreshToken', 'users-permissions').create({ user: user.id, token: token, agent: useragent });
return token;
}
};
Expand Up @@ -266,10 +266,11 @@ module.exports = {
const isRegister = obj.action === 'register' && obj.controller === 'auth' && obj.type === 'users-permissions' && role.type === 'public';
const isConfirmation = obj.action === 'emailconfirmation' && obj.controller === 'auth' && obj.type === 'users-permissions' && role.type === 'public';
const isNewPassword = obj.action === 'changepassword' && obj.controller === 'auth' && obj.type === 'users-permissions' && role.type === 'public';
const isToken = obj.action === 'token' && obj.controller === 'auth' && obj.type === 'users-permissions' && role.type === 'public';
const isInit = obj.action === 'init' && obj.controller === 'userspermissions';
const isMe = obj.action === 'me' && obj.controller === 'user' && obj.type === 'users-permissions';
const isReload = obj.action === 'autoreload';
const enabled = isCallback || isRegister || role.type === 'root' || isInit || isPassword || isNewPassword || isMe || isReload || isConnect || isConfirmation;
const enabled = isCallback || isRegister || isToken || role.type === 'root' || isInit || isPassword || isNewPassword || isMe || isReload || isConnect || isConfirmation;

return Object.assign(obj, { enabled, policy: '' });
};
Expand Down