We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
Jump to file
- ∟ lib/my_app/accounts.ex
- ∟ lib/my_app/accounts/user.ex
- ∟ lib/my_app/accounts/user_backup_code.ex
- ∟ lib/my_app_web/live/user_live/settings.ex
- ∟ lib/my_app_web/live/user_live/totp_backup_code.ex
- ∟ lib/my_app_web/live/user_live/totp_verify.ex
- ∟ lib/my_app_web/router.ex
- ∟ priv/repo/migrations/0002_add_user_backup_codes.exs
Add to existing TOTP functions
When we first built TOTP into our app we added several new functions in our
Accounts
context. Here, we'll add to those functions so that backup codes are generated and deleted when TOTP is enabled and disabled, respectively.
The
enable_user_totp/4
function now returns
{:ok, {user, backup_codes}}
on success, where
backup_codes
is a list of plain-text codes to display to the user once.
Then, we add functions for generating, counting, and consuming backup codes. To consume a backup code, we query for the code while scoping our query to the signed-in user. Requiring a user is a security control, as backup codes are not typically long enough to be secure on their own.
A backup code is valid if our scoped query deletes exactly one record. Once verified, we use the same underlying function that is used for verifying a normal TOTP code to sign the user in. Since we deleted the backup code in this process, it is not reusable.
Association with the user struct
While not strictly necessary (we won't use this field to preload data anywhere), we document the
has_many
relationship between our User and UserBackupCode structs here.
The UserBackupCode schema
There is not a lot to this module in particular. One thing to note is that it has a composite primary key, using the
code
and
user_id
fields. This is the "natural" key of the record, because it signifies identity. It would not make sense for multiple records in this table to have this combination of identical values. So, we spare ourselves of the need to generate a meaningless primary key.
To build a backup code string, we sample some random bytes and hex encode them. The number of bytes is tunable with the
@rand_size
module attribute (a size of 6 generates 12-character hex codes). These bytes do not have to be particularly long because they are not global, rather they are scoped to a single user.
The codes are stored hashed in the database using SHA256, so they cannot be retrieved later. The query function used to consume backup codes hashes the input and selects based on the compound primary key of code and user.
Showing backup codes once
We only want to show backup codes to the user once, immediately after they first enable TOTP. This is because this is when we are most sure that the current user should in fact have access to these secrets.
When TOTP is enabled, the backup codes are returned from the
enable_user_totp/4
function and stored in a
@totp_backup_codes
assign. When this assign is present, we display a warning alert with the codes in a grid. The codes are displayed in a monospace font for easy reading.
Consuming a backup code
To consume a backup code, we render a simple LiveView form for accepting the code and call our
Accounts.consume_backup_code(user, user_token, code)
function. If the function returns
:ok
, the code was valid and consumed, and we can forward the user to where they belong in the app.
Showing the backup code option
In the LiveView that handles TOTP verification, we show a link to use a backup code. This is the most likely place for a user to realize they no longer have access to their authenticator device.
Router changes
In our router, we add the new LiveView for consuming backup codes at
/users/verify-backup
. This is placed in the
:require_authenticated_user
live_session alongside the TOTP verification route, as both require a signed-in user but not TOTP verification.
Migration to add backup codes
Our table is straightforward as it only includes the code string and the ID of the user it belongs to. We do not even need to store any kind of status or redeemed_at field, as our design decision dictates that only the presence of a code is enough to determine validity. Remember to include the index as well, otherwise the performance of deleting codes (an operation which uses only user_id) may degrade over time.