Compare commits

...

22 Commits

Author SHA1 Message Date
Markus Frindt
97b17d9e1d Improved invalidation of JWT in redis (#139)
* Improved invalidation of JWT in redis

* use jwt as default value in generateRedisKey

* import logger in app.js

---------

Co-authored-by: Markus Frindt <m.frindt@cognigy.com>
2023-03-31 16:19:34 -04:00
Dave Horton
57110ede76 fix: Dockerfile to reduce vulnerabilities (#137)
The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-ALPINE316-OPENSSL-3368756
- https://snyk.io/vuln/SNYK-ALPINE316-OPENSSL-3368756
- https://snyk.io/vuln/SNYK-ALPINE316-OPENSSL-5291792
- https://snyk.io/vuln/SNYK-ALPINE316-OPENSSL-5291792

Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2023-03-31 07:57:08 -04:00
Guilherme Rauen
d656857509 extend sid validation to all routes (#138)
Co-authored-by: Guilherme Rauen <g.rauen@cognigy.com>
2023-03-31 07:46:33 -04:00
Hoan Luu Huu
bb705fe808 feat: custom email vendor (#130)
* feat: custom email vendor

* feat: custom email vendor

* feat: custom email vendor

* feat: custom email vendor

---------

Co-authored-by: Quan HL <quanluuhoang8@gmail.com>
2023-03-29 12:48:53 -04:00
Guilherme Rauen
789a0ba3ff Fix SQL Injection Vulnerabilities (#134)
* avoid sql injections

* linter

* fix test using random sid

* add some test cases

* remove tests that don't use the new validation

* add test

* linter

* fix tests

* add test

---------

Co-authored-by: Guilherme Rauen <g.rauen@cognigy.com>
2023-03-29 12:36:51 -04:00
EgleH
27cb7c471a Add passwordSettings validation (#136)
* add password Settings validation

* fix test failing because of pass validation

---------

Co-authored-by: eglehelms <e.helms@cognigy.com>
2023-03-29 08:54:05 -04:00
Dave Horton
39260f0b47 bump version 2023-03-28 14:14:44 -04:00
Anton Voylenko
75a2b42d65 update README (#135) 2023-03-27 14:17:03 -04:00
Anton Voylenko
518a9163fb add ENCRYPTION_SECRET variable (#132) 2023-03-25 15:34:09 -04:00
Hoan Luu Huu
5fb4bd7bd1 feat: add nuance on-premise (#131)
* feat: add nuance on-premise

* feat: update fetch nuance credential

* fix: update

* fix nuance tts test against on-prem, refactor aws/google tts testing to use speech-utils package

---------

Co-authored-by: Quan HL <quanluuhoang8@gmail.com>
Co-authored-by: Dave Horton <daveh@beachdognet.com>
2023-03-25 11:20:44 -04:00
Dave Horton
409ad68123 fix tests for AWS speech 2023-03-24 08:59:16 -04:00
Dave Horton
17afb7102a update speech-utils with fix for aws tts 2023-03-24 08:19:18 -04:00
Anton Voylenko
6e7cb9b332 update README (#129) 2023-03-23 15:44:45 -04:00
Anton Voylenko
34f83e323c update swagger yaml (#127) 2023-03-23 09:08:38 -04:00
Anton Voylenko
00af458cb3 Migrate to argon2 from argon2-ffi (#126) 2023-03-22 16:15:01 -04:00
Dave Horton
389017a5c4 update to latest speech-utils 2023-03-20 15:37:35 -04:00
Dave Horton
c4cc6c51ee eliminate parsing of jwt to support either jwt or api key (#124)
* eliminate parsing of jwt to support either jwt or api key

* fixes for preventing non-authorized changes to users

* update to AWS v3 api
2023-03-14 18:54:56 -04:00
Dave Horton
aea7388ba0 refactor of speech-utils (#123) 2023-03-14 10:01:05 -04:00
Dave Horton
3d86292a90 prevent updates to users that would move them to a different account … (#122)
* prevent updates to users that would move them to a different account or service provider, or make them admin users

* bugfix: when updating account as admin user, verify the account sid

* validate account sid when SP user tries to update
2023-03-08 11:38:49 -05:00
Dave Horton
08962fe7ba bugfix: get of speech credential was not returning soniox api_key 2023-03-03 13:49:36 -05:00
Dave Horton
e573f6ab06 change property names for custom speech 2023-03-02 15:28:53 -05:00
Dave Horton
4934e2a1ca add support for custom speech api (#121) 2023-03-02 14:13:41 -05:00
48 changed files with 3810 additions and 670 deletions

View File

@@ -1,4 +1,4 @@
FROM --platform=linux/amd64 node:18.14.1-alpine3.16 as base
FROM --platform=linux/amd64 node:18.15-alpine3.16 as base
RUN apk --update --no-cache add --virtual .builds-deps build-base python3

View File

@@ -1,29 +1,44 @@
# jambonz-api-server ![Build Status](https://github.com/jambonz/jambonz-api-server/workflows/CI/badge.svg)
Jambones REST API server.
Jambones REST API server of the jambones platform.
## Configuration
This process requires the following environment variables to be set.
Configuration is provided via environment variables:
```
JAMBONES_MYSQL_HOST
JAMBONES_MYSQL_USER
JAMBONES_MYSQL_PASSWORD
JAMBONES_MYSQL_DATABASE
JAMBONES_MYSQL_CONNECTION_LIMIT # defaults to 10
JAMBONES_REDIS_HOST
JAMBONES_REDIS_PORT
JAMBONES_LOGLEVEL # defaults to info
JAMBONES_API_VERSION # defaults to v1
HTTP_PORT # defaults to 3000
```
| variable | meaning | required?|
|----------|----------|---------|
|JWT_SECRET| secret for signing JWT token |yes|
|JWT_EXPIRES_IN| expiration time for JWT token(in minutes) |no|
|ENCRYPTION_SECRET| secret for credential encryption(JWT_SECRET is deprecated) |yes|
|HTTP_PORT| tcp port to listen on for API requests from jambonz-api-server |no|
|JAMBONES_LOGLEVEL| log level for application, 'info' or 'debug' |no|
|JAMBONES_MYSQL_HOST| mysql host |yes|
|JAMBONES_MYSQL_USER| mysql username |yes|
|JAMBONES_MYSQL_PASSWORD| mysql password |yes|
|JAMBONES_MYSQL_DATABASE| mysql data |yes|
|JAMBONES_MYSQL_PORT| mysql port |no|
|JAMBONES_MYSQL_CONNECTION_LIMIT| mysql connection limit |no|
|JAMBONES_REDIS_HOST| redis host |yes|
|JAMBONES_REDIS_PORT| redis port |no|
|RATE_LIMIT_WINDOWS_MINS| rate limit window |no|
|RATE_LIMIT_MAX_PER_WINDOW| number of requests per window |no|
|JAMBONES_TRUST_PROXY| trust proxies, must be a number |no|
|JAMBONES_API_VERSION| api version |no|
|JAMBONES_TIME_SERIES_HOST| influxdb host |yes|
|JAMBONES_CLUSTER_ID| cluster id |no|
|HOMER_BASE_URL| HOMER URL |no|
|HOMER_USERNAME| HOMER username |no|
|HOMER_PASSWORD| HOMER password |no|
|K8S| service running as kubernetes service |no|
|K8S_FEATURE_SERVER_SERVICE_NAME| feature server name(required for K8S) |no|
|K8S_FEATURE_SERVER_SERVICE_PORT| feature server port(required for K8S) |no|
#### Database dependency
A mysql database is used to store long-lived objects such as Accounts, Applications, etc. To create the database schema, use or review the scripts in the 'db' folder, particularly:
- [create_db.sql](db/create_db.sql), which creates the database and associated user (you may want to edit the username and password),
- [jambones-sql.sql](db/jambones-sql.sql), which creates the schema,
- [create-admin-token.sql](db/create-admin-token.sql), which creates an admin-level auth token that can be used for testing/exercising the API.
- [seed-production-database-open-source.sql](db/seed-production-database-open-source.sql), which seeds the database with initial dataset(accounts, permissions, api keys, applications etc).
- [create-admin-user.sql](db/create-admin-user.sql), which creates admin user with password set to "admin". The password will be forced to change after the first login.
> Note: due to the dependency on the npmjs [mysql](https://www.npmjs.com/package/mysql) package, the mysql database must be configured to use sql [native authentication](https://medium.com/@crmcmullen/how-to-run-mysql-8-0-with-native-password-authentication-502de5bac661).

18
app.js
View File

@@ -1,12 +1,5 @@
const assert = require('assert');
const opts = Object.assign({
timestamp: () => {
return `, "time": "${new Date().toISOString()}"`;
}
}, {
level: process.env.JAMBONES_LOGLEVEL || 'info'
});
const logger = require('pino')(opts);
const logger = require('./lib/logger');
const express = require('express');
const app = express();
const helmet = require('helmet');
@@ -21,6 +14,9 @@ assert.ok(process.env.JAMBONES_MYSQL_HOST &&
process.env.JAMBONES_MYSQL_DATABASE, 'missing JAMBONES_MYSQL_XXX env vars');
assert.ok(process.env.JAMBONES_REDIS_HOST, 'missing JAMBONES_REDIS_HOST env var');
assert.ok(process.env.JAMBONES_TIME_SERIES_HOST, 'missing JAMBONES_TIME_SERIES_HOST env var');
assert.ok(process.env.ENCRYPTION_SECRET || process.env.JWT_SECRET, 'missing ENCRYPTION_SECRET env var');
assert.ok(process.env.JWT_SECRET, 'missing JWT_SECRET env var');
const {
queryCdrs,
queryCdrsSP,
@@ -41,9 +37,11 @@ const {
addKey,
retrieveKey,
deleteKey,
} = require('./lib/helpers/realtimedb-helpers');
const {
getTtsVoices
} = require('@jambonz/realtimedb-helpers')({
host: process.env.JAMBONES_REDIS_HOST || 'localhost',
} = require('@jambonz/speech-utils')({
host: process.env.JAMBONES_REDIS_HOST,
port: process.env.JAMBONES_REDIS_PORT || 6379
}, logger);
const {

10
db/create-admin-user.sql Normal file
View File

@@ -0,0 +1,10 @@
/* hashed password is "admin" */
insert into users (user_sid, name, email, hashed_password, force_change, provider, email_validated)
values ('12c80508-edf9-4b22-8d09-55abd02648eb', 'admin', 'joe@foo.bar', '$argon2i$v=19$m=65536,t=3,p=4$c2FsdHNhbHRzYWx0c2FsdA$x5OO6gXFXS25oqUU2JvbYqrSgRxBujNUJBq6xv9EgjM', 1, 'local', 1);
insert into user_permissions (user_permissions_sid, user_sid, permission_sid)
values ('8919e0dc-4d69-4de5-be56-a121598d9093', '12c80508-edf9-4b22-8d09-55abd02648eb', 'ffbc342a-546a-11ed-bdc3-0242ac120002');
insert into user_permissions (user_permissions_sid, user_sid, permission_sid)
values ('d6fdf064-0a65-4b17-8b10-5500e956a159', '12c80508-edf9-4b22-8d09-55abd02648eb', 'ffbc3a10-546a-11ed-bdc3-0242ac120002');
insert into user_permissions (user_permissions_sid, user_sid, permission_sid)
values ('f68185dd-0486-4767-a77d-a0b84c1b236e' ,'12c80508-edf9-4b22-8d09-55abd02648eb', 'ffbc3c5e-546a-11ed-bdc3-0242ac120002');

View File

@@ -1,41 +1,42 @@
const Strategy = require('passport-http-bearer').Strategy;
const {getMysqlConnection} = require('../db');
const {hashString} = require('../utils/password-utils');
const debug = require('debug')('jambonz:api-server');
const {cacheClient} = require('../helpers');
const jwt = require('jsonwebtoken');
const sql = `
SELECT *
FROM api_keys
WHERE api_keys.token = ?`;
function makeStrategy(logger, retrieveKey) {
function makeStrategy(logger) {
return new Strategy(
async function(token, done) {
//logger.debug(`validating with token ${token}`);
jwt.verify(token, process.env.JWT_SECRET, async(err, decoded) => {
if (err) {
if (err.name === 'TokenExpiredError') {
logger.debug('jwt expired');
return done(null, false);
}
/* its not a jwt obtained through login, check api leys */
/* its not a jwt obtained through login, check api keys */
checkApiTokens(logger, token, done);
}
else {
/* validated -- make sure it is not on blacklist */
try {
const s = `jwt:${hashString(token)}`;
const result = await retrieveKey(s);
if (result) {
debug(`result from searching for ${s}: ${result}`);
const {user_sid} = decoded;
/* Valid jwt tokens are stored in redis by hashed user_id */
const redisKey = cacheClient.generateRedisKey('jwt', user_sid, 'v2');
const result = await cacheClient.get(redisKey);
if (result === null) {
debug(`result from searching for ${redisKey}: ${result}`);
logger.info('jwt invalidated after logout');
return done(null, false);
}
} catch (err) {
} catch (error) {
debug(err);
logger.info({err}, 'Error checking blacklist for jwt');
logger.info({err}, 'Error checking redis for jwt');
}
const {user_sid, service_provider_sid, account_sid, email, name, scope, permissions} = decoded;
const { user_sid, service_provider_sid, account_sid, email, name, scope, permissions } = decoded;
const user = {
service_provider_sid,
account_sid,
@@ -99,7 +100,7 @@ const checkApiTokens = (logger, token, done) => {
hasServiceProviderAuth: scope === 'service_provider',
hasAccountAuth: scope === 'account'
};
logger.info(user, `successfully validated with scope ${scope}`);
logger.debug({user}, `successfully validated with scope ${scope}`);
return done(null, user, {scope});
});
});

View File

@@ -0,0 +1,82 @@
const {
addKey: addKeyRedis,
deleteKey: deleteKeyRedis,
retrieveKey: retrieveKeyRedis,
} = require('./realtimedb-helpers');
const { hashString } = require('../utils/password-utils');
const logger = require('../logger');
class CacheClient {
constructor() { }
async set(params) {
const {
redisKey,
value = '1',
time = 3600,
} = params || {};
try {
await addKeyRedis(redisKey, value, time);
} catch (err) {
logger.error('CacheClient.get set', {
error: {
message: err.message,
name: err.name
},
...params
});
}
}
async get(redisKey) {
try {
const result = await retrieveKeyRedis(redisKey);
return result;
} catch (err) {
logger.error('CacheClient.get error', {
error: {
message: err.message,
name: err.name
},
redisKey
});
}
}
async delete(key) {
try {
await deleteKeyRedis(key);
logger.debug('CacheClient.delete key from redis', { key });
} catch (err) {
logger.error('CacheClient.delete error', {
error: {
message: err.message,
name: err.name
},
key
});
}
}
generateRedisKey(type, key, version) {
let suffix = '';
if (version) {
suffix = `:version:${version}`;
}
switch (type) {
case 'reset-link':
return `reset-link:${key}`;
case 'jwt':
default:
return `jwt:${hashString(key)}${suffix}`;
}
}
}
const cacheClient = new CacheClient();
module.exports = { cacheClient };

4
lib/helpers/index.js Normal file
View File

@@ -0,0 +1,4 @@
module.exports = {
...require('./cache-client'),
...require('./realtimedb-helpers'),
};

View File

@@ -0,0 +1,28 @@
const logger = require('../logger');
const {
retrieveCall,
deleteCall,
listCalls,
purgeCalls,
retrieveSet,
addKey,
retrieveKey,
deleteKey,
client: redisClient,
} = require('@jambonz/realtimedb-helpers')({
host: process.env.JAMBONES_REDIS_HOST || 'localhost',
port: process.env.JAMBONES_REDIS_PORT || 6379
}, logger);
module.exports = {
retrieveCall,
deleteCall,
listCalls,
purgeCalls,
retrieveSet,
addKey,
retrieveKey,
deleteKey,
redisClient,
};

11
lib/logger.js Normal file
View File

@@ -0,0 +1,11 @@
const opts = Object.assign({
timestamp: () => {
return `, "time": "${new Date().toISOString()}"`;
}
}, {
level: process.env.JAMBONES_LOGLEVEL || 'info'
});
const logger = require('pino')(opts);
module.exports = logger;

View File

@@ -107,7 +107,7 @@ class Model extends Emitter {
if (pk.name in obj) throw new DbErrorBadRequest(`primary key ${pk.name} is immutable`);
getMysqlConnection((err, conn) => {
if (err) return reject(err);
conn.query(`UPDATE ${this.table} SET ? WHERE ${pk.name} = '${sid}'`, obj, (err, results, fields) => {
conn.query(`UPDATE ${this.table} SET ? WHERE ${pk.name} = ?`, [obj, sid], (err, results, fields) => {
conn.release();
if (err) return reject(err);
resolve(results.affectedRows);

View File

@@ -12,7 +12,14 @@ const { v4: uuidv4 } = require('uuid');
const snakeCase = require('../../utils/snake-case');
const sysError = require('../error');
const {promisePool} = require('../../db');
const {hasAccountPermissions, parseAccountSid, enableSubspace, disableSubspace} = require('./utils');
const {
hasAccountPermissions,
parseAccountSid,
parseCallSid,
enableSubspace,
disableSubspace,
parseVoipCarrierSid
} = require('./utils');
const short = require('short-uuid');
const VoipCarrier = require('../../models/voip-carrier');
const translator = short();
@@ -45,18 +52,33 @@ const stripPort = (hostport) => {
return hostport;
};
const validateUpdateForCarrier = async(req) => {
const account_sid = parseAccountSid(req);
if (req.user.hasScope('admin')) return ;
if (req.user.hasScope('account')) {
if (account_sid === req.user.account_sid) return ;
throw new DbErrorForbidden('insufficient permissions to update account');
}
if (req.user.hasScope('service_provider')) {
const [r] = await promisePool.execute(
'SELECT service_provider_sid from accounts WHERE account_sid = ?', [account_sid]);
if (r.length === 1 && r[0].service_provider_sid === req.user.service_provider_sid) return;
throw new DbErrorForbidden('insufficient permissions to update account');
const validateUpdateForCarrier = async(req, account_sid) => {
try {
if (req.user.hasScope('admin')) {
return;
}
if (req.user.hasScope('account')) {
if (account_sid === req.user.account_sid) {
return;
}
throw new DbErrorForbidden('insufficient permissions to update account');
}
if (req.user.hasScope('service_provider')) {
const [r] = await promisePool.execute(
'SELECT service_provider_sid from accounts WHERE account_sid = ?', [account_sid]
);
if (r.length === 1 && r[0].service_provider_sid === req.user.service_provider_sid) {
return;
}
throw new DbErrorForbidden('insufficient permissions to update account');
}
} catch (error) {
throw error;
}
};
@@ -89,13 +111,18 @@ router.get('/:sid/VoipCarriers', async(req, res) => {
});
router.put('/:sid/VoipCarriers/:voip_carrier_sid', async(req, res) => {
const logger = req.app.locals.logger;
try {
await validateUpdateForCarrier(req);
const rowsAffected = await VoipCarrier.update(req.params.voip_carrier_sid, req.body);
const sid = parseVoipCarrierSid(req);
const account_sid = parseAccountSid(req);
await validateUpdateForCarrier(req, account_sid);
const rowsAffected = await VoipCarrier.update(sid, req.body);
if (rowsAffected === 0) {
return res.sendStatus(404);
}
res.status(204).end();
return res.status(204).end();
} catch (err) {
sysError(logger, res, err);
}
@@ -215,6 +242,7 @@ function validateTo(to) {
}
throw new DbErrorBadRequest(`missing or invalid to property: ${JSON.stringify(to)}`);
}
async function validateCreateCall(logger, sid, req) {
const {lookupAppBySid} = req.app.locals;
const obj = req.body;
@@ -334,6 +362,7 @@ async function validateAdd(req) {
throw new DbErrorBadRequest('\'queue_event_hook\' must be an object when adding an account');
}
}
async function validateUpdate(req, sid) {
if (req.user.hasAccountAuth && req.user.account_sid !== sid) {
throw new DbErrorUnprocessableRequest('insufficient privileges to update this account');
@@ -344,12 +373,23 @@ async function validateUpdate(req, sid) {
if (req.user.service_provider_sid && !req.user.hasScope('admin')) {
const result = await Account.retrieve(sid);
if (!result || result.length === 0) {
throw new DbErrorBadRequest(`account not found for sid ${sid}`);
}
if (result[0].service_provider_sid !== req.user.service_provider_sid) {
throw new DbErrorUnprocessableRequest('cannot update account from different service provider');
}
}
if (req.user.hasScope('admin')) {
/* check to be sure that the account_sid exists */
const result = await Account.retrieve(sid);
if (!result || result.length === 0) {
throw new DbErrorBadRequest(`account not found for sid ${sid}`);
}
}
if (req.body.service_provider_sid) throw new DbErrorBadRequest('service_provider_sid may not be modified');
}
async function validateDelete(req, sid) {
if (req.user.hasAccountAuth && req.user.account_sid !== sid) {
throw new DbErrorUnprocessableRequest('insufficient privileges to update this account');
@@ -362,7 +402,6 @@ async function validateDelete(req, sid) {
}
}
/* add */
router.post('/', async(req, res) => {
const logger = req.app.locals.logger;
@@ -404,8 +443,9 @@ router.get('/', async(req, res) => {
router.get('/:sid', async(req, res) => {
const logger = req.app.locals.logger;
try {
const account_sid = parseAccountSid(req);
const service_provider_sid = req.user.hasServiceProviderAuth ? req.user.service_provider_sid : null;
const results = await Account.retrieve(req.params.sid, service_provider_sid);
const results = await Account.retrieve(account_sid, service_provider_sid);
if (results.length === 0) return res.status(404).end();
return res.status(200).json(results[0]);
}
@@ -417,13 +457,14 @@ router.get('/:sid', async(req, res) => {
router.get('/:sid/WebhookSecret', async(req, res) => {
const logger = req.app.locals.logger;
try {
const account_sid = parseAccountSid(req);
const service_provider_sid = req.user.hasServiceProviderAuth ? req.user.service_provider_sid : null;
const results = await Account.retrieve(req.params.sid, service_provider_sid);
const results = await Account.retrieve(account_sid, service_provider_sid);
if (results.length === 0) return res.status(404).end();
let {webhook_secret} = results[0];
if (req.query.regenerate) {
const secret = `wh_secret_${translator.generate()}`;
await Account.update(req.params.sid, {webhook_secret: secret});
await Account.update(account_sid, {webhook_secret: secret});
webhook_secret = secret;
}
return res.status(200).json({webhook_secret});
@@ -436,8 +477,9 @@ router.get('/:sid/WebhookSecret', async(req, res) => {
router.post('/:sid/SubspaceTeleport', async(req, res) => {
const logger = req.app.locals.logger;
try {
const account_sid = parseAccountSid(req);
const service_provider_sid = req.user.hasServiceProviderAuth ? req.user.service_provider_sid : null;
const results = await Account.retrieve(req.params.sid, service_provider_sid);
const results = await Account.retrieve(account_sid, service_provider_sid);
if (results.length === 0) return res.status(404).end();
const {subspace_client_id, subspace_client_secret} = results[0];
const {destination} = req.body;
@@ -450,7 +492,7 @@ router.post('/:sid/SubspaceTeleport', async(req, res) => {
destination: dest
});
logger.info({destination, teleport}, 'SubspaceTeleport - create teleport');
await Account.update(req.params.sid, {
await Account.update(account_sid, {
subspace_sip_teleport_id: teleport.id,
subspace_sip_teleport_destinations: JSON.stringify(teleport.teleport_entry_points)//hacky
});
@@ -468,13 +510,14 @@ router.post('/:sid/SubspaceTeleport', async(req, res) => {
router.delete('/:sid/SubspaceTeleport', async(req, res) => {
const logger = req.app.locals.logger;
try {
const account_sid = parseAccountSid(req);
const service_provider_sid = req.user.hasServiceProviderAuth ? req.user.service_provider_sid : null;
const results = await Account.retrieve(req.params.sid, service_provider_sid);
const results = await Account.retrieve(account_sid, service_provider_sid);
if (results.length === 0) return res.status(404).end();
const {subspace_client_id, subspace_client_secret, subspace_sip_teleport_id} = results[0];
await disableSubspace({subspace_client_id, subspace_client_secret, subspace_sip_teleport_id});
await Account.update(req.params.sid, {
await Account.update(account_sid, {
subspace_sip_teleport_id: null,
subspace_sip_teleport_destinations: null
});
@@ -485,11 +528,13 @@ router.delete('/:sid/SubspaceTeleport', async(req, res) => {
}
});
/* update */
/**
* update
*/
router.put('/:sid', async(req, res) => {
const sid = req.params.sid;
const logger = req.app.locals.logger;
try {
const sid = parseAccountSid(req);
// create webhooks if provided
const obj = Object.assign({}, req.body);
@@ -549,12 +594,13 @@ router.put('/:sid', async(req, res) => {
/* delete */
router.delete('/:sid', async(req, res) => {
const sid = req.params.sid;
const logger = req.app.locals.logger;
const sqlDeleteGateways = `DELETE from sip_gateways
WHERE voip_carrier_sid IN
(SELECT voip_carrier_sid from voip_carriers where account_sid = ?)`;
try {
const sid = parseAccountSid(req);
await validateDelete(req, sid);
const [account] = await promisePool.query('SELECT * FROM accounts WHERE account_sid = ?', sid);
@@ -618,13 +664,17 @@ account_subscriptions WHERE account_sid = ?)
}
});
/* retrieve account level api keys */
/**
* retrieve account level api keys
*/
router.get('/:sid/ApiKeys', async(req, res) => {
const logger = req.app.locals.logger;
try {
const results = await ApiKey.retrieveAll(req.params.sid);
const sid = parseAccountSid(req);
const results = await ApiKey.retrieveAll(sid);
res.status(200).json(results);
updateLastUsed(logger, req.params.sid, req).catch((err) => {});
updateLastUsed(logger, sid, req).catch((err) => {});
} catch (err) {
sysError(logger, res, err);
}
@@ -634,36 +684,38 @@ router.get('/:sid/ApiKeys', async(req, res) => {
* create a new Call
*/
router.post('/:sid/Calls', async(req, res) => {
const sid = req.params.sid;
const setName = `${(process.env.JAMBONES_CLUSTER_ID || 'default')}:active-fs`;
const {retrieveSet, logger} = req.app.locals;
const setName = `${(process.env.JAMBONES_CLUSTER_ID || 'default')}:active-fs`;
const serviceUrl = await getFsUrl(logger, retrieveSet, setName);
if (!serviceUrl) {
res.status(480).json({msg: 'no available feature servers at this time'});
} else {
try {
await validateCreateCall(logger, sid, req);
updateLastUsed(logger, sid, req).catch((err) => {});
request({
url: serviceUrl,
method: 'POST',
json: true,
body: Object.assign(req.body, {account_sid: sid})
}, (err, response, body) => {
if (err) {
logger.error(err, `Error sending createCall POST to ${serviceUrl}`);
return res.sendStatus(500);
}
if (response.statusCode !== 201) {
logger.error({statusCode: response.statusCode}, `Non-success response returned by createCall ${serviceUrl}`);
return res.sendStatus(500);
}
res.status(201).json(body);
});
} catch (err) {
sysError(logger, res, err);
}
return res.status(480).json({msg: 'no available feature servers at this time'});
}
try {
const sid = parseAccountSid(req);
await validateCreateCall(logger, sid, req);
updateLastUsed(logger, sid, req).catch((err) => {});
request({
url: serviceUrl,
method: 'POST',
json: true,
body: Object.assign(req.body, {account_sid: sid})
}, (err, response, body) => {
if (err) {
logger.error(err, `Error sending createCall POST to ${serviceUrl}`);
return res.sendStatus(500);
}
if (response.statusCode !== 201) {
logger.error({statusCode: response.statusCode}, `Non-success response returned by createCall ${serviceUrl}`);
return res.sendStatus(500);
}
return res.status(201).json(body);
});
} catch (err) {
sysError(logger, res, err);
}
});
@@ -671,10 +723,10 @@ router.post('/:sid/Calls', async(req, res) => {
* retrieve info for a group of calls under an account
*/
router.get('/:sid/Calls', async(req, res) => {
const accountSid = req.params.sid;
const {logger, listCalls} = req.app.locals;
try {
const accountSid = parseAccountSid(req);
const calls = await listCalls(accountSid);
logger.debug(`retrieved ${calls.length} calls for account sid ${accountSid}`);
res.status(200).json(coerceNumbers(snakeCase(calls)));
@@ -688,11 +740,11 @@ router.get('/:sid/Calls', async(req, res) => {
* retrieve single call
*/
router.get('/:sid/Calls/:callSid', async(req, res) => {
const accountSid = req.params.sid;
const callSid = req.params.callSid;
const {logger, retrieveCall} = req.app.locals;
try {
const accountSid = parseAccountSid(req);
const callSid = parseCallSid(req);
const callInfo = await retrieveCall(accountSid, callSid);
if (callInfo) {
logger.debug(callInfo, `retrieved call info for call sid ${callSid}`);
@@ -712,11 +764,11 @@ router.get('/:sid/Calls/:callSid', async(req, res) => {
* delete call
*/
router.delete('/:sid/Calls/:callSid', async(req, res) => {
const accountSid = req.params.sid;
const callSid = req.params.callSid;
const {logger, deleteCall} = req.app.locals;
try {
const accountSid = parseAccountSid(req);
const callSid = parseCallSid(req);
const result = await deleteCall(accountSid, callSid);
if (result) {
logger.debug(`successfully deleted call ${callSid}`);
@@ -736,11 +788,11 @@ router.delete('/:sid/Calls/:callSid', async(req, res) => {
* update a call
*/
const updateCall = async(req, res) => {
const accountSid = req.params.sid;
const callSid = req.params.callSid;
const {logger, retrieveCall} = req.app.locals;
try {
const accountSid = parseAccountSid(req);
const callSid = parseCallSid(req);
validateUpdateCall(req.body);
const call = await retrieveCall(accountSid, callSid);
if (call) {
@@ -767,6 +819,7 @@ const updateCall = async(req, res) => {
router.post('/:sid/Calls/:callSid', async(req, res) => {
await updateCall(req, res);
});
router.put('/:sid/Calls/:callSid', async(req, res) => {
await updateCall(req, res);
});
@@ -775,11 +828,11 @@ router.put('/:sid/Calls/:callSid', async(req, res) => {
* create a new Message
*/
router.post('/:sid/Messages', async(req, res) => {
const account_sid = parseAccountSid(req);
const setName = `${(process.env.JAMBONES_CLUSTER_ID || 'default')}:active-fs`;
const {retrieveSet, logger} = req.app.locals;
try {
const account_sid = parseAccountSid(req);
const setName = `${(process.env.JAMBONES_CLUSTER_ID || 'default')}:active-fs`;
const serviceUrl = await getFsUrl(logger, retrieveSet, setName);
if (!serviceUrl) res.json({msg: 'no available feature servers at this time'}).status(480);
await validateCreateMessage(logger, account_sid, req);

View File

@@ -24,13 +24,13 @@ router.post('/:sid', async(req, res) => {
let service_provider_sid;
const {account_sid} = req.user;
if (!account_sid) {
service_provider_sid = parseServiceProviderSid(req);
} else {
service_provider_sid = req.user.service_provider_sid;
}
try {
if (!account_sid) {
service_provider_sid = parseServiceProviderSid(req);
} else {
service_provider_sid = req.user.service_provider_sid;
}
const [template] = await PredefinedCarrier.retrieve(sid);
logger.debug({template}, `Retrieved template carrier for sid ${sid}`);
if (!template) return res.sendStatus(404);

View File

@@ -1,17 +1,13 @@
const router = require('express').Router();
const sysError = require('../error');
const {DbErrorBadRequest} = require('../../utils/errors');
const { parseServiceProviderSid } = require('./utils');
const parseAccountSid = (url) => {
const arr = /Accounts\/([^\/]*)/.exec(url);
if (arr) return arr[1];
};
const parseServiceProviderSid = (url) => {
const arr = /ServiceProviders\/([^\/]*)/.exec(url);
if (arr) return arr[1];
};
router.get('/', async(req, res) => {
const {logger, queryAlerts, queryAlertsSP} = req.app.locals;
try {

View File

@@ -7,6 +7,7 @@ const {promisePool} = require('../../db');
const decorate = require('./decorate');
const sysError = require('../error');
const { validate } = require('@jambonz/verb-specifications');
const { parseApplicationSid } = require('./utils');
const preconditions = {
'add': validateAdd,
'update': validateUpdate
@@ -111,9 +112,10 @@ router.get('/', async(req, res) => {
router.get('/:sid', async(req, res) => {
const logger = req.app.locals.logger;
try {
const application_sid = parseApplicationSid(req);
const service_provider_sid = req.user.hasServiceProviderAuth ? req.user.service_provider_sid : null;
const account_sid = req.user.hasAccountAuth ? req.user.account_sid : null;
const results = await Application.retrieve(req.params.sid, service_provider_sid, account_sid);
const results = await Application.retrieve(application_sid, service_provider_sid, account_sid);
if (results.length === 0) return res.status(404).end();
return res.status(200).json(results[0]);
}
@@ -124,9 +126,9 @@ router.get('/:sid', async(req, res) => {
/* delete */
router.delete('/:sid', async(req, res) => {
const sid = req.params.sid;
const logger = req.app.locals.logger;
try {
const sid = parseApplicationSid(req);
await validateDelete(req, sid);
const [application] = await promisePool.query('SELECT * FROM applications WHERE application_sid = ?', sid);
@@ -165,9 +167,9 @@ router.delete('/:sid', async(req, res) => {
/* update */
router.put('/:sid', async(req, res) => {
const sid = req.params.sid;
const logger = req.app.locals.logger;
try {
const sid = parseApplicationSid(req);
await validateUpdate(req, sid);
// create webhooks if provided

View File

@@ -1,15 +1,15 @@
const router = require('express').Router();
//const debug = require('debug')('jambonz:api-server');
const {DbErrorBadRequest} = require('../../utils/errors');
const {generateHashedPassword, verifyPassword} = require('../../utils/password-utils');
const {promisePool} = require('../../db');
const {cacheClient} = require('../../helpers');
const sysError = require('../error');
const sqlUpdatePassword = `UPDATE users
SET hashed_password= ?
WHERE user_sid = ?`;
router.post('/', async(req, res) => {
const {logger, retrieveKey, deleteKey} = req.app.locals;
const {logger} = req.app.locals;
const {user_sid} = req.user;
const {old_password, new_password} = req.body;
try {
@@ -26,10 +26,10 @@ router.post('/', async(req, res) => {
const isCorrect = await verifyPassword(r[0].hashed_password, old_password);
if (!isCorrect) {
const key = `reset-link:${old_password}`;
const user_sid = await retrieveKey(key);
const key = cacheClient.generateRedisKey('reset-link', old_password);
const user_sid = await cacheClient.get(key);
if (!user_sid) throw new DbErrorBadRequest('old_password is incorrect');
await deleteKey(key);
await cacheClient.delete(key);
}
}
@@ -42,6 +42,9 @@ router.post('/', async(req, res) => {
sysError(logger, res, err);
return;
}
const redisKey = cacheClient.generateRedisKey('jwt', user_sid, 'v2');
await cacheClient.delete(redisKey);
});
module.exports = router;

View File

@@ -1,6 +1,10 @@
const {DbErrorBadRequest, DbErrorUnprocessableRequest} = require('../../utils/errors');
const { BadRequestError, DbErrorBadRequest, DbErrorUnprocessableRequest } = require('../../utils/errors');
function sysError(logger, res, err) {
if (err instanceof BadRequestError) {
logger.info(err, err.message);
return res.status(400).json({msg: 'Bad request'});
}
if (err instanceof DbErrorBadRequest) {
logger.info(err, 'invalid client request');
return res.status(400).json({msg: err.message});

View File

@@ -4,6 +4,7 @@ const short = require('short-uuid');
const translator = short();
const {validateEmail, emailSimpleText} = require('../../utils/email-utils');
const {promisePool} = require('../../db');
const {cacheClient} = require('../../helpers');
const sysError = require('../error');
const sql = `SELECT * from users user
LEFT JOIN accounts AS acc
@@ -45,6 +46,7 @@ function createResetEmailText(link) {
router.post('/', async(req, res) => {
const {logger, addKey} = req.app.locals;
const {email} = req.body;
const {user_sid} = req.user;
let obj;
try {
if (!email || !validateEmail(email)) {
@@ -73,10 +75,14 @@ router.post('/', async(req, res) => {
else {
/* generate a link for this user to reset, send email */
const link = translator.generate();
addKey(`reset-link:${link}`, obj.user.user_sid, 3600)
const redisKey = cacheClient.generateRedisKey('reset-link', link);
addKey(redisKey, obj.user.user_sid, 3600)
.catch((err) => logger.error({err}, 'Error adding reset link to redis'));
emailSimpleText(logger, email, 'Reset password request', createResetEmailText(link));
}
const redisKey = cacheClient.generateRedisKey('jwt', user_sid, 'v2');
await cacheClient.delete(redisKey);
});
module.exports = router;

View File

@@ -22,22 +22,25 @@ DELETE FROM account_limits
WHERE account_sid = ?
AND category = ?
`;
router.post('/', async(req, res) => {
const logger = req.app.locals.logger;
const {
category,
quantity
} = req.body;
const account_sid = parseAccountSid(req);
let service_provider_sid;
if (!account_sid) {
if (!req.user.hasServiceProviderAuth && !req.user.hasAdminAuth) {
logger.error('POST /SpeechCredentials invalid credentials');
return res.sendStatus(403);
}
service_provider_sid = parseServiceProviderSid(req);
}
try {
let service_provider_sid;
const account_sid = parseAccountSid(req);
if (!account_sid) {
if (!req.user.hasServiceProviderAuth && !req.user.hasAdminAuth) {
logger.error('POST /SpeechCredentials invalid credentials');
return res.sendStatus(403);
}
service_provider_sid = parseServiceProviderSid(req);
}
let uuid;
if (account_sid) {
const existing = (await AccountLimits.retrieve(account_sid) || [])
@@ -80,10 +83,11 @@ router.post('/', async(req, res) => {
*/
router.get('/', async(req, res) => {
let service_provider_sid;
const account_sid = parseAccountSid(req);
if (!account_sid) service_provider_sid = parseServiceProviderSid(req);
const logger = req.app.locals.logger;
try {
const account_sid = parseAccountSid(req);
if (!account_sid) service_provider_sid = parseServiceProviderSid(req);
const limits = account_sid ?
await AccountLimits.retrieve(account_sid) :
await ServiceProviderLimits.retrieve(service_provider_sid);
@@ -99,10 +103,11 @@ router.get('/', async(req, res) => {
router.delete('/', async(req, res) => {
const logger = req.app.locals.logger;
const account_sid = parseAccountSid(req);
const {category} = req.query;
const service_provider_sid = parseServiceProviderSid(req);
try {
const account_sid = parseAccountSid(req);
const {category} = req.query;
const service_provider_sid = parseServiceProviderSid(req);
if (account_sid) {
if (category) {
await promisePool.execute(sqlDeleteAccountLimitsByCategory, [account_sid, category]);

View File

@@ -2,6 +2,7 @@ const router = require('express').Router();
const jwt = require('jsonwebtoken');
const {verifyPassword} = require('../../utils/password-utils');
const {promisePool} = require('../../db');
const {cacheClient} = require('../../helpers');
const Account = require('../../models/account');
const ServiceProvider = require('../../models/service-provider');
const sysError = require('../error');
@@ -71,12 +72,22 @@ router.post('/', async(req, res) => {
}),
user_sid: obj.user_sid
};
const expiresIn = parseInt(process.env.JWT_EXPIRES_IN || 60) * 60;
const token = jwt.sign(
payload,
process.env.JWT_SECRET,
{ expiresIn: parseInt(process.env.JWT_EXPIRES_IN || 60) * 60 }
{ expiresIn }
);
res.json({token, ...obj});
/* Store jwt based on user_id after successful login */
await cacheClient.set({
redisKey: cacheClient.generateRedisKey('jwt', obj.user_sid, 'v2'),
value: token,
time: expiresIn,
});
} catch (err) {
sysError(logger, res, err);
}

View File

@@ -1,19 +1,18 @@
const router = require('express').Router();
const debug = require('debug')('jambonz:api-server');
const {hashString} = require('../../utils/password-utils');
const {cacheClient} = require('../../helpers');
const sysError = require('../error');
router.post('/', async(req, res) => {
const {logger, addKey} = req.app.locals;
const {jwt} = req.user;
const {logger} = req.app.locals;
const {user_sid} = req.user;
debug(`adding jwt to blacklist: ${jwt}`);
debug(`logout user and invalidate jwt token for user: ${user_sid}`);
try {
/* add key to blacklist */
const s = `jwt:${hashString(jwt)}`;
const result = await addKey(s, '1', 3600);
debug(`result from adding ${s}: ${result}`);
const redisKey = cacheClient.generateRedisKey('jwt', user_sid, 'v2');
await cacheClient.delete(redisKey);
res.sendStatus(204);
} catch (err) {
sysError(logger, res, err);

View File

@@ -15,6 +15,9 @@ const validate = (obj) => {
router.post('/', async(req, res) => {
const logger = req.app.locals.logger;
try {
if (!req.user.hasAdminAuth) {
return res.sendStatus(403);
}
validate(req.body);
const [existing] = (await PasswordSettings.retrieve() || []);
if (existing) {

View File

@@ -10,6 +10,7 @@ const preconditions = {
'update': validateUpdate
};
const sysError = require('../error');
const { parsePhoneNumberSid } = require('./utils');
/* check for required fields when adding */
@@ -85,8 +86,9 @@ router.get('/', async(req, res) => {
router.get('/:sid', async(req, res) => {
const logger = req.app.locals.logger;
try {
const sid = parsePhoneNumberSid(req);
const account_sid = req.user.hasAccountAuth ? req.user.account_sid : null;
const results = await PhoneNumber.retrieve(req.params.sid, account_sid);
const results = await PhoneNumber.retrieve(sid, account_sid);
if (results.length === 0) return res.status(404).end();
return res.status(200).json(results[0]);
}

View File

@@ -4,6 +4,7 @@ const {DbErrorBadRequest, DbErrorUnprocessableRequest} = require('../../utils/er
const {promisePool} = require('../../db');
const {doGithubAuth, doGoogleAuth, doLocalAuth} = require('../../utils/oauth-utils');
const {validateEmail} = require('../../utils/email-utils');
const {cacheClient} = require('../../helpers');
const { v4: uuid } = require('uuid');
const short = require('short-uuid');
const translator = short();
@@ -338,16 +339,19 @@ router.post('/', async(req, res) => {
/* deactivate the old/replaced user */
const [r] = await promisePool.execute('DELETE FROM users WHERE user_sid = ?', [user_sid]);
logger.debug({r}, 'register - removed old user');
const redisKey = cacheClient.generateRedisKey('jwt', user_sid, 'v2');
await cacheClient.delete(redisKey);
}
}
const expiresIn = parseInt(process.env.JWT_EXPIRES_IN || 60) * 60 ;
// generate a json web token for this user
const token = jwt.sign({
user_sid: userProfile.user_sid,
account_sid: userProfile.account_sid,
email: userProfile.email,
name: userProfile.name
}, process.env.JWT_SECRET, { expiresIn: parseInt(process.env.JWT_EXPIRES_IN || 60) * 60 });
}, process.env.JWT_SECRET, { expiresIn });
logger.debug({
user_sid: userProfile.user_sid,
@@ -356,6 +360,13 @@ router.post('/', async(req, res) => {
res.json({jwt: token, ...userProfile});
/* Store jwt based on user_id after successful login */
await cacheClient.set({
redisKey: cacheClient.generateRedisKey('jwt', userProfile.user_sid, 'v2'),
value: token,
time: expiresIn,
});
} catch (err) {
debug(err, 'Error');
sysError(logger, res, err);

View File

@@ -8,7 +8,7 @@ const VoipCarrier = require('../../models/voip-carrier');
const Application = require('../../models/application');
const PhoneNumber = require('../../models/phone-number');
const ApiKey = require('../../models/api-key');
const {hasServiceProviderPermissions, parseServiceProviderSid} = require('./utils');
const {hasServiceProviderPermissions, parseServiceProviderSid, parseVoipCarrierSid} = require('./utils');
const sysError = require('../error');
const decorate = require('./decorate');
const preconditions = {
@@ -35,30 +35,49 @@ function validateAdd(req) {
}
async function validateRetrieve(req) {
const service_provider_sid = parseServiceProviderSid(req);
if (req.user.hasScope('admin')) return ;
if (req.user.hasScope('service_provider')) {
if (service_provider_sid === req.user.service_provider_sid) return ;
try {
const service_provider_sid = parseServiceProviderSid(req);
if (req.user.hasScope('admin')) {
return;
}
if (req.user.hasScope('service_provider')) {
if (service_provider_sid === req.user.service_provider_sid) return ;
}
if (req.user.hasScope('account')) {
/* allow account users to retrieve service provider data from parent SP */
const sid = req.user.account_sid;
const [r] = await promisePool.execute('SELECT service_provider_sid from accounts WHERE account_sid = ?', [sid]);
if (r.length === 1 && r[0].service_provider_sid === req.user.service_provider_sid) return;
}
throw new DbErrorForbidden('insufficient permissions to update service provider');
} catch (error) {
throw error;
}
if (req.user.hasScope('account')) {
/* allow account users to retrieve service provider data from parent SP */
const sid = req.user.account_sid;
const [r] = await promisePool.execute('SELECT service_provider_sid from accounts WHERE account_sid = ?', [sid]);
if (r.length === 1 && r[0].service_provider_sid === req.user.service_provider_sid) return;
}
throw new DbErrorForbidden('insufficient permissions to update service provider');
}
function validateUpdate(req) {
if (req.user.hasScope('admin')) return ;
if (req.user.hasScope('service_provider')) {
try {
const service_provider_sid = parseServiceProviderSid(req);
if (service_provider_sid === req.user.service_provider_sid) return ;
if (req.user.hasScope('admin')) {
return;
}
if (req.user.hasScope('service_provider')) {
if (service_provider_sid === req.user.service_provider_sid) return;
}
throw new DbErrorForbidden('insufficient permissions to update service provider');
} catch (error) {
throw error;
}
throw new DbErrorForbidden('insufficient permissions to update service provider');
}
/* can not delete a service provider if it has any active accounts */
/* can not delete a service provider if it has any active accounts or users*/
async function noActiveAccountsOrUsers(req, sid) {
if (!req.user.hasAdminAuth) {
throw new DbErrorForbidden('only admin users can delete a service provider');
@@ -79,6 +98,7 @@ async function noActiveAccountsOrUsers(req, sid) {
await promisePool.query(sqlDeleteSipGateways, [sid]);
await promisePool.query(sqlDeleteSmppGateways, [sid]);
await promisePool.query('DELETE from voip_carriers WHERE service_provider_sid = ?', [sid]);
await promisePool.query('DELETE from api_keys WHERE service_provider_sid = ?', [sid]);
}
decorate(router, ServiceProvider, ['delete'], preconditions);
@@ -161,7 +181,8 @@ router.put('/:sid/VoipCarriers/:voip_carrier_sid', async(req, res) => {
const logger = req.app.locals.logger;
try {
validateUpdate(req);
const rowsAffected = await VoipCarrier.update(req.params.voip_carrier_sid, req.body);
const sid = parseVoipCarrierSid(req);
const rowsAffected = await VoipCarrier.update(sid, req.body);
if (rowsAffected === 0) {
return res.sendStatus(404);
}
@@ -191,7 +212,6 @@ router.post('/', async(req, res) => {
const logger = req.app.locals.logger;
try {
validateAdd(req);
// create webhooks if provided
const obj = Object.assign({}, req.body);
for (const prop of ['registration_hook']) {
@@ -212,7 +232,6 @@ router.post('/', async(req, res) => {
/* list */
router.get('/', async(req, res) => {
const logger = req.app.locals.logger;
try {
const results = await ServiceProvider.retrieveAll();
logger.debug({results, user: req.user}, 'ServiceProvider.retrieveAll');
@@ -231,7 +250,8 @@ router.get('/', async(req, res) => {
router.get('/:sid', async(req, res) => {
const logger = req.app.locals.logger;
try {
const results = await ServiceProvider.retrieve(req.params.sid);
const sid = parseServiceProviderSid(req);
const results = await ServiceProvider.retrieve(sid);
if (results.length === 0) return res.status(404).end();
return res.status(200).json(results[0]);
}
@@ -242,10 +262,10 @@ router.get('/:sid', async(req, res) => {
/* update */
router.put('/:sid', async(req, res) => {
const sid = req.params.sid;
const logger = req.app.locals.logger;
try {
validateUpdate(req);
const sid = parseServiceProviderSid(req);
// create webhooks if provided
const obj = Object.assign({}, req.body);
@@ -255,15 +275,14 @@ router.put('/:sid', async(req, res) => {
const sid = obj[prop]['webhook_sid'];
delete obj[prop]['webhook_sid'];
await Webhook.update(sid, obj[prop]);
}
else {
} else {
const sid = await Webhook.make(obj[prop]);
obj[`${prop}_sid`] = sid;
}
}
else {
} else {
obj[`${prop}_sid`] = null;
}
delete obj[prop];
}
@@ -271,6 +290,7 @@ router.put('/:sid', async(req, res) => {
if (rowsAffected === 0) {
return res.status(404).end();
}
res.status(204).end();
} catch (err) {
sysError(logger, res, err);

View File

@@ -3,6 +3,7 @@ const router = require('express').Router();
const {DbErrorBadRequest} = require('../../utils/errors');
const {promisePool} = require('../../db');
const {verifyPassword} = require('../../utils/password-utils');
const {cacheClient} = require('../../helpers');
const jwt = require('jsonwebtoken');
const sysError = require('../error');
@@ -64,11 +65,12 @@ router.post('/', async(req, res) => {
pristine: false
});
const expiresIn = parseInt(process.env.JWT_EXPIRES_IN || 60) * 60;
// generate a json web token for this session
const token = jwt.sign({
user_sid: userProfile.user_sid,
account_sid: userProfile.account_sid
}, process.env.JWT_SECRET, { expiresIn: parseInt(process.env.JWT_EXPIRES_IN || 60) * 60 });
}, process.env.JWT_SECRET, { expiresIn });
logger.debug({
user_sid: userProfile.user_sid,
@@ -76,6 +78,14 @@ router.post('/', async(req, res) => {
}, 'generated jwt');
res.json({jwt: token, ...userProfile});
/* Store jwt based on user_id after successful login */
await cacheClient.set({
redisKey: cacheClient.generateRedisKey('jwt', userProfile.user_sid, 'v2'),
value: token,
time: expiresIn,
});
} catch (err) {
sysError(logger, res, err);
}

View File

@@ -4,7 +4,7 @@ const Account = require('../../models/account');
const SpeechCredential = require('../../models/speech-credential');
const sysError = require('../error');
const {decrypt, encrypt} = require('../../utils/encrypt-decrypt');
const {parseAccountSid, parseServiceProviderSid} = require('./utils');
const {parseAccountSid, parseServiceProviderSid, parseSpeechCredentialSid} = require('./utils');
const {DbErrorUnprocessableRequest} = require('../../utils/errors');
const {
testGoogleTts,
@@ -44,6 +44,8 @@ const encryptCredential = (obj) => {
region,
client_id,
secret,
nuance_tts_uri,
nuance_stt_uri,
use_custom_tts,
custom_tts_endpoint,
use_custom_stt,
@@ -53,7 +55,10 @@ const encryptCredential = (obj) => {
stt_api_key,
stt_region,
riva_server_uri,
instance_id
instance_id,
custom_stt_url,
custom_tts_url,
auth_token = ''
} = obj;
switch (vendor) {
@@ -94,9 +99,10 @@ const encryptCredential = (obj) => {
return encrypt(wsData);
case 'nuance':
assert(client_id, 'invalid nuance speech credential: client_id is required');
assert(secret, 'invalid nuance speech credential: secret is required');
const nuanceData = JSON.stringify({client_id, secret});
const checked = (client_id && secret) || (nuance_tts_uri || nuance_stt_uri);
assert(checked, 'invalid nuance speech credential: either entered client id and\
secret or entered a nuance_tts_uri or nuance_stt_uri');
const nuanceData = JSON.stringify({client_id, secret, nuance_tts_uri, nuance_stt_uri});
return encrypt(nuanceData);
case 'deepgram':
@@ -119,27 +125,34 @@ const encryptCredential = (obj) => {
return encrypt(sonioxData);
default:
assert(false, `invalid or missing vendor: ${vendor}`);
if (vendor.startsWith('custom:')) {
const customData = JSON.stringify({auth_token, custom_stt_url, custom_tts_url});
return encrypt(customData);
}
else assert(false, `invalid or missing vendor: ${vendor}`);
}
};
router.post('/', async(req, res) => {
const logger = req.app.locals.logger;
const {
use_for_stt,
use_for_tts,
vendor,
} = req.body;
const account_sid = req.user.account_sid || req.body.account_sid;
const service_provider_sid = req.user.service_provider_sid ||
req.body.service_provider_sid || parseServiceProviderSid(req);
if (!account_sid) {
if (!req.user.hasServiceProviderAuth && !req.user.hasAdminAuth) {
logger.error('POST /SpeechCredentials invalid credentials');
return res.sendStatus(403);
}
}
try {
const {
use_for_stt,
use_for_tts,
vendor,
} = req.body;
const account_sid = req.user.account_sid || req.body.account_sid;
const service_provider_sid = req.user.service_provider_sid ||
req.body.service_provider_sid || parseServiceProviderSid(req);
if (!account_sid) {
if (!req.user.hasServiceProviderAuth && !req.user.hasAdminAuth) {
logger.error('POST /SpeechCredentials invalid credentials');
return res.sendStatus(403);
}
}
const encrypted_credential = encryptCredential(req.body);
const uuid = await SpeechCredential.make({
account_sid,
@@ -159,10 +172,11 @@ router.post('/', async(req, res) => {
* retrieve all speech credentials for an account
*/
router.get('/', async(req, res) => {
const account_sid = parseAccountSid(req) || req.user.account_sid;
const service_provider_sid = parseServiceProviderSid(req) || req.user.service_provider_sid;
const logger = req.app.locals.logger;
try {
const account_sid = parseAccountSid(req) || req.user.account_sid;
const service_provider_sid = parseServiceProviderSid(req) || req.user.service_provider_sid;
const credsAccount = account_sid ? await SpeechCredential.retrieveAll(account_sid) : [];
const credsSP = service_provider_sid ?
await SpeechCredential.retrieveAllForSP(service_provider_sid) :
@@ -209,7 +223,7 @@ router.get('/', async(req, res) => {
else if ('nuance' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.client_id = o.client_id;
obj.secret = obscureKey(o.secret);
obj.secret = o.secret ? obscureKey(o.secret) : null;
}
else if ('deepgram' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
@@ -230,6 +244,12 @@ router.get('/', async(req, res) => {
const o = JSON.parse(decrypt(credential));
obj.api_key = obscureKey(o.api_key);
}
else if (obj.vendor.startsWith('custom:')) {
const o = JSON.parse(decrypt(credential));
obj.auth_token = obscureKey(o.auth_token);
obj.custom_stt_url = o.custom_stt_url;
obj.custom_tts_url = o.custom_tts_url;
}
return obj;
}));
} catch (err) {
@@ -241,9 +261,9 @@ router.get('/', async(req, res) => {
* retrieve a specific speech credential
*/
router.get('/:sid', async(req, res) => {
const sid = req.params.sid;
const logger = req.app.locals.logger;
try {
const sid = parseSpeechCredentialSid(req);
const cred = await SpeechCredential.retrieve(sid);
if (0 === cred.length) return res.sendStatus(404);
const {credential, ...obj} = cred[0];
@@ -278,7 +298,9 @@ router.get('/:sid', async(req, res) => {
else if ('nuance' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.client_id = o.client_id;
obj.secret = obscureKey(o.secret);
obj.secret = o.secret ? obscureKey(o.secret) : null;
obj.nuance_tts_uri = o.nuance_tts_uri;
obj.nuance_stt_uri = o.nuance_stt_uri;
}
else if ('deepgram' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
@@ -295,6 +317,16 @@ router.get('/:sid', async(req, res) => {
const o = JSON.parse(decrypt(credential));
obj.riva_server_uri = o.riva_server_uri;
}
else if ('soniox' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.api_key = obscureKey(o.api_key);
}
else if (obj.vendor.startsWith('custom:')) {
const o = JSON.parse(decrypt(credential));
obj.auth_token = obscureKey(o.auth_token);
obj.custom_stt_url = o.custom_stt_url;
obj.custom_tts_url = o.custom_tts_url;
}
res.status(200).json(obj);
} catch (err) {
sysError(logger, res, err);
@@ -305,9 +337,9 @@ router.get('/:sid', async(req, res) => {
* delete a speech credential
*/
router.delete('/:sid', async(req, res) => {
const sid = req.params.sid;
const logger = req.app.locals.logger;
try {
const sid = parseSpeechCredentialSid(req);
const count = await SpeechCredential.remove(sid);
if (0 === count) return res.sendStatus(404);
res.sendStatus(204);
@@ -321,10 +353,11 @@ router.delete('/:sid', async(req, res) => {
* update a speech credential -- we only allow use_for_tts and use_for_stt to be updated
*/
router.put('/:sid', async(req, res) => {
const sid = req.params.sid;
const logger = req.app.locals.logger;
try {
const {use_for_tts, use_for_stt, region, aws_region, stt_region, tts_region, riva_server_uri} = req.body;
const sid = parseSpeechCredentialSid(req);
const {use_for_tts, use_for_stt, region, aws_region, stt_region, tts_region,
riva_server_uri, nuance_tts_uri, nuance_stt_uri} = req.body;
if (typeof use_for_tts === 'undefined' && typeof use_for_stt === 'undefined') {
throw new DbErrorUnprocessableRequest('use_for_tts and use_for_stt are the only updateable fields');
}
@@ -360,7 +393,9 @@ router.put('/:sid', async(req, res) => {
custom_stt_endpoint,
stt_region,
tts_region,
riva_server_uri
riva_server_uri,
nuance_stt_uri,
nuance_tts_uri
};
logger.info({o, newCred}, 'updating speech credential with this new credential');
obj.credential = encryptCredential(newCred);
@@ -389,9 +424,9 @@ router.put('/:sid', async(req, res) => {
* Test a credential
*/
router.get('/:sid/test', async(req, res) => {
const sid = req.params.sid;
const logger = req.app.locals.logger;
try {
const sid = parseSpeechCredentialSid(req);
const creds = await SpeechCredential.retrieve(sid);
if (!creds || 0 === creds.length) return res.sendStatus(404);
@@ -412,7 +447,8 @@ router.get('/:sid/test', async(req, res) => {
if (cred.use_for_tts) {
try {
await testGoogleTts(logger, credential);
const {getTtsVoices} = req.app.locals;
await testGoogleTts(logger, getTtsVoices, credential);
results.tts.status = 'ok';
SpeechCredential.ttsTestResult(sid, true);
} catch (err) {
@@ -434,8 +470,9 @@ router.get('/:sid/test', async(req, res) => {
}
else if (cred.vendor === 'aws') {
if (cred.use_for_tts) {
const {getTtsVoices} = req.app.locals;
try {
await testAwsTts(logger, {
await testAwsTts(logger, getTtsVoices, {
accessKeyId: credential.access_key_id,
secretAccessKey: credential.secret_access_key,
region: credential.aws_region || process.env.AWS_REGION
@@ -517,13 +554,16 @@ router.get('/:sid/test', async(req, res) => {
const {
client_id,
secret
secret,
nuance_tts_uri,
nuance_stt_uri
} = credential;
if (cred.use_for_tts) {
try {
await testNuanceTts(logger, getTtsVoices, {
client_id,
secret
secret,
nuance_tts_uri
});
results.tts.status = 'ok';
SpeechCredential.ttsTestResult(sid, true);
@@ -538,7 +578,7 @@ router.get('/:sid/test', async(req, res) => {
}
if (cred.use_for_stt) {
try {
await testNuanceStt(logger, {client_id, secret});
await testNuanceStt(logger, {client_id, secret, nuance_stt_uri});
results.stt.status = 'ok';
SpeechCredential.sttTestResult(sid, true);
} catch (err) {
@@ -592,7 +632,8 @@ router.get('/:sid/test', async(req, res) => {
SpeechCredential.sttTestResult(sid, false);
}
}
} else if (cred.vendor === 'soniox') {
}
else if (cred.vendor === 'soniox') {
const {api_key} = credential;
if (cred.use_for_stt) {
try {
@@ -607,6 +648,7 @@ router.get('/:sid/test', async(req, res) => {
}
res.status(200).json(results);
} catch (err) {
sysError(logger, res, err);
}

View File

@@ -1,11 +1,11 @@
const router = require('express').Router();
const User = require('../../models/user');
const jwt = require('jsonwebtoken');
const request = require('request');
const {DbErrorBadRequest} = require('../../utils/errors');
const {generateHashedPassword, verifyPassword} = require('../../utils/password-utils');
const {promisePool} = require('../../db');
const {validatePasswordSettings} = require('./utils');
const {decrypt} = require('../../utils/encrypt-decrypt');
const {cacheClient} = require('../../helpers');
const sysError = require('../error');
const retrieveMyDetails = `SELECT *
FROM users user
@@ -28,7 +28,8 @@ AND account_subscriptions.pending=0`;
const updateSql = 'UPDATE users set hashed_password = ?, force_change = false WHERE user_sid = ?';
const retrieveStaticIps = 'SELECT * FROM account_static_ips WHERE account_sid = ?';
const validateRequest = async(user_sid, payload) => {
const validateRequest = async(user_sid, req) => {
const payload = req.body;
const {
old_password,
new_password,
@@ -37,15 +38,53 @@ const validateRequest = async(user_sid, payload) => {
email,
email_activation_code,
force_change,
is_active} = payload;
is_active
} = payload;
const [r] = await promisePool.query(retrieveSql, user_sid);
if (r.length === 0) return null;
if (r.length === 0) {
throw new DbErrorBadRequest('Invalid request: user_sid does not exist');
}
const user = r[0];
/* it is not allowed for anyone to promote a user to a higher level of authority */
if (null === payload.account_sid || null === payload.service_provider_sid) {
throw new DbErrorBadRequest('Invalid request: user may not be promoted');
}
if (req.user.hasAccountAuth) {
/* account user may not change modify account_sid or service_provider_sid */
if ('account_sid' in payload && payload.account_sid !== user.account_sid) {
throw new DbErrorBadRequest('Invalid request: user may not be promoted or moved to another account');
}
if ('service_provider_sid' in payload && payload.service_provider_sid !== user.service_provider_sid) {
throw new DbErrorBadRequest('Invalid request: user may not be promoted or moved to another service provider');
}
}
if (req.user.hasServiceProviderAuth) {
if ('service_provider_sid' in payload && payload.service_provider_sid !== user.service_provider_sid) {
throw new DbErrorBadRequest('Invalid request: user may not be promoted or moved to another service provider');
}
}
if ('account_sid' in payload) {
const [r] = await promisePool.query('SELECT * FROM accounts WHERE account_sid = ?', payload.account_sid);
if (r.length === 0) throw new DbErrorBadRequest('Invalid request: account_sid does not exist');
const {service_provider_sid} = r[0];
if (service_provider_sid !== user.service_provider_sid) {
throw new DbErrorBadRequest('Invalid request: user may not be moved to another service provider');
}
}
if (initial_password) {
await validatePasswordSettings(initial_password);
}
if ((old_password && !new_password) || (new_password && !old_password)) {
throw new DbErrorBadRequest('new_password and old_password both required');
}
if (new_password) {
await validatePasswordSettings(new_password);
}
if (new_password && name) throw new DbErrorBadRequest('can not change name and password simultaneously');
if (new_password && user.provider !== 'local') {
throw new DbErrorBadRequest('can not change password when using oauth2');
@@ -62,23 +101,18 @@ const validateRequest = async(user_sid, payload) => {
router.get('/', async(req, res) => {
const logger = req.app.locals.logger;
const token = req.user.jwt;
const decodedJwt = jwt.verify(token, process.env.JWT_SECRET);
let usersList;
try {
let results;
if (decodedJwt.scope === 'admin') {
if (req.user.hasAdminAuth) {
results = await User.retrieveAll();
}
else if (decodedJwt.scope === 'account') {
results = await User.retrieveAllForAccount(decodedJwt.account_sid, true);
else if (req.user.hasAccountAuth) {
results = await User.retrieveAllForAccount(req.user.account_sid, true);
}
else if (decodedJwt.scope === 'service_provider') {
results = await User.retrieveAllForServiceProvider(decodedJwt.service_provider_sid, true);
}
else {
throw new DbErrorBadRequest(`invalid scope: ${decodedJwt.scope}`);
else if (req.user.hasServiceProviderAuth) {
results = await User.retrieveAllForServiceProvider(req.user.service_provider_sid, true);
}
if (results.length === 0) throw new Error('failure retrieving users list');
@@ -222,8 +256,6 @@ router.get('/me', async(req, res) => {
router.get('/:user_sid', async(req, res) => {
const logger = req.app.locals.logger;
const token = req.user.jwt;
const decodedJwt = jwt.verify(token, process.env.JWT_SECRET);
const {user_sid} = req.params;
try {
@@ -232,9 +264,9 @@ router.get('/:user_sid', async(req, res) => {
const {hashed_password, ...rest} = user;
if (!user) throw new Error('failure retrieving user');
if (decodedJwt.scope === 'admin' ||
decodedJwt.scope === 'account' && decodedJwt.account_sid === user.account_sid ||
decodedJwt.scope === 'service_provider' && decodedJwt.service_provider_sid === user.service_provider_sid) {
if (req.user.hasAdminAuth ||
req.user.hasAccountAuth && req.user.account_sid === user.account_sid ||
req.user.hasServiceProviderAuth && req.user.service_provider_sid === user.service_provider_sid) {
res.status(200).json(rest);
} else {
res.sendStatus(403);
@@ -249,8 +281,7 @@ router.put('/:user_sid', async(req, res) => {
const logger = req.app.locals.logger;
const {user_sid} = req.params;
const user = await User.retrieve(user_sid);
const token = req.user.jwt;
const decodedJwt = jwt.verify(token, process.env.JWT_SECRET);
const {hasAccountAuth, hasServiceProviderAuth, hasAdminAuth} = req.user;
const {
old_password,
new_password,
@@ -266,15 +297,15 @@ router.put('/:user_sid', async(req, res) => {
//if (req.user.user_sid && req.user.user_sid !== user_sid) return res.sendStatus(403);
if (decodedJwt.scope !== 'admin' &&
!(decodedJwt.scope === 'account' && decodedJwt.account_sid === user[0].account_sid) &&
!(decodedJwt.scope === 'service_provider' && decodedJwt.service_provider_sid === user[0].service_provider_sid) &&
if (!hasAdminAuth &&
!(hasAccountAuth && req.user.account_sid === user[0].account_sid) &&
!(hasServiceProviderAuth && req.user.service_provider_sid === user[0].service_provider_sid) &&
(req.user.user_sid && req.user.user_sid !== user_sid)) {
return res.sendStatus(403);
}
try {
const user = await validateRequest(user_sid, req.body);
const user = await validateRequest(user_sid, req);
if (!user) return res.sendStatus(404);
if (new_password) {
@@ -327,6 +358,8 @@ router.put('/:user_sid', async(req, res) => {
'UPDATE users SET account_sid = ? WHERE user_sid = ?',
[account_sid, user_sid]);
if (0 === r.changedRows) throw new Error('database update failed');
const redisKey = cacheClient.generateRedisKey('jwt', user_sid, 'v2');
await cacheClient.delete(redisKey);
}
if (service_provider_sid || service_provider_sid === null) {
@@ -334,6 +367,8 @@ router.put('/:user_sid', async(req, res) => {
'UPDATE users SET service_provider_sid = ? WHERE user_sid = ?',
[service_provider_sid, user_sid]);
if (0 === r.changedRows) throw new Error('database update failed');
const redisKey = cacheClient.generateRedisKey('jwt', user_sid, 'v2');
await cacheClient.delete(redisKey);
}
if (email) {
@@ -367,11 +402,12 @@ router.post('/', async(req, res) => {
hashed_password: passwordHash,
};
const allUsers = await User.retrieveAll();
const token = req.user.jwt;
const decodedJwt = jwt.verify(token, process.env.JWT_SECRET);
delete payload.initial_password;
try {
if (req.body.initial_password) {
await validatePasswordSettings(req.body.initial_password);
}
const email = allUsers.find((e) => e.email === payload.email);
const name = allUsers.find((e) => e.name === payload.name);
@@ -385,30 +421,27 @@ router.post('/', async(req, res) => {
return res.status(422).json({msg: 'user with this email already exists'});
}
if (decodedJwt.scope === 'admin') {
if (req.user.hasAdminAuth) {
logger.debug({payload}, 'POST /users');
const uuid = await User.make(payload);
res.status(201).json({user_sid: uuid});
}
else if (decodedJwt.scope === 'account') {
else if (req.user.hasAccountAuth) {
logger.debug({payload}, 'POST /users');
const uuid = await User.make({
...payload,
account_sid: decodedJwt.account_sid,
account_sid: req.user.account_sid,
});
res.status(201).json({user_sid: uuid});
}
else if (decodedJwt.scope === 'service_provider') {
else if (req.user.hasServiceProviderAuth) {
logger.debug({payload}, 'POST /users');
const uuid = await User.make({
...payload,
service_provider_sid: decodedJwt.service_provider_sid,
service_provider_sid: req.user.service_provider_sid,
});
res.status(201).json({user_sid: uuid});
}
else {
throw new DbErrorBadRequest(`invalid scope: ${decodedJwt.scope}`);
}
} catch (err) {
sysError(logger, res, err);
}
@@ -417,43 +450,30 @@ router.post('/', async(req, res) => {
router.delete('/:user_sid', async(req, res) => {
const logger = req.app.locals.logger;
const {user_sid} = req.params;
const token = req.user.jwt;
const decodedJwt = jwt.verify(token, process.env.JWT_SECRET);
const allUsers = await User.retrieveAll();
const activeAdminUsers = allUsers.filter((e) => !e.account_sid && !e.service_provider_sid && e.is_active);
const user = await User.retrieve(user_sid);
try {
if (decodedJwt.scope === 'admin' && !user.account_sid && !user.service_provider_sid &&
activeAdminUsers.length === 1) {
if (req.user.hasAdminAuth && activeAdminUsers.length === 1) {
throw new Error('cannot delete this admin user - there are no other active admin users');
}
if (decodedJwt.scope === 'admin' ||
(decodedJwt.scope === 'account' && decodedJwt.account_sid === user[0].account_sid) ||
(decodedJwt.scope === 'service_provider' && decodedJwt.service_provider_sid === user[0].service_provider_sid)) {
if (req.user.hasAdminAuth ||
(req.user.hasAccountAuth && req.user.account_sid === user[0].account_sid) ||
(req.user.hasServiceProviderAuth && req.user.service_provider_sid === user[0].service_provider_sid)) {
await User.remove(user_sid);
//logout user after self-delete
if (decodedJwt.user_sid === user_sid) {
request({
url:'http://localhost:3000/v1/logout',
method: 'POST',
}, (err) => {
if (err) {
logger.error(err, 'could not log out user');
return res.sendStatus(500);
}
logger.debug({user}, 'user deleted and logged out');
});
}
/* invalidate the jwt of the deleted user */
const redisKey = cacheClient.generateRedisKey('jwt', user_sid, 'v2');
await cacheClient.delete(redisKey);
return res.sendStatus(204);
} else {
throw new DbErrorBadRequest(`invalid scope: ${decodedJwt.scope}`);
throw new DbErrorBadRequest('invalid request');
}
} catch (err) {
sysError(logger, res, err);
}
});
module.exports = router;

View File

@@ -1,9 +1,10 @@
const { v4: uuid } = require('uuid');
const { v4: uuid, validate } = require('uuid');
const bent = require('bent');
const Account = require('../../models/account');
const {promisePool} = require('../../db');
const {cancelSubscription, detachPaymentMethod} = require('../../utils/stripe-utils');
const freePlans = require('../../utils/free_plans');
const { BadRequestError, DbErrorBadRequest } = require('../../utils/errors');
const insertAccountSubscriptionSql = `INSERT INTO account_subscriptions
(account_subscription_sid, account_sid)
values (?, ?)`;
@@ -137,39 +138,132 @@ const createTestAlerts = async(writeAlerts, AlertType, account_sid) => {
};
const validateSid = (model, req) => {
const arr = new RegExp(`${model}\/([^\/]*)`).exec(req.originalUrl);
if (arr) {
const sid = arr[1];
const sid_validation = validate(sid);
if (!sid_validation) {
throw new BadRequestError(`invalid ${model}Sid format`);
}
return arr[1];
}
return;
};
const parseServiceProviderSid = (req) => {
const arr = /ServiceProviders\/([^\/]*)/.exec(req.originalUrl);
if (arr) return arr[1];
try {
return validateSid('ServiceProviders', req);
} catch (error) {
throw error;
}
};
const parseAccountSid = (req) => {
const arr = /Accounts\/([^\/]*)/.exec(req.originalUrl);
if (arr) return arr[1];
try {
return validateSid('Accounts', req);
} catch (error) {
throw error;
}
};
const parseApplicationSid = (req) => {
try {
return validateSid('Applications', req);
} catch (error) {
throw error;
}
};
const parseCallSid = (req) => {
try {
return validateSid('Calls', req);
} catch (error) {
throw error;
}
};
const parsePhoneNumberSid = (req) => {
try {
return validateSid('PhoneNumbers', req);
} catch (error) {
throw error;
}
};
const parseSpeechCredentialSid = (req) => {
try {
return validateSid('SpeechCredentials', req);
} catch (error) {
throw error;
}
};
const parseVoipCarrierSid = (req) => {
try {
return validateSid('VoipCarriers', req);
} catch (error) {
throw error;
}
};
const parseWebhookSid = (req) => {
try {
return validateSid('Webhooks', req);
} catch (error) {
throw error;
}
};
const hasAccountPermissions = (req, res, next) => {
if (req.user.hasScope('admin')) return next();
if (req.user.hasScope('service_provider')) return next();
if (req.user.hasScope('account')) {
const account_sid = parseAccountSid(req);
if (account_sid === req.user.account_sid) return next();
try {
if (req.user.hasScope('admin')) {
return next();
}
if (req.user.hasScope('service_provider')) {
return next();
}
if (req.user.hasScope('account')) {
const account_sid = parseAccountSid(req);
if (account_sid === req.user.account_sid) {
return next();
}
}
res.status(403).json({
status: 'fail',
message: 'insufficient privileges'
});
} catch (error) {
throw error;
}
res.status(403).json({
status: 'fail',
message: 'insufficient privileges'
});
};
const hasServiceProviderPermissions = (req, res, next) => {
if (req.user.hasScope('admin')) return next();
if (req.user.hasScope('service_provider')) {
const service_provider_sid = parseServiceProviderSid(req);
if (service_provider_sid === req.user.service_provider_sid) return next();
try {
if (req.user.hasScope('admin')) {
return next();
}
if (req.user.hasScope('service_provider')) {
const service_provider_sid = parseServiceProviderSid(req);
if (service_provider_sid === req.user.service_provider_sid) {
return next();
}
}
res.status(403).json({
status: 'fail',
message: 'insufficient privileges'
});
} catch (error) {
throw error;
}
res.status(403).json({
status: 'fail',
message: 'insufficient privileges'
});
};
const checkLimits = async(req, res, next) => {
@@ -274,15 +368,47 @@ const disableSubspace = async(opts) => {
return;
};
const validatePasswordSettings = async(password) => {
const sql = 'SELECT * from password_settings';
const [rows] = await promisePool.execute(sql);
const specialChars = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]+/;
const numbers = /[0-9]+/;
if (rows.length === 0) {
if (password.length < 8 || password.length > 20) {
throw new DbErrorBadRequest('password length must be between 8 and 20');
}
} else {
if (rows[0].min_password_length && password.length < rows[0].min_password_length) {
throw new DbErrorBadRequest(`password must be at least ${rows[0].min_password_length} characters long`);
}
if (rows[0].require_digit === 1 && !numbers.test(password)) {
throw new DbErrorBadRequest('password must contain at least one digit');
}
if (rows[0].require_special_character === 1 && !specialChars.test(password)) {
throw new DbErrorBadRequest('password must contain at least one special character');
}
}
return;
};
module.exports = {
setupFreeTrial,
createTestCdrs,
createTestAlerts,
parseAccountSid,
parseApplicationSid,
parseCallSid,
parsePhoneNumberSid,
parseServiceProviderSid,
parseSpeechCredentialSid,
parseVoipCarrierSid,
parseWebhookSid,
hasAccountPermissions,
hasServiceProviderPermissions,
checkLimits,
enableSubspace,
disableSubspace
disableSubspace,
validatePasswordSettings
};

View File

@@ -4,6 +4,7 @@ const VoipCarrier = require('../../models/voip-carrier');
const {promisePool} = require('../../db');
const decorate = require('./decorate');
const sysError = require('../error');
const { parseVoipCarrierSid } = require('./utils');
const validate = async(req) => {
const {lookupAppBySid, lookupAccountBySid} = req.app.locals;
@@ -84,8 +85,9 @@ router.get('/', async(req, res) => {
router.get('/:sid', async(req, res) => {
const logger = req.app.locals.logger;
try {
const sid = parseVoipCarrierSid(req);
const account_sid = req.user.hasAccountAuth ? req.user.account_sid : null;
const results = await VoipCarrier.retrieve(req.params.sid, account_sid);
const results = await VoipCarrier.retrieve(sid, account_sid);
if (results.length === 0) return res.status(404).end();
return res.status(200).json(results[0]);
}

View File

@@ -2,6 +2,7 @@ const router = require('express').Router();
const Webhook = require('../../models/webhook');
const decorate = require('./decorate');
const sysError = require('../error');
const { parseWebhookSid } = require('./utils');
decorate(router, Webhook, ['add']);
@@ -9,7 +10,8 @@ decorate(router, Webhook, ['add']);
router.get('/:sid', async(req, res) => {
const logger = req.app.locals.logger;
try {
const results = await Webhook.retrieve(req.params.sid);
const sid = parseWebhookSid(req);
const results = await Webhook.retrieve(sid);
if (results.length === 0) return res.status(404).end();
return res.status(200).json(results[0]);
}

View File

@@ -1,6 +1,15 @@
const {DbErrorBadRequest, DbErrorUnprocessableRequest, DbErrorForbidden} = require('../utils/errors');
const {
BadRequestError,
DbErrorBadRequest,
DbErrorUnprocessableRequest,
DbErrorForbidden
} = require('../utils/errors');
function sysError(logger, res, err) {
if (err instanceof BadRequestError) {
logger.info(err, err.message);
return res.status(400).json({msg: 'Bad request'});
}
if (err instanceof DbErrorBadRequest) {
logger.info(err, 'invalid client request');
return res.status(400).json({msg: err.message});

View File

@@ -10,7 +10,34 @@ info:
version: 1.0.0
servers:
- url: /v1
description: development server
description: jambonz API server
tags:
- name: Authentication
description: Authentication operations
- name: Accounts
description: Accounts operations
- name: Users
description: Users operations
- name: Applications
description: Applications operations
- name: Phone Numbers
description: Phone Numbers operations
- name: Api Keys
description: Api Keys operations
- name: Service Providers
description: Service Providers operations
- name: SBCs
description: SBCs operations
- name: Voip Carriers
description: Voip Carriers operations
- name: Sip Gateways
description: Sip Gateways operations
- name: Smpp Gateways
description: Smpp Gateways operations
- name: Webhooks
description: Webhooks operations
- name: Microsoft Teams Tenants
description: Microsoft Teams Tenants operations
paths:
/BetaInviteCodes:
post:
@@ -79,6 +106,8 @@ paths:
type: string
format: uuid
post:
tags:
- Accounts
summary: add a VoiPCarrier to an account based on PredefinedCarrier template
operationId: createVoipCarrierFromTemplate
responses:
@@ -96,6 +125,8 @@ paths:
$ref: '#/components/schemas/GeneralError'
/Sbcs:
post:
tags:
- SBCs
summary: add an SBC address
operationId: createSbc
requestBody:
@@ -134,7 +165,9 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
get:
summary: retrieve public IP addresses of the jambonz Sbcs
tags:
- SBCs
summary: retrieve public IP addresses of the jambonz SBCs
operationId: listSbcs
parameters:
- in: query
@@ -171,7 +204,9 @@ paths:
schema:
type: string
delete:
summary: delete sbc address
tags:
- SBCs
summary: delete SBC address
operationId: deleteSbcAddress
responses:
200:
@@ -240,6 +275,8 @@ paths:
/ApiKeys:
post:
tags:
- Api Keys
summary: create an api key
operationId: createApikey
requestBody:
@@ -277,7 +314,7 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/GeneralError'
/Apikeys/{ApiKeySid}:
/ApiKeys/{ApiKeySid}:
parameters:
- name: ApiKeySid
in: path
@@ -285,6 +322,8 @@ paths:
schema:
type: string
delete:
tags:
- Api Keys
summary: delete api key
operationId: deleteApiKey
responses:
@@ -294,6 +333,8 @@ paths:
description: api key or account not found
/signin:
post:
tags:
- Authentication
summary: sign in using email and password
operationId: loginUser
requestBody:
@@ -335,6 +376,8 @@ paths:
$ref: '#/components/schemas/GeneralError'
/logout:
post:
tags:
- Authentication
summary: log out and deactivate jwt
operationId: logoutUser
responses:
@@ -348,6 +391,8 @@ paths:
$ref: '#/components/schemas/GeneralError'
/forgot-password:
post:
tags:
- Authentication
summary: send link to reset password
operationId: forgotPassword
requestBody:
@@ -377,6 +422,8 @@ paths:
$ref: '#/components/schemas/GeneralError'
/change-password:
post:
tags:
- Authentication
summary: changePassword
operationId: changePassword
requestBody:
@@ -408,6 +455,8 @@ paths:
$ref: '#/components/schemas/GeneralError'
/register:
post:
tags:
- Authentication
summary: create a new user and account
operationId: registerUser
requestBody:
@@ -516,7 +565,9 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
/Users:
get:
get:
tags:
- Users
summary: list all users
operationId: listUsers
responses:
@@ -547,6 +598,8 @@ paths:
schema:
type: string
get:
tags:
- Users
summary: retrieve user information
operationId: getUser
requestBody:
@@ -583,6 +636,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
put:
tags:
- Users
summary: update user information
operationId: updateUser
requestBody:
@@ -603,8 +658,6 @@ paths:
new_password:
type: string
description: new password
name:
type: string
is_active:
type: boolean
force_change:
@@ -629,6 +682,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
post:
tags:
- Users
summary: create a new user
operationId: createUser
requestBody:
@@ -649,8 +704,6 @@ paths:
type: string
permissions:
type: array
force_change:
type: boolean
old_password:
type: string
description: existing password, which is to be replaced
@@ -670,6 +723,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
delete:
tags:
- Users
summary: delete a user
operationId: deleteUser
responses:
@@ -687,6 +742,8 @@ paths:
$ref: '#/components/schemas/GeneralError'
/Users/me:
get:
tags:
- Users
summary: retrieve details about logged-in user and associated account
operationId: getMyDetails
responses:
@@ -852,6 +909,8 @@ paths:
/ActivationCode:
post:
tags:
- Authentication
summary: send an activation code to the user
operationId: sendActivationCode
requestBody:
@@ -897,6 +956,8 @@ paths:
schema:
type: string
put:
tags:
- Authentication
summary: validate an activation code
operationId: validateActivationCode
requestBody:
@@ -976,6 +1037,8 @@ paths:
schema:
type: string
get:
tags:
- Webhooks
summary: retrieve webhook
operationId: getWebhook
responses:
@@ -996,6 +1059,8 @@ paths:
/VoipCarriers:
post:
tags:
- Voip Carriers
summary: create voip carrier
operationId: createVoipCarrier
requestBody:
@@ -1083,6 +1148,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
get:
tags:
- Voip Carriers
summary: list voip carriers
operationId: listVoipCarriers
responses:
@@ -1110,6 +1177,8 @@ paths:
schema:
type: string
delete:
tags:
- Voip Carriers
summary: delete a voip carrier
operationId: deleteVoipCarrier
responses:
@@ -1132,6 +1201,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
get:
tags:
- Voip Carriers
summary: retrieve voip carrier
operationId: getVoipCarrier
responses:
@@ -1150,6 +1221,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
put:
tags:
- Voip Carriers
summary: update voip carrier
operationId: updateVoipCarrier
parameters:
@@ -1189,6 +1262,8 @@ paths:
/SipGateways:
post:
tags:
- Sip Gateways
summary: create sip gateway
operationId: createSipGateway
requestBody:
@@ -1240,6 +1315,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
get:
tags:
- Sip Gateways
summary: list sip gateways
operationId: listSipGateways
parameters:
@@ -1274,6 +1351,8 @@ paths:
schema:
type: string
delete:
tags:
- Sip Gateways
summary: delete a sip gateway
operationId: deleteSipGateway
responses:
@@ -1288,6 +1367,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
get:
tags:
- Sip Gateways
summary: retrieve sip gateway
operationId: getSipGateway
responses:
@@ -1306,6 +1387,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
put:
tags:
- Sip Gateways
summary: update sip gateway
operationId: updateSipGateway
requestBody:
@@ -1332,6 +1415,8 @@ paths:
$ref: '#/components/schemas/GeneralError'
/SmppGateways:
post:
tags:
- Smpp Gateways
summary: create smpp gateway
operationId: createSmppGateway
requestBody:
@@ -1387,6 +1472,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
get:
tags:
- Smpp Gateways
summary: list smpp gateways
operationId: listSmppGateways
responses:
@@ -1414,6 +1501,8 @@ paths:
schema:
type: string
delete:
tags:
- Smpp Gateways
summary: delete a smpp gateway
operationId: deleteSmppGateway
responses:
@@ -1428,6 +1517,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
get:
tags:
- Smpp Gateways
summary: retrieve smpp gateway
operationId: getSmppGateway
responses:
@@ -1446,7 +1537,9 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
put:
summary: update sip gateway
tags:
- Smpp Gateways
summary: update smpp gateway
operationId: updateSmppGateway
requestBody:
content:
@@ -1472,6 +1565,8 @@ paths:
$ref: '#/components/schemas/GeneralError'
/PhoneNumbers:
post:
tags:
- Phone Numbers
summary: provision a phone number into inventory from a Voip Carrier
operationId: provisionPhoneNumber
requestBody:
@@ -1527,6 +1622,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
get:
tags:
- Phone Numbers
summary: list phone numbers
operationId: listProvisionedPhoneNumbers
responses:
@@ -1554,6 +1651,8 @@ paths:
schema:
type: string
delete:
tags:
- Phone Numbers
summary: delete a phone number
operationId: deletePhoneNumber
responses:
@@ -1576,6 +1675,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
get:
tags:
- Phone Numbers
summary: retrieve phone number
operationId: getPhoneNumber
responses:
@@ -1594,6 +1695,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
put:
tags:
- Phone Numbers
summary: update phone number
operationId: updatePhoneNumber
requestBody:
@@ -1625,6 +1728,8 @@ paths:
/ServiceProviders:
post:
tags:
- Service Providers
summary: create service provider
operationId: createServiceProvider
requestBody:
@@ -1667,6 +1772,8 @@ paths:
$ref: '#/components/schemas/GeneralError'
get:
tags:
- Service Providers
summary: list service providers
operationId: listServiceProviders
responses:
@@ -1695,6 +1802,8 @@ paths:
schema:
type: string
delete:
tags:
- Service Providers
summary: delete a service provider
operationId: deleteServiceProvider
responses:
@@ -1716,6 +1825,8 @@ paths:
$ref: '#/components/schemas/GeneralError'
get:
tags:
- Service Providers
summary: retrieve service provider
operationId: getServiceProvider
responses:
@@ -1733,8 +1844,10 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/GeneralError'
put:
tags:
- Service Providers
summary: update service provider
operationId: updateServiceProvider
requestBody:
@@ -1769,6 +1882,8 @@ paths:
type: string
format: uuid
get:
tags:
- Service Providers
summary: get all accounts for a service provider
operationId: getServiceProviderAccounts
responses:
@@ -1794,6 +1909,8 @@ paths:
type: string
format: uuid
get:
tags:
- Service Providers
summary: get all carriers for a service provider
operationId: getServiceProviderCarriers
responses:
@@ -1810,6 +1927,8 @@ paths:
404:
description: service provider not found
post:
tags:
- Service Providers
summary: create a carrier
operationId: createCarrierForServiceProvider
requestBody:
@@ -1841,6 +1960,8 @@ paths:
type: string
format: uuid
post:
tags:
- Service Providers
summary: add a VoiPCarrier to a service provider based on PredefinedCarrier template
operationId: createVoipCarrierFromTemplate
responses:
@@ -1866,6 +1987,8 @@ paths:
type: string
format: uuid
post:
tags:
- Service Providers
summary: create a speech credential for a service provider
operationId: addSpeechCredentialForSeerviceProvider
requestBody:
@@ -1897,6 +2020,8 @@ paths:
type: string
format: uuid
get:
tags:
- Service Providers
summary: get a specific speech credential
operationId: getSpeechCredential
responses:
@@ -1909,6 +2034,8 @@ paths:
404:
description: credential not found
put:
tags:
- Service Providers
summary: update a speech credential
operationId: updateSpeechCredential
requestBody:
@@ -1928,6 +2055,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
delete:
tags:
- Service Providers
summary: delete a speech credential
operationId: deleteSpeechCredential
responses:
@@ -1937,6 +2066,8 @@ paths:
description: credential not found
/ServiceProviders/{ServiceProviderSid}/SpeechCredentials/{SpeechCredentialSid}/test:
get:
tags:
- Service Providers
summary: test a speech credential
operationId: testSpeechCredential
parameters:
@@ -1991,6 +2122,8 @@ paths:
$ref: '#/components/schemas/GeneralError'
/ServiceProviders/{ServiceProviderSid}/Limits:
post:
tags:
- Service Providers
summary: create a limit for a service provider
operationId: addLimitForServiceProvider
parameters:
@@ -2021,6 +2154,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
get:
tags:
- Service Providers
summary: retrieve call capacity and other limits from the service provider
operationId: getServiceProviderLimits
parameters:
@@ -2049,6 +2184,8 @@ paths:
$ref: '#/components/schemas/GeneralError'
/Accounts/{AccountSid}/Limits:
post:
tags:
- Accounts
summary: create a limit for an account
operationId: addLimitForAccount
parameters:
@@ -2079,6 +2216,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
get:
tags:
- Accounts
summary: retrieve call capacity and other limits from the account
operationId: getAccountLimits
parameters:
@@ -2107,6 +2246,8 @@ paths:
$ref: '#/components/schemas/GeneralError'
/MicrosoftTeamsTenants:
post:
tags:
- Microsoft Teams Tenants
summary: provision a customer tenant for MS Teams
operationId: createMsTeamsTenant
requestBody:
@@ -2154,6 +2295,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
get:
tags:
- Microsoft Teams Tenants
summary: list MS Teams tenants
operationId: listMsTeamsTenants
responses:
@@ -2180,6 +2323,8 @@ paths:
type: string
format: uuid
delete:
tags:
- Microsoft Teams Tenants
summary: delete an MS Teams tenant
operationId: deleteTenant
responses:
@@ -2194,6 +2339,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
get:
tags:
- Microsoft Teams Tenants
summary: retrieve an MS Teams tenant
operationId: getTenant
responses:
@@ -2213,7 +2360,9 @@ paths:
$ref: '#/components/schemas/GeneralError'
put:
summary: update tenant
tags:
- Microsoft Teams Tenants
summary: update an MS Teams tenant
operationId: putTenant
requestBody:
content:
@@ -2241,6 +2390,8 @@ paths:
/Accounts:
post:
tags:
- Accounts
summary: create an account
operationId: createAccount
requestBody:
@@ -2408,6 +2559,8 @@ paths:
address should be blacklisted by the platform (0 means forever).
get:
tags:
- Accounts
summary: list accounts
operationId: listAccounts
responses:
@@ -2435,6 +2588,8 @@ paths:
type: string
format: uuid
delete:
tags:
- Accounts
summary: delete an account
operationId: deleteAccount
responses:
@@ -2455,6 +2610,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
get:
tags:
- Accounts
summary: retrieve account
operationId: getAccount
responses:
@@ -2474,6 +2631,8 @@ paths:
$ref: '#/components/schemas/GeneralError'
put:
tags:
- Accounts
summary: update account
operationId: updateAccount
requestBody:
@@ -2512,6 +2671,8 @@ paths:
schema:
type: boolean
get:
tags:
- Accounts
summary: get webhook signing secret, regenerating if requested
operationId: getWebhookSecret
responses:
@@ -2542,6 +2703,8 @@ paths:
type: string
format: uuid
get:
tags:
- Accounts
summary: get all api keys for an account
operationId: getAccountApiKeys
responses:
@@ -2576,6 +2739,8 @@ paths:
schema:
type: string
post:
tags:
- Accounts
summary: add or change the sip realm
operationId: createSipRealm
responses:
@@ -2604,6 +2769,8 @@ paths:
format: uuid
post:
tags:
- Accounts
summary: add a speech credential
operationId: createSpeechCredential
requestBody:
@@ -2631,6 +2798,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
get:
tags:
- Accounts
summary: retrieve all speech credentials for an account
operationId: listSpeechCredentials
responses:
@@ -2659,6 +2828,8 @@ paths:
type: string
format: uuid
get:
tags:
- Accounts
summary: get a specific speech credential
operationId: getSpeechCredential
responses:
@@ -2671,6 +2842,8 @@ paths:
404:
description: credential not found
put:
tags:
- Accounts
summary: update a speech credential
operationId: updateSpeechCredential
requestBody:
@@ -2690,6 +2863,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
delete:
tags:
- Accounts
summary: delete a speech credential
operationId: deleteSpeechCredential
responses:
@@ -2699,6 +2874,8 @@ paths:
description: credential not found
/Accounts/{AccountSid}/SpeechCredentials/{SpeechCredentialSid}/test:
get:
tags:
- Accounts
summary: test a speech credential
operationId: testSpeechCredential
parameters:
@@ -2812,6 +2989,8 @@ paths:
- inbound
- outbound
get:
tags:
- Accounts
summary: retrieve recent calls for an account
operationId: listRecentCalls
responses:
@@ -2908,6 +3087,8 @@ paths:
schema:
type: string
get:
tags:
- Accounts
summary: retrieve sip trace detail for a call
operationId: getRecentCallTrace
responses:
@@ -2933,6 +3114,8 @@ paths:
schema:
type: string
get:
tags:
- Service Providers
summary: retrieve pcap for a call
operationId: getRecentCallTrace
responses:
@@ -3005,6 +3188,8 @@ paths:
- inbound
- outbound
get:
tags:
- Service Providers
summary: retrieve recent calls for an account
operationId: listRecentCalls
responses:
@@ -3104,6 +3289,8 @@ paths:
schema:
type: string
get:
tags:
- Service Providers
summary: retrieve sip trace detail for a call
operationId: getRecentCallTrace
responses:
@@ -3129,6 +3316,8 @@ paths:
schema:
type: string
get:
tags:
- Accounts
summary: retrieve pcap for a call
operationId: getRecentCallTrace
responses:
@@ -3201,6 +3390,8 @@ paths:
- device-limit
- api-limit
get:
tags:
- Service Providers
summary: retrieve alerts for a service provider
operationId: listAlerts
responses:
@@ -3311,6 +3502,8 @@ paths:
- device-limit
- api-limit
get:
tags:
- Accounts
summary: retrieve alerts for an account
operationId: listAlerts
responses:
@@ -3359,6 +3552,8 @@ paths:
description: account not found
/Applications:
post:
tags:
- Applications
summary: create application
operationId: createApplication
requestBody:
@@ -3424,6 +3619,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
get:
tags:
- Applications
summary: list applications
operationId: listApplications
responses:
@@ -3452,6 +3649,8 @@ paths:
schema:
type: string
delete:
tags:
- Applications
summary: delete an application
operationId: deleteApplication
responses:
@@ -3472,6 +3671,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
get:
tags:
- Applications
summary: retrieve an application
responses:
200:
@@ -3489,6 +3690,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
put:
tags:
- Applications
summary: update application
operationId: updateApplication
requestBody:
@@ -3517,6 +3720,8 @@ paths:
/Accounts/{AccountSid}/Calls:
post:
tags:
- Accounts
summary: create a call
operationId: createCall
parameters:
@@ -3578,6 +3783,8 @@ paths:
400:
description: bad request
get:
tags:
- Accounts
summary: list calls
operationId: listCalls
parameters:
@@ -3618,6 +3825,8 @@ paths:
schema:
type: string
delete:
tags:
- Accounts
summary: delete a call
operationId: deleteCall
responses:
@@ -3638,6 +3847,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
get:
tags:
- Accounts
summary: retrieve a call
operationId: getCall
responses:
@@ -3656,6 +3867,8 @@ paths:
schema:
$ref: '#/components/schemas/GeneralError'
post:
tags:
- Accounts
summary: update a call
operationId: updateCall
requestBody:
@@ -3741,6 +3954,8 @@ paths:
$ref: '#/components/schemas/GeneralError'
/Accounts/{AccountSid}/Messages:
post:
tags:
- Accounts
summary: create an outgoing SMS message
operationId: createMessage
parameters:

View File

@@ -1,6 +1,7 @@
const formData = require('form-data');
const Mailgun = require('mailgun.js');
const mailgun = new Mailgun(formData);
const bent = require('bent');
const validateEmail = (email) => {
// eslint-disable-next-line max-len
const re = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
@@ -8,6 +9,44 @@ const validateEmail = (email) => {
};
const emailSimpleText = async(logger, to, subject, text) => {
const from = 'jambonz Support <support@jambonz.org>';
if (process.env.CUSTOM_EMAIL_VENDOR_URL) {
await sendEmailByCustomVendor(logger, from, to, subject, text);
} else {
await sendEmailByMailgun(logger, from, to, subject, text);
}
};
const sendEmailByCustomVendor = async(logger, from, to, subject, text) => {
try {
const post = bent('POST', {
'Content-Type': 'application/json',
...((process.env.CUSTOM_EMAIL_VENDOR_USERNAME && process.env.CUSTOM_EMAIL_VENDOR_PASSWORD) &&
({
'Authorization':`Basic ${Buffer.from(
`${process.env.CUSTOM_EMAIL_VENDOR_USERNAME}:${process.env.CUSTOM_EMAIL_VENDOR_PASSWORD}`
).toString('base64')}`
}))
});
const res = await post(process.env.CUSTOM_EMAIL_VENDOR_URL, {
from,
to,
subject,
text
});
logger.debug({
res
}, 'sent email to custom vendor.');
} catch (err) {
logger.info({
err
}, 'Error sending email From Custom email vendor');
}
};
const sendEmailByMailgun = async(logger, from, to, subject, text) => {
const mg = mailgun.client({
username: 'api',
key: process.env.MAILGUN_API_KEY
@@ -17,14 +56,18 @@ const emailSimpleText = async(logger, to, subject, text) => {
try {
const res = await mg.messages.create(process.env.MAILGUN_DOMAIN, {
from: 'jambonz Support <support@jambonz.org>',
from,
to,
subject,
text
});
logger.debug({res}, 'sent email');
logger.debug({
res
}, 'sent email');
} catch (err) {
logger.info({err}, 'Error sending email');
logger.info({
err
}, 'Error sending email From mailgun');
}
};

View File

@@ -2,9 +2,9 @@ const crypto = require('crypto');
const algorithm = process.env.LEGACY_CRYPTO ? 'aes-256-ctr' : 'aes-256-cbc';
const iv = crypto.randomBytes(16);
const secretKey = crypto.createHash('sha256')
.update(String(process.env.JWT_SECRET))
.update(process.env.ENCRYPTION_SECRET || process.env.JWT_SECRET)
.digest('base64')
.substr(0, 32);
.substring(0, 32);
const encrypt = (text) => {
const cipher = crypto.createCipheriv(algorithm, secretKey, iv);

View File

@@ -1,3 +1,9 @@
class BadRequestError extends Error {
constructor(msg) {
super(msg);
}
}
class DbError extends Error {
constructor(msg) {
super(msg);
@@ -23,6 +29,7 @@ class DbErrorForbidden extends DbError {
}
module.exports = {
BadRequestError,
DbError,
DbErrorBadRequest,
DbErrorUnprocessableRequest,

View File

@@ -1,18 +1,19 @@
const crypto = require('crypto');
const { argon2i } = require('argon2-ffi');
const argon2 = require('argon2');
const util = require('util');
const { argon2i } = argon2;
const getRandomBytes = util.promisify(crypto.randomBytes);
const generateHashedPassword = async(password) => {
const salt = await getRandomBytes(32);
const passwordHash = await argon2i.hash(password, salt);
const passwordHash = await argon2.hash(password, { type: argon2i, salt });
return passwordHash;
};
const verifyPassword = async(passwordHash, password) => {
const isCorrect = await argon2i.verify(passwordHash, password);
return isCorrect;
const verifyPassword = (passwordHash, password) => {
return argon2.verify(passwordHash, password);
};
const hashString = (s) => crypto.createHash('md5').update(s).digest('hex');

View File

@@ -1,7 +1,5 @@
const ttsGoogle = require('@google-cloud/text-to-speech');
const sttGoogle = require('@google-cloud/speech').v1p1beta1;
const Polly = require('aws-sdk/clients/polly');
const AWS = require('aws-sdk');
const { TranscribeClient, ListVocabulariesCommand } = require('@aws-sdk/client-transcribe');
const { Deepgram } = require('@deepgram/sdk');
const sdk = require('microsoft-cognitiveservices-speech-sdk');
const { SpeechClient } = require('@soniox/soniox-node');
@@ -34,9 +32,10 @@ const testNuanceStt = async(logger, credentials) => {
return true;
};
const testGoogleTts = async(logger, credentials) => {
const client = new ttsGoogle.TextToSpeechClient({credentials});
await client.listVoices();
const testGoogleTts = async(logger, getTtsVoices, credentials) => {
const voices = await getTtsVoices({vendor: 'google', credentials});
return voices;
};
const testGoogleStt = async(logger, credentials) => {
@@ -120,25 +119,33 @@ const testMicrosoftStt = async(logger, credentials) => {
});
};
const testAwsTts = (logger, credentials) => {
const polly = new Polly(credentials);
return new Promise((resolve, reject) => {
polly.describeVoices({LanguageCode: 'en-US'}, (err, data) => {
if (err) return reject(err);
resolve();
});
});
const testAwsTts = async(logger, getTtsVoices, credentials) => {
try {
const voices = await getTtsVoices({vendor: 'aws', credentials});
return voices;
} catch (err) {
logger.info({err}, 'testMicrosoftTts - failed to list voices for region ${region}');
throw err;
}
};
const testAwsStt = (logger, credentials) => {
const transcribeservice = new AWS.TranscribeService(credentials);
return new Promise((resolve, reject) => {
transcribeservice.listVocabularies((err, data) => {
if (err) return reject(err);
logger.info({data}, 'retrieved language models');
resolve();
const testAwsStt = async(logger, credentials) => {
try {
const {region, accessKeyId, secretAccessKey} = credentials;
const client = new TranscribeClient({
region,
credentials: {
accessKeyId,
secretAccessKey
}
});
});
const command = new ListVocabulariesCommand({});
const response = await client.send(command);
return response;
} catch (err) {
logger.info({err}, 'testMicrosoftTts - failed to list voices for region ${region}');
throw err;
}
};
const testMicrosoftTts = async(logger, credentials) => {
@@ -198,7 +205,7 @@ const testWellSaidTts = async(logger, credentials) => {
const testIbmTts = async(logger, getTtsVoices, credentials) => {
const {tts_api_key, tts_region} = credentials;
const voices = await getTtsVoices({vendor: 'ibm', credentials: {api_key: tts_api_key, region: tts_region}});
const voices = await getTtsVoices({vendor: 'ibm', credentials: {tts_api_key, tts_region}});
return voices;
};

2851
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "jambonz-api-server",
"version": "v0.8.1",
"version": "v0.8.2",
"description": "",
"main": "app.js",
"scripts": {
@@ -10,6 +10,7 @@
"upgrade-db": "node ./db/upgrade-jambonz-db.js",
"coverage": "./node_modules/.bin/nyc --reporter html --report-dir ./coverage npm run test",
"jslint": "eslint app.js lib",
"jslint:fix": "eslint app.js lib --fix",
"prepare": "husky install"
},
"author": "Dave Horton",
@@ -18,16 +19,16 @@
"url": "https://github.com/jambonz/jambonz-api-server.git"
},
"dependencies": {
"@aws-sdk/client-transcribe": "^3.290.0",
"@deepgram/sdk": "^1.10.2",
"@google-cloud/speech": "^5.1.0",
"@google-cloud/text-to-speech": "^4.0.3",
"@jambonz/db-helpers": "^0.7.3",
"@jambonz/realtimedb-helpers": "^0.6.0",
"@jambonz/realtimedb-helpers": "^0.7.0",
"@jambonz/speech-utils": "^0.0.8",
"@jambonz/time-series": "^0.2.5",
"@jambonz/verb-specifications": "^0.0.3",
"@soniox/soniox-node": "^1.1.0",
"argon2-ffi": "^2.0.0",
"aws-sdk": "^2.1302.0",
"argon2": "^0.30.3",
"bent": "^7.3.12",
"cors": "^2.8.5",
"debug": "^4.3.4",

View File

@@ -219,6 +219,21 @@ test('account tests', async(t) => {
});
t.ok(result.statusCode === 201, 'successfully updated a call session limit to an account');
/* try to update an existing limit for an account giving a invalid sid */
try {
result = await request.post(`/Accounts/invalid-sid/Limits`, {
auth: authAdmin,
json: true,
resolveWithFullResponse: true,
body: {
category: 'voice_call_session',
quantity: 205
}
});
} catch (err) {
t.ok(err.statusCode === 400, 'returns 400 bad request if account sid param is not a valid uuid');
}
/* query all limits for an account */
result = await request.get(`/Accounts/${sid}/Limits`, {
auth: authAdmin,

View File

@@ -133,4 +133,14 @@ services:
- "3100:3000/tcp"
networks:
jambonz-api:
ipv4_address: 172.58.0.9
ipv4_address: 172.58.0.9
webhook-tts-scaffold:
image: jambonz/webhook-tts-test-scaffold:latest
ports:
- "3101:3000/tcp"
volumes:
- ./test-apps:/tmp
networks:
jambonz-api:
ipv4_address: 172.58.0.10

29
test/email_utils.js Normal file
View File

@@ -0,0 +1,29 @@
const test = require('tape');
const {emailSimpleText} = require('../lib/utils/email-utils');
const bent = require('bent');
const getJSON = bent('json')
const logger = {
debug: () =>{},
info: () => {}
}
test('email-test', async(t) => {
// Prepare env:
process.env.CUSTOM_EMAIL_VENDOR_URL = 'http://127.0.0.1:3101/custom_email_vendor';
process.env.CUSTOM_EMAIL_VENDOR_USERNAME = 'USERNAME';
process.env.CUSTOM_EMAIL_VENDOR_PASSWORD = 'PASSWORD';
await emailSimpleText(logger, 'test@gmail.com', 'subject', 'body text');
const obj = await getJSON(`http://127.0.0.1:3101/lastRequest/custom_email_vendor`);
t.ok(obj.headers['Content-Type'] == 'application/json');
t.ok(obj.headers.Authorization == 'Basic VVNFUk5BTUU6UEFTU1dPUkQ=');
t.ok(obj.body.from == 'jambonz Support <support@jambonz.org>');
t.ok(obj.body.to == 'test@gmail.com');
t.ok(obj.body.subject == 'subject');
t.ok(obj.body.text == 'body text');
process.env.CUSTOM_EMAIL_VENDOR_URL = null;
process.env.CUSTOM_EMAIL_VENDOR_USERNAME = null;
process.env.CUSTOM_EMAIL_VENDOR_PASSWORD = null;
});

View File

@@ -17,4 +17,5 @@ require('./webapp_tests');
// require('./homer');
require('./call-test');
require('./password-settings');
require('./email_utils');
require('./docker_stop');

View File

@@ -61,6 +61,16 @@ test('phone number tests', async(t) => {
});
t.ok(result.number === '16173333456' , 'successfully retrieved phone number by sid');
/* fail to query one phone number with invalid uuid */
try {
result = await request.get(`/PhoneNumbers/foobar`, {
auth: authAdmin,
json: true,
});
} catch (err) {
t.ok(err.statusCode === 400, 'returns 400 bad request if phone number sid param is not a valid uuid');
}
/* delete phone number */
result = await request.delete(`/PhoneNumbers/${sid}`, {
auth: authAdmin,

View File

@@ -107,6 +107,20 @@ test('service provider tests', async(t) => {
});
t.ok(result.statusCode === 204, 'successfully updated service provider');
/* try to update service providers with invalid sid format*/
try {
result = await request.put(`/ServiceProviders/123`, {
auth: authAdmin,
json: true,
resolveWithFullResponse: true,
body: {
name: 'robb'
}
});
} catch (err) {
t.ok(err.statusCode === 400, 'returns 400 bad request if service provider sid param is not a valid uuid');
}
/* add an api key for a service provider */
result = await request.post(`/ApiKeys`, {
auth: authAdmin,

View File

@@ -22,6 +22,24 @@ test('speech credentials tests', async(t) => {
const service_provider_sid = await createServiceProvider(request);
const account_sid = await createAccount(request, service_provider_sid);
/* return 400 if invalid sid param is used */
try {
result = await request.post(`/ServiceProviders/foobarbaz/SpeechCredentials`, {
resolveWithFullResponse: true,
simple: false,
auth: authAdmin,
json: true,
body: {
vendor: 'google',
service_key: jsonKey,
use_for_tts: true,
use_for_stt: true
}
});
} catch (err) {
t.ok(err.statusCode === 400, 'returns 400 bad request if service provider sid param is not a valid uuid');
}
/* add a speech credential to a service provider */
result = await request.post(`/ServiceProviders/${service_provider_sid}/SpeechCredentials`, {
resolveWithFullResponse: true,
@@ -73,8 +91,8 @@ test('speech credentials tests', async(t) => {
t.ok(result.statusCode === 201, 'successfully added speech credential');
const sid1 = result.body.sid;
/* return 403 if invalid account is used */
result = await request.post(`/Accounts/foobarbaz/SpeechCredentials`, {
/* return 403 if invalid account is used - randomSid: bed7ae17-f8b4-4b74-9e5b-4f6318aae9c9 */
result = await request.post(`/Accounts/bed7ae17-f8b4-4b74-9e5b-4f6318aae9c9/SpeechCredentials`, {
resolveWithFullResponse: true,
simple: false,
auth: authUser,
@@ -101,12 +119,20 @@ test('speech credentials tests', async(t) => {
t.ok(result[0].vendor === 'google' && result.length === 1, 'successfully retrieved all speech credentials');
/* return 404 when deleting unknown credentials */
/* return 400 when deleting credentials with invalid uuid */
result = await request.delete(`/Accounts/${account_sid}/SpeechCredentials/foobarbaz`, {
auth: authUser,
resolveWithFullResponse: true,
simple: false
});
t.ok(result.statusCode === 400, 'return 400 when attempting to delete credential with invalid uuid');
/* return 404 when deleting unknown credentials - randomSid: bed7ae17-f8b4-4b74-9e5b-4f6318aae9c9 */
result = await request.delete(`/Accounts/${account_sid}/SpeechCredentials/`, {
auth: authUser,
resolveWithFullResponse: true,
simple: false
});
t.ok(result.statusCode === 404, 'return 404 when attempting to delete unknown credential');
/* delete the credential */
@@ -171,6 +197,35 @@ test('speech credentials tests', async(t) => {
t.ok(result.statusCode === 200 && result.body.stt.status === 'ok', 'successfully tested speech credential for microsoft stt');
}
/* add / test a credential for AWS */
if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY && process.env.AWS_REGION) {
result = await request.post(`/Accounts/${account_sid}/SpeechCredentials`, {
resolveWithFullResponse: true,
auth: authUser,
json: true,
body: {
vendor: 'aws',
use_for_tts: true,
use_for_stt: true,
access_key_id: process.env.AWS_ACCESS_KEY_ID,
secret_access_key: process.env.AWS_SECRET_ACCESS_KEY,
aws_region: process.env.AWS_REGION
}
});
t.ok(result.statusCode === 201, 'successfully added speech credential for AWS');
const ms_sid = result.body.sid;
/* test the speech credential */
result = await request.get(`/Accounts/${account_sid}/SpeechCredentials/${ms_sid}/test`, {
resolveWithFullResponse: true,
auth: authUser,
json: true,
});
//console.log(JSON.stringify(result));
t.ok(result.statusCode === 200 && result.body.tts.status === 'ok', 'successfully tested speech credential for AWS tts');
t.ok(result.statusCode === 200 && result.body.stt.status === 'ok', 'successfully tested speech credential for AWS stt');
}
/* add a credential for wellsaid */
if (process.env.WELLSAID_API_KEY) {
result = await request.post(`/Accounts/${account_sid}/SpeechCredentials`, {
@@ -363,9 +418,34 @@ test('speech credentials tests', async(t) => {
});
t.ok(result.statusCode === 204, 'successfully deleted speech credential');
/* add a credential for nuance */
result = await request.post(`/Accounts/${account_sid}/SpeechCredentials`, {
resolveWithFullResponse: true,
auth: authUser,
json: true,
body: {
vendor: 'nuance',
use_for_stt: true,
use_for_tts: true,
client_id: 'client_id',
secret: 'secret',
nuance_tts_uri: "192.168.1.2:5060",
nuance_stt_uri: "192.168.1.2:5061"
}
});
t.ok(result.statusCode === 201, 'successfully added speech credential for nuance');
const nuance_sid = result.body.sid;
/* delete the credential */
result = await request.delete(`/Accounts/${account_sid}/SpeechCredentials/${nuance_sid}`, {
auth: authUser,
resolveWithFullResponse: true,
});
t.ok(result.statusCode === 204, 'successfully deleted speech credential');
await deleteObjectBySid(request, '/Accounts', account_sid);
await deleteObjectBySid(request, '/ServiceProviders', service_provider_sid);
//t.end();
t.end();
}
catch (err) {
console.error(err);

View File

@@ -23,7 +23,7 @@ test('add an admin user', (t) => {
test('user tests', async(t) => {
const app = require('../app');
const password = await generateHashedPassword('abcd1234-');
const password = 'abcde12345-';
try {
let result;

View File

@@ -43,6 +43,15 @@ test('voip carrier tests', async(t) => {
});
t.ok(result.name === 'daveh' , 'successfully retrieved voip carrier by sid');
/* fail to query one voip carriers with invalid uuid */
try {
result = await request.get(`/VoipCarriers/123`, {
auth: authAdmin,
json: true,
});
} catch (err) {
t.ok(err.statusCode === 400, 'returns 400 bad request if voip carrier sid param is not a valid uuid');
}
/* update voip carriers */
result = await request.put(`/VoipCarriers/${sid}`, {