Skip to content

Commit 287692e

Browse files
committed
feat: Add generateRecoveryCodes method
1 parent de40195 commit 287692e

File tree

3 files changed

+94
-0
lines changed

3 files changed

+94
-0
lines changed

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,18 @@ Generates a QR Code to use with authenticator apps containing the authenticator
7070
| width | numeric | false | 128 | The width of the QR code. |
7171
| height | numeric | false | 128 | The height of the QR code. |
7272

73+
#### `generateRecoveryCodes`
74+
75+
Generates an array of recovery codes.
76+
77+
Each code composed of numbers and lower case characters from latin alphabet (36 possible characters).
78+
The code is split in groups separated with dash for better readability.
79+
For example: `4ckn-xspn-et8t-xgr0`
80+
81+
| Name | Type | Required | Default | Description |
82+
| ------ | ------- | -------- | ------- | -------------------------------- |
83+
| amount | numeric | true | | The amount of codes to generate. |
84+
7385
#### `generateCode`
7486

7587
Generates a Time-based One-time Password (TOTP) for a given secret.

models/TOTP.cfc

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,59 @@ component singleton accessors="true" {
108108
);
109109
}
110110

111+
/**
112+
* Generates an array of recovery codes.
113+
*
114+
* @amount The amount of codes to generate.
115+
*
116+
* @returns An array of recovery codes.
117+
*/
118+
public array function generateRecoveryCodes( required numeric amount ) {
119+
if ( arguments.amount <= 0 ) {
120+
throw(
121+
type = "totp.InvalidRecoveryCodeAmount",
122+
message = "You must generate a positive amount of recovery codes."
123+
);
124+
}
125+
126+
var codes = [];
127+
for ( var i = 1; i <= arguments.amount; i++ ) {
128+
codes.append( generateRecoveryCode() );
129+
}
130+
return codes;
131+
}
132+
133+
/**
134+
* Generates a code composed of numbers and lower case characters from latin alphabet (36 possible characters).
135+
* The code is split in groups separated with dash for better readability.
136+
* For example: `4ckn-xspn-et8t-xgr0`
137+
*
138+
* Recovery codes must reach a minimum entropy to be secured
139+
* `code entropy = log( {characters-count} ^ {code-length} ) / log(2)`
140+
* The settings used below allows the code to reach an entropy of 82 bits :
141+
* log(36^16) / log(2) == 82.7...
142+
*
143+
* @returns A recovery code string.
144+
*/
145+
private string function generateRecoveryCode() {
146+
var CODE_LENGTH = 16;
147+
var GROUPS_NUMBER = 4;
148+
var CHARACTERS = listToArray( "abcdefghijklmnopqrstuvwxyz0123456789", "" );
149+
var CHARACTERS_LENGTH = CHARACTERS.len();
150+
151+
var code = "";
152+
for ( var i = 1; i <= CODE_LENGTH; i++ ) {
153+
// Append random character from authorized ones
154+
code &= CHARACTERS[ variables.secureRandom.nextInt( CHARACTERS_LENGTH ) + 1 ];
155+
// Split code into groups for increased readability
156+
if ( i % GROUPS_NUMBER == 0 && i != CODE_LENGTH ) {
157+
code &= "-";
158+
}
159+
}
160+
161+
return code;
162+
}
163+
111164
/**
112165
* Generates a TOTP for a given secret.
113166
*

tests/specs/unit/TOTPSpec.cfc

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,35 @@ component extends="testbox.system.BaseSpec" {
161161
} );
162162
} );
163163

164+
describe( "generateRecoveryCodes", function() {
165+
it( "generates human-readable recovery codes", function() {
166+
var codes = variables.totp.generateRecoveryCodes( 4 );
167+
expect( codes ).toBeArray();
168+
expect( codes ).toHaveLength( 4 );
169+
170+
var uniqueCodes = {};
171+
for ( var code in codes ) {
172+
expect( code ).toMatchWithCase(
173+
"[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}",
174+
"Recovery code does not match the expected format."
175+
);
176+
uniqueCodes[ code ] = "";
177+
}
178+
179+
expect( uniqueCodes ).toHaveLength( 4, "All codes should be unique" );
180+
} );
181+
182+
it( "throws an excpetion if the amount is not positive", function() {
183+
expect( function() {
184+
var codes = variables.totp.generateRecoveryCodes( 0 );
185+
} ).toThrow( type = "totp.InvalidRecoveryCodeAmount" );
186+
187+
expect( function() {
188+
var codes = variables.totp.generateRecoveryCodes( -2 );
189+
} ).toThrow( type = "totp.InvalidRecoveryCodeAmount" );
190+
} );
191+
} );
192+
164193
it( "can use a generated secret to generate and verify a code", function() {
165194
var secret = variables.totp.generateSecret();
166195
var code = variables.totp.generateCode( secret );

0 commit comments

Comments
 (0)