Skip to content

Conversation

pulkit110
Copy link

@pulkit110 pulkit110 commented Jul 24, 2025

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
Copy link
Collaborator

joshk commented Jul 25, 2025

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
Author

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

Copy link
Collaborator

@joshk joshk left a comment

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?

field(:secret, :binary)
field(:code, :string, virtual: true)

belongs_to(:user, NervesHub.Accounts.User)
Copy link
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

|> 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
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

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
Collaborator

Choose a reason for hiding this comment

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

same as above

@@ -0,0 +1,96 @@
defmodule NervesHub.MFA do
Copy link
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

The MFA context.
"""

import Ecto.Query, warn: false
Copy link
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

"""
def regenerate_user_totp_backup_codes(totp) do
{:ok, updated_totp} =
Repo.transaction(fn ->
Copy link
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
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
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
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.

timestamps()
end

create index(:user_totps, [:user_id])
Copy link
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
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
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
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
Collaborator

Choose a reason for hiding this comment

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

Awesome, thank you!

user_totp = MFA.get_user_totp(user)

if user_totp do
MFA.delete_user_totp(user_totp)
Copy link
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
Copy link
Collaborator

joshk commented Aug 16, 2025

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
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
Copy link
Collaborator

joshk commented Aug 18, 2025

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
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
Copy link
Collaborator

joshk commented Aug 20, 2025

@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
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