Skip to content

Add support for Multi Factor Authentication using TOTP#2229

Open
pulkit110 wants to merge 6 commits into
nerves-hub:mainfrom
spendra-inc:main
Open

Add support for Multi Factor Authentication using TOTP#2229
pulkit110 wants to merge 6 commits into
nerves-hub:mainfrom
spendra-inc:main

Conversation

@pulkit110

@pulkit110 pulkit110 commented Jul 24, 2025

Copy link
Copy Markdown

This PR allows users to configure multi factor authentication using the TOTP method using NimbleTOTP under the hood.

Here are some screenshots:

User Account:
Screenshot 2025-07-24 at 11 30 27

Login:
Screenshot 2025-07-24 at 12 43 55

@joshk

joshk commented Jul 25, 2025

Copy link
Copy Markdown
Collaborator

Hi @sapandiwakar and @pulkit110

Thanks for the PR!

I've had a quick look over it and things look very good. I'll give it a full review shortly.

@joshk joshk self-assigned this Jul 25, 2025
@pulkit110

Copy link
Copy Markdown
Author

Hi @joshk, thanks for the quick look! Let me know if you need anything from me to help get this ready.

@joshk joshk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is very cool, and heading in a good direction.

I still need to try this out locally for a full review.

One question I have is how this will impact API auth?

Comment thread lib/nerves_hub/mfa/user_totp.ex Outdated
field(:secret, :binary)
field(:code, :string, virtual: true)

belongs_to(:user, NervesHub.Accounts.User)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you please alias NervesHub.Accounts.User and then change this to just User

Comment thread lib/nerves_hub/mfa/user_totp.ex Outdated
|> validate_required([:code])
|> validate_format(:code, ~r/^\d{6}$/, message: "should be a 6 digit number")

code = Ecto.Changeset.get_field(changeset, :code)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since you have imported Ecto.Changeset, you can use get_field without the module

Comment thread lib/nerves_hub/mfa/user_totp.ex Outdated
code = Ecto.Changeset.get_field(changeset, :code)

if changeset.valid? and not valid_totp?(totp, code) do
Ecto.Changeset.add_error(changeset, :code, "invalid code")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above

Comment thread lib/nerves_hub/mfa.ex Outdated
@@ -0,0 +1,96 @@
defmodule NervesHub.MFA do

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should move this to NervesHub.Accounts.MFA

Comment thread lib/nerves_hub/mfa.ex Outdated
The MFA context.
"""

import Ecto.Query, warn: false

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the warn: false is needed

Comment thread lib/nerves_hub/mfa.ex Outdated
"""
def regenerate_user_totp_backup_codes(totp) do
{:ok, updated_totp} =
Repo.transaction(fn ->

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

transaction is no longer recommended for use, transact is the preferred option.
(I also realize that we use transaction everywhere)

user_id = get_session(conn, :mfa_user_id)

if user_id do
form = Phoenix.Component.to_form(%{}, as: "mfa")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe you can just use to_form

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, but it doesn't seem to work without it - controllers don't have a Phoenix.Component import. It is also being used here:

form = Phoenix.Component.to_form(%{}, as: "User")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, ok, thanks for checking.

Comment thread lib/nerves_hub_web/live/account_mfa.ex
timestamps()
end

create index(:user_totps, [:user_id])

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be a unique index?

@@ -0,0 +1,95 @@
defmodule NervesHubWeb.MFAControllerTest do

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We use https://hexdocs.pm/phoenix_test/PhoenixTest.html where possible. Could you please look at our other controller tests, like OAuthControllerTest, switch to this setup.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just noticed that some of your view tests do use PhoenixTest, is there a reason you aren't using it here?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We noticed you were using PhoenixTest for the view tests, but the controller tests had already been prepared earlier. We'll update them.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome, thank you!

Comment thread lib/nerves_hub_web/live/account_mfa.ex Outdated
user_totp = MFA.get_user_totp(user)

if user_totp do
MFA.delete_user_totp(user_totp)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since delete_user_totp uses Repo.delete! under the hood, if there is an error it will bubble up and cause the LiveView to crash, and not displaying any sort of error message to the user.

I would suggest not using the ! variety of delete, and handle this use case.

@joshk

joshk commented Aug 16, 2025

Copy link
Copy Markdown
Collaborator

Oh, and final review comment, could we update the MFA UI to use a more PIN style UI, eg. https://preline.co/docs/pin-input.html

@pulkit110

Copy link
Copy Markdown
Author

Thanks for the detailed review, @joshk. We’ve addressed all your review points except the last one (MFA UI using a more PIN-style UI), since we have a few questions:

  1. Please correct me if I’m wrong, but it seems the project has both the legacy Bootstrap-based UI and the new Tailwind-based UI. However, the user settings are still only in Bootstrap for now. Is it okay to use the PIN-style UI only in the Tailwind-based views (login flow)?
  2. So far, we’ve avoided adding external libraries on the UI side because we weren’t sure if that would be acceptable. Is it okay to include preline, as you suggested? I think the UI library will be important to support a good UX with the PIN UI (pasting OTPs, auto-advancing to the next input, auto-back on backspace, etc.).

@joshk

joshk commented Aug 18, 2025

Copy link
Copy Markdown
Collaborator

Hey @pulkit110

Thanks for your awesome work on the PR and addressing our feedback.

1. Please correct me if I’m wrong, but it seems the project has both the legacy Bootstrap-based UI and the new Tailwind-based UI. However, the user settings are still only in Bootstrap for now. Is it okay to use the PIN-style UI only in the Tailwind-based views (login flow)?

Yep, that is more than perfect. We will be switching the other UI over soon, but its the login flow that I care most about.

2. So far, we’ve avoided adding external libraries on the UI side because we weren’t sure if that would be acceptable. Is it okay to include `preline`, as you suggested? I think the UI library will be important to support a good UX with the PIN UI (pasting OTPs, auto-advancing to the next input, auto-back on backspace, etc.).

Although I was using preline as an example, it was more for the UI than a recommendation to include the library. I'm hesitant to include preline if its just for this one thing. Are you able to look into 'copying' some of the sugar it provides and LiveView-ize it? You can then comment here on the pros and cons of this approach and we can decide on the best path forward.

You are a ⭐

@pulkit110

Copy link
Copy Markdown
Author

Thanks for your thoughtful feedback and kind words @joshk.
We’ve updated the PR to use a PIN input without adding any dependencies.

Here are some screenshots:
Screenshot 2025-08-19 at 12 50 42
Screenshot 2025-08-19 at 12 51 58

@joshk

joshk commented Aug 20, 2025

Copy link
Copy Markdown
Collaborator

@sapandiwakar I'll give this a full review shortly, but I wanted to say that the UI change is awesome!

One question we still need to address is what this means for API/CLI auth?

@pulkit110

Copy link
Copy Markdown
Author

Ah, yes. Forgot to answer that. MFA is not enforced on API side. I think that's the standard practice as well when token based with is used. Please feel free to let us know if you'd like to change something on this side.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants