Skip to content
This repository was archived by the owner on Apr 3, 2019. It is now read-only.

Commit 995d52b

Browse files
authored
feat(totp): initial recovery codes (#319), r=@philbooth
1 parent 818edcf commit 995d52b

File tree

11 files changed

+560
-51
lines changed

11 files changed

+560
-51
lines changed

db-server/index.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,16 @@ function createServer(db) {
225225
op(req => db.consumeSigninCode(req.params.code))
226226
)
227227

228+
api.post('/account/:id/recoveryCodes',
229+
op((req) => {
230+
return db.replaceRecoveryCodes(req.params.id, req.body.count)
231+
})
232+
)
233+
234+
api.post('/account/:id/recoveryCodes/:code',
235+
op((req) => db.consumeRecoveryCode(req.params.id, req.params.code))
236+
)
237+
228238
api.get(
229239
'/',
230240
function (req, res, next) {

db-server/test/backend/db_tests.js

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1985,6 +1985,103 @@ module.exports = function (config, DB) {
19851985
})
19861986
})
19871987

1988+
describe('recovery codes', () => {
1989+
let account
1990+
beforeEach(() => {
1991+
account = createAccount()
1992+
account.emailVerified = true
1993+
return db.createAccount(account.uid, account)
1994+
})
1995+
1996+
it('should fail to generate for unknown user', () => {
1997+
return db.replaceRecoveryCodes(hex16(), 2)
1998+
.then(assert.fail, (err) => {
1999+
assert.equal(err.errno, 116, 'correct errno, not found')
2000+
})
2001+
})
2002+
2003+
const codeLengthTest = [0, 4, 8]
2004+
codeLengthTest.forEach((num) => {
2005+
it('should generate ' + num + ' recovery codes', () => {
2006+
return db.replaceRecoveryCodes(account.uid, num)
2007+
.then((codes) => {
2008+
assert.equal(codes.length, num, 'correct number of codes')
2009+
}, (err) => {
2010+
assert.equal(err.errno, 116, 'correct errno, not found')
2011+
})
2012+
})
2013+
})
2014+
2015+
it('should replace recovery codes', () => {
2016+
let firstCodes
2017+
return db.replaceRecoveryCodes(account.uid, 2)
2018+
.then((codes) => {
2019+
firstCodes = codes
2020+
assert.equal(firstCodes.length, 2, 'correct number of codes')
2021+
2022+
return db.replaceRecoveryCodes(account.uid, 3)
2023+
})
2024+
.then((codes) => {
2025+
assert.equal(codes.length, 3, 'correct number of codes')
2026+
assert.notDeepEqual(codes, firstCodes, 'codes are different')
2027+
})
2028+
})
2029+
2030+
describe('should consume recovery codes', () => {
2031+
let recoveryCodes
2032+
beforeEach(() => {
2033+
return db.replaceRecoveryCodes(account.uid, 2)
2034+
.then((codes) => {
2035+
recoveryCodes = codes
2036+
assert.equal(recoveryCodes.length, 2, 'correct number of recovery codes')
2037+
})
2038+
})
2039+
2040+
it('should fail to consume recovery code with unknown uid', () => {
2041+
return db.consumeRecoveryCode(hex16(), 'recoverycodez')
2042+
.then(assert.fail, (err) => {
2043+
assert.equal(err.errno, 116, 'correct errno, not found')
2044+
})
2045+
})
2046+
2047+
it('should fail to consume recovery code with unknown code', () => {
2048+
return db.replaceRecoveryCodes(account.uid, 3)
2049+
.then(() => {
2050+
return db.consumeRecoveryCode(account.uid, 'notvalidcode')
2051+
.then(assert.fail, (err) => {
2052+
assert.equal(err.errno, 116, 'correct errno, unknown recovery code')
2053+
})
2054+
})
2055+
})
2056+
2057+
it('should fail to consume code twice', () => {
2058+
return db.consumeRecoveryCode(account.uid, recoveryCodes[0])
2059+
.then((result) => {
2060+
assert.equal(result.remaining, 1, 'correct number of remaining codes')
2061+
2062+
// Should fail to consume code twice
2063+
return db.consumeRecoveryCode(account.uid, recoveryCodes[0])
2064+
.then(assert.fail, (err) => {
2065+
assert.equal(err.errno, 116, 'correct errno, unknown recovery code')
2066+
})
2067+
})
2068+
})
2069+
2070+
it('should consume code', () => {
2071+
return db.consumeRecoveryCode(account.uid, recoveryCodes[0])
2072+
.then((result) => {
2073+
assert.equal(result.remaining, 1, 'correct number of remaining codes')
2074+
2075+
return db.consumeRecoveryCode(account.uid, recoveryCodes[1])
2076+
.then((result) => {
2077+
assert.equal(result.remaining, 0, 'correct number of remaining codes')
2078+
})
2079+
})
2080+
})
2081+
})
2082+
})
2083+
2084+
19882085
after(() => db.close())
19892086
})
19902087
}

db-server/test/backend/remote.js

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1627,7 +1627,7 @@ module.exports = function(cfg, makeServer) {
16271627
.then((res) => respOkEmpty(res))
16281628
})
16291629

1630-
it('set session verification method', () => {
1630+
it('set session verification method - totp-2fa', () => {
16311631
const verifyOptions = {
16321632
verificationMethod: 'totp-2fa',
16331633
}
@@ -1643,6 +1643,60 @@ module.exports = function(cfg, makeServer) {
16431643
})
16441644
})
16451645

1646+
it('set session verification method - recovery-code', () => {
1647+
const verifyOptions = {
1648+
verificationMethod: 'recovery-code',
1649+
}
1650+
return client.postThen('/tokens/' + user.sessionTokenId + '/verifyWithMethod', verifyOptions)
1651+
.then((res) => {
1652+
respOkEmpty(res)
1653+
return client.getThen('/sessionToken/' + user.sessionTokenId + '/device')
1654+
})
1655+
.then((sessionToken) => {
1656+
sessionToken = sessionToken.obj
1657+
assert.equal(sessionToken.verificationMethod, 3, 'verificationMethod set')
1658+
assert.ok(sessionToken.verifiedAt, 'verifiedAt set')
1659+
})
1660+
})
1661+
})
1662+
1663+
describe('recovery codes', () => {
1664+
let user
1665+
beforeEach(() => {
1666+
user = fake.newUserDataHex()
1667+
return client.putThen('/account/' + user.accountId, user.account)
1668+
.then((r) => {
1669+
respOkEmpty(r)
1670+
})
1671+
})
1672+
1673+
it('should generate new recovery codes', () => {
1674+
return client.postThen('/account/' + user.accountId + '/recoveryCodes', {count: 8})
1675+
.then((res) => {
1676+
const codes = res.obj
1677+
assert.equal(codes.length, 8, 'correct number of codes')
1678+
})
1679+
})
1680+
1681+
it('should fail to consume unknown recovery code', () => {
1682+
return client.postThen('/account/' + user.accountId + '/recoveryCodes/' + '12345678')
1683+
.then(assert.fail, (err) => {
1684+
testNotFound(err)
1685+
})
1686+
})
1687+
1688+
it('should consume recovery code', () => {
1689+
return client.postThen('/account/' + user.accountId + '/recoveryCodes', {count: 8})
1690+
.then((res) => {
1691+
const codes = res.obj
1692+
assert.equal(codes.length, 8, 'correct number of codes')
1693+
return client.postThen('/account/' + user.accountId + '/recoveryCodes/' + codes[0])
1694+
})
1695+
.then((res) => {
1696+
const result = res.obj
1697+
assert.equal(result.remaining, 7, 'correct number of remaining codes')
1698+
})
1699+
})
16461700
})
16471701

16481702
after(() => server.close())

docs/API.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ The following datatypes are used throughout this document:
100100
* totpToken : `GET /totp/:id`
101101
* deleteTotpToken : `DEL /totp/:id`
102102
* updateTotpToken : `POST /totp/:id/update`
103+
* Recovery codes:
104+
* replaceRecoveryCodes : `POST /account/:id/recoveryCodes`
105+
* consumeRecoveryCode : `POST /account/:id/recoveryCodes/:code`
103106

104107
## Ping : `GET /`
105108

@@ -2066,3 +2069,90 @@ Content-Length: 2
20662069
* Conditions: if something goes wrong on the server
20672070
* Content-Type : `application/json`
20682071
* Body : `{"code":"InternalError","message":"..."}`
2072+
2073+
## replaceRecoveryCodes : `GET /account/:uid/recoveryCodes`
2074+
2075+
Replaces a users current recovery codes with new ones.
2076+
2077+
### Example
2078+
2079+
```
2080+
curl \
2081+
-v \
2082+
-X POST \
2083+
-H "Content-Type: application/json" \
2084+
-d '{
2085+
"count" : 8
2086+
}'
2087+
http://localhost:8000/account/1234567890ab/recoveryCodes
2088+
```
2089+
2090+
### Request
2091+
2092+
* Method : `POST`
2093+
* Path : `/account/<uid>/recoveryCodes`
2094+
* `uid` : hex
2095+
*
2096+
2097+
### Response
2098+
2099+
```
2100+
HTTP/1.1 200 OK
2101+
Content-Type: application/json
2102+
Content-Length: 2
2103+
2104+
["code1", "code2"]
2105+
```
2106+
2107+
* Status Code : `200 OK`
2108+
* Content-Type : `application/json`
2109+
* Body : `["code1", "code2"]`
2110+
* Status Code : `404 Not Found`
2111+
* Conditions: if no user found
2112+
* Content-Type : `application/json`
2113+
* Status Code : `500 Internal Server Error`
2114+
* Conditions: if something goes wrong on the server
2115+
* Content-Type : `application/json`
2116+
* Body : `{"code":"InternalError","message":"..."}`
2117+
2118+
## consumeRecoveryCode : `POST /account/:uid/recoveryCodes/:code`
2119+
2120+
Consumes a recovery code.
2121+
2122+
### Example
2123+
2124+
```
2125+
curl \
2126+
-v \
2127+
-X POST \
2128+
-H "Content-Type: application/json" \
2129+
http://localhost:8000/account/1234567890ab/recoveryCodes/1123
2130+
```
2131+
2132+
### Request
2133+
2134+
* Method : `POST`
2135+
* Path : `/account/<uid>/recoveryCodes/<code>`
2136+
* `uid` : hex
2137+
* `code`: hex
2138+
2139+
### Response
2140+
2141+
```
2142+
HTTP/1.1 200 OK
2143+
Content-Type: application/json
2144+
Content-Length: 2
2145+
2146+
{"remaining" : 1}
2147+
```
2148+
2149+
* Status Code : `200 OK`
2150+
* Content-Type : `application/json`
2151+
* Body : `{"remaining" : 1}`
2152+
* Status Code : `404 Not Found`
2153+
* Conditions: if no user found or code not found
2154+
* Content-Type : `application/json`
2155+
* Status Code : `500 Internal Server Error`
2156+
* Conditions: if something goes wrong on the server
2157+
* Content-Type : `application/json`
2158+
* Body : `{"code":"InternalError","message":"..."}`

0 commit comments

Comments
 (0)