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