Merge pull request #175 from thevahidal/171_logout_api

Add logout API
This commit is contained in:
Vahid Al
2024-04-05 13:20:43 +03:30
committed by GitHub
14 changed files with 187 additions and 6 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "soul-cli",
"version": "0.7.2",
"version": "0.7.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "soul-cli",
"version": "0.7.2",
"version": "0.7.3",
"license": "MIT",
"dependencies": {
"bcrypt": "^5.1.1",

View File

@@ -1,6 +1,6 @@
{
"name": "soul-cli",
"version": "0.7.2",
"version": "0.7.3",
"description": "A SQLite REST and Realtime server",
"main": "src/server.js",
"bin": {

View File

@@ -2,4 +2,5 @@ module.exports = {
SALT_ROUNDS: 10,
ACCESS_TOKEN_SUBJECT: 'accessToken',
REFRESH_TOKEN_SUBJECT: 'refreshToken',
REVOKED_REFRESH_TOKENS_REMOVAL_TIME_RANGE: 3 * 24 * 60 * 60 * 1000, // 3 days * 24 hours * 60 minutes * 60 seconds * 1000 milliseconds =
};

View File

@@ -5,6 +5,7 @@ module.exports = {
PASSWORD_UPDATE_SUCCESS: 'Password updated successfully',
USER_UPDATE_SUCCESS: 'User updated successfully',
INITIAL_USER_CREATED_SUCCESS: 'Initial user created successfully',
LOGOUT_MESSAGE: 'Logout successful',
},
errorMessage: {

View File

@@ -2,12 +2,15 @@ const USERS_TABLE = '_users';
const ROLES_TABLE = '_roles';
const USERS_ROLES_TABLE = '_users_roles';
const ROLES_PERMISSIONS_TABLE = '_roles_permissions';
const REVOKED_REFRESH_TOKENS_TABLE = '_revoked_refresh_tokens';
module.exports = {
// db table names
USERS_TABLE,
ROLES_TABLE,
USERS_ROLES_TABLE,
ROLES_PERMISSIONS_TABLE,
REVOKED_REFRESH_TOKENS_TABLE,
reservedTableNames: [
USERS_TABLE,
@@ -43,5 +46,9 @@ module.exports = {
// _users_roles fields
USER_ID: 'user_id',
//_revoked_refresh_tokens
REFRESH_TOKEN: 'refresh_token',
EXPIRES_AT: 'expires_at',
},
};

View File

@@ -7,6 +7,7 @@ const {
ROLES_TABLE,
USERS_ROLES_TABLE,
ROLES_PERMISSIONS_TABLE,
REVOKED_REFRESH_TOKENS_TABLE,
constraints,
tableFields,
} = dbConstants;
@@ -21,6 +22,9 @@ const createDefaultTables = async () => {
ROLES_PERMISSIONS_TABLE,
);
const usersRolesTable = tableService.checkTableExists(USERS_ROLES_TABLE);
const revokedRefreshTokensTable = tableService.checkTableExists(
REVOKED_REFRESH_TOKENS_TABLE,
);
// create _users table
if (!usersTable) {
@@ -89,6 +93,14 @@ const createDefaultTables = async () => {
fields: permissions,
});
}
// create _revoked_refresh_tokens table
if (!revokedRefreshTokensTable) {
tableService.createTable(
REVOKED_REFRESH_TOKENS_TABLE,
schema.revokedRefreshTokensSchema,
);
}
};
module.exports = {

View File

@@ -123,11 +123,19 @@ const refreshAccessToken = async (req, res) => {
#swagger.summary = 'Refresh Access Token'
#swagger.description = 'Endpoint to refresh access and refresh tokens'
*/
const refreshTokenFromCookies = req.cookies.refreshToken;
try {
// check if the refresh token is revoked
if (isRefreshTokenRevoked({ refreshToken: refreshTokenFromCookies })) {
return res
.status(403)
.send({ message: errorMessage.INVALID_REFRESH_TOKEN_ERROR });
}
// extract the payload from the token and verify it
const payload = await decodeToken(
req.cookies.refreshToken,
refreshTokenFromCookies,
config.tokenSecret,
);
@@ -217,6 +225,52 @@ const refreshAccessToken = async (req, res) => {
}
};
const removeTokens = async (req, res) => {
/*
#swagger.tags = ['Auth']
#swagger.summary = 'Remove Tokens'
#swagger.description = 'Endpoint to remove access and refresh tokens'
*/
const refreshToken = req.cookies.refreshToken;
try {
// decode the token
const payload = await decodeToken(refreshToken, config.tokenSecret);
// store the refresh token in the _revoked_refresh_tokens table
authService.saveRevokedRefreshToken({
refreshToken,
expiresAt: payload.exp,
});
// remove the token from the cookie
res.clearCookie(authConstants.ACCESS_TOKEN_SUBJECT);
res.clearCookie(authConstants.REFRESH_TOKEN_SUBJECT);
res
.status(200)
.send({ message: responseMessages.successMessage.LOGOUT_MESSAGE });
/*
#swagger.responses[200] = {
description: 'Tokens Removed',
schema: {
$ref: '#/definitions/RemoveTokensResponse'
}
}
*/
} catch (error) {
res.status(500).send({ message: errorMessage.SERVER_ERROR });
}
};
const removeRevokedRefreshTokens = () => {
authService.deleteRevokedRefreshTokens({
lookupField: `WHERE expires_at < CURRENT_TIMESTAMP`,
});
};
const getUsersRoleAndPermission = ({ userId, res }) => {
const userRoles = authService.getUserRoleByUserId({ userId });
@@ -233,7 +287,14 @@ const getUsersRoleAndPermission = ({ userId, res }) => {
return { userRoles, roleIds, permissions };
};
const isRefreshTokenRevoked = ({ refreshToken }) => {
const tokens = authService.getRevokedRefreshToken({ refreshToken });
return tokens.length > 0;
};
module.exports = {
obtainAccessToken,
refreshAccessToken,
removeTokens,
removeRevokedRefreshTokens,
};

View File

@@ -115,4 +115,22 @@ module.exports = {
foreignKey: { table: ROLES_TABLE, column: tableFields.ID },
},
],
revokedRefreshTokensSchema: [
{
name: tableFields.REFRESH_TOKEN,
type: 'TEXT',
primaryKey: false,
notNull: true,
unique: false,
},
{
name: tableFields.EXPIRES_AT,
type: 'NUMERIC',
primaryKey: false,
notNull: true,
unique: false,
},
],
};

View File

@@ -22,9 +22,11 @@ const { setupExtensions } = require('./extensions');
const {
createDefaultTables,
createInitialUser,
removeRevokedRefreshTokens,
} = require('./controllers/auth');
const { runCLICommands } = require('./commands');
const { authConstants } = require('./constants');
const app = express();
app.get('/health', (req, res) => {
@@ -93,6 +95,12 @@ if (config.auth) {
);
}
// remove revoked refresh tokens every X days
setInterval(
removeRevokedRefreshTokens,
authConstants.REVOKED_REFRESH_TOKENS_REMOVAL_TIME_RANGE,
);
// If the user has passed custom CLI commands run the command and exit to avoid running the server
runCLICommands();

View File

@@ -26,4 +26,10 @@ router.put(
controllers.changePassword,
);
router.get(
'/logout',
validator(schema.removeAccessTokens),
controllers.removeTokens,
);
module.exports = router;

View File

@@ -79,10 +79,21 @@ const updateRolePermissions = Joi.object({
}).required(),
});
const removeAccessTokens = Joi.object({
query: Joi.object().required(),
params: Joi.object({}).required(),
body: Joi.object({}).required(),
cookies: Joi.object({
refreshToken: Joi.string().required(),
accessToken: Joi.string().required(),
}).required(),
});
module.exports = {
obtainAccessToken,
refreshAccessToken,
changePassword,
registerUser,
updateRolePermissions,
removeAccessTokens,
};

View File

@@ -8,10 +8,11 @@ const {
ROLES_TABLE,
USERS_ROLES_TABLE,
ROLES_PERMISSIONS_TABLE,
REVOKED_REFRESH_TOKENS_TABLE,
tableFields,
} = dbConstants;
module.exports = () => {
module.exports = (db) => {
return {
getUsersByUsername({ username }) {
const users = rowService.get({
@@ -43,6 +44,7 @@ module.exports = () => {
return users;
},
// TODO: bypass pagination by providing query param for number of rows
getPermissionByRoleIds({ roleIds }) {
const permissions = rowService.get({
tableName: ROLES_PERMISSIONS_TABLE,
@@ -50,6 +52,7 @@ module.exports = () => {
() => '?',
)})`,
whereStringValues: [...roleIds],
limit: 10000,
});
return permissions;
@@ -74,5 +77,34 @@ module.exports = () => {
return defaultRole;
},
saveRevokedRefreshToken({ refreshToken, expiresAt }) {
const { lastInsertRowid } = rowService.save({
tableName: REVOKED_REFRESH_TOKENS_TABLE,
fields: {
refresh_token: refreshToken,
expires_at: expiresAt,
},
});
return { id: lastInsertRowid };
},
getRevokedRefreshToken({ refreshToken }) {
const token = rowService.get({
tableName: REVOKED_REFRESH_TOKENS_TABLE,
whereString: `WHERE ${tableFields.REFRESH_TOKEN}=?`,
whereStringValues: [refreshToken],
});
return token;
},
deleteRevokedRefreshTokens({ lookupField }) {
const query = `DELETE FROM ${REVOKED_REFRESH_TOKENS_TABLE} ${lookupField}`;
const statement = db.prepare(query);
const result = statement.run();
return result;
},
};
};

View File

@@ -189,6 +189,10 @@ const doc = {
},
InvalidPasswordErrorResponse: { message: 'Invalid password' },
RemoveTokensResponse: {
message: 'Logout successful',
},
},
};

View File

@@ -1,7 +1,7 @@
{
"swagger": "2.0",
"info": {
"version": "0.7.0",
"version": "0.7.2",
"title": "Soul API",
"description": "API Documentation for <b>Soul</b>, a SQLite REST and realtime server. "
},
@@ -535,6 +535,17 @@
}
}
}
},
"/api/auth/logout": {
"get": {
"description": "",
"parameters": [],
"responses": {
"400": {
"description": "Bad Request"
}
}
}
}
},
"definitions": {
@@ -985,6 +996,15 @@
"example": "Invalid password"
}
}
},
"RemoveTokensResponse": {
"type": "object",
"properties": {
"message": {
"type": "string",
"example": "Logout successful"
}
}
}
}
}