Merge pull request #157 from thevahidal/authorization_feature

Add authorization feature + modify unit tests
This commit is contained in:
Ian Mayo
2024-03-11 11:01:24 +00:00
committed by GitHub
25 changed files with 1524 additions and 201 deletions

View File

@@ -9,6 +9,12 @@ RATE_LIMIT_ENABLED=false
RATE_LIMIT_WINDOW_MS=1000
RATE_LIMIT_MAX_REQUESTS=10
JWT_SECRET=ABCD23DCAA
JWT_EXPIRATION_TIME=10H
INITIAL_USER_USERNAME=<your_username>
INITIAL_USER_PASSWORD=<your_password>
DB=foobar.db
START_WITH_STUDIO=false
@@ -16,6 +22,3 @@ START_WITH_STUDIO=false
TOKEN_SECRET=ABCD23DCAA
ACCESS_TOKEN_EXPIRATION_TIME=10H
REFRESH_TOKEN_EXPIRATION_TIME=2D
INITIAL_USER_USERNAME=superuser
INITIAL_USER_PASSWORD=hello@3245CD$

View File

@@ -35,9 +35,13 @@ Options:
-r, --rate-limit-enabled Enable rate limiting [boolean]
-c, --cors CORS whitelist origins [string]
-a, --auth Enable authentication and authorization [boolean]
-ats, --accesstokensecret Access Token Secret [string]
-iuu, --initialuserusername Initial user username [string]
-iup, --initialuserpassword Initial user password [string]
-ats, --accesstokensecret Access Token Secret [string]
-atet, --accesstokenexpirationtime Access Token Expiration Time [string]
-rts, --refreshtokensecret Refresh Token Secret [string]
-rts, --refreshtokensecret Refresh Token Secret [string]
-rtet, --refreshtokenexpirationtime Refresh Token Expiration Time [string]
-S, --studio Start Soul Studio in parallel
--help Show help
@@ -58,18 +62,23 @@ To run Soul in auth mode, allowing login and signup features with authorization
Run the Soul command with the necessary parameters:
```
soul --d foobar.db -a -ts <your_jwt_secret_value> -atet=4H -rtet=3D
```
```
soul --d foobar.db -a -ts <your_jwt_secret_value> -atet=4H -rtet=3D -iuu=john -iup=<your_password>
```
Note: When configuring your JWT Secret, it is recommended to use a long string value for enhanced security. It is advisable to use a secret that is at least 10 characters in length.
In this example:
The `-a` flag instructs Soul to run in auth mode.
The `-ts` flag allows you to pass a JWT secret value for the `access and refresh tokens` generation and verification. Replace <your_jwt\_\_secret_value> with your desired secret value.
The `-ts` flag allows you to pass a JWT secret value for the `access and refresh tokens` generation and verification. Replace <your_jwt_secret_value> with your desired secret value.
The `-atet` flag sets the JWT expiration time for the access token. In this case, it is set to four hours (4H), meaning the token will expire after 4 hours.
Teh `-rtet` flag sets the JWT expiration time for the refresh token. In this case, it is set to three days (3D), meaning the token will expire after 3 days.
The `-rtet` flag sets the JWT expiration time for the refresh token. In this case, it is set to three days (3D), meaning the token will expire after 3 days.
The `-iuu` flag is used to pass a username for the initial user
The `-iup` flag is used to pass a password for the initial user
Here are some example values for the `-atet` and `rtet` flags
@@ -129,6 +138,14 @@ npm install # Install dependencies
npm run dev # Start the dev server
```
## Testing
Set the `AUTH` variable to `true` in your `.env` file and use the command below to run the tests
```
npm run test
```
## Community
[Join](https://bit.ly/soul-discord) the discussion in our Discord server and help making Soul together.

View File

@@ -74,6 +74,18 @@ if (process.env.NO_CLI !== 'true') {
default: '3D',
demandOption: false,
})
.options('iuu', {
alias: 'initialuserusername',
describe: 'Initial superuser username',
type: 'string',
demandOption: false,
})
.options('iup', {
alias: 'initialuserpassword',
describe: 'Initial superuser password',
type: 'string',
demandOption: false,
})
.options('S', {
alias: 'studio',
describe: 'Start Soul Studio in parallel',

View File

@@ -30,6 +30,9 @@ const envVarsSchema = Joi.object()
START_WITH_STUDIO: Joi.boolean().default(false),
INITIAL_USER_USERNAME: Joi.string(),
INITIAL_USER_PASSWORD: Joi.string(),
TOKEN_SECRET: Joi.string().default(null),
ACCESS_TOKEN_EXPIRATION_TIME: Joi.string().default('5H'),
REFRESH_TOKEN_EXPIRATION_TIME: Joi.string().default('3D'),
@@ -76,6 +79,14 @@ if (argv.refreshtokenexpirationtime) {
env.REFRESH_TOKEN_EXPIRATION_TIME = argv.refreshtokenexpirationtime;
}
if (argv.initialuserusername) {
env.INITIAL_USER_USERNAME = argv.initialuserusername;
}
if (argv.initialuserpassword) {
env.INITIAL_USER_PASSWORD = argv.initialuserpassword;
}
const { value: envVars, error } = envVarsSchema
.prefs({ errors: { label: 'key' } })
.validate(env);
@@ -109,6 +120,11 @@ module.exports = {
refreshTokenExpirationTime:
argv.refreshtokenexpirationtime || envVars.REFRESH_TOKEN_EXPIRATION_TIME,
initialUserUsername:
argv.initialuserusername || envVars.INITIAL_USER_USERNAME,
initialUserPassword:
argv.initialuserpassword || envVars.INITIAL_USER_PASSWORD,
rateLimit: {
enabled: argv['rate-limit-enabled'] || envVars.RATE_LIMIT_ENABLED,
windowMs: envVars.RATE_LIMIT_WINDOW,

View File

@@ -1,5 +1,13 @@
module.exports = {
defaultRoutes: ['_users', '_roles', '_roles_permissions', '_users_roles'],
baseTableUrl: '/api/tables',
fields: {
_users: {
SALT: 'salt',
IS_SUPERUSER: 'is_superuser',
HASHED_PASSWORD: 'hashed_password',
},
},
DEFAULT_PAGE_LIMIT: 10,
DEFAULT_PAGE_INDEX: 0,
PASSWORD: {

View File

@@ -0,0 +1,6 @@
module.exports = {
POST: 'CREATE',
GET: 'READ',
PUT: 'UPDATE',
DELETE: 'DELETE',
};

View File

@@ -1,5 +1,5 @@
const dbTables = require('./dbTables');
const dbConstants = require('./tables');
const apiConstants = require('./api');
const constantRoles = require('./roles');
module.exports = { dbTables, apiConstants, constantRoles };
module.exports = { dbConstants, apiConstants, constantRoles };

13
src/constants/tables.js Normal file
View File

@@ -0,0 +1,13 @@
module.exports = {
reservedTableNames: [
'_users',
'_roles',
'_roles_permissions',
'_users_roles',
],
USER_TABLE: '_users',
ROLE_TABLE: '_roles',
USER_ROLES_TABLE: '_users_roles',
ROLE_PERMISSIONS_TABLE: '_roles_permissions',
};

View File

@@ -1,4 +1,6 @@
const { tableService, rowService } = require('../services');
const { constantRoles, apiConstants, dbConstants } = require('../constants');
const schema = require('../db/schema');
const config = require('../config');
const {
hashPassword,
@@ -6,40 +8,53 @@ const {
comparePasswords,
generateToken,
decodeToken,
toBoolean,
} = require('../utils');
const { dbTables, constantRoles, apiConstants } = require('../constants');
const { USER_TABLE, ROLE_TABLE, USERS_ROLES_TABLE, ROLE_PERMISSIONS_TABLE } =
dbConstants;
const createDefaultTables = async () => {
let roleId;
// check if the default tables are already created
const roleTable = tableService.checkTableExists('_roles');
const usersTable = tableService.checkTableExists('_users');
const rolesPermissionTable =
tableService.checkTableExists('_roles_permissions');
const usersRolesTable = tableService.checkTableExists('_users_roles');
const roleTable = tableService.checkTableExists(ROLE_TABLE);
const usersTable = tableService.checkTableExists(USER_TABLE);
const rolesPermissionTable = tableService.checkTableExists(
ROLE_PERMISSIONS_TABLE,
);
const usersRolesTable = tableService.checkTableExists(USERS_ROLES_TABLE);
// create _users table
if (!usersTable) {
// create the _users table
tableService.createTable('_users', dbTables.userSchema);
tableService.createTable(USER_TABLE, schema.userSchema);
}
// create _users_roles table
if (!usersRolesTable) {
// create the _users_roles table
tableService.createTable('_users_roles', dbTables.usersRoleSchema);
tableService.createTable(
USERS_ROLES_TABLE,
schema.usersRoleSchema,
{
multipleUniqueConstraints: {
name: 'unique_users_role',
fields: ['user_id', 'role_id'],
},
},
);
}
// create _roles table
if (!roleTable) {
// create the _role table
tableService.createTable('_roles', dbTables.roleSchema);
tableService.createTable(ROLE_TABLE, schema.roleSchema);
// create a default role in the _roles table
const role = rowService.save({
tableName: '_roles',
tableName: ROLE_TABLE,
fields: { name: constantRoles.DEFAULT_ROLE },
});
roleId = role.lastInsertRowid;
@@ -49,8 +64,8 @@ const createDefaultTables = async () => {
if (!rolesPermissionTable && roleId) {
// create the _roles_permissions table
tableService.createTable(
'_roles_permissions',
dbTables.rolePermissionSchema,
ROLE_PERMISSIONS_TABLE,
schema.rolePermissionSchema,
{
multipleUniqueConstraints: {
name: 'unique_role_table',
@@ -77,7 +92,7 @@ const createDefaultTables = async () => {
// store the permissions in the db
rowService.bulkWrite({
tableName: '_roles_permissions',
tableName: ROLE_PERMISSIONS_TABLE,
fields: permissions,
});
}
@@ -91,7 +106,7 @@ const updateSuperuser = async (fields) => {
try {
// find the user by using the id field
const users = rowService.get({
tableName: '_users',
tableName: USER_TABLE,
whereString: 'WHERE id=?',
whereStringValues: [id],
});
@@ -131,7 +146,7 @@ const updateSuperuser = async (fields) => {
// update the user
rowService.update({
tableName: '_users',
tableName: USER_TABLE,
lookupField: `id`,
fieldsString,
pks: `${id}`,
@@ -147,18 +162,48 @@ const updateSuperuser = async (fields) => {
};
const registerUser = async (req, res) => {
/*
#swagger.tags = ['Auth']
#swagger.summary = 'Register User'
#swagger.description = 'Endpoint to signup'
#swagger.parameters['username'] = {
in: 'body',
required: true,
type: 'object',
schema: { $ref: '#/definitions/UserRegistrationRequestBody' }
}
*/
const { username, password } = req.body.fields;
try {
if (!username) {
return res.status(400).send({ message: 'username is required' });
}
if (!password) {
return res.status(400).send({ message: 'password is required' });
}
// check if the username is taken
let user = rowService.get({
tableName: '_users',
tableName: USER_TABLE,
whereString: 'WHERE username=?',
whereStringValues: [username],
});
if (user.length > 0) {
return res.status(409).send({ message: 'This username is taken' });
/*
#swagger.responses[409] = {
description: 'Username taken error',
schema: {
$ref: '#/definitions/UsernameTakenErrorResponse'
}
}
*/
}
// check if the password is weak
@@ -170,6 +215,15 @@ const registerUser = async (req, res) => {
return res.status(400).send({
message: 'This password is weak, please use another password',
});
/*
#swagger.responses[400] = {
description: 'Weak password error',
schema: {
$ref: '#/definitions/WeakPasswordErrorResponse'
}
}
*/
}
// hash the password
@@ -177,7 +231,7 @@ const registerUser = async (req, res) => {
// create the user
const newUser = rowService.save({
tableName: '_users',
tableName: USER_TABLE,
fields: {
username,
salt,
@@ -188,7 +242,7 @@ const registerUser = async (req, res) => {
// find the default role from the DB
let defaultRole = rowService.get({
tableName: '_roles',
tableName: ROLE_TABLE,
whereString: 'WHERE name=?',
whereStringValues: [constantRoles.DEFAULT_ROLE],
});
@@ -197,15 +251,32 @@ const registerUser = async (req, res) => {
return res.status(500).send({
message: 'Please restart soul so a default role can be created',
});
/*
#swagger.responses[500] = {
description: 'Server error',
schema: {
$ref: '#/definitions/DefaultRoleNotCreatedErrorResponse'
}
}
*/
}
// create a role for the user
rowService.save({
tableName: '_users_roles',
tableName: USERS_ROLES_TABLE,
fields: { user_id: newUser.lastInsertRowid, role_id: defaultRole[0].id },
});
res.status(201).send({ message: 'Row Inserted' });
/*
#swagger.responses[201] = {
description: 'Row inserted',
schema: {
$ref: '#/definitions/InsertRowSuccessResponse'
}
}
*/
} catch (error) {
console.log(error);
res.status(500).send({ message: error.message });
@@ -213,13 +284,26 @@ const registerUser = async (req, res) => {
};
const obtainAccessToken = async (req, res) => {
/*
#swagger.tags = ['Auth']
#swagger.summary = 'Obtain Access Token'
#swagger.description = 'Endpoint to generate access and refresh tokens'
#swagger.parameters['body'] = {
in: 'body',
required: true,
type: 'object',
schema: { $ref: '#/definitions/ObtainAccessTokenRequestBody' }
}
*/
// extract payload
const { username, password } = req.body.fields;
try {
// check if the username exists in the Db
let users = rowService.get({
tableName: '_users',
const users = rowService.get({
tableName: USER_TABLE,
whereString: 'WHERE username=?',
whereStringValues: [username],
});
@@ -229,28 +313,53 @@ const obtainAccessToken = async (req, res) => {
}
// check if the password is valid
let user = users[0];
const user = users[0];
const isMatch = await comparePasswords(password, user.hashed_password);
if (!isMatch) {
return res.status(401).send({ message: 'Invalid username or password' });
/*
#swagger.responses[401] = {
description: 'Invalid username or password error',
schema: {
$ref: '#/definitions/InvalidCredentialErrorResponse'
}
}
*/
}
// get the user roles from the DB
const userRoles = rowService.get({
tableName: '_users_roles',
whereString: 'WHERE user_id=?',
whereStringValues: [user.id],
});
let userRoles, permissions, roleIds;
if (userRoles < 0) {
return res.status(404).send({ message: 'Default role not found' });
// if the user is not a superuser get the role and its permission from the DB
if (!toBoolean(user.is_superuser)) {
userRoles = rowService.get({
tableName: USERS_ROLES_TABLE,
whereString: 'WHERE user_id=?',
whereStringValues: [user.id],
});
if (userRoles <= 0) {
return res
.status(404)
.send({ message: 'Role not found for this user' });
}
roleIds = userRoles.map((role) => role.role_id);
// get the permission of the role
permissions = rowService.get({
tableName: ROLE_PERMISSIONS_TABLE,
whereString: `WHERE role_id IN (${roleIds.map(() => '?')})`,
whereStringValues: [...roleIds],
});
}
const payload = {
username: user.username,
userId: user.id,
roleId: userRoles,
isSuperuser: user.is_superuser,
roleIds,
permissions,
};
// generate an access token
@@ -273,6 +382,15 @@ const obtainAccessToken = async (req, res) => {
res.cookie('refreshToken', refreshToken, cookieOptions);
res.status(201).send({ message: 'Success', data: { userId: user.id } });
/*
#swagger.responses[201] = {
description: 'Access token and Refresh token generated',
schema: {
$ref: '#/definitions/ObtainAccessTokenSuccessResponse'
}
}
*/
} catch (error) {
console.log(error);
return res.status(500).json({
@@ -283,6 +401,12 @@ const obtainAccessToken = async (req, res) => {
};
const refreshAccessToken = async (req, res) => {
/*
#swagger.tags = ['Auth']
#swagger.summary = 'Refresh Access Token'
#swagger.description = 'Endpoint to refresh access and refresh tokens'
*/
try {
// extract the payload from the token and verify it
const payload = await decodeToken(
@@ -292,7 +416,7 @@ const refreshAccessToken = async (req, res) => {
// find the user
const users = rowService.get({
tableName: '_users',
tableName: USER_TABLE,
whereString: 'WHERE id=?',
whereStringValues: [payload.userId],
});
@@ -301,25 +425,56 @@ const refreshAccessToken = async (req, res) => {
return res
.status(401)
.send({ message: `User with userId = ${payload.userId} not found` });
/*
#swagger.responses[401] = {
description: 'User not found error',
schema: {
$ref: '#/definitions/UserNotFoundErrorResponse'
}
}
*/
}
let userRoles, permissions, roleIds;
const user = users[0];
const newPaylod = {
username: payload.username,
userId: payload.userId,
roleId: payload.roleId,
// if the user is not a superuser get the role and its permission from the DB
if (!toBoolean(user.is_superuser)) {
userRoles = rowService.get({
tableName: USERS_ROLES_TABLE,
whereString: 'WHERE user_id=?',
whereStringValues: [user.id],
});
roleIds = userRoles.map((role) => role.role_id);
// get the permission of the role
permissions = rowService.get({
tableName: ROLE_PERMISSIONS_TABLE,
whereString: `WHERE role_id IN (${roleIds.map(() => '?')})`,
whereStringValues: [...roleIds],
});
}
const newPayload = {
username: user.username,
userId: user.id,
isSuperuser: user.is_superuser,
roleIds,
permissions,
};
// generate an access token
const accessToken = await generateToken(
{ subject: 'accessToken', ...newPaylod },
{ subject: 'accessToken', ...newPayload },
config.tokenSecret,
config.accessTokenExpirationTime,
);
// generate a refresh token
const refreshToken = await generateToken(
{ subject: 'refreshToken', ...newPaylod },
{ subject: 'refreshToken', ...newPayload },
config.tokenSecret,
config.refreshTokenExpirationTime,
);
@@ -329,20 +484,52 @@ const refreshAccessToken = async (req, res) => {
res.cookie('accessToken', accessToken, cookieOptions);
res.cookie('refreshToken', refreshToken, cookieOptions);
res.status(201).send({ message: 'Success', data: { userId: user.id } });
res.status(200).send({ message: 'Success', data: { userId: user.id } });
/*
#swagger.responses[200] = {
description: 'Access token refreshed',
schema: {
$ref: '#/definitions/RefreshAccessTokenSuccessResponse'
}
}
*/
} catch (error) {
res.status(401).send({ message: 'Invalid refresh token' });
res.status(403).send({ message: 'Invalid refresh token' });
/*
#swagger.responses[401] = {
description: 'Invalid refresh token error',
schema: {
$ref: '#/definitions/InvalidRefreshTokenErrorResponse'
}
}
*/
}
};
const changePassword = async (req, res) => {
/*
#swagger.tags = ['Auth']
#swagger.summary = 'Change Password'
#swagger.description = 'Endpoint to change a password'
#swagger.parameters['body'] = {
in: 'body',
required: true,
type: 'object',
schema: {
$ref: '#/definitions/ChangePasswordRequestBody'
}
}
*/
const userInfo = req.user;
const { currentPassword, newPassword } = req.body.fields;
try {
// get the user from the Db
const users = rowService.get({
tableName: '_users',
tableName: USER_TABLE,
whereString: 'WHERE id=?',
whereStringValues: [userInfo.userId],
});
@@ -361,6 +548,14 @@ const changePassword = async (req, res) => {
if (!isMatch) {
return res.status(401).send({ message: 'Invalid current password' });
/*
#swagger.responses[401] = {
description: 'User not found error',
schema: {
$ref: '#/definitions/InvalidPasswordErrorResponse'
}
}
*/
}
// check if the new password is strong
@@ -372,6 +567,15 @@ const changePassword = async (req, res) => {
return res.status(400).send({
message: 'This password is weak, please use another password',
});
/*
#swagger.responses[400] = {
description: 'Weak password error',
schema: {
$ref: '#/definitions/WeakPasswordErrorResponse'
}
}
*/
}
// hash the password
@@ -382,21 +586,119 @@ const changePassword = async (req, res) => {
// update the user
rowService.update({
tableName: '_users',
tableName: USER_TABLE,
lookupField: `id`,
fieldsString: `hashed_password = '${hashedPassword}', salt = '${salt}'`,
pks: `${user.id}`,
});
res.status(201).send({
res.status(200).send({
message: 'Password updated successfully',
data: { id: user.id, username: user.username },
});
/*
#swagger.responses[200] = {
description: 'Weak password error',
schema: {
$ref: '#/definitions/ChangePasswordSuccessResponse'
}
}
*/
} catch (error) {
res.status(500).send({ message: error.message });
}
};
const createInitialUser = async () => {
// extract some fields from the environment variables or from the CLI
const { initialUserUsername: username, initialUserPassword: password } =
config;
try {
// check if there is are users in the DB
const users = rowService.get({
tableName: USER_TABLE,
whereString: '',
whereStringValues: [],
});
if (users.length <= 0) {
// check if initial users username is passed from the env or CLI
if (!username) {
console.error(
'Error: You should pass the initial users username either from the CLI with the --iuu or from the environment variable using the INITIAL_USER_USERNAME flag',
);
process.exit(1);
}
// check if initial users password is passed from the env or CLI
if (!password) {
console.error(
'Error: You should pass the initial users password either from the CLI with the --iup or from the environment variable using the INITIAL_USER_PASSWORD flag',
);
process.exit(1);
}
// check if the usernmae is taken
const users = rowService.get({
tableName: USER_TABLE,
whereString: 'WHERE username=?',
whereStringValues: [username],
});
if (users.length > 0) {
console.error(
'Error: The username you passed for the initial user is already taken, please use another username',
);
process.exit(1);
}
// check if the password is strong
if (
[apiConstants.PASSWORD.TOO_WEAK, apiConstants.PASSWORD.WEAK].includes(
checkPasswordStrength(password),
)
) {
console.error(
'Error: The password you passed for the initial user is weak, please use another password',
);
process.exit(1);
}
// hash the password
const { hashedPassword, salt } = await hashPassword(password, 10);
// create the initial user
rowService.save({
tableName: USER_TABLE,
fields: {
username,
hashed_password: hashedPassword,
salt,
is_superuser: 'false',
},
});
console.log('Initial user created');
} else {
console.log('Initial user is already created');
}
} catch (error) {
console.log(error);
}
};
const isUsernameTaken = (username) => {
let user = rowService.get({
tableName: USER_TABLE,
whereString: 'WHERE username=?',
whereStringValues: [username],
});
return user.length > 0;
};
module.exports = {
createDefaultTables,
updateSuperuser,
@@ -404,4 +706,6 @@ module.exports = {
obtainAccessToken,
refreshAccessToken,
changePassword,
createInitialUser,
isUsernameTaken,
};

View File

@@ -0,0 +1,313 @@
const supertest = require('supertest');
const app = require('../index');
const config = require('../config');
const { generateToken } = require('../utils');
const { testData } = require('../tests/testData');
const requestWithSupertest = supertest(app);
describe('Auth Endpoints', () => {
describe('User Endpoints', () => {
it('POST /tables/_users/rows should register a user', async () => {
const accessToken = await generateToken(
{ username: 'John', userId: 1, isSuperuser: true },
config.tokenSecret,
'1H',
);
const res = await requestWithSupertest
.post('/api/tables/_users/rows')
.set('Cookie', [`accessToken=${accessToken}`])
.send({
fields: {
username: testData.users.user1.username,
password: testData.strongPassword,
},
});
expect(res.status).toEqual(201);
expect(res.type).toEqual(expect.stringContaining('json'));
expect(res.body).toHaveProperty('message');
expect(res.body.message).toBe('Row Inserted');
});
it('POST /tables/_users/rows should throw 400 error if username is not passed', async () => {
const accessToken = await generateToken(
{ username: 'John', isSuperuser: true },
config.tokenSecret,
'1H',
);
const res = await requestWithSupertest
.post('/api/tables/_users/rows')
.set('Cookie', [`accessToken=${accessToken}`])
.send({
fields: { password: testData.strongPassword },
});
expect(res.status).toEqual(400);
expect(res.body.message).toBe('username is required');
});
it('POST /tables/_users/rows should throw 400 error if the password is not strong', async () => {
const accessToken = await generateToken(
{ username: 'John', isSuperuser: true },
config.tokenSecret,
'1H',
);
const res = await requestWithSupertest
.post('/api/tables/_users/rows')
.set('Cookie', [`accessToken=${accessToken}`])
.send({
fields: {
username: testData.users.user2.username,
password: testData.weakPassword,
},
});
expect(res.status).toEqual(400);
expect(res.body.message).toBe(
'This password is weak, please use another password',
);
});
it('POST /tables/_users/rows should throw 409 error if the username is taken', async () => {
const accessToken = await generateToken(
{ username: 'John', isSuperuser: true },
config.tokenSecret,
'1H',
);
const res = await requestWithSupertest
.post('/api/tables/_users/rows')
.set('Cookie', [`accessToken=${accessToken}`])
.send({
fields: {
username: testData.users.user1.username,
password: testData.strongPassword,
},
});
expect(res.status).toEqual(409);
expect(res.body.message).toBe('This username is taken');
});
it('GET /tables/_users/rows should return list of users', async () => {
const accessToken = await generateToken(
{ username: 'John', isSuperuser: true },
config.tokenSecret,
'1H',
);
const res = await requestWithSupertest
.get('/api/tables/_users/rows')
.set('Cookie', [`accessToken=${accessToken}`]);
expect(res.status).toEqual(200);
expect(res.body.data[0]).toHaveProperty('id');
expect(res.body.data[0]).toHaveProperty('username');
expect(res.body.data[0]).toHaveProperty('is_superuser');
expect(res.body.data[0]).toHaveProperty('createdAt');
});
it('GET /tables/_users/rows/:id should retrive a single user', async () => {
const accessToken = await generateToken(
{ username: 'John', isSuperuser: true },
config.tokenSecret,
'1H',
);
const res = await requestWithSupertest
.get('/api/tables/_users/rows/1')
.set('Cookie', [`accessToken=${accessToken}`]);
expect(res.status).toEqual(200);
expect(res.body.data[0]).toHaveProperty('id');
expect(res.body.data[0]).toHaveProperty('username');
expect(res.body.data[0]).toHaveProperty('is_superuser');
expect(res.body.data[0]).toHaveProperty('createdAt');
});
it('PUT /tables/_users/rows/:id should update a user', async () => {
const accessToken = await generateToken(
{ username: 'John', isSuperuser: true },
config.tokenSecret,
'1H',
);
const res = await requestWithSupertest
.put('/api/tables/_users/rows/1')
.set('Cookie', [`accessToken=${accessToken}`])
.send({
fields: {
username: testData.users.user3.username,
},
});
expect(res.status).toEqual(200);
});
it('PUT /tables/_users/rows/:id should throw a 409 error if the username is taken', async () => {
const accessToken = await generateToken(
{ username: 'John', isSuperuser: true },
config.tokenSecret,
'1H',
);
const res = await requestWithSupertest
.put('/api/tables/_users/rows/1')
.set('Cookie', [`accessToken=${accessToken}`])
.send({
fields: {
username: testData.users.user1.username, //A user with user1.username is already created in the first test suite
},
});
expect(res.status).toEqual(409);
expect(res.body.message).toEqual('This username is already taken');
});
it('DELETE /tables/_users/rows/:id should remove a user', async () => {
const accessToken = await generateToken(
{ username: 'John', isSuperuser: true },
config.tokenSecret,
'1H',
);
const res = await requestWithSupertest
.delete('/api/tables/_users/rows/2')
.set('Cookie', [`accessToken=${accessToken}`]);
expect(res.status).toEqual(400);
expect(res.body.message).toBe('FOREIGN KEY constraint failed');
});
});
describe('Obtain Access Token Endpoint', () => {
it('POST /auth/token/obtain should return an access token and refresh token values and a success message', async () => {
const res = await requestWithSupertest
.post('/api/auth/token/obtain')
.send({
fields: {
username: testData.users.user1.username,
password: testData.strongPassword,
},
});
expect(res.status).toEqual(201);
expect(res.type).toEqual(expect.stringContaining('json'));
expect(res.body).toHaveProperty('message');
expect(res.body.message).toBe('Success');
});
it('POST /auth/token/obtain should throw a 401 error if the username does not exist in the DB', async () => {
const res = await requestWithSupertest
.post('/api/auth/token/obtain')
.send({
fields: {
username: testData.invalidUsername,
password: testData.strongPassword,
},
});
expect(res.status).toEqual(401);
expect(res.type).toEqual(expect.stringContaining('json'));
expect(res.body).toHaveProperty('message');
expect(res.body.message).toBe('Invalid username or password');
});
it('POST /auth/token/obtain should throw a 401 error if the password is invalid', async () => {
const res = await requestWithSupertest
.post('/api/auth/token/obtain')
.send({
fields: {
username: testData.users.user1.username,
password: testData.invalidPassword,
},
});
expect(res.status).toEqual(401);
expect(res.type).toEqual(expect.stringContaining('json'));
expect(res.body).toHaveProperty('message');
expect(res.body.message).toBe('Invalid username or password');
});
});
describe('Refresh Access Token Endpoint', () => {
it('GET /auth/token/refresh should refresh the access and refresh tokens', async () => {
const accessToken = await generateToken(
{ username: 'John', userId: 1, isSuperuser: true },
config.tokenSecret,
'1H',
);
const refreshToken = await generateToken(
{ username: 'John', userId: 1, isSuperuser: true },
config.tokenSecret,
'1H',
);
const res = await requestWithSupertest
.get('/api/auth/token/refresh')
.set('Cookie', [
`accessToken=${accessToken}`,
`refreshToken=${refreshToken}`,
]);
expect(res.status).toEqual(200);
expect(res.type).toEqual(expect.stringContaining('json'));
expect(res.body).toHaveProperty('message');
expect(res.body.message).toBe('Success');
});
});
describe('Change Password Endpoint', () => {
it('PUT /auth/change-password/ should change a password', async () => {
const accessToken = await generateToken(
{ username: 'John', userId: 2, isSuperuser: true },
config.tokenSecret,
'1H',
);
const res = await requestWithSupertest
.put('/api/auth/change-password')
.set('Cookie', [`accessToken=${accessToken}`])
.send({
fields: {
currentPassword: testData.strongPassword,
newPassword: testData.strongPassword2,
},
});
expect(res.status).toEqual(200);
expect(res.type).toEqual(expect.stringContaining('json'));
expect(res.body).toHaveProperty('message');
expect(res.body.message).toBe('Password updated successfully');
});
it('PUT /auth/change-password/ should throw 401 error if the current password is not valid', async () => {
const accessToken = await generateToken(
{ username: 'John', userId: 2, isSuperuser: true },
config.tokenSecret,
'1H',
);
const res = await requestWithSupertest
.put('/api/auth/change-password')
.set('Cookie', [`accessToken=${accessToken}`])
.send({
fields: {
currentPassword: testData.invalidPassword,
newPassword: testData.strongPassword2,
},
});
expect(res.status).toEqual(401);
expect(res.type).toEqual(expect.stringContaining('json'));
expect(res.body).toHaveProperty('message');
expect(res.body.message).toBe('Invalid current password');
});
});
});

View File

@@ -2,6 +2,9 @@ const { not } = require('joi');
const supertest = require('supertest');
const app = require('../index');
const config = require('../config');
const { generateToken } = require('../utils');
const requestWithSupertest = supertest(app);
function queryString(params) {
@@ -14,7 +17,15 @@ function queryString(params) {
describe('Rows Endpoints', () => {
it('GET /tables/:name/rows should return a list of all rows', async () => {
const res = await requestWithSupertest.get('/api/tables/users/rows');
const accessToken = await generateToken(
{ username: 'John', isSuperuser: true },
config.tokenSecret,
'1H',
);
const res = await requestWithSupertest
.get('/api/tables/users/rows')
.set('Cookie', [`accessToken=${accessToken}`]);
expect(res.status).toEqual(200);
expect(res.type).toEqual(expect.stringContaining('json'));
@@ -26,17 +37,23 @@ describe('Rows Endpoints', () => {
});
it('GET /tables/:name/rows?_limit=8&_schema=firstName,lastName&_ordering:-firstName&_page=2: should query the rows by the provided query params', async () => {
const accessToken = await generateToken(
{ username: 'John', isSuperuser: true },
config.tokenSecret,
'1H',
);
const params = {
_search: 'a',
_ordering: '-firstName',
_schema: 'firstName,lastName',
_limit: 8,
_page: 2
_page: 2,
};
const query = queryString(params);
const res = await requestWithSupertest.get(
`/api/tables/users/rows?${query}`
);
const res = await requestWithSupertest
.get(`/api/tables/users/rows?${query}`)
.set('Cookie', [`accessToken=${accessToken}`]);
expect(res.status).toEqual(200);
expect(res.type).toEqual(expect.stringContaining('json'));
@@ -48,34 +65,46 @@ describe('Rows Endpoints', () => {
expect(res.body.next).toEqual(
`/tables/users/rows?${queryString({
...params,
_page: params._page + 1
}).toString()}`
_page: params._page + 1,
}).toString()}`,
);
expect(res.body.previous).toEqual(
`/tables/users/rows?${queryString({
...params,
_page: params._page - 1
}).toString()}`
_page: params._page - 1,
}).toString()}`,
);
});
it('GET /tables/:name/rows: should return a null field', async () => {
const res = await requestWithSupertest.get(
'/api/tables/users/rows?_filters=firstName__null,lastName__notnull'
const accessToken = await generateToken(
{ username: 'John', isSuperuser: true },
config.tokenSecret,
'1H',
);
const res = await requestWithSupertest
.get('/api/tables/users/rows?_filters=firstName__null,lastName__notnull')
.set('Cookie', [`accessToken=${accessToken}`]);
expect(res.status).toEqual(200);
expect(res.body.data[0].firstName).toBeNull();
expect(res.body.data[0].lastName).not.toBeNull();
});
it('GET /tables/:name/rows: should successfully retrieve users created after 2010-01-01 00:00:00.', async () => {
const date = '2010-01-01 00:00:00';
const res = await requestWithSupertest.get(
`/api/tables/users/rows?_filters=createdAt__gte:${date}`
const accessToken = await generateToken(
{ username: 'John', isSuperuser: true },
config.tokenSecret,
'1H',
);
const date = '2010-01-01 00:00:00';
const res = await requestWithSupertest
.get(`/api/tables/users/rows?_filters=createdAt__gte:${date}`)
.set('Cookie', [`accessToken=${accessToken}`]);
res.body.data.map((user) => {
const createdAt = new Date(user.createdAt);
const referenceDate = new Date(date);
@@ -90,11 +119,17 @@ describe('Rows Endpoints', () => {
});
it('GET /tables/:name/rows: should successfully retrieve users created before 2008-01-20 00:00:00.', async () => {
const date = '2008-01-20 00:00:00';
const res = await requestWithSupertest.get(
`/api/tables/users/rows?_filters=createdAt__lte:${date}`
const accessToken = await generateToken(
{ username: 'John', isSuperuser: true },
config.tokenSecret,
'1H',
);
const date = '2008-01-20 00:00:00';
const res = await requestWithSupertest
.get(`/api/tables/users/rows?_filters=createdAt__lte:${date}`)
.set('Cookie', [`accessToken=${accessToken}`]);
res.body.data.map((user) => {
const createdAt = new Date(user.createdAt);
const referenceDate = new Date(date);
@@ -109,11 +144,17 @@ describe('Rows Endpoints', () => {
});
it('GET /tables/:name/rows: should successfully retrieve users created at 2013-01-08 00:00:00', async () => {
const date = '2013-01-08 00:00:00';
const res = await requestWithSupertest.get(
`/api/tables/users/rows?_filters=createdAt__eq:${date}`
const accessToken = await generateToken(
{ username: 'John', isSuperuser: true },
config.tokenSecret,
'1H',
);
const date = '2013-01-08 00:00:00';
const res = await requestWithSupertest
.get(`/api/tables/users/rows?_filters=createdAt__eq:${date}`)
.set('Cookie', [`accessToken=${accessToken}`]);
res.body.data.map((user) => {
const createdAt = new Date(user.createdAt);
const referenceDate = new Date(date);
@@ -128,22 +169,34 @@ describe('Rows Endpoints', () => {
});
it('GET /tables/:name/rows: should successfully retrieve users created at 2007-01-08 00:00:00', async () => {
const date = '2007-01-08 00:00:00';
const res = await requestWithSupertest.get(
`/api/tables/users/rows?_filters=createdAt__eq:${date}`
const accessToken = await generateToken(
{ username: 'John', isSuperuser: true },
config.tokenSecret,
'1H',
);
const date = '2007-01-08 00:00:00';
const res = await requestWithSupertest
.get(`/api/tables/users/rows?_filters=createdAt__eq:${date}`)
.set('Cookie', [`accessToken=${accessToken}`]);
//There are no users that are created at 2007-01-08 00:00:00 so the API should return empty data
expect(res.body.data).toHaveLength(0);
expect(res.status).toEqual(200);
});
it('GET /tables/:name/rows: should successfully retrieve users that are not created at 2021-01-08 00:00:00', async () => {
const date = '2021-01-08 00:00:00';
const res = await requestWithSupertest.get(
`/api/tables/users/rows?_filters=createdAt__neq:${date}`
const accessToken = await generateToken(
{ username: 'John', isSuperuser: true },
config.tokenSecret,
'1H',
);
const date = '2021-01-08 00:00:00';
const res = await requestWithSupertest
.get(`/api/tables/users/rows?_filters=createdAt__neq:${date}`)
.set('Cookie', [`accessToken=${accessToken}`]);
res.body.data.map((user) => {
const createdAt = new Date(user.createdAt);
const referenceDate = new Date(date);
@@ -158,16 +211,33 @@ describe('Rows Endpoints', () => {
});
it('POST /tables/:name/rows should insert a new row and return the lastInsertRowid', async () => {
const accessToken = await generateToken(
{ username: 'John', isSuperuser: true },
config.tokenSecret,
'1H',
);
const res = await requestWithSupertest
.post('/api/tables/users/rows')
.set('Cookie', [`accessToken=${accessToken}`])
.send({ fields: { firstName: 'Jane', lastName: 'Doe' } });
expect(res.status).toEqual(201);
expect(res.type).toEqual(expect.stringContaining('json'));
expect(res.body).toHaveProperty('data');
});
it('GET /tables/:name/rows/:pks should return a row by its primary key', async () => {
const res = await requestWithSupertest.get('/api/tables/users/rows/1');
const accessToken = await generateToken(
{ username: 'John', isSuperuser: true },
config.tokenSecret,
'1H',
);
const res = await requestWithSupertest
.get('/api/tables/users/rows/1')
.set('Cookie', [`accessToken=${accessToken}`]);
expect(res.status).toEqual(200);
expect(res.type).toEqual(expect.stringContaining('json'));
expect(res.body).toHaveProperty('data');
@@ -177,37 +247,65 @@ describe('Rows Endpoints', () => {
});
it('PUT /tables/:name/rows/:pks should update a row by its primary key and return the number of changes', async () => {
const accessToken = await generateToken(
{ username: 'John', isSuperuser: true },
config.tokenSecret,
'1H',
);
const res = await requestWithSupertest
.put('/api/tables/users/rows/1')
.set('Cookie', [`accessToken=${accessToken}`])
.send({ fields: { firstName: 'Jane', lastName: 'Doe' } });
expect(res.status).toEqual(200);
expect(res.type).toEqual(expect.stringContaining('json'));
});
it('DELETE /tables/:name/rows/:pks should delete a row by its primary key and return the number of changes', async () => {
const res = await requestWithSupertest.delete('/api/tables/users/rows/1');
const accessToken = await generateToken(
{ username: 'John', isSuperuser: true },
config.tokenSecret,
'1H',
);
const res = await requestWithSupertest
.delete('/api/tables/users/rows/1')
.set('Cookie', [`accessToken=${accessToken}`]);
expect(res.status).toEqual(200);
expect(res.type).toEqual(expect.stringContaining('json'));
});
it('POST /tables/:name/rows should insert a new row if any of the value of the object being inserted is null', async () => {
const res = await requestWithSupertest.post('/api/tables/users/rows').send({
fields: {
firstName: null,
lastName: 'Doe',
email: null,
username: 'Jane'
}
});
const accessToken = await generateToken(
{ username: 'John', isSuperuser: true },
config.tokenSecret,
'1H',
);
const res = await requestWithSupertest
.post('/api/tables/users/rows')
.send({
fields: {
firstName: null,
lastName: 'Doe',
email: null,
username: 'Jane',
},
})
.set('Cookie', [`accessToken=${accessToken}`]);
expect(res.status).toEqual(201);
expect(res.type).toEqual(expect.stringContaining('json'));
expect(res.body).toHaveProperty('data');
});
it('GET /tables/:name/rows should return values if any of the IDs from the array match the user ID.', async () => {
const res = await requestWithSupertest.get(
'/api/tables/users/rows?_filters=id:[2,3]'
const accessToken = await generateToken(
{ username: 'John', isSuperuser: true },
config.tokenSecret,
'1H',
);
const res = await requestWithSupertest
.get('/api/tables/users/rows?_filters=id:[2,3]')
.set('Cookie', [`accessToken=${accessToken}`]);
expect(res.status).toEqual(200);
expect(res.body).toHaveProperty('data');
expect(res.body.data).toEqual(expect.any(Array));
@@ -215,9 +313,17 @@ describe('Rows Endpoints', () => {
});
it('GET /tables/:name/rows should return values if the provided ID matches the user ID.', async () => {
const res = await requestWithSupertest.get(
'/api/tables/users/rows?_filters=id:2,firstName:Michael,lastName:Lee'
const accessToken = await generateToken(
{ username: 'John', isSuperuser: true },
config.tokenSecret,
'1H',
);
const res = await requestWithSupertest
.get(
'/api/tables/users/rows?_filters=id:2,firstName:Michael,lastName:Lee',
)
.set('Cookie', [`accessToken=${accessToken}`]);
expect(res.status).toEqual(200);
expect(res.body).toHaveProperty('data');
expect(res.body.data).toEqual(expect.any(Array));

View File

@@ -1,11 +1,23 @@
const supertest = require('supertest');
const app = require('../index');
const { generateToken } = require('../utils');
const config = require('../config');
const requestWithSupertest = supertest(app);
describe('Tables Endpoints', () => {
it('GET /tables should return a list of all tables', async () => {
const res = await requestWithSupertest.get('/api/tables');
const accessToken = await generateToken(
{ username: 'John', isSuperuser: true },
config.tokenSecret,
'1H',
);
const res = await requestWithSupertest
.get('/api/tables')
.set('Cookie', [`accessToken=${accessToken}`]);
expect(res.status).toEqual(200);
expect(res.type).toEqual(expect.stringContaining('json'));
expect(res.body).toHaveProperty('data');
@@ -14,33 +26,42 @@ describe('Tables Endpoints', () => {
});
it('POST /tables should create a new table and return generated schema', async () => {
const res = await requestWithSupertest.post('/api/tables').send({
name: 'pets',
autoAddCreatedAt: true,
autoAddUpdatedAt: false,
schema: [
{
name: 'owner',
type: 'INTEGER',
foreignKey: {
table: 'users',
column: 'id',
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
const accessToken = await generateToken(
{ username: 'John', isSuperuser: true },
config.tokenSecret,
'1H',
);
const res = await requestWithSupertest
.post('/api/tables')
.send({
name: 'pets',
autoAddCreatedAt: true,
autoAddUpdatedAt: false,
schema: [
{
name: 'owner',
type: 'INTEGER',
foreignKey: {
table: 'users',
column: 'id',
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
},
},
{
name: 'name',
type: 'TEXT',
notNull: true,
},
{
name: 'petId',
unique: true,
type: 'INTEGER',
},
],
});
{
name: 'name',
type: 'TEXT',
notNull: true,
},
{
name: 'petId',
unique: true,
type: 'INTEGER',
},
],
})
.set('Cookie', [`accessToken=${accessToken}`]);
expect(res.status).toEqual(201);
expect(res.type).toEqual(expect.stringContaining('json'));
@@ -53,7 +74,16 @@ describe('Tables Endpoints', () => {
});
it('GET /tables/:name should return schema of the table', async () => {
const res = await requestWithSupertest.get('/api/tables/users');
const accessToken = await generateToken(
{ username: 'John', isSuperuser: true },
config.tokenSecret,
'1H',
);
const res = await requestWithSupertest
.get('/api/tables/users')
.set('Cookie', [`accessToken=${accessToken}`]);
expect(res.status).toEqual(200);
expect(res.type).toEqual(expect.stringContaining('json'));
expect(res.body).toHaveProperty('data');

View File

@@ -98,7 +98,7 @@ module.exports = {
type: 'NUMERIC',
primaryKey: false,
notNull: true,
unique: true,
unique: false,
foreignKey: { table: '_users', column: 'id' },
},

View File

@@ -19,7 +19,11 @@ const authRoutes = require('./routes/auth');
const swaggerFile = require('./swagger/swagger.json');
const { setupExtensions } = require('./extensions');
const { createDefaultTables } = require('./controllers/auth');
const {
createDefaultTables,
createInitialUser,
} = require('./controllers/auth');
const { runCLICommands } = require('./commands');
const app = express();
@@ -75,9 +79,10 @@ if (config.rateLimit.enabled) {
app.use(limiter);
}
//If Auth mode is activated then create auth tables in the DB
//If Auth mode is activated then create auth tables in the DB & create a super user if there are no users in the DB
if (config.auth) {
createDefaultTables();
createInitialUser();
} else {
console.warn(
'Warning: Soul is running in open mode without authentication or authorization for API endpoints. Please be aware that your API endpoints will not be secure.',

View File

@@ -1,10 +1,15 @@
const config = require('../config');
const { registerUser } = require('../controllers/auth');
const { apiConstants } = require('../constants/');
const { registerUser, isUsernameTaken } = require('../controllers/auth');
const { apiConstants, dbConstants } = require('../constants/');
const { removeFields } = require('../utils');
const processRequest = async (req, res, next) => {
const { SALT, HASHED_PASSWORD, IS_SUPERUSER } = apiConstants.fields._users;
const { reservedTableNames } = dbConstants;
const processRowRequest = async (req, res, next) => {
const resource = req.params.name;
const method = req.method;
const { method, body } = req;
const fields = body.fields;
// If the user sends a request when auth is set to false, throw an error
if (apiConstants.defaultRoutes.includes(resource) && !config.auth) {
@@ -13,15 +18,33 @@ const processRequest = async (req, res, next) => {
});
}
// Execute user registration function
if (resource === '_users' && method === 'POST' && config.auth) {
// Redirect this request to the registerUser controller => POST /api/tables/_users/rows
if (resource === '_users' && method === 'POST') {
return registerUser(req, res);
}
// Remove some fields for this request and check the username field => PUT /api/tables/_users/rows
if (resource === '_users' && method === 'PUT') {
// check if the username is taken
if (fields.username) {
if (isUsernameTaken(fields.username)) {
return res
.status(409)
.send({ message: 'This username is already taken' });
}
}
/**
* remove some user fields from the request like (is_superuser, hashed_password, salt).
* NOTE: password can be updated via the /change-password API and superuser status can be only updated from the CLI
*/
removeFields([req.body.fields], [SALT, IS_SUPERUSER, HASHED_PASSWORD]);
}
next();
};
const processResponse = async (req, res, next) => {
const processRowResponse = async (req, res, next) => {
// Extract payload data
const resource = req.params.name;
const status = req.response.status;
@@ -29,24 +52,30 @@ const processResponse = async (req, res, next) => {
// Remove some fields from the response
if (resource === '_users') {
removeFields(payload.data, ['salt', 'hashed_password']);
removeFields(payload.data, [SALT, HASHED_PASSWORD]);
}
res.status(status).send(payload);
next();
};
const removeFields = async (rows, fields) => {
const newPayload = rows.map((row) => {
fields.map((field) => {
delete row[field];
});
});
const processTableRequest = async (req, res, next) => {
const { method, body, baseUrl } = req;
return newPayload;
// if the user tries to create a table with the reserved table names throw an error. Request => POST /api/tables
if (baseUrl === apiConstants.baseTableUrl && method === 'POST') {
if (reservedTableNames.includes(body.name)) {
return res.status(409).send({
message: `The table name is reserved. Please choose a different name for the table. Table name: ${body.name}.`,
});
}
}
next();
};
module.exports = {
processRequest,
processResponse,
processRowRequest,
processRowResponse,
processTableRequest,
};

View File

@@ -1,18 +1,68 @@
const config = require('../config');
const { decodeToken } = require('../utils/index');
const { decodeToken, toBoolean } = require('../utils/index');
const httpVerbs = require('../constants/httpVerbs');
const isAuthenticated = async (req, res, next) => {
let payload;
const { name: tableName } = req.params;
const verb = req.method;
try {
// extract the payload from the token and verify it
const payload = await decodeToken(
req.cookies.accessToken,
config.jwtSecret,
);
if (config.auth) {
// extract the payload from the token and verify it
try {
payload = await decodeToken(
req.cookies.accessToken,
config.tokenSecret,
);
req.user = payload;
} catch (error) {
return res.status(403).send({ message: 'Invalid access token' });
}
req.user = payload;
// if the user is a super_user, allow access on the resource
if (toBoolean(payload.isSuperuser)) {
return next();
}
next();
// if table_name is not passed from the router throw unauthorized error
if (!tableName) {
return res.status(403).send({ message: 'Not authorized' });
}
// if the user is not a super user, check the users permission on the resource
const permissions = payload.permissions.filter((row) => {
return row.table_name === tableName;
});
if (permissions.length <= 0) {
return res
.status(403)
.send({ message: 'Permission not defined for this role' });
}
// If the user has permission on the table in at least in one of the roles then allow access on the table
let hasPermission = false;
permissions.some((resource) => {
const httpMethod = httpVerbs[verb].toLowerCase();
if (toBoolean(resource[httpMethod])) {
hasPermission = true;
return true;
}
});
if (hasPermission) {
next();
} else {
return res.status(403).send({ message: 'Not authorized' });
}
} else {
next();
}
} catch (error) {
console.log(error);
res.status(401).send({ message: error.message });
}
};

View File

@@ -3,39 +3,46 @@ const express = require('express');
const controllers = require('../controllers/rows');
const { broadcast } = require('../middlewares/broadcast');
const { validator } = require('../middlewares/validation');
const { processRequest, processResponse } = require('../middlewares/api');
const { processRowRequest, processRowResponse } = require('../middlewares/api');
const { isAuthenticated } = require('../middlewares/auth');
const schema = require('../schemas/rows');
const router = express.Router();
router.get(
'/:name/rows',
isAuthenticated,
validator(schema.listTableRows),
processRequest,
processRowRequest,
controllers.listTableRows,
processResponse,
processRowResponse,
);
router.post(
'/:name/rows',
isAuthenticated,
validator(schema.insertRowInTable),
processRequest,
processRowRequest,
controllers.insertRowInTable,
broadcast,
);
router.get(
'/:name/rows/:pks',
isAuthenticated,
validator(schema.getRowInTableByPK),
controllers.getRowInTableByPK,
processResponse,
processRowResponse,
);
router.put(
'/:name/rows/:pks',
isAuthenticated,
validator(schema.updateRowInTableByPK),
processRowRequest,
controllers.updateRowInTableByPK,
broadcast,
);
router.delete(
'/:name/rows/:pks',
isAuthenticated,
validator(schema.deleteRowInTableByPK),
controllers.deleteRowInTableByPK,
broadcast,

View File

@@ -3,16 +3,38 @@ const express = require('express');
const controllers = require('../controllers/tables');
const { validator } = require('../middlewares/validation');
const schema = require('../schemas/tables');
const { isAuthenticated } = require('../middlewares/auth');
const { processTableRequest } = require('../middlewares/api');
const router = express.Router();
router.get('/', validator(schema.listTables), controllers.listTables);
router.post('/', validator(schema.createTable), controllers.createTable);
router.get(
'/',
isAuthenticated,
validator(schema.listTables),
controllers.listTables,
);
router.post(
'/',
processTableRequest,
isAuthenticated,
validator(schema.createTable),
controllers.createTable,
);
router.get(
'/:name',
isAuthenticated,
validator(schema.getTableSchema),
controllers.getTableSchema
controllers.getTableSchema,
);
router.delete(
'/:name',
isAuthenticated,
validator(schema.deleteTable),
controllers.deleteTable,
);
router.delete('/:name', validator(schema.deleteTable), controllers.deleteTable);
module.exports = router;

View File

@@ -20,7 +20,7 @@ const refreshAccessToken = Joi.object({
const changePassword = Joi.object({
query: Joi.object().required(),
params: Joi.object({ userId: Joi.string().required() }).required(),
params: Joi.object().required(),
body: Joi.object({
fields: Joi.object({

View File

@@ -37,7 +37,9 @@ module.exports = (db) => {
save(data) {
// wrap text values in quotes
const fieldsString = Object.keys(data.fields).join(', ');
const fieldsString = Object.keys(data.fields)
.map((field) => `'${field}'`)
.join(', ');
// wrap text values in quotes
const valuesString = Object.values(data.fields).map((value) => value);

View File

@@ -31,6 +31,10 @@ const doc = {
name: 'Rows',
description: 'Rows endpoints',
},
{
name: 'Auth',
description: 'Auth endpoints',
},
],
securityDefinitions: {},
definitions: {
@@ -122,6 +126,69 @@ const doc = {
TransactionRequestBody: {
$ref: '#/definitions/Transaction',
},
ObtainAccessTokenRequestBody: {
fields: {
username: '@john',
password: 'Ak22#cPM33@v*#',
},
},
ObtainAccessTokenSuccessResponse: {
message: 'Success',
data: {
userId: 1,
},
},
InvalidCredentialErrorResponse: {
message: 'Invalid username or password',
},
UserRegisterationRequestBody: {
fields: {
username: '@john',
password: 'Ak22#cPM33@v*#',
},
},
WeakPasswordErrorResponse: {
message: 'This password is weak, please use another password',
},
UsernameTakenErrorResponse: {
message: 'This username is taken',
},
DefaultRoleNotCreatedErrorResponse: {
message: 'Please restart soul so a default role can be created',
},
UserNotFoundErrorResponse: {
message: 'User not found',
},
InvalidRefreshTokenErrorResponse: {
message: 'Invalid refresh token',
},
ChangePasswordRequestBody: {
fields: {
currentPassword: 'Ak22#cPM33@v*#',
newPassword: 'hKB33o@3245CD$',
},
},
ChangePasswordSuccessResponse: {
message: 'Password updated successfully',
data: { id: 1, username: '@john' },
},
RefreshAccessTokenSuccessResponse: {
message: 'Success',
data: { userId: 1 },
},
InvalidPasswordErrorResponse: { message: 'Invalid password' },
},
};

View File

@@ -19,6 +19,10 @@
{
"name": "Rows",
"description": "Rows endpoints"
},
{
"name": "Auth",
"description": "Auth endpoints"
}
],
"schemes": ["http", "https"],
@@ -102,6 +106,12 @@
},
"400": {
"description": "Bad Request"
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
}
}
},
@@ -131,6 +141,15 @@
"schema": {
"$ref": "#/definitions/CreateTableErrorResponse"
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
},
"409": {
"description": "Conflict"
}
}
}
@@ -155,6 +174,12 @@
},
"400": {
"description": "Bad Request"
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
}
}
},
@@ -177,6 +202,12 @@
},
"400": {
"description": "Bad Request"
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
}
}
}
@@ -242,8 +273,14 @@
"400": {
"description": "Bad Request"
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
},
"409": {
"description": "Conflict"
}
}
},
@@ -281,8 +318,14 @@
"$ref": "#/definitions/InsertRowErrorResponse"
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
},
"409": {
"description": "Conflict"
}
}
}
@@ -333,6 +376,12 @@
"400": {
"description": "Bad Request"
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
}
@@ -399,6 +448,15 @@
},
"400": {
"description": "Bad Request"
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
},
"409": {
"description": "Conflict"
}
}
},
@@ -436,6 +494,12 @@
"400": {
"description": "Bad Request"
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
}
@@ -444,30 +508,34 @@
},
"/api/auth/token/obtain": {
"post": {
"description": "",
"tags": ["Auth"],
"summary": "Obtain Access Token",
"description": "Endpoint to generate access and refresh tokens",
"parameters": [
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"type": "object",
"properties": {
"fields": {
"example": "any"
}
}
"$ref": "#/definitions/ObtainAccessTokenRequestBody"
}
}
],
"responses": {
"201": {
"description": "Created"
"description": "Access token and Refresh token generated",
"schema": {
"$ref": "#/definitions/ObtainAccessTokenSuccessResponse"
}
},
"400": {
"description": "Bad Request"
},
"401": {
"description": "Unauthorized"
"description": "Invalid username or password error",
"schema": {
"$ref": "#/definitions/InvalidCredentialErrorResponse"
}
},
"404": {
"description": "Not Found"
@@ -480,47 +548,68 @@
},
"/api/auth/token/refresh": {
"get": {
"description": "",
"tags": ["Auth"],
"summary": "Refresh Access Token",
"description": "Endpoint to refresh access and refresh tokens",
"parameters": [],
"responses": {
"201": {
"description": "Created"
"200": {
"description": "Access token refreshed",
"schema": {
"$ref": "#/definitions/RefreshAccessTokenSuccessResponse"
}
},
"400": {
"description": "Bad Request"
},
"401": {
"description": "Unauthorized"
"description": "Invalid refresh token error",
"schema": {
"$ref": "#/definitions/InvalidRefreshTokenErrorResponse"
}
},
"403": {
"description": "Forbidden"
}
}
}
},
"/api/auth/change-password": {
"put": {
"description": "",
"tags": ["Auth"],
"summary": "Change Password",
"description": "Endpoint to change a password",
"parameters": [
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"type": "object",
"properties": {
"fields": {
"example": "any"
}
}
"$ref": "#/definitions/ChangePasswordRequestBody"
}
}
],
"responses": {
"201": {
"description": "Created"
"200": {
"description": "Weak password error",
"schema": {
"$ref": "#/definitions/ChangePasswordSuccessResponse"
}
},
"400": {
"description": "Bad Request"
"description": "Weak password error",
"schema": {
"$ref": "#/definitions/WeakPasswordErrorResponse"
}
},
"401": {
"description": "Unauthorized"
"description": "User not found error",
"schema": {
"$ref": "#/definitions/InvalidPasswordErrorResponse"
}
},
"403": {
"description": "Forbidden"
},
"500": {
"description": "Internal Server Error"
@@ -802,6 +891,181 @@
},
"TransactionRequestBody": {
"$ref": "#/definitions/Transaction"
},
"ObtainAccessTokenRequestBody": {
"type": "object",
"properties": {
"fields": {
"type": "object",
"properties": {
"username": {
"type": "string",
"example": "@john"
},
"password": {
"type": "string",
"example": "Ak22#cPM33@v*#"
}
}
}
}
},
"ObtainAccessTokenSuccessResponse": {
"type": "object",
"properties": {
"message": {
"type": "string",
"example": "Success"
},
"data": {
"type": "object",
"properties": {
"userId": {
"type": "number",
"example": 1
}
}
}
}
},
"InvalidCredentialErrorResponse": {
"type": "object",
"properties": {
"message": {
"type": "string",
"example": "Invalid username or password"
}
}
},
"UserRegisterationRequestBody": {
"type": "object",
"properties": {
"fields": {
"type": "object",
"properties": {
"username": {
"type": "string",
"example": "@john"
},
"password": {
"type": "string",
"example": "Ak22#cPM33@v*#"
}
}
}
}
},
"WeakPasswordErrorResponse": {
"type": "object",
"properties": {
"message": {
"type": "string",
"example": "This password is weak, please use another password"
}
}
},
"UsernameTakenErrorResponse": {
"type": "object",
"properties": {
"message": {
"type": "string",
"example": "This username is taken"
}
}
},
"DefaultRoleNotCreatedErrorResponse": {
"type": "object",
"properties": {
"message": {
"type": "string",
"example": "Please restart soul so a default role can be created"
}
}
},
"UserNotFoundErrorResponse": {
"type": "object",
"properties": {
"message": {
"type": "string",
"example": "User not found"
}
}
},
"InvalidRefreshTokenErrorResponse": {
"type": "object",
"properties": {
"message": {
"type": "string",
"example": "Invalid refresh token"
}
}
},
"ChangePasswordRequestBody": {
"type": "object",
"properties": {
"fields": {
"type": "object",
"properties": {
"currentPassword": {
"type": "string",
"example": "Ak22#cPM33@v*#"
},
"newPassword": {
"type": "string",
"example": "hKB33o@3245CD$"
}
}
}
}
},
"ChangePasswordSuccessResponse": {
"type": "object",
"properties": {
"message": {
"type": "string",
"example": "Password updated successfully"
},
"data": {
"type": "object",
"properties": {
"id": {
"type": "number",
"example": 1
},
"username": {
"type": "string",
"example": "@john"
}
}
}
}
},
"RefreshAccessTokenSuccessResponse": {
"type": "object",
"properties": {
"message": {
"type": "string",
"example": "Success"
},
"data": {
"type": "object",
"properties": {
"userId": {
"type": "number",
"example": 1
}
}
}
}
},
"InvalidPasswordErrorResponse": {
"type": "object",
"properties": {
"message": {
"type": "string",
"example": "Invalid password"
}
}
}
}
}

View File

@@ -1,6 +1,5 @@
const fs = require('fs');
const { unlink } = require('fs/promises');
const db = require('../db/index');
const { testNames } = require('./testData');
@@ -18,7 +17,7 @@ const dropTestDatabase = async (path = 'test.db') => {
if (fs.existsSync(path + '-wal')) {
try {
await Promise.allSettled(unlink(path + '-wal'), unlink(path + '-shm'));
await Promise.allSettled([unlink(path + '-wal'), unlink(path + '-shm')]);
} catch (error) {
console.error('there was an error:', error);
}
@@ -27,13 +26,13 @@ const dropTestDatabase = async (path = 'test.db') => {
const createTestTable = (table = 'users') => {
db.prepare(
`CREATE TABLE ${table} (id INTEGER PRIMARY KEY, firstName TEXT, lastName TEXT, email TEXT, username TEXT, createdAt TEXT)`
`CREATE TABLE ${table} (id INTEGER PRIMARY KEY, firstName TEXT, lastName TEXT, email TEXT, username TEXT, createdAt TEXT)`,
).run();
};
const insertIntoTestTable = (table = 'users') => {
const statement = db.prepare(
`INSERT INTO ${table} (firstName, lastName, createdAt) VALUES (?, ?, ?)`
`INSERT INTO ${table} (firstName, lastName, createdAt) VALUES (?, ?, ?)`,
);
for (const user of testNames) {
@@ -45,5 +44,5 @@ module.exports = {
dropTestTable,
dropTestDatabase,
createTestTable,
insertIntoTestTable
insertIntoTestTable,
};

View File

@@ -6,37 +6,37 @@ const testNames = [
{
firstName: 'Olivia',
lastName: 'William',
createdAt: '2012-01-08 00:00:00'
createdAt: '2012-01-08 00:00:00',
},
{ firstName: 'William', lastName: 'Kim', createdAt: '2013-01-08 00:00:00' },
{ firstName: 'Sophia', lastName: 'Singh', createdAt: '2013-02-08 00:00:00' },
{
firstName: 'James',
lastName: 'Rodriguez',
createdAt: '2013-03-08 00:00:00'
createdAt: '2013-03-08 00:00:00',
},
{ firstName: 'Ava', lastName: 'Patel', createdAt: '2013-01-04 00:00:00' },
{
firstName: 'Benjamin',
lastName: 'Garcia',
createdAt: '2015-01-08 00:00:00'
createdAt: '2015-01-08 00:00:00',
},
{
firstName: 'Isabella',
lastName: 'Nguyen',
createdAt: '2014-01-08 00:00:00'
createdAt: '2014-01-08 00:00:00',
},
{ firstName: 'Ethan', lastName: 'Lee', createdAt: '2016-01-08 00:00:00' },
{ firstName: 'Mia', lastName: 'Wilson', createdAt: '2017-01-08 00:00:00' },
{
firstName: 'Alexander',
lastName: 'William',
createdAt: '2018-01-08 00:00:00'
createdAt: '2018-01-08 00:00:00',
},
{
firstName: 'Charlotte',
lastName: 'Hernandez',
createdAt: '2019-01-08 00:00:00'
createdAt: '2019-01-08 00:00:00',
},
{ firstName: 'Liam', lastName: 'Gonzalez', createdAt: '2020-01-08 00:00:00' },
{ firstName: 'Emma', lastName: 'Gomez', createdAt: '2021-01-08 00:00:00' },
@@ -46,17 +46,30 @@ const testNames = [
{
firstName: 'Abigail',
lastName: 'Williams',
createdAt: '2023-02-10 00:00:00'
createdAt: '2023-02-10 00:00:00',
},
{ firstName: 'Elijah', lastName: 'Hall', createdAt: '2023-04-02 00:00:00' },
{ firstName: 'Mila', lastName: 'Flores', createdAt: '2023-05-13 00:00:00' },
{
firstName: 'Evelyn',
lastName: 'Morales',
createdAt: '2023-06-05 00:00:00'
createdAt: '2023-06-05 00:00:00',
},
{ firstName: 'Logan', lastName: 'Collins', createdAt: '2023-06-07 00:00:00' },
{ firstName: null, lastName: 'Flores', createdAt: '2023-06-09 00:00:00' }
{ firstName: null, lastName: 'Flores', createdAt: '2023-06-09 00:00:00' },
];
module.exports = { testNames };
const testData = {
strongPassword: 'HeK34#C44DMJ',
strongPassword2: 'Mk22#c9@Cv!K',
weakPassword: '12345678',
invalidUsername: 'invalid_username',
invalidPassword: 'invalid_password',
users: {
user1: { username: 'Jane' },
user2: { username: 'Mike' },
user3: { username: 'John' },
},
};
module.exports = { testNames, testData };

View File

@@ -32,10 +32,47 @@ const decodeToken = async (token, secret) => {
}
};
const toBoolean = (value) => {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'string') {
const lowerCaseValue = value.toLowerCase();
if (lowerCaseValue === 'true') {
return true;
} else if (lowerCaseValue === 'false') {
return false;
}
}
if (typeof value === 'number') {
if (value === 1) {
return true;
} else if (value === 0) {
return false;
}
}
throw new Error('Invalid value. Cannot convert to boolean.');
};
const removeFields = async (rows, fields) => {
const newPayload = rows.map((row) => {
fields.map((field) => {
delete row[field];
});
});
return newPayload;
};
module.exports = {
hashPassword,
comparePasswords,
checkPasswordStrength,
generateToken,
decodeToken,
toBoolean,
removeFields,
};