Merge pull request #152 from thevahidal/user_registration_feature

User registration feature
This commit is contained in:
Ian Mayo
2024-03-11 11:02:15 +00:00
committed by GitHub
30 changed files with 2289 additions and 237 deletions

View File

@@ -9,6 +9,16 @@ 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

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).
@@ -33,6 +35,14 @@ Options:
-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
@@ -46,7 +56,39 @@ curl http://localhost:8000/api/tables
It should return a list of the tables inside `sqlite.db` database.
### Updating superusers
### 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:
@@ -96,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.

151
package-lock.json generated
View File

@@ -12,12 +12,15 @@
"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",
@@ -2298,6 +2301,11 @@
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -2380,6 +2388,11 @@
"node": ">=10"
}
},
"node_modules/check-password-strength": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/check-password-strength/-/check-password-strength-2.0.7.tgz",
"integrity": "sha512-VyklBkB6dOKnCIh63zdVr7QKVMN9/npwUqNAXxWrz8HabVZH/n/d+lyNm1O/vbXFJlT/Hytb5ouYKYGkoeZirQ=="
},
"node_modules/chokidar": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
@@ -2571,6 +2584,26 @@
"node": ">= 0.6"
}
},
"node_modules/cookie-parser": {
"version": "1.4.6",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz",
"integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==",
"dependencies": {
"cookie": "0.4.1",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz",
"integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
@@ -2764,6 +2797,14 @@
"node": ">=12"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -5876,6 +5917,81 @@
"node": ">=6"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
"dependencies": {
"jws": "^3.2.2",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsonwebtoken/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/jsonwebtoken/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/jsonwebtoken/node_modules/semver": {
"version": "7.6.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
"integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
"dependencies": {
"lru-cache": "^6.0.0"
},
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/jsonwebtoken/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
},
"node_modules/jwa": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz",
"integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==",
"dependencies": {
"buffer-equal-constant-time": "1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
"dependencies": {
"jwa": "^1.4.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -5944,12 +6060,47 @@
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"dev": true
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="
},
"node_modules/logform": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/logform/-/logform-2.5.1.tgz",

View File

@@ -30,12 +30,15 @@
"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

@@ -53,6 +53,39 @@ if (process.env.NO_CLI !== 'true') {
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',

View File

@@ -29,6 +29,13 @@ const envVarsSchema = Joi.object()
EXTENSIONS: Joi.string().default(null),
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();
@@ -60,6 +67,26 @@ 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);
@@ -87,6 +114,16 @@ module.exports = {
},
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,

View File

@@ -1,4 +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',
};

View File

@@ -1,5 +1,5 @@
const dbTables = require('./dbTables');
const constantRoles = require('./roles');
const dbConstants = require('./tables');
const apiConstants = require('./api');
const constantRoles = require('./roles');
module.exports = { dbTables, constantRoles, apiConstants };
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,38 +1,60 @@
const { tableService } = require('../services');
const { rowService } = require('../services');
const { dbTables, constantRoles } = require('../constants');
const { hashPassword, checkPasswordStrength } = require('../utils');
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('_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;
@@ -42,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',
@@ -70,7 +92,7 @@ const createDefaultTables = async () => {
// store the permissions in the db
rowService.bulkWrite({
tableName: '_roles_permissions',
tableName: ROLE_PERMISSIONS_TABLE,
fields: permissions,
});
}
@@ -84,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],
});
@@ -103,7 +125,11 @@ const updateSuperuser = async (fields) => {
// if the password is sent from the CLI, update it
if (password) {
// check if the password is weak
if (['Too weak', 'Weak'].includes(checkPasswordStrength(password))) {
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);
}
@@ -120,7 +146,7 @@ const updateSuperuser = async (fields) => {
// update the user
rowService.update({
tableName: '_users',
tableName: USER_TABLE,
lookupField: `id`,
fieldsString,
pks: `${id}`,
@@ -135,4 +161,551 @@ const updateSuperuser = async (fields) => {
}
};
module.exports = { createDefaultTables, updateSuperuser };
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({
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'
}
});
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,7 +26,15 @@ describe('Tables Endpoints', () => {
});
it('POST /tables should create a new table and return generated schema', async () => {
const res = await requestWithSupertest.post('/api/tables').send({
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,
@@ -40,7 +60,8 @@ describe('Tables Endpoints', () => {
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

@@ -7,15 +7,23 @@ 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 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();
@@ -24,6 +32,7 @@ app.get('/health', (req, res) => {
});
app.use(bodyParser.json());
app.use(cookieParser());
// Activate wal mode
db.exec('PRAGMA journal_mode = WAL');
@@ -70,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.',
@@ -87,6 +97,8 @@ app.use('/api', rootRoutes);
app.use('/api/tables', tablesRoutes);
app.use('/api/tables', rowsRoutes);
app.use('/api/auth', authRoutes);
setupExtensions(app, db);
module.exports = app;

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

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

View File

@@ -1,4 +1,7 @@
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);
@@ -11,4 +14,65 @@ const comparePasswords = async (plainPassword, hashedPassword) => {
return isMatch;
};
module.exports = { hashPassword, comparePasswords };
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,
};