Merge pull request #148 from thevahidal/authentication_and_authorization

Add feature to create default db tables
This commit is contained in:
Ian Mayo
2024-03-11 11:02:53 +00:00
committed by GitHub
36 changed files with 3193 additions and 892 deletions

View File

@@ -3,6 +3,9 @@
# Top-most EditorConfig file
root = true
[*.{js,jsx,ts,tsx}]
quote_type = single
[*]
# Set default charset to utf-8
charset = utf-8

View File

@@ -3,11 +3,22 @@ CORE_PORT=8000
NODE_ENV=development
CORS_ORIGIN_WHITELIST=http://localhost:3000,http://127.0.0.1:3000
AUTH=false
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
TOKEN_SECRET=ABCD23DCAA
ACCESS_TOKEN_EXPIRATION_TIME=10H
REFRESH_TOKEN_EXPIRATION_TIME=2D

0
.vscode/settings.json vendored Normal file
View File

View File

@@ -19,6 +19,8 @@ Install Soul CLI with npm
## Usage
### 1. Running Soul
Soul is command line tool, after installing it,
Run `soul -d sqlite.db -p 8000` and it'll start a REST API on [http://localhost:8000](http://localhost:8000) and a Websocket server on [ws://localhost:8000](ws://localhost:8000).
@@ -32,6 +34,15 @@ Options:
-p, --port Port to listen on [number]
-r, --rate-limit-enabled Enable rate limiting [boolean]
-c, --cors CORS whitelist origins [string]
-a, --auth Enable authentication and authorization [boolean]
-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]
-rtet, --refreshtokenexpirationtime Refresh Token Expiration Time [string]
-S, --studio Start Soul Studio in parallel
--help Show help
@@ -45,6 +56,50 @@ curl http://localhost:8000/api/tables
It should return a list of the tables inside `sqlite.db` database.
### 2. Running Soul in Auth mode
To run Soul in auth mode, allowing login and signup features with authorization capabilities in your database tables, follow these steps:
Run the Soul command with the necessary parameters:
```
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 `-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.
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
- 60M: Represents a duration of 60 minutes.
- 5H: Represents a duration of 5 hours.
- 1D: Represents a duration of 1 day.
NOTE: It is crucial to securely store a copy of the `-ts`(`Token Secret`) value used in Soul. Once you pass this values, make sure to keep a backup because you will need it every time you restart Soul. Losing this secret values can result in a situation where all of your users are blocked from accessing Soul.
### 3. Updating Super Users
To modify a superuser information in a database, you can utilize the `updatesuperuser` command. This command allows you to change a superuser's `password` or upgrade/downgrade a normal user to a `superuser`. Below is an example of how to use it:
```
soul --d foobar.db updatesuperuser --id=1 password=<new_password_for_the_user> // Update the password for the superuser with ID 1
soul --d foobar.db updatesuperuser --id=1 --is_superuser=true // Upgrade the user with ID 1 to a superuser
soul --d foobar.db updatesuperuser --id=1 --is_superuser=false // Revoke the superuser role from the superuser with ID 1
```
## Documentation
API documentation is available while the project is running at [http://localhost:8000/api/docs](http://localhost:8000/api/docs)
@@ -63,8 +118,8 @@ A collection of projects that revolve around the Soul ecosystem.
- [Soul Studio](https://github.com/thevahidal/soul-studio) provides a GUI to work with your database.
Right now Soul Studio is in early stages of development and not useful to work with.
Right now Soul Studio is in early stages of development and not useful to work with.
<p align="center">
<img src='docs/soul-studio.png' style="">
</p>
@@ -83,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.
@@ -91,7 +154,6 @@ npm run dev # Start the dev server
[MIT](https://choosealicense.com/licenses/mit/)
## Contributing
Contributions are always welcome!

1166
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -27,14 +27,18 @@
},
"homepage": "https://github.com/thevahidal/soul#readme",
"dependencies": {
"bcrypt": "^5.1.1",
"better-sqlite3": "^8.1.0",
"body-parser": "^1.20.2",
"check-password-strength": "^2.0.7",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"dotenv": "^16.0.3",
"express": "^4.18.2",
"express-rate-limit": "^6.7.0",
"express-winston": "^4.2.0",
"joi": "^17.8.3",
"jsonwebtoken": "^9.0.2",
"soul-studio": "^0.0.1",
"swagger-ui-express": "^4.6.1",
"winston": "^3.8.2",

View File

@@ -46,11 +46,69 @@ if (process.env.NO_CLI !== 'true') {
type: 'string',
demandOption: false,
})
.options('a', {
alias: 'auth',
describe: 'Enable authentication and authorization',
type: 'boolean',
default: false,
demandOption: false,
})
.options('ts', {
alias: 'tokensecret',
describe: 'JWT secret for the access and refresh tokens',
type: 'string',
default: null,
demandOption: false,
})
.options('atet', {
alias: 'accesstokenexpirationtime',
describe: 'JWT expiration time for access token',
type: 'string',
default: '5H',
demandOption: false,
})
.options('rtet', {
alias: 'refreshtokenexpirationtime',
describe: 'JWT expiration time for refresh token',
type: 'string',
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',
type: 'boolean',
demandOption: false
demandOption: false,
})
.command('updatesuperuser', 'Update a superuser', (yargs) => {
return yargs
.option('id', {
describe: 'The ID of the superuser you want to update',
type: 'number',
demandOption: true,
})
.option('password', {
describe: 'The new password for the superuser you want to update',
type: 'string',
demandOption: false,
})
.option('is_superuser', {
describe: 'The role of the superuser you want to update',
type: 'boolean',
demandOption: false,
});
})
.help(true).argv;
}

22
src/commands.js Normal file
View File

@@ -0,0 +1,22 @@
const { yargs } = require('./cli');
const { updateSuperuser } = require('./controllers/auth');
const { argv } = yargs;
const runCLICommands = () => {
//If the updatesuperuser command is passed from the CLI execute the updatesuperuser function
if (argv._.includes('updatesuperuser')) {
const { id, password, is_superuser } = argv;
if (!password && !is_superuser) {
console.log(
'Please provide either the --password or --is_superuser flag when using the updateuser command.',
);
process.exit(1);
} else {
updateSuperuser({ id, password, is_superuser });
}
}
};
module.exports = { runCLICommands };

View File

@@ -2,7 +2,7 @@ const dotenv = require('dotenv');
const Joi = require('joi');
const path = require('path');
const { yargs, usage, options } = require('../cli');
const { yargs } = require('../cli');
const { argv } = yargs;
@@ -20,6 +20,7 @@ const envVarsSchema = Joi.object()
VERBOSE: Joi.string().valid('console', null).default(null),
CORS_ORIGIN_WHITELIST: Joi.string().default('*'),
AUTH: Joi.boolean().default(false),
RATE_LIMIT_ENABLED: Joi.boolean().default(false),
RATE_LIMIT_WINDOW_MS: Joi.number().positive().default(1000),
@@ -27,7 +28,14 @@ const envVarsSchema = Joi.object()
EXTENSIONS: Joi.string().default(null),
START_WITH_STUDIO: Joi.boolean().default(false)
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'),
})
.unknown();
@@ -51,10 +59,34 @@ if (argv.cors) {
env.CORS_ORIGIN_WHITELIST = argv.cors;
}
if (argv.auth) {
env.AUTH = argv.auth;
}
if (argv['rate-limit-enabled']) {
env.RATE_LIMIT_ENABLED = argv['rate-limit-enabled'];
}
if (argv.tokensecret) {
env.TOKEN_SECRET = argv.tokensecret;
}
if (argv.accesstokenexpirationtime) {
env.ACCESS_TOKEN_EXPIRATION_TIME = argv.accesstokenexpirationtime;
}
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);
@@ -80,6 +112,19 @@ module.exports = {
origin: argv.cors?.split(',') ||
envVars.CORS_ORIGIN_WHITELIST?.split(',') || ['*'],
},
auth: argv.auth || envVars.AUTH,
tokenSecret: argv.tokensecret || envVars.TOKEN_SECRET,
accessTokenExpirationTime:
argv.accesstokenexpirationtime || envVars.ACCESS_TOKEN_EXPIRATION_TIME,
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,
@@ -90,5 +135,5 @@ module.exports = {
path: argv.extensions || envVars.EXTENSIONS,
},
startWithStudio: argv.studio || envVars.START_WITH_STUDIO
startWithStudio: argv.studio || envVars.START_WITH_STUDIO,
};

17
src/constants/api.js Normal file
View File

@@ -0,0 +1,17 @@
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: {
TOO_WEAK: 'Too weak',
WEAK: 'Weak',
},
};

View File

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

5
src/constants/index.js Normal file
View File

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

3
src/constants/roles.js Normal file
View File

@@ -0,0 +1,3 @@
module.exports = {
DEFAULT_ROLE: 'default',
};

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',
};

711
src/controllers/auth.js Normal file
View File

@@ -0,0 +1,711 @@
const { tableService, rowService } = require('../services');
const { constantRoles, apiConstants, dbConstants } = require('../constants');
const schema = require('../db/schema');
const config = require('../config');
const {
hashPassword,
checkPasswordStrength,
comparePasswords,
generateToken,
decodeToken,
toBoolean,
} = require('../utils');
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(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(USER_TABLE, schema.userSchema);
}
// create _users_roles table
if (!usersRolesTable) {
// create the _users_roles table
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(ROLE_TABLE, schema.roleSchema);
// create a default role in the _roles table
const role = rowService.save({
tableName: ROLE_TABLE,
fields: { name: constantRoles.DEFAULT_ROLE },
});
roleId = role.lastInsertRowid;
}
// create _roles_permissions table
if (!rolesPermissionTable && roleId) {
// create the _roles_permissions table
tableService.createTable(
ROLE_PERMISSIONS_TABLE,
schema.rolePermissionSchema,
{
multipleUniqueConstraints: {
name: 'unique_role_table',
fields: ['role_id', 'table_name'],
},
},
);
// fetch all DB tables
const tables = tableService.listTables();
// add permission for the default role (for each db table)
const permissions = [];
for (const table of tables) {
permissions.push({
role_id: roleId,
table_name: table.name,
create: 'false',
read: 'true',
update: 'false',
delete: 'false',
});
}
// store the permissions in the db
rowService.bulkWrite({
tableName: ROLE_PERMISSIONS_TABLE,
fields: permissions,
});
}
};
const updateSuperuser = async (fields) => {
const { id, password, is_superuser } = fields;
let newHashedPassword, newSalt;
let fieldsString = '';
try {
// find the user by using the id field
const users = rowService.get({
tableName: USER_TABLE,
whereString: 'WHERE id=?',
whereStringValues: [id],
});
// abort if the id is invalid
if (users.length === 0) {
console.log('The user id you passed does not exist in the database');
process.exit(1);
}
// check if the is_superuser field is passed
if (is_superuser !== undefined) {
fieldsString = `is_superuser = '${is_superuser}'`;
}
// if the password is sent from the CLI, update it
if (password) {
// check if the password is weak
if (
[apiConstants.PASSWORD.TOO_WEAK, apiConstants.PASSWORD.WEAK].includes(
checkPasswordStrength(password),
)
) {
console.log('Your password should be at least 8 charachters long');
process.exit(1);
}
//hash the password
const { hashedPassword, salt } = await hashPassword(password, 10);
newHashedPassword = hashedPassword;
newSalt = salt;
fieldsString = `${
fieldsString ? fieldsString + ', ' : ''
}hashed_password = '${newHashedPassword}', salt = '${newSalt}'`;
}
// update the user
rowService.update({
tableName: USER_TABLE,
lookupField: `id`,
fieldsString,
pks: `${id}`,
});
console.log(
'User updated successfully, you can now restart soul without the updateuser command',
);
process.exit(1);
} catch (error) {
console.log(error);
}
};
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: 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
if (
[apiConstants.PASSWORD.TOO_WEAK, apiConstants.PASSWORD.WEAK].includes(
checkPasswordStrength(password),
)
) {
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
const { salt, hashedPassword } = await hashPassword(password, 10);
// create the user
const newUser = rowService.save({
tableName: USER_TABLE,
fields: {
username,
salt,
hashed_password: hashedPassword,
is_superuser: 'false',
},
});
// find the default role from the DB
let defaultRole = rowService.get({
tableName: ROLE_TABLE,
whereString: 'WHERE name=?',
whereStringValues: [constantRoles.DEFAULT_ROLE],
});
if (defaultRole.length <= 0) {
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_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 });
}
};
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
const users = rowService.get({
tableName: USER_TABLE,
whereString: 'WHERE username=?',
whereStringValues: [username],
});
if (users.length <= 0) {
return res.status(401).send({ message: 'Invalid username or password' });
}
// check if the password is valid
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'
}
}
*/
}
let userRoles, permissions, roleIds;
// 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,
isSuperuser: user.is_superuser,
roleIds,
permissions,
};
// generate an access token
const accessToken = await generateToken(
{ subject: 'accessToken', ...payload },
config.tokenSecret,
config.accessTokenExpirationTime,
);
// generate a refresh token
const refreshToken = await generateToken(
{ subject: 'refreshToken', ...payload },
config.tokenSecret,
config.refreshTokenExpirationTime,
);
// set the token in the cookie
let cookieOptions = { httpOnly: true, secure: false, Path: '/' };
res.cookie('accessToken', accessToken, cookieOptions);
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({
message: error.message,
error: error,
});
}
};
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(
req.cookies.refreshToken,
config.tokenSecret,
);
// find the user
const users = rowService.get({
tableName: USER_TABLE,
whereString: 'WHERE id=?',
whereStringValues: [payload.userId],
});
if (users.length <= 0) {
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];
// 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', ...newPayload },
config.tokenSecret,
config.accessTokenExpirationTime,
);
// generate a refresh token
const refreshToken = await generateToken(
{ subject: 'refreshToken', ...newPayload },
config.tokenSecret,
config.refreshTokenExpirationTime,
);
// set the token in the cookie
let cookieOptions = { httpOnly: true, secure: false, Path: '/' };
res.cookie('accessToken', accessToken, cookieOptions);
res.cookie('refreshToken', refreshToken, cookieOptions);
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(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: USER_TABLE,
whereString: 'WHERE id=?',
whereStringValues: [userInfo.userId],
});
if (users.length <= 0) {
return res.status(401).send({ message: 'User not found' });
}
const user = users[0];
// check if the users current password is valid
const isMatch = await comparePasswords(
currentPassword,
user.hashed_password,
);
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
if (
[apiConstants.PASSWORD.TOO_WEAK, apiConstants.PASSWORD.WEAK].includes(
checkPasswordStrength(newPassword),
)
) {
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
const { salt, hashedPassword } = await hashPassword(newPassword, 10);
user.salt = salt;
user.hashed_password = hashedPassword;
// update the user
rowService.update({
tableName: USER_TABLE,
lookupField: `id`,
fieldsString: `hashed_password = '${hashedPassword}', salt = '${salt}'`,
pks: `${user.id}`,
});
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,
registerUser,
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

@@ -1,12 +1,6 @@
const db = require('../db/index');
const { rowService } = require('../services');
const quotePrimaryKeys = (pks) => {
const primaryKeys = pks.split(',');
const quotedPks = primaryKeys.map((id) => `'${id}'`).join(',');
return quotedPks;
};
const operators = {
eq: '=',
lt: '<',
@@ -15,11 +9,11 @@ const operators = {
gte: '>=',
neq: '!=',
null: 'IS NULL',
notnull: 'IS NOT NULL'
notnull: 'IS NOT NULL',
};
// Return paginated rows of a table
const listTableRows = async (req, res) => {
const listTableRows = async (req, res, next) => {
/*
#swagger.tags = ['Rows']
#swagger.summary = 'List Rows'
@@ -66,7 +60,7 @@ const listTableRows = async (req, res) => {
_ordering,
_schema,
_extend,
_filters = ''
_filters = '',
} = req.query;
const page = parseInt(_page);
@@ -80,7 +74,7 @@ const listTableRows = async (req, res) => {
let filters = [];
// split the filters by comma(,) except when in an array
const re = /,(?![^\[]*?\])/;
const re = /,(?![^[]*?\])/;
try {
filters = _filters.split(re).map((filter) => {
//NOTE: When using the _filter parameter, the values are split using the ":" sign, like this (_filters=Total__eq:1). However, if the user sends a date value, such as (_filters=InvoiceDate__eq:2010-01-08 00:00:00), there will be additional colon (":") signs present.
@@ -98,7 +92,7 @@ const listTableRows = async (req, res) => {
fieldOperator = 'eq';
} else if (!operators[fieldOperator]) {
throw new Error(
`Invalid field operator '${fieldOperator}' for field '${field}'. You can only use the following operators after the '${field}' field: __lt, __gt, __lte, __gte, __eq, __neq.`
`Invalid field operator '${fieldOperator}' for field '${field}'. You can only use the following operators after the '${field}' field: __lt, __gt, __lte, __gte, __eq, __neq.`,
);
}
@@ -112,7 +106,7 @@ const listTableRows = async (req, res) => {
} catch (error) {
return res.status(400).json({
message: error.message,
error: error
error: error,
});
}
@@ -161,7 +155,7 @@ const listTableRows = async (req, res) => {
} catch (error) {
return res.status(400).json({
message: error.message,
error: error
error: error,
});
}
}
@@ -236,7 +230,7 @@ const listTableRows = async (req, res) => {
if (!foreignKey) {
throw new Error(
`Foreign key not found for extended field '${extendedField}'`
`Foreign key not found for extended field '${extendedField}'`,
);
}
@@ -254,7 +248,7 @@ const listTableRows = async (req, res) => {
joinedTableFields
.map(
(joinedTableField) =>
`'${joinedTableField.name}', ${joinedTableName}.${joinedTableField.name}`
`'${joinedTableField.name}', ${joinedTableName}.${joinedTableField.name}`,
)
.join(', ') +
' ) as ' +
@@ -277,7 +271,7 @@ const listTableRows = async (req, res) => {
if (foreignKeyError.error) {
return res.status(400).json({
message: foreignKeyError.message,
error: foreignKeyError.error
error: foreignKeyError.error,
});
}
}
@@ -291,7 +285,7 @@ const listTableRows = async (req, res) => {
orderString,
limit,
page: limit * (page - 1),
whereStringValues
whereStringValues,
});
// parse json extended files
@@ -311,10 +305,10 @@ const listTableRows = async (req, res) => {
const total = rowService.getCount({
tableName,
whereString,
whereStringValues
whereStringValues,
});
const next =
const nextPage =
data.length === limit
? `/tables/${tableName}/rows?${params}_limit=${_limit}&_page=${
page + 1
@@ -327,16 +321,15 @@ const listTableRows = async (req, res) => {
}`
: null;
res.json({
data,
total,
next,
previous
});
req.response = {
status: 200,
payload: { data, total, next: nextPage, previous },
};
next();
} catch (error) {
res.status(400).json({
message: error.message,
error: error
error: error,
});
}
};
@@ -367,7 +360,7 @@ const insertRowInTable = async (req, res, next) => {
// Remove null values from fields for accurate query construction.
const fields = Object.fromEntries(
Object.entries(queryFields).filter(([_, value]) => value !== null)
Object.entries(queryFields).filter(([, value]) => value !== null),
);
try {
@@ -383,14 +376,15 @@ const insertRowInTable = async (req, res, next) => {
*/
res.status(201).json({
message: 'Row inserted',
data
data,
});
req.broadcast = {
type: 'INSERT',
data: {
pk: data.lastInsertRowid,
...fields
}
...fields,
},
};
next();
} catch (error) {
@@ -404,13 +398,13 @@ const insertRowInTable = async (req, res, next) => {
*/
res.status(400).json({
message: error.message,
error: error
error: error,
});
}
};
// Get a row by pk
const getRowInTableByPK = async (req, res) => {
const getRowInTableByPK = async (req, res, next) => {
/*
#swagger.tags = ['Rows']
#swagger.summary = 'Retrieve Row'
@@ -465,7 +459,7 @@ const getRowInTableByPK = async (req, res) => {
} catch (error) {
return res.status(400).json({
message: error.message,
error: error
error: error,
});
}
}
@@ -542,7 +536,7 @@ const getRowInTableByPK = async (req, res) => {
joinedTableFields
.map(
(joinedTableField) =>
`'${joinedTableField.name}', ${joinedTableName}.${joinedTableField.name}`
`'${joinedTableField.name}', ${joinedTableName}.${joinedTableField.name}`,
)
.join(', ') +
' ) as ' +
@@ -557,7 +551,7 @@ const getRowInTableByPK = async (req, res) => {
} catch (error) {
return res.status(400).json({
message: error.message,
error: error
error: error,
});
}
});
@@ -569,7 +563,7 @@ const getRowInTableByPK = async (req, res) => {
tableName,
extendString,
lookupField,
pks
pks,
});
// parse json extended files
@@ -588,17 +582,16 @@ const getRowInTableByPK = async (req, res) => {
if (data.length === 0) {
return res.status(404).json({
message: 'Row not found',
error: 'not_found'
error: 'not_found',
});
} else {
res.json({
data
});
req.response = { status: 200, payload: { data } };
next();
}
} catch (error) {
return res.status(400).json({
message: error.message,
error: error
error: error,
});
}
};
@@ -652,7 +645,7 @@ const updateRowInTableByPK = async (req, res, next) => {
} catch (error) {
return res.status(400).json({
message: error.message,
error: error
error: error,
});
}
}
@@ -671,7 +664,7 @@ const updateRowInTableByPK = async (req, res, next) => {
if (fieldsString === '') {
return res.status(400).json({
message: 'No fields provided',
error: 'no_fields_provided'
error: 'no_fields_provided',
});
}
@@ -680,26 +673,26 @@ const updateRowInTableByPK = async (req, res, next) => {
tableName,
fieldsString,
lookupField,
pks
pks,
});
res.json({
message: 'Row updated',
data
data,
});
req.broadcast = {
type: 'UPDATE',
_lookup_field: lookupField,
data: {
pks: pks.split(','),
...fields
}
...fields,
},
};
next();
} catch (error) {
res.status(400).json({
message: error.message,
error: error
error: error,
});
}
};
@@ -744,7 +737,7 @@ const deleteRowInTableByPK = async (req, res, next) => {
} catch (error) {
return res.status(400).json({
message: error.message,
error: error
error: error,
});
}
}
@@ -754,26 +747,26 @@ const deleteRowInTableByPK = async (req, res, next) => {
if (data.changes === 0) {
res.status(404).json({
error: 'not_found'
error: 'not_found',
});
} else {
res.json({
message: 'Row deleted',
data
data,
});
req.broadcast = {
type: 'DELETE',
_lookup_field: lookupField,
data: {
pks: pks.split(',')
}
pks: pks.split(','),
},
};
next();
}
} catch (error) {
res.status(400).json({
message: error.message,
error: error
error: error,
});
}
};
@@ -783,5 +776,5 @@ module.exports = {
insertRowInTable,
getRowInTableByPK,
updateRowInTableByPK,
deleteRowInTableByPK
deleteRowInTableByPK,
};

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');

114
src/db/schema.js Normal file
View File

@@ -0,0 +1,114 @@
module.exports = {
roleSchema: [
{
name: 'name',
type: 'TEXT',
primaryKey: false,
notNull: true,
unique: true,
},
],
userSchema: [
{
name: 'username',
type: 'TEXT',
primaryKey: false,
notNull: true,
unique: true,
},
{
name: 'hashed_password',
type: 'TEXT',
primaryKey: false,
notNull: true,
unique: true,
},
{
name: 'salt',
type: 'TEXT',
primaryKey: false,
notNull: true,
unique: false,
},
{
name: 'is_superuser',
type: 'BOOLEAN',
primaryKey: false,
notNull: true,
unique: false,
},
],
rolePermissionSchema: [
{
name: 'role_id',
type: 'NUMERIC',
primaryKey: false,
notNull: true,
unique: false,
foreignKey: { table: '_roles', column: 'id' },
},
{
name: 'table_name',
type: 'TEXT',
primaryKey: false,
notNull: true,
unique: false,
},
{
name: 'create',
type: 'BOOLEAN',
primaryKey: false,
notNull: true,
unique: false,
},
{
name: 'read',
type: 'BOOLEAN',
primaryKey: false,
notNull: true,
unique: false,
},
{
name: 'update',
type: 'BOOLEAN',
primaryKey: false,
notNull: true,
unique: false,
},
{
name: 'delete',
type: 'BOOLEAN',
primaryKey: false,
notNull: true,
unique: false,
},
],
usersRoleSchema: [
{
name: 'user_id',
type: 'NUMERIC',
primaryKey: false,
notNull: true,
unique: false,
foreignKey: { table: '_users', column: 'id' },
},
{
name: 'role_id',
type: 'NUMERIC',
primaryKey: false,
notNull: true,
unique: false,
foreignKey: { table: '_roles', column: 'id' },
},
],
};

View File

@@ -1,37 +1,47 @@
#! /usr/bin/env node
const express = require("express");
const bodyParser = require("body-parser");
const winston = require("winston");
const expressWinston = require("express-winston");
const cors = require("cors");
const rateLimit = require("express-rate-limit");
const swaggerUi = require("swagger-ui-express");
const express = require('express');
const bodyParser = require('body-parser');
const winston = require('winston');
const expressWinston = require('express-winston');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
const swaggerUi = require('swagger-ui-express');
const cookieParser = require('cookie-parser');
const config = require("./config/index");
const db = require("./db/index");
const rootRoutes = require("./routes/index");
const tablesRoutes = require("./routes/tables");
const rowsRoutes = require("./routes/rows");
const swaggerFile = require("./swagger/swagger.json");
const { setupExtensions } = require("./extensions");
const config = require('./config/index');
const db = require('./db/index');
const rootRoutes = require('./routes/index');
const tablesRoutes = require('./routes/tables');
const rowsRoutes = require('./routes/rows');
const authRoutes = require('./routes/auth');
const swaggerFile = require('./swagger/swagger.json');
const { setupExtensions } = require('./extensions');
const {
createDefaultTables,
createInitialUser,
} = require('./controllers/auth');
const { runCLICommands } = require('./commands');
const app = express();
app.get("/health", (req, res) => {
res.send("OK");
app.get('/health', (req, res) => {
res.send('OK');
});
app.use(bodyParser.json());
app.use(cookieParser());
// Activate wal mode
db.exec("PRAGMA journal_mode = WAL");
db.exec('PRAGMA journal_mode = WAL');
// Enable CORS
let corsOrigin = config.cors.origin;
if (corsOrigin.includes("*")) {
corsOrigin = "*";
if (corsOrigin.includes('*')) {
corsOrigin = '*';
}
const corsOptions = { origin: corsOrigin };
@@ -49,13 +59,14 @@ if (config.verbose !== null) {
winston.format.json(),
),
meta: false,
msg: "HTTP {{req.method}} {{req.url}}",
msg: 'HTTP {{req.method}} {{req.url}}',
expressFormat: true,
colorize: false,
}),
);
}
if (config.rateLimit.enabled) {
const limiter = rateLimit({
windowMs: config.rateLimit.windowMs,
@@ -68,10 +79,25 @@ if (config.rateLimit.enabled) {
app.use(limiter);
}
app.use("/api/docs", swaggerUi.serve, swaggerUi.setup(swaggerFile));
app.use("/api", rootRoutes);
app.use("/api/tables", tablesRoutes);
app.use("/api/tables", rowsRoutes);
//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.',
);
}
// If the user has passed custom CLI commands run the command and exit to avoid running the server
runCLICommands();
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerFile));
app.use('/api', rootRoutes);
app.use('/api/tables', tablesRoutes);
app.use('/api/tables', rowsRoutes);
app.use('/api/auth', authRoutes);
setupExtensions(app, db);

81
src/middlewares/api.js Normal file
View File

@@ -0,0 +1,81 @@
const config = require('../config');
const { registerUser, isUsernameTaken } = require('../controllers/auth');
const { apiConstants, dbConstants } = require('../constants/');
const { removeFields } = require('../utils');
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, 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) {
return res.status(403).send({
message: 'You can not access this endpoint while AUTH is set to false',
});
}
// 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 processRowResponse = async (req, res, next) => {
// Extract payload data
const resource = req.params.name;
const status = req.response.status;
const payload = req.response.payload;
// Remove some fields from the response
if (resource === '_users') {
removeFields(payload.data, [SALT, HASHED_PASSWORD]);
}
res.status(status).send(payload);
next();
};
const processTableRequest = async (req, res, next) => {
const { method, body, baseUrl } = req;
// 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 = {
processRowRequest,
processRowResponse,
processTableRequest,
};

70
src/middlewares/auth.js Normal file
View File

@@ -0,0 +1,70 @@
const config = require('../config');
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 {
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' });
}
// if the user is a super_user, allow access on the resource
if (toBoolean(payload.isSuperuser)) {
return 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 });
}
};
module.exports = { isAuthenticated };

View File

@@ -1,6 +1,6 @@
const { websocketSubscribers } = require('../websocket');
const broadcast = (req, res, next) => {
const broadcast = (req) => {
const data = req.broadcast;
const { name: tableName } = req.params;

29
src/routes/auth.js Normal file
View File

@@ -0,0 +1,29 @@
const express = require('express');
const controllers = require('../controllers/auth');
const { validator } = require('../middlewares/validation');
const schema = require('../schemas/auth');
const { isAuthenticated } = require('../middlewares/auth');
const router = express.Router();
router.post(
'/token/obtain',
validator(schema.obtainAccessToken),
controllers.obtainAccessToken,
);
router.get(
'/token/refresh',
validator(schema.refreshAccessToken),
controllers.refreshAccessToken,
);
router.put(
'/change-password',
validator(schema.changePassword),
isAuthenticated,
controllers.changePassword,
);
module.exports = router;

View File

@@ -3,37 +3,49 @@ const express = require('express');
const controllers = require('../controllers/rows');
const { broadcast } = require('../middlewares/broadcast');
const { validator } = require('../middlewares/validation');
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),
controllers.listTableRows
processRowRequest,
controllers.listTableRows,
processRowResponse,
);
router.post(
'/:name/rows',
isAuthenticated,
validator(schema.insertRowInTable),
processRowRequest,
controllers.insertRowInTable,
broadcast
broadcast,
);
router.get(
'/:name/rows/:pks',
isAuthenticated,
validator(schema.getRowInTableByPK),
controllers.getRowInTableByPK
controllers.getRowInTableByPK,
processRowResponse,
);
router.put(
'/:name/rows/:pks',
isAuthenticated,
validator(schema.updateRowInTableByPK),
processRowRequest,
controllers.updateRowInTableByPK,
broadcast
broadcast,
);
router.delete(
'/:name/rows/:pks',
isAuthenticated,
validator(schema.deleteRowInTableByPK),
controllers.deleteRowInTableByPK,
broadcast
broadcast,
);
module.exports = router;

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;

37
src/schemas/auth.js Normal file
View File

@@ -0,0 +1,37 @@
const Joi = require('joi');
const obtainAccessToken = Joi.object({
query: Joi.object().required(),
params: Joi.object({}).required(),
body: Joi.object({
fields: Joi.object({
username: Joi.string().required(),
password: Joi.string().required(),
}).required(),
}).required(),
});
const refreshAccessToken = Joi.object({
query: Joi.object().required(),
params: Joi.object({}).required(),
body: Joi.object({}).required(),
});
const changePassword = Joi.object({
query: Joi.object().required(),
params: Joi.object().required(),
body: Joi.object({
fields: Joi.object({
currentPassword: Joi.string().required(),
newPassword: Joi.string().required(),
}).required(),
}).required(),
});
module.exports = {
obtainAccessToken,
refreshAccessToken,
changePassword,
};

View File

@@ -1,7 +1,6 @@
#!/usr/bin/env node
const http = require('http');
const express = require('express');
const app = require('./index');
const { wss } = require('./websocket');
@@ -9,7 +8,9 @@ const config = require('./config/index');
if (config.startWithStudio) {
(async () => {
const { handler: soulStudioHandler } = await import('soul-studio/build/handler.js');
const { handler: soulStudioHandler } = await import(
'soul-studio/build/handler.js'
);
app.use('/studio', soulStudioHandler);
})();
}

View File

@@ -1,19 +1,27 @@
const { apiConstants } = require('../constants');
module.exports = (db) => {
return {
get(data) {
const query = `SELECT ${data.schemaString} FROM ${data.tableName} ${data.extendString} ${data.whereString} ${data.orderString} LIMIT ? OFFSET ?`;
const query = `SELECT ${data.schemaString || '*'} FROM ${
data.tableName
} ${data.extendString || ''} ${data.whereString || ''} ${
data.orderString || ''
} LIMIT ? OFFSET ?`;
const statement = db.prepare(query);
const result = statement.all(
...data.whereStringValues,
data.limit,
data.page
data.limit || apiConstants.DEFAULT_PAGE_LIMIT,
data.page || apiConstants.DEFAULT_PAGE_INDEX,
);
return result;
},
getById(data) {
const pks = data.pks.split(',');
const placeholders = pks.map((pk) => '?').join(',');
const placeholders = pks.map(() => '?').join(',');
const query = `SELECT ${data.schemaString} FROM ${data.tableName} ${data.extendString} WHERE ${data.tableName}.${data.lookupField} in (${placeholders})`;
const statement = db.prepare(query);
const result = statement.all(...pks);
@@ -29,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);
@@ -48,9 +58,28 @@ module.exports = (db) => {
return result;
},
bulkWrite(data) {
const { tableName, fields } = data;
const fieldNames = Object.keys(fields[0]);
const valueSets = fields.map((row) => Object.values(row));
const placeholders = fieldNames.map(() => '?');
const valuesString = valueSets
.map(() => `(${placeholders.join(',')})`)
.join(',');
const query = `INSERT INTO ${tableName} (${fieldNames
.map((field) => `'${field}'`)
.join(', ')}) VALUES ${valuesString}`;
const statement = db.prepare(query);
const result = statement.run(...valueSets.flat());
return result;
},
update(data) {
const pks = data.pks.split(',');
const placeholders = pks.map((pk) => '?').join(',');
const placeholders = pks.map(() => '?').join(',');
const query = `UPDATE ${data.tableName} SET ${data.fieldsString} WHERE ${data.lookupField} in (${placeholders})`;
const statement = db.prepare(query);
const result = statement.run(...pks);
@@ -59,7 +88,7 @@ module.exports = (db) => {
delete(data) {
const pks = data.pks.split(',');
const placeholders = pks.map((pk) => '?').join(',');
const placeholders = pks.map(() => '?').join(',');
const query = `DELETE FROM ${data.tableName} WHERE ${data.lookupField} in (${placeholders})`;
const statement = db.prepare(query);
const result = statement.run(...pks);

View File

@@ -1,5 +1,131 @@
module.exports = (db) => {
return {
get() {},
createTable(tableName, schema, options = {}) {
const {
autoAddCreatedAt = true,
autoAddUpdatedAt = true,
multipleUniqueConstraints,
} = options;
let indices = [];
let schemaString = schema
.map(({ name, type, notNull, unique, primaryKey, foreignKey }) => {
let column = `'${name}' '${type}'`;
if (notNull) {
column += ' NOT NULL';
}
if (unique) {
column += ' UNIQUE';
}
if (primaryKey) {
column += ' PRIMARY KEY';
}
if (foreignKey) {
column += ` REFERENCES ${foreignKey.table}(${foreignKey.column})`;
}
if (foreignKey && foreignKey.onDelete) {
column += ` ON DELETE ${foreignKey.onDelete}`;
}
if (foreignKey && foreignKey.onUpdate) {
column += ` ON UPDATE ${foreignKey.onUpdate}`;
}
return column;
})
.join(', ');
// add id if primary key is not defined
if (!schema.find((field) => field.primaryKey)) {
schemaString = `
id INTEGER PRIMARY KEY AUTOINCREMENT,
${schemaString}
`;
}
// add created at and updated at
if (autoAddCreatedAt) {
schemaString = `${schemaString}, createdAt DATETIME DEFAULT CURRENT_TIMESTAMP`;
}
if (autoAddUpdatedAt) {
schemaString = `${schemaString}, updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP`;
}
if (multipleUniqueConstraints) {
schemaString = `${schemaString}, CONSTRAINT ${
multipleUniqueConstraints.name
} UNIQUE (${multipleUniqueConstraints.fields
.map((field) => field)
.join(' ,')})`;
}
let indicesString = indices
.map((field) => {
return `
CREATE INDEX ${tableName}_${field}_index
ON ${tableName} (${field})
`;
})
.join(';');
const query = `CREATE TABLE ${tableName} (${schemaString})`;
try {
db.prepare(query).run();
if (indicesString) {
db.prepare(indicesString).run();
}
db.prepare(`PRAGMA table_info(${tableName})`).all();
} catch (error) {
console.log(error);
}
},
listTables(options = {}) {
const { search, ordering, exclude } = options;
let query = `SELECT name FROM sqlite_master WHERE type IN ('table', 'view')`;
// if search is provided, search the tables
// e.g. search=users
if (search) {
query += ` AND name LIKE $searchQuery`;
}
// if exclude is passed don't return the some tables
// e.g. exclude=['_users', '_roles']
if (exclude) {
const excludeTables = exclude.map((field) => `'${field}'`).join(' ,');
query += `AND name NOT IN (${excludeTables});`;
}
// if ordering is provided, order the tables
// e.g. ordering=name (ascending) or ?_ordering=-name (descending)
if (ordering) {
query += ` ORDER BY $ordering`;
}
try {
const tables = db.prepare(query).all({
searchQuery: `%${search}%`,
ordering: `${ordering?.replace('-', '')} ${
ordering?.startsWith('-') ? 'DESC' : 'ASC'
}`,
});
return tables;
} catch (error) {
console.log(error);
}
},
checkTableExists(tableName) {
const query = `SELECT name FROM sqlite_master WHERE type='table' AND name='${tableName}'`;
const result = db.prepare(query).get();
return result;
},
};
};

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,19 +19,16 @@
{
"name": "Rows",
"description": "Rows endpoints"
},
{
"name": "Auth",
"description": "Auth endpoints"
}
],
"schemes": [
"http",
"https"
],
"schemes": ["http", "https"],
"securityDefinitions": {},
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"consumes": ["application/json"],
"produces": ["application/json"],
"paths": {
"/health": {
"get": {
@@ -46,9 +43,7 @@
},
"/api/": {
"get": {
"tags": [
"Root"
],
"tags": ["Root"],
"summary": "Timestamp",
"description": "Endpoint to return server timestamp",
"parameters": [],
@@ -61,9 +56,7 @@
},
"/api/transaction": {
"post": {
"tags": [
"Root"
],
"tags": ["Root"],
"summary": "Transaction",
"description": "Endpoint to run any transaction, e.g. [{ \"query\": \"\" }, { \"statement\": \"\", \"values\": {} }, { \"query\": \"\" }]",
"parameters": [
@@ -88,9 +81,7 @@
},
"/api/tables/": {
"get": {
"tags": [
"Tables"
],
"tags": ["Tables"],
"summary": "List Tables",
"description": "Endpoint to list all tables",
"parameters": [
@@ -115,13 +106,17 @@
},
"400": {
"description": "Bad Request"
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
}
}
},
"post": {
"tags": [
"Tables"
],
"tags": ["Tables"],
"summary": "Create Table",
"description": "Endpoint to create a table",
"parameters": [
@@ -146,15 +141,22 @@
"schema": {
"$ref": "#/definitions/CreateTableErrorResponse"
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
},
"409": {
"description": "Conflict"
}
}
}
},
"/api/tables/{name}": {
"get": {
"tags": [
"Tables"
],
"tags": ["Tables"],
"summary": "Get Table Schema",
"description": "Endpoint to get the schema of a table",
"parameters": [
@@ -172,13 +174,17 @@
},
"400": {
"description": "Bad Request"
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
}
}
},
"delete": {
"tags": [
"Tables"
],
"tags": ["Tables"],
"summary": "Delete Table",
"description": "Endpoint to delete a table",
"parameters": [
@@ -196,15 +202,19 @@
},
"400": {
"description": "Bad Request"
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
}
}
}
},
"/api/tables/{name}/rows": {
"get": {
"tags": [
"Rows"
],
"tags": ["Rows"],
"summary": "List Rows",
"description": "Endpoint to list rows of a table.",
"parameters": [
@@ -260,18 +270,22 @@
}
],
"responses": {
"200": {
"description": "OK"
},
"400": {
"description": "Bad Request"
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
},
"409": {
"description": "Conflict"
}
}
},
"post": {
"tags": [
"Rows"
],
"tags": ["Rows"],
"summary": "Insert Row",
"description": "Insert a new row in a table",
"parameters": [
@@ -303,15 +317,22 @@
"schema": {
"$ref": "#/definitions/InsertRowErrorResponse"
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
},
"409": {
"description": "Conflict"
}
}
}
},
"/api/tables/{name}/rows/{pks}": {
"get": {
"tags": [
"Rows"
],
"tags": ["Rows"],
"summary": "Retrieve Row",
"description": "Retrieve a row by primary key",
"parameters": [
@@ -352,21 +373,22 @@
}
],
"responses": {
"200": {
"description": "OK"
},
"400": {
"description": "Bad Request"
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
}
}
},
"put": {
"tags": [
"Rows"
],
"tags": ["Rows"],
"summary": "Update Row",
"description": "Update a row by primary key",
"parameters": [
@@ -426,13 +448,20 @@
},
"400": {
"description": "Bad Request"
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
},
"409": {
"description": "Conflict"
}
}
},
"delete": {
"tags": [
"Rows"
],
"tags": ["Rows"],
"summary": "Delete Row",
"description": "Delete a row by primary key",
"parameters": [
@@ -465,11 +494,128 @@
"400": {
"description": "Bad Request"
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
}
}
}
},
"/api/auth/token/obtain": {
"post": {
"tags": ["Auth"],
"summary": "Obtain Access Token",
"description": "Endpoint to generate access and refresh tokens",
"parameters": [
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/ObtainAccessTokenRequestBody"
}
}
],
"responses": {
"201": {
"description": "Access token and Refresh token generated",
"schema": {
"$ref": "#/definitions/ObtainAccessTokenSuccessResponse"
}
},
"400": {
"description": "Bad Request"
},
"401": {
"description": "Invalid username or password error",
"schema": {
"$ref": "#/definitions/InvalidCredentialErrorResponse"
}
},
"404": {
"description": "Not Found"
},
"500": {
"description": "Internal Server Error"
}
}
}
},
"/api/auth/token/refresh": {
"get": {
"tags": ["Auth"],
"summary": "Refresh Access Token",
"description": "Endpoint to refresh access and refresh tokens",
"parameters": [],
"responses": {
"200": {
"description": "Access token refreshed",
"schema": {
"$ref": "#/definitions/RefreshAccessTokenSuccessResponse"
}
},
"400": {
"description": "Bad Request"
},
"401": {
"description": "Invalid refresh token error",
"schema": {
"$ref": "#/definitions/InvalidRefreshTokenErrorResponse"
}
},
"403": {
"description": "Forbidden"
}
}
}
},
"/api/auth/change-password": {
"put": {
"tags": ["Auth"],
"summary": "Change Password",
"description": "Endpoint to change a password",
"parameters": [
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/ChangePasswordRequestBody"
}
}
],
"responses": {
"200": {
"description": "Weak password error",
"schema": {
"$ref": "#/definitions/ChangePasswordSuccessResponse"
}
},
"400": {
"description": "Weak password error",
"schema": {
"$ref": "#/definitions/WeakPasswordErrorResponse"
}
},
"401": {
"description": "User not found error",
"schema": {
"$ref": "#/definitions/InvalidPasswordErrorResponse"
}
},
"403": {
"description": "Forbidden"
},
"500": {
"description": "Internal Server Error"
}
}
}
}
},
"definitions": {
@@ -718,11 +864,7 @@
"properties": {
"pks": {
"type": "array",
"example": [
1,
2,
3
],
"example": [1, 2, 3],
"items": {
"type": "number"
}
@@ -740,11 +882,7 @@
"properties": {
"pks": {
"type": "array",
"example": [
1,
2,
3
],
"example": [1, 2, 3],
"items": {
"type": "number"
}
@@ -753,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 };

78
src/utils/index.js Normal file
View File

@@ -0,0 +1,78 @@
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { passwordStrength } = require('check-password-strength');
const hashPassword = async (password, saltRounds) => {
const salt = await bcrypt.genSalt(saltRounds);
const hashedPassword = await bcrypt.hash(password, saltRounds);
return { hashedPassword, salt };
};
const comparePasswords = async (plainPassword, hashedPassword) => {
const isMatch = await bcrypt.compare(plainPassword, hashedPassword);
return isMatch;
};
const checkPasswordStrength = (password) => {
const value = passwordStrength(password).value;
return value;
};
const generateToken = async (payload, secret, expiresIn) => {
return jwt.sign(payload, secret, { expiresIn });
};
const decodeToken = async (token, secret) => {
try {
const decoded = jwt.verify(token, secret);
return decoded;
} catch (error) {
throw new Error('Invalid token');
}
};
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,
};