diff --git a/db/jambones-sql.sql b/db/jambones-sql.sql
index 22254b8..ae3244a 100644
--- a/db/jambones-sql.sql
+++ b/db/jambones-sql.sql
@@ -56,6 +56,8 @@ token CHAR(36) NOT NULL UNIQUE ,
account_sid CHAR(36),
service_provider_sid CHAR(36),
expires_at TIMESTAMP,
+last_used TIMESTAMP ON UPDATE NOW(),
+created_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (api_key_sid)
) ENGINE=InnoDB COMMENT='An authorization token that is used to access the REST api';
diff --git a/db/jambones.sqs b/db/jambones.sqs
index 73f953c..7e1432a 100644
--- a/db/jambones.sqs
+++ b/db/jambones.sqs
@@ -137,12 +137,12 @@
- 1302.00
- 61.00
+ 1319.00
+ 38.00
252.00
- 120.00
+ 160.00
1
@@ -196,6 +196,17 @@
+
+
+
+
+
+
+
+
+
+
+
@@ -1072,17 +1083,17 @@
-
+
-
-
-
-
-
+
+
+
+
+
diff --git a/lib/models/api-key.js b/lib/models/api-key.js
index 44fbf9f..c2b88c8 100644
--- a/lib/models/api-key.js
+++ b/lib/models/api-key.js
@@ -1,11 +1,53 @@
const Model = require('./model');
+const {getMysqlConnection} = require('../db');
class ApiKey extends Model {
constructor() {
super();
}
+
+ /**
+ * list all api keys for an account
+ */
+ static retrieveAll(account_sid) {
+ const sql = account_sid ?
+ 'SELECT * from api_keys WHERE account_sid = ?' :
+ 'SELECT * from api_keys WHERE account_sid IS NULL';
+ const args = account_sid ? [account_sid] : [];
+
+ return new Promise((resolve, reject) => {
+ getMysqlConnection((err, conn) => {
+ if (err) return reject(err);
+ conn.query(sql, args, (err, results) => {
+ conn.release();
+ if (err) return reject(err);
+ resolve(results);
+ });
+ });
+ });
+ }
+
+ /**
+ * update last_used api key for an account
+ */
+ static updateLastUsed(account_sid) {
+ const sql = 'UPDATE api_keys SET last_used = NOW() WHERE account_sid = ?';
+ const args = [account_sid];
+
+ return new Promise((resolve, reject) => {
+ getMysqlConnection((err, conn) => {
+ if (err) return reject(err);
+ conn.query(sql, args, (err, results) => {
+ conn.release();
+ if (err) return reject(err);
+ resolve(results);
+ });
+ });
+ });
+ }
}
+
ApiKey.table = 'api_keys';
ApiKey.fields = [
{
@@ -25,6 +67,18 @@ ApiKey.fields = [
{
name: 'service_provider_sid',
type: 'string'
+ },
+ {
+ name: 'expires_at',
+ type: 'date'
+ },
+ {
+ name: 'created_at',
+ type: 'date'
+ },
+ {
+ name: 'last_used',
+ type: 'date'
}
];
diff --git a/lib/routes/api/accounts.js b/lib/routes/api/accounts.js
index f3117e4..d72c8ab 100644
--- a/lib/routes/api/accounts.js
+++ b/lib/routes/api/accounts.js
@@ -3,6 +3,7 @@ const request = require('request');
const {DbErrorBadRequest, DbErrorUnprocessableRequest} = require('../../utils/errors');
const Account = require('../../models/account');
const Webhook = require('../../models/webhook');
+const ApiKey = require('../../models/api-key');
const ServiceProvider = require('../../models/service-provider');
const decorate = require('./decorate');
const snakeCase = require('../../utils/snake-case');
@@ -27,6 +28,15 @@ function coerceNumbers(callInfo) {
return callInfo;
}
+async function updateLastUsed(logger, sid, req) {
+ if (req.user.hasAdminAuth || req.user.hasServiceProviderAuth) return;
+ try {
+ await ApiKey.updateLastUsed(sid);
+ } catch (err) {
+ logger.error({err}, `Error updating last used for accountSid ${sid}`);
+ }
+}
+
function validateUpdateCall(opts) {
// only one type of update can be supplied per request
const hasWhisper = opts.whisper;
@@ -251,6 +261,19 @@ router.put('/:sid', async(req, res) => {
return res.status(404).end();
}
res.status(204).end();
+ updateLastUsed(logger, sid, req).catch((err) => {});
+ } catch (err) {
+ sysError(logger, res, err);
+ }
+});
+
+/* 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);
+ res.status(200).json(results);
+ updateLastUsed(logger, req.params.sid, req).catch((err) => {});
} catch (err) {
sysError(logger, res, err);
}
@@ -276,6 +299,7 @@ router.post('/:sid/Calls', async(req, res) => {
await validateCreateCall(logger, sid, req);
logger.debug({payload: req.body}, `sending createCall API request to to ${ip}`);
+ updateLastUsed(logger, sid, req).catch((err) => {});
request({
url: serviceUrl,
method: 'POST',
@@ -308,6 +332,7 @@ router.get('/:sid/Calls', async(req, res) => {
const calls = await listCalls(accountSid);
logger.debug(`retrieved ${calls.length} calls for account sid ${accountSid}`);
res.status(200).json(coerceNumbers(snakeCase(calls)));
+ updateLastUsed(logger, accountSid, req).catch((err) => {});
} catch (err) {
sysError(logger, res, err);
}
@@ -331,6 +356,7 @@ router.get('/:sid/Calls/:callSid', async(req, res) => {
logger.debug(`call not found for call sid ${callSid}`);
res.sendStatus(404);
}
+ updateLastUsed(logger, accountSid, req).catch((err) => {});
} catch (err) {
sysError(logger, res, err);
}
@@ -354,6 +380,7 @@ router.delete('/:sid/Calls/:callSid', async(req, res) => {
logger.debug(`call not found for call sid ${callSid}`);
res.sendStatus(404);
}
+ updateLastUsed(logger, accountSid, req).catch((err) => {});
} catch (err) {
sysError(logger, res, err);
}
@@ -384,6 +411,7 @@ router.post('/:sid/Calls/:callSid', async(req, res) => {
logger.debug(`updateCall: call not found for call sid ${callSid}`);
res.sendStatus(404);
}
+ updateLastUsed(logger, accountSid, req).catch((err) => {});
} catch (err) {
sysError(logger, res, err);
}
diff --git a/lib/swagger/swagger.yaml b/lib/swagger/swagger.yaml
index f37fd90..bc1d427 100644
--- a/lib/swagger/swagger.yaml
+++ b/lib/swagger/swagger.yaml
@@ -37,7 +37,7 @@ paths:
content:
application/json:
schema:
- $ref: '#/components/schemas/SuccessfulApiKeyAdd'
+ $ref: '#/components/schemas/SuccessfulAdd'
400:
description: bad request
content:
@@ -222,7 +222,8 @@ paths:
description: password successfully changed
content:
application/json:
- schema: '#/components/schemas/Login'
+ schema:
+ $ref: '#/components/schemas/Login'
403:
description: password change failed
content:
@@ -1201,6 +1202,34 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/GeneralError'
+ /Accounts/{AccountSid}/ApiKeys:
+ parameters:
+ - name: AccountSid
+ in: path
+ required: true
+ schema:
+ type: string
+ format: uuid
+ get:
+ summary: get all api keys for an account
+ operationId: getAccountApiKeys
+ responses:
+ 200:
+ description: list of api keys
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/ApiKey'
+ 404:
+ description: account not found
+ 500:
+ description: system error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/GeneralError'
/Applications:
post:
@@ -1701,6 +1730,30 @@ components:
- account_sid
- inbound_hook
- inbound_status_hook
+ ApiKey:
+ type: object
+ properties:
+ api_key_sid:
+ type: string
+ format: uuid
+ token:
+ type: string
+ format: uuid
+ account_sid:
+ type: string
+ format: uuid
+ service_provider_sid:
+ type: string
+ format: uuid
+ expires_at:
+ type: dateTime
+ created_at:
+ type: dateTime
+ last_used:
+ type: dateTime
+ required:
+ - api_key_sid
+ - token
PhoneNumber:
type: object
properties:
diff --git a/package.json b/package.json
index e5673cc..dc86149 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "jambonz-api-server",
- "version": "1.1.6",
+ "version": "1.1.7",
"description": "",
"main": "app.js",
"scripts": {
@@ -15,27 +15,27 @@
"url": "https://github.com/jambonz/jambonz-api-server.git"
},
"dependencies": {
- "cors": "^2.8.5",
- "express": "^4.17.1",
"@jambonz/db-helpers": "^0.3.8",
"@jambonz/realtimedb-helpers": "0.2.15",
- "mysql2": "^2.0.2",
- "passport": "^0.4.0",
+ "cors": "^2.8.5",
+ "express": "^4.17.1",
+ "mysql2": "^2.1.0",
+ "passport": "^0.4.1",
"passport-http-bearer": "^1.0.1",
- "pino": "^5.14.0",
- "request": "^2.88.0",
+ "pino": "^5.17.0",
+ "request": "^2.88.2",
"request-debug": "^0.2.0",
- "swagger-ui-express": "^4.1.2",
- "uuid": "^3.3.3",
+ "swagger-ui-express": "^4.1.4",
+ "uuid": "^3.4.0",
"yamljs": "^0.3.0"
},
"devDependencies": {
"eslint": "^6.8.0",
"eslint-plugin-promise": "^4.2.1",
- "nyc": "^15.0.1",
- "request-promise-native": "^1.0.8",
+ "nyc": "^15.1.0",
+ "request-promise-native": "^1.0.9",
"tap-dot": "^2.0.0",
"tap-spec": "^5.0.0",
- "tape": "^4.13.2"
+ "tape": "^4.13.3"
}
}
diff --git a/test/accounts.js b/test/accounts.js
index 789295a..b7f5280 100644
--- a/test/accounts.js
+++ b/test/accounts.js
@@ -4,7 +4,11 @@ const authAdmin = {bearer: ADMIN_TOKEN};
const request = require('request-promise-native').defaults({
baseUrl: 'http://127.0.0.1:3000/v1'
});
-const {createVoipCarrier, createServiceProvider, createPhoneNumber, deleteObjectBySid} = require('./utils');
+const {
+ createVoipCarrier,
+ createServiceProvider,
+ createPhoneNumber,
+ deleteObjectBySid} = require('./utils');
process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
@@ -38,6 +42,26 @@ test('account tests', async(t) => {
t.ok(result.statusCode === 201, 'successfully created account');
const sid = result.body.sid;
+ /* add an account level api key */
+ result = await request.post(`/ApiKeys`, {
+ auth: authAdmin,
+ json: true,
+ resolveWithFullResponse: true,
+ body: {
+ account_sid: sid
+ }
+ });
+ t.ok(result.statusCode === 201 && result.body.token, 'successfully created account level token');
+ const apiKeySid = result.body.sid;
+ const accountLevelToken = result.body.token;
+
+ /* query all account level api keys */
+ result = await request.get(`/Accounts/${sid}/ApiKeys`, {
+ auth: {bearer: accountLevelToken},
+ json: true,
+ });
+ t.ok(Array.isArray(result) && result.length === 1, 'successfully queried account level keys');
+
/* query all accounts */
result = await request.get('/Accounts', {
auth: authAdmin,
@@ -54,9 +78,9 @@ test('account tests', async(t) => {
});
t.ok(result.name === 'daveh' , 'successfully retrieved account by sid');
- /* update accounts */
+ /* update account with account level token */
result = await request.put(`/Accounts/${sid}`, {
- auth: authAdmin,
+ auth: {bearer: accountLevelToken},
json: true,
resolveWithFullResponse: true,
body: {
@@ -67,8 +91,15 @@ test('account tests', async(t) => {
}
}
});
- t.ok(result.statusCode === 204, 'successfully updated account');
+ t.ok(result.statusCode === 204, 'successfully updated account using account level token');
+ /* verify that account level api key last_used was updated*/
+ result = await request.get(`/Accounts/${sid}/ApiKeys`, {
+ auth: {bearer: accountLevelToken},
+ json: true,
+ });
+ t.ok(typeof result[0].last_used === 'string', 'api_key last_used timestamp was updated');
+
result = await request.get(`/Accounts/${sid}`, {
auth: authAdmin,
json: true,
@@ -97,6 +128,7 @@ test('account tests', async(t) => {
t.ok(result.statusCode === 422 && result.body.msg === 'cannot delete account with phone numbers', 'cannot delete account with phone numbers');
/* delete account */
+ await request.delete(`ApiKeys/${apiKeySid}`, {auth: {bearer: accountLevelToken}});
await request.delete(`/PhoneNumbers/${phone_number_sid}`, {auth: authAdmin});
result = await request.delete(`/Accounts/${sid}`, {
auth: authAdmin,
@@ -109,7 +141,7 @@ test('account tests', async(t) => {
t.end();
}
catch (err) {
- //console.error(err);
+ console.error(err);
t.end(err);
}
});