feat(totp): Add totp management api (#299), r=@philbooth
This commit is contained in:
Родитель
6a4fb6771d
Коммит
9b8efcb3c9
|
@ -154,6 +154,10 @@ function createServer(db) {
|
|||
api.get('/emailRecord/:id', withIdAndBody(db.emailRecord))
|
||||
api.head('/emailRecord/:id', withIdAndBody(db.accountExists))
|
||||
|
||||
api.get('/totp/:id', withIdAndBody(db.totpToken))
|
||||
api.del('/totp/:id', withIdAndBody(db.deleteTotpToken))
|
||||
api.put('/totp/:id', withIdAndBody(db.createTotpToken))
|
||||
|
||||
api.get('/__heartbeat__', withIdAndBody(db.ping))
|
||||
|
||||
function op(fn) {
|
||||
|
|
|
@ -1794,6 +1794,49 @@ module.exports = function (config, DB) {
|
|||
|
||||
})
|
||||
|
||||
describe('Totp handling', () => {
|
||||
let sharedSecret, epoch
|
||||
beforeEach(() => {
|
||||
sharedSecret = crypto.randomBytes(40).toString('hex')
|
||||
epoch = 0
|
||||
return db.createTotpToken(accountData.uid, {sharedSecret, epoch})
|
||||
.then((result) => assert.ok(result, 'token created'))
|
||||
})
|
||||
|
||||
it('should create totp token', () => {
|
||||
return db.totpToken(accountData.uid)
|
||||
.then((token) => {
|
||||
assert.equal(token.sharedSecret, sharedSecret, 'correct sharedSecret')
|
||||
assert.equal(token.epoch, epoch, 'correct epoch')
|
||||
})
|
||||
})
|
||||
|
||||
it('should fail to get unknown totp token', () => {
|
||||
return db.totpToken(newUuid())
|
||||
.then(assert.fail, (err) => {
|
||||
assert.equal(err.errno, 116, 'correct errno, not found')
|
||||
})
|
||||
})
|
||||
|
||||
it('should fail to create second token for same user', () => {
|
||||
return db.createTotpToken(accountData.uid, {sharedSecret, epoch})
|
||||
.then(assert.fail, (err) => {
|
||||
assert.equal(err.errno, 101, 'correct errno, duplicate')
|
||||
})
|
||||
})
|
||||
|
||||
it('should delete totp token', () => {
|
||||
return db.deleteTotpToken(accountData.uid)
|
||||
.then((result) => {
|
||||
assert.ok(result)
|
||||
return db.totpToken(accountData.uid)
|
||||
.then(assert.fail, (err) => {
|
||||
assert.equal(err.errno, 116, 'correct errno, not found')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
after(() => db.close())
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1595,6 +1595,38 @@ module.exports = function(cfg, makeServer) {
|
|||
}
|
||||
)
|
||||
|
||||
describe('totp tokens', () => {
|
||||
let user
|
||||
|
||||
beforeEach(() => {
|
||||
user = fake.newUserDataHex()
|
||||
return client.putThen('/account/' + user.accountId, user.account)
|
||||
.then((r) => {
|
||||
respOkEmpty(r)
|
||||
return client.putThen('/totp/' + user.accountId, user.totp)
|
||||
})
|
||||
.then((r) => respOkEmpty(r))
|
||||
})
|
||||
|
||||
it('should get totp token', () => {
|
||||
return client.getThen('/totp/' + user.accountId)
|
||||
.then((r) => {
|
||||
const result = r.obj
|
||||
assert.equal(result.sharedSecret, user.totp.sharedSecret, 'sharedSecret set')
|
||||
assert.equal(result.epoch, user.totp.epoch, 'epoch set')
|
||||
})
|
||||
})
|
||||
|
||||
it('should delete totp token', () => {
|
||||
return client.delThen('/totp/' + user.accountId)
|
||||
.then((r) => {
|
||||
respOkEmpty(r)
|
||||
return client.getThen('/totp/' + user.accountId)
|
||||
.then(assert.fail, (err) => testNotFound(err))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
after(() => server.close())
|
||||
|
||||
})
|
||||
|
|
|
@ -126,6 +126,11 @@ module.exports.newUserDataHex = function() {
|
|||
}
|
||||
data.email.normalizedEmail = data.email.email.toLowerCase()
|
||||
|
||||
data.totp = {
|
||||
sharedSecret: hex(10),
|
||||
epoch: 0
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
|
|
131
docs/API.md
131
docs/API.md
|
@ -95,6 +95,10 @@ The following datatypes are used throughout this document:
|
|||
* Sign-in codes
|
||||
* createSigninCode : `PUT /signinCodes/:code`
|
||||
* consumeSigninCode : `POST /signinCodes/:code/consume`
|
||||
* TOTP resetTokens
|
||||
* createTotpToken : `PUT /totp/:id`
|
||||
* totpToken : `GET /totp/:id`
|
||||
* deleteTotpToken : `DEL /totp/:id`
|
||||
|
||||
## Ping : `GET /`
|
||||
|
||||
|
@ -1867,3 +1871,130 @@ Content-Length: 2
|
|||
* Content-Type : `application/json`
|
||||
* Body : `{"code":"InternalError","message":"..."}`
|
||||
|
||||
## createTotpToken : `PUT /totp/:uid`
|
||||
|
||||
Used to create a TOTP token for a user.
|
||||
|
||||
### Example
|
||||
|
||||
```
|
||||
curl \
|
||||
-v \
|
||||
-X PUT \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"sharedSecret": "LEVXGTLWMFITC6BSIF2DOQKTIU2WUOKJ", "epoch": 0}' \
|
||||
http://localhost:8000/totp/1234567890ab
|
||||
```
|
||||
|
||||
### Request
|
||||
|
||||
* Method : `PUT`
|
||||
* Path : `/totp/<uid>
|
||||
* `uid` : hex
|
||||
* Params:
|
||||
* `sharedSecret` : hex10
|
||||
* `epoch` : epoch
|
||||
|
||||
### Response
|
||||
|
||||
```
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
Content-Length: 2
|
||||
|
||||
{}
|
||||
```
|
||||
|
||||
* Status Code : `200 OK`
|
||||
* Content-Type : `application/json`
|
||||
* Body : `{}`
|
||||
* Status Code : `409 Conflict`
|
||||
* Conditions: if the user already has a TOTP device
|
||||
* Content-Type : `application/json`
|
||||
* Body : `{"errno":101,"message":"Record already exists"}`
|
||||
* Status Code : `500 Internal Server Error`
|
||||
* Conditions: if something goes wrong on the server
|
||||
* Content-Type : `application/json`
|
||||
* Body : `{"code":"InternalError","message":"..."}`
|
||||
|
||||
## totpToken : `GET /totp/:uid`
|
||||
|
||||
Get the user's TOTP token.
|
||||
|
||||
### Example
|
||||
|
||||
```
|
||||
curl \
|
||||
-v \
|
||||
-X GET \
|
||||
-H "Content-Type: application/json" \
|
||||
http://localhost:8000/totp/1234567890ab
|
||||
```
|
||||
|
||||
### Request
|
||||
|
||||
* Method : `GET`
|
||||
* Path : `/totp/<uid>
|
||||
* `uid` : hex
|
||||
|
||||
### Response
|
||||
|
||||
```
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
Content-Length: 2
|
||||
|
||||
{
|
||||
"sharedSecret": "LEVXGTLWMFITC6BSIF2DOQKTIU2WUOKJ",
|
||||
"epoch": 0
|
||||
}
|
||||
```
|
||||
|
||||
* Status Code : `200 OK`
|
||||
* Content-Type : `application/json`
|
||||
* Body : `{"sharedSecret": "LEVXGTLWMFITC6BSIF2DOQKTIU2WUOKJ", "epoch": 0}`
|
||||
* Status Code : `404 Not Found`
|
||||
* Conditions: if no TOTP token found for user
|
||||
* Content-Type : `application/json`
|
||||
* Status Code : `500 Internal Server Error`
|
||||
* Conditions: if something goes wrong on the server
|
||||
* Content-Type : `application/json`
|
||||
* Body : `{"code":"InternalError","message":"..."}`
|
||||
|
||||
## deleteTotpToken : `DEL /totp/:uid`
|
||||
|
||||
Delete the user's TOTP token.
|
||||
|
||||
### Example
|
||||
|
||||
```
|
||||
curl \
|
||||
-v \
|
||||
-X DEL \
|
||||
-H "Content-Type: application/json" \
|
||||
http://localhost:8000/totp/1234567890ab
|
||||
```
|
||||
|
||||
### Request
|
||||
|
||||
* Method : `DEL`
|
||||
* Path : `/totp/<uid>
|
||||
* `uid` : hex
|
||||
|
||||
### Response
|
||||
|
||||
```
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
Content-Length: 2
|
||||
|
||||
{}
|
||||
```
|
||||
|
||||
* Status Code : `200 OK`
|
||||
* Content-Type : `application/json`
|
||||
* Body : `{}`
|
||||
* Status Code : `500 Internal Server Error`
|
||||
* Conditions: if something goes wrong on the server
|
||||
* Content-Type : `application/json`
|
||||
* Body : `{"code":"InternalError","message":"..."}`
|
||||
|
|
|
@ -63,6 +63,10 @@ There are a number of methods that a DB storage backend should implement:
|
|||
* Signin codes
|
||||
* .createSigninCode(code, uid, createdAt, flowId)
|
||||
* .consumeSigninCode(code)
|
||||
* TOTP
|
||||
* .createTotpToken(uid, sharedSecret, epoch)
|
||||
* .totpToken(uid)
|
||||
* .deleteTotpToken(uid)
|
||||
* General
|
||||
* .ping()
|
||||
* .close()
|
||||
|
@ -815,3 +819,58 @@ Returns:
|
|||
* Rejects with:
|
||||
* Any error from the underlying storage system (wrapped in `error.wrap()`)
|
||||
|
||||
## createTotpToken(uid, sharedSecret, epoch)
|
||||
|
||||
Creates a new TOTP token for the user.
|
||||
|
||||
Parameters:
|
||||
|
||||
* `uid` (Buffer16):
|
||||
The uid of the owning account
|
||||
* `sharedSecret` (string):
|
||||
The shared secret used to generate TOTP code
|
||||
* `epoch` (number):
|
||||
The epoch used to generate TOTP code (default 0)
|
||||
|
||||
Returns:
|
||||
|
||||
* Resolves with:
|
||||
* An empty object `{}`
|
||||
* Rejects with:
|
||||
* Any error from the underlying storage system (wrapped in `error.wrap()`)
|
||||
* `error.duplicate()` if this user had a token already
|
||||
|
||||
## totpToken(uid)
|
||||
|
||||
Get's the TOTP token for the user.
|
||||
|
||||
Parameters:
|
||||
|
||||
* `uid` (Buffer16):
|
||||
The uid of the owning account
|
||||
|
||||
Returns:
|
||||
|
||||
* Resolves with:
|
||||
* An object `{}`
|
||||
* sharedSecret
|
||||
* epoch
|
||||
* Rejects with:
|
||||
* Any error from the underlying storage system (wrapped in `error.wrap()`)
|
||||
* `error.duplicate()` if this user had a token already
|
||||
|
||||
## deleteTotpToken(uid)
|
||||
|
||||
Delete the TOTP token for the user.
|
||||
|
||||
Parameters:
|
||||
|
||||
* `uid` (Buffer16):
|
||||
The uid of the owning account
|
||||
|
||||
Returns:
|
||||
|
||||
* Resolves with:
|
||||
* An empty object `{}`
|
||||
* Rejects with:
|
||||
* Any error from the underlying storage system (wrapped in `error.wrap()`)
|
||||
|
|
|
@ -25,6 +25,7 @@ var unblockCodes = {}
|
|||
var emailBounces = {}
|
||||
var emails = {}
|
||||
var signinCodes = {}
|
||||
const totpTokens = {}
|
||||
|
||||
var DEVICE_FIELDS = [
|
||||
'sessionTokenId',
|
||||
|
@ -904,6 +905,7 @@ module.exports = function (log, error) {
|
|||
|
||||
delete uidByNormalizedEmail[account.normalizedEmail]
|
||||
delete accounts[uid]
|
||||
delete totpTokens[uid]
|
||||
return []
|
||||
}
|
||||
)
|
||||
|
@ -1221,6 +1223,43 @@ module.exports = function (log, error) {
|
|||
return P.resolve({})
|
||||
}
|
||||
|
||||
Memory.prototype.createTotpToken = (uid, data) => {
|
||||
uid = uid.toString('hex')
|
||||
|
||||
const totpToken = totpTokens[uid]
|
||||
|
||||
if (totpToken) {
|
||||
return P.reject(error.duplicate())
|
||||
}
|
||||
|
||||
totpTokens[uid] = {
|
||||
sharedSecret: data.sharedSecret,
|
||||
epoch: data.epoch || 0
|
||||
}
|
||||
|
||||
return Promise.resolve({})
|
||||
}
|
||||
|
||||
Memory.prototype.totpToken = (uid) => {
|
||||
uid = uid.toString('hex')
|
||||
|
||||
const totpToken = totpTokens[uid]
|
||||
|
||||
if (! totpToken) {
|
||||
return P.reject(error.notFound())
|
||||
}
|
||||
|
||||
return Promise.resolve(totpToken)
|
||||
}
|
||||
|
||||
Memory.prototype.deleteTotpToken = function (uid) {
|
||||
uid = uid.toString('hex')
|
||||
|
||||
delete totpTokens[uid]
|
||||
|
||||
return Promise.resolve({})
|
||||
}
|
||||
|
||||
// UTILITY FUNCTIONS
|
||||
|
||||
Memory.prototype.ping = function () {
|
||||
|
|
|
@ -537,9 +537,9 @@ module.exports = function (log, error) {
|
|||
// DELETE
|
||||
|
||||
// Delete : sessionTokens, keyFetchTokens, accountResetTokens, passwordChangeTokens,
|
||||
// passwordForgotTokens, accounts, devices, unverifiedTokens, emails, signinCodes
|
||||
// passwordForgotTokens, accounts, devices, unverifiedTokens, emails, signinCodes, totp
|
||||
// Where : uid = $1
|
||||
var DELETE_ACCOUNT = 'CALL deleteAccount_13(?)'
|
||||
var DELETE_ACCOUNT = 'CALL deleteAccount_14(?)'
|
||||
|
||||
MySql.prototype.deleteAccount = function (uid) {
|
||||
return this.write(DELETE_ACCOUNT, [uid])
|
||||
|
@ -1285,6 +1285,21 @@ module.exports = function (log, error) {
|
|||
)
|
||||
}
|
||||
|
||||
const CREATE_TOTP_TOKEN = 'CALL createTotpToken_1(?, ?, ?, ?)'
|
||||
MySql.prototype.createTotpToken = function (uid, data) {
|
||||
return this.write(CREATE_TOTP_TOKEN, [uid, data.sharedSecret, data.epoch, Date.now()])
|
||||
}
|
||||
|
||||
const GET_TOTP_TOKEN = 'CALL totpToken_1(?)'
|
||||
MySql.prototype.totpToken = function (uid) {
|
||||
return this.readFirstResult(GET_TOTP_TOKEN, [uid])
|
||||
}
|
||||
|
||||
const DELETE_TOTP_TOKEN = 'CALL deleteTotpToken_1(?)'
|
||||
MySql.prototype.deleteTotpToken = function (uid) {
|
||||
return this.write(DELETE_TOTP_TOKEN, [uid])
|
||||
}
|
||||
|
||||
return MySql
|
||||
}
|
||||
|
||||
|
|
|
@ -4,4 +4,4 @@
|
|||
|
||||
// The expected patch level of the database. Update if you add a new
|
||||
// patch in the ./schema/ directory.
|
||||
module.exports.level = 70
|
||||
module.exports.level = 71
|
||||
|
|
|
@ -0,0 +1,81 @@
|
|||
SET NAMES utf8mb4 COLLATE utf8mb4_bin;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS totp (
|
||||
uid BINARY(16) NOT NULL,
|
||||
sharedSecret VARCHAR(80) NOT NULL,
|
||||
epoch BIGINT NOT NULL,
|
||||
createdAt BIGINT UNSIGNED NOT NULL,
|
||||
UNIQUE KEY (`uid`)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE PROCEDURE `createTotpToken_1` (
|
||||
IN `uidArg` BINARY(16),
|
||||
IN `sharedSecretArg` VARCHAR(80),
|
||||
IN `epochArg` BIGINT UNSIGNED,
|
||||
IN `createdAtArg` BIGINT UNSIGNED
|
||||
)
|
||||
BEGIN
|
||||
|
||||
INSERT INTO totp(
|
||||
uid,
|
||||
sharedSecret,
|
||||
epoch,
|
||||
createdAt
|
||||
)
|
||||
VALUES(
|
||||
uidArg,
|
||||
sharedSecretArg,
|
||||
epochArg,
|
||||
createdAtArg
|
||||
);
|
||||
|
||||
END;
|
||||
|
||||
CREATE PROCEDURE `totpToken_1` (
|
||||
IN `uidArg` BINARY(16)
|
||||
)
|
||||
BEGIN
|
||||
|
||||
SELECT sharedSecret, epoch FROM totp WHERE uid = uidArg;
|
||||
|
||||
END;
|
||||
|
||||
CREATE PROCEDURE `deleteTotpToken_1` (
|
||||
IN `uidArg` BINARY(16)
|
||||
)
|
||||
BEGIN
|
||||
|
||||
DELETE FROM totp WHERE uid = uidArg;
|
||||
|
||||
END;
|
||||
|
||||
CREATE PROCEDURE `deleteAccount_14` (
|
||||
IN `uidArg` BINARY(16)
|
||||
)
|
||||
BEGIN
|
||||
DECLARE EXIT HANDLER FOR SQLEXCEPTION
|
||||
BEGIN
|
||||
ROLLBACK;
|
||||
RESIGNAL;
|
||||
END;
|
||||
|
||||
START TRANSACTION;
|
||||
|
||||
DELETE FROM sessionTokens WHERE uid = uidArg;
|
||||
DELETE FROM keyFetchTokens WHERE uid = uidArg;
|
||||
DELETE FROM accountResetTokens WHERE uid = uidArg;
|
||||
DELETE FROM passwordChangeTokens WHERE uid = uidArg;
|
||||
DELETE FROM passwordForgotTokens WHERE uid = uidArg;
|
||||
DELETE FROM accounts WHERE uid = uidArg;
|
||||
DELETE FROM devices WHERE uid = uidArg;
|
||||
DELETE FROM unverifiedTokens WHERE uid = uidArg;
|
||||
DELETE FROM unblockCodes WHERE uid = uidArg;
|
||||
DELETE FROM emails WHERE uid = uidArg;
|
||||
DELETE FROM signinCodes WHERE uid = uidArg;
|
||||
DELETE FROM totp WHERE uid = uidArg;
|
||||
|
||||
COMMIT;
|
||||
END;
|
||||
|
||||
UPDATE dbMetadata SET value = '71' WHERE name = 'schema-patch-level';
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
-- SET NAMES utf8mb4 COLLATE utf8mb4_bin;
|
||||
|
||||
-- DROP PROCEDURE `createTotpToken_1`;
|
||||
-- DROP PROCEDURE `totpToken_1`;
|
||||
-- DROP PROCEDURE `deleteTotpToken_1`;
|
||||
-- DROP PROCEDURE `deleteAccount_14`;
|
||||
|
||||
-- UPDATE dbMetadata SET value = '70' WHERE name = 'schema-patch-level';
|
||||
|
Загрузка…
Ссылка в новой задаче