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_settings_live.ex
- ∟ lib/my_app_web/live/user_totp_backup_live.ex
- ∟ lib/my_app_web/live/user_verify_totp_live.ex
- ∟ lib/my_app_web/router.ex
- ∟ priv/repo/migrations/20240425003002_add_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.
Then, we add functions for generating, listing, 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_bytes
module attribute. These bytes do not have to be particularly long because they are not global,
rather they are scoped to a single user.
The query function used to consume backup codes is simple, we just select for codes 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. To accomplish this, we list codes assigned to the current user in the LiveView handler for enabling TOTP, and when these codes are present show a modal containing them.
Consuming a backup code
To consume a backup code, we'll 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, 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 just need to add our new LiveView for consuming backup codes. This should be placed in a scope that requires a signed-in user.
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 dictate 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.