dwyl / elixir-auth-microsoft

🪟 Authenticate with your Microsoft Account in any Elixir App!
GNU General Public License v2.0
36 stars 5 forks source link

What's the correct way to enforce that users are logged in when visiting an URL? #54

Open stabenfeldt opened 5 months ago

stabenfeldt commented 5 months ago

Hi,

The gen.auth project has a require_authenticated_user() plug that can be used. I could not find anything here.

Can you guys please guide me on how the best security practices for implementing something similar?

LuchoTurtle commented 5 months ago

Hey, thanks for opening an issue :)

This topic has a little bit that can be said about it, so I'll try to keep it short.

A foreword

It's important to recognize the difference between authorization and authentication.

The Azure Identity platform does this like so:

The platform uses OAuth for authorization and OpenID Connect (OIDC) for authentication. OpenID Connect is built on top of OAuth 2.0, so the terminology and flow are similar between the two. You can even both authenticate a user (through OpenID Connect) and get authorization to access a protected resource that the user owns (through OAuth 2.0) in one request.

So this package makes it possible for your web application to authorize itself to your app registration that you've defined in Azure AD. The latter uses the OpenID Connect protocol to do this.

What does gen.auth do?

From what I've gathered in their docs, when you use get.auth in your project, they create an Accounts schema where they store the sessions in the database. I think they bootstrap it with e-mail and password.

As per their own words, what you should do to enforce that users are logged in when visiting a URL, is to have this check on the mount/3 function. If there is no token that is valid, you redirect the user. Simple as. Here's a snippet from https://hexdocs.pm/phoenix_live_view/security-model.html#content.

def mount(_params, %{"user_id" => user_id} = _session, socket) do
  socket = assign(socket, current_user: Accounts.get_user!(user_id))

  socket =
    if socket.assigns.current_user.confirmed_at do
      socket
    else
      redirect(socket, to: "/login")
    end

  {:ok, socket}
end

I'll give you another example. We do the same thing in https://github.com/dwyl/learn-payment-processing. In this project, we only allow the user to see a page if they bought the product on Stripe. Otherwise, we just redirect them to the home page.

 def success(conn, %{"session_id" => session_id}) do
    case Stripe.Checkout.Session.retrieve(session_id) do
      {:ok, session} ->
        person_id = conn.assigns.person.id

        UsersTable.create_user(%{
          person_id: person_id,
          stripe_id: session.customer,
          status: true
        })

        render(conn, :success, layout: false)

      {:error, _error} ->
        conn
        |> put_status(303)
        |> redirect(to: ~p"/")
    end
  end

Want another example? It's on our demo! :D

https://github.com/dwyl/elixir-auth-microsoft/blob/43116e76ba9930c83c6793cd0833f91d891f4deb/demo/lib/app_web/controllers/page_controller.ex#L11-L28

We simply check if there's a token to control who gets to see this URL.

But wait. What if the token is expired/invalid?

Yes, access tokens in Azure AD are short-lived and need to be refreshed if they are expired to continue accessing Azure resources. There refresh tokens usually have a longer lifetime.

As it stands, this package does not have a way for you to refresh the token. I'll open an issue for that.

If you see in our demo, we just assume the token is valid and let it crash if it's not.

{:ok, profile} = ElixirAuthMicrosoft.get_user_profile(token.access_token)

You can handle the scenario with your token if the request fails and handle this error (usually redirecting them to the login page suffices, forcing them to get a new valid access token).

Until we have provided you a way to refresh tokens, you can still use get_token/2 to keep your web app alive in case a request fails because your current token is invalid. So, if a request fails because the token is invalid, use get_token/2 to get a new one :).

Here's an image of what a usual flow with tokens will look like with your web application. Our package makes it easy to get those tokens and return them to you. We´ll eventually provide you with the tools to refresh the token, but it's up to you to handle the error scenarios. If a request fails because the token is invalid, you need to refresh the token or get a new one (this is temporary while we implement a function to refresh the token) or force the user to go to the home page to log in.

image

To give you another perspective, msal is Microsoft's official package for various frontend frameworks to make this process easy. The way you're meant to check if a given person is already logged in and able to see the package is silently acquiring a token (which is checking if the token is valid) -> https://stackoverflow.com/questions/54346058/how-to-know-if-a-given-user-is-already-logged-in-with-msal

Alrighty. But what about roles? I want to allow a specific group of people to access a page and forbid others. How do I do that?

So far we've dealt with access tokens (used for authorization). The token returned by this package is the access token. ID Tokens can also be sent to the client application with claims with information about roles and permissions of the authorized user.

Microsoft's documentation are stellar. Read this for more information about the diff between tokens -> https://learn.microsoft.com/en-us/entra/identity-platform/security-tokens.

If you want to add roles and see them in your client application, you can control access at the App Registration-level by creating the roles there and making it so the roles are accessible in the token for you to see in the client application.

This goes a bit beyond the scope of what the package is now (though in the future it makes sense to have a an easy way to get the user's roles, even if you just need to JWT decode it) but to have role-based access on your application and have this information be returned on the access token, see https://learn.microsoft.com/en-us/answers/questions/1509291/(oidc)-configure-app-registration-to-return-roles.

TLDR;

Enforce the check on the mount/3 function to see if the session token exists. Redirect the user if to the login page if it's invalid/expired.

ndrean commented 5 months ago

Adding my one cent. For Liveview, one can rewritte this by using on_mount to "slightly clean" the code.

It can be used as the callback module of the live_session macro in the router, or declared directly (as a macro) in the Live component:. Check this post

stabenfeldt commented 5 months ago

Thanks for the good and detailed answers, @LuchoTurtle & @ndrean! ❤️ You gave me a good starting point.