coherence icon indicating copy to clipboard operation
coherence copied to clipboard

Possible to use Coherence with SPA front-ends?

Open egeersoz opened this issue 8 years ago • 10 comments
trafficstars

I'm wondering how Coherence would work with a single-page application, such as one written in React or Vue.

Right now the library seems to assume everything will be rendered on the server and sent to the browser.

But what if my Phoenix app is just an API endpoint and sends data in JSON?

Is there a way to do this in Coherence without re-writing all the controllers and views?

egeersoz avatar Mar 16 '17 00:03 egeersoz

I've written a controller which can provide Token Auth for a SPA:

/web/controllers/api/session_controller.ex:

defmodule Example.Api.Coherence.SessionController do

  @moduledoc """
  Handle the authentication actions.
  """
  use Coherence.Web, :controller
  use Timex
  require Logger
  alias Coherence.{Rememberable}
  use Coherence.Config
  import Ecto.Query
  import Rememberable, only: [hash: 1]
  alias Coherence.ControllerHelpers, as: Helpers
  alias Coherence.{ConfirmableService}
  import Coherence.TrackableService

  plug :layout_view, view: Example.Api.Coherence.SessionView

  @type schema :: Ecto.Schema.t
  @type conn :: Plug.Conn.t
  @type params :: Map.t

  @doc """
  Produce an API token when a user enters the correct user/password combo. For use with single-page applications.
  """
  @spec create(conn, params) :: conn
  def create(conn, params) do
    user_schema     = Config.user_schema
    login_field     = Config.login_field
    login_field_str = to_string login_field
    login           = params["session"][login_field_str]
    password        = params["session"]["password"]

    user = Config.repo.one(from u in user_schema, where: field(u, ^login_field) == ^login)
    lockable? = user_schema.lockable?

    if user != nil and user_schema.checkpw(password, Map.get(user, Config.password_hash)) do
      if ConfirmableService.confirmed?(user) || ConfirmableService.unconfirmed_access?(user) do
        unless lockable? and user_schema.locked?(user) do
          conn = if user.locked_at do
            Helpers.unlock!(user)
            track_unlock conn, user, user_schema.trackable_table?
          else
            conn
          end

          # Create a token for the user.
          token = Coherence.Authentication.Token.generate_token
          :ok = Coherence.CredentialStore.Agent.put_credentials(token, user)

          conn
          |> reset_failed_attempts(user, lockable?)
          |> track_login(user, user_schema.trackable?, user_schema.trackable_table?)
          |> assign(:user, user)
          |> assign(:token, token)
          |> render("login.json")
        else
          conn
          |> assign(:error, {:user_locked, "Too many failed login attempts. Account has been locked."})
          |> put_status(423)
          |> render("error.json")
        end
      else
        conn
        |> assign(:error, {:user_unconfirmed, "You must confirm your account before you can login."})
        |> put_status(406)
        |> render("error.json")
      end
    else
      conn
      |> track_failed_login(user, user_schema.trackable_table?)
      |> failed_login(user, lockable?)
      |> put_status(401)
      |> assign(:error, {:login_failed, "Your details were incorrect"})
      |> render("error.json")
    end
  end


  @flash_invalid "Incorrect #{Config.login_field} or password."
  @flash_locked "Maximum Login attempts exceeded. Your account has been locked."

  defp log_lockable_update({:error, changeset}) do
    lockable_failure changeset
  end
  defp log_lockable_update(_), do: :ok

  @spec reset_failed_attempts(conn, Ecto.Schema.t, boolean) :: conn
  def reset_failed_attempts(conn, %{failed_attempts: attempts} = user, true) when attempts > 0 do
    Helpers.changeset(:session, user.__struct__, user, %{failed_attempts: 0})
    |> Config.repo.update
    |> log_lockable_update
    conn
  end
  def reset_failed_attempts(conn, _user, _), do: conn

  defp failed_login(conn, %{} = user, true) do
    attempts = user.failed_attempts + 1
    {conn, flash, params} =
    if attempts >= Config.max_failed_login_attempts do
      new_conn = assign(conn, :locked, true)
      |> track_lock(user, user.__struct__.trackable_table?)
      {new_conn, @flash_locked, %{locked_at: Ecto.DateTime.utc}}
    else
      {conn, @flash_invalid, %{}}
    end

    Helpers.changeset(:session, user.__struct__, user, Map.put(params, :failed_attempts, attempts))
    |> Config.repo.update
    |> log_lockable_update

    assign(conn, :error, flash)
  end
  defp failed_login(conn, _user, _), do: assign(conn, :error, @flash_invalid)

  @doc """
  Validate the login cookie.

  Check the following conditions:

    * a record exists for the user, the series, but a different token
      * assume a fraud case
      * remove the rememberable cookie and delete the session
    * a record exists for the user, the series, and the token
      * a valid remembered user
    * otherwise, this is an unknown user.
  """
  @spec validate_login(integer, String.t, String.t) :: {:ok, schema} | {:error, atom}
  def validate_login(user_id, series, token) do
    hash_series = hash series
    hash_token = hash token
    repo = Config.repo

    with :ok <- get_invalid_login!(repo, user_id, hash_series, hash_token),
         {:ok, rememberable} <- get_valid_login!(repo, user_id, hash_series, hash_token),
      do: {:ok, rememberable}
  end

  defp get_invalid_login!(repo, user_id, series, token) do
    case repo.one Rememberable.get_invalid_login(user_id, series, token) do
      0 -> :ok
      _ ->
        repo.delete_all Rememberable.delete_all(user_id)
        {:error, :invalid_token}
    end
  end

  defp get_valid_login!(repo, user_id, series, token) do
    case repo.one Rememberable.get_valid_login(user_id, series, token) do
      nil   -> {:error, :not_found}
      item  -> {:ok, item}
    end
  end
end

/web/views/api/session_view.ex:

defmodule Example.Api.Coherence.SessionView do
  use Pod.Web, :view

  def render("login.json", %{user: user, token: token}) do
    %{
      status: "success",
      token: token,
      user: %{
        id: user.id,
        name: user.name,
        email: user.email
      }
    }
  end

  def render("error.json", %{error: {codename, message}}) do
    %{
      status: "error",
      error_type: codename,
      message: message
    }
  end

end

/web/router.ex:

pipeline :protected_api do
    plug :accepts, ["json"]
    plug Coherence.Authentication.Token, source: :params, param: "token", error: Poison.encode!(%{error: %{unauthorized: "Authentication Required"}}))
end

johnhamelink avatar Mar 17 '17 12:03 johnhamelink

@johnhamelink Thanks for posting this. Some day I might around to adding generators for this, but for now, its a pretty easy to modify generated controllers to like you did.

smpallen99 avatar Mar 17 '17 16:03 smpallen99

@smpallen99 one thing that would be nice is if the error could be taken from a view instead of being a string.

johnhamelink avatar Mar 17 '17 17:03 johnhamelink

@johnhamelink What do you mean by taken from a view?

I have been working on moving all messages to gettext.

smpallen99 avatar Mar 17 '17 21:03 smpallen99

@smpallen99 in my example I'm returning JSON, so it'd be much cleaner if I could refer to a function in a view just as I do when I render("error.json") in the controller.

johnhamelink avatar Mar 18 '17 02:03 johnhamelink

Would love to have this built in one day <3

deini avatar Mar 19 '17 21:03 deini

Have been playing around with the code from @johnhamelink - cool, thanks for putting it here!

However, the above controller and the Token plug by default use CredentialStore.Agent, which means tokens will be gone after ie. a server restart.

If I want persistent token storage, it seems I could try to use Coherence.CredentialStore.Session with Coherence.Authentication.Token by implementing a DbStore for my user schema? Before I go down that road: @smpallen99 do you think thats reasonable? Will there be road blocks lateron?

casio avatar Mar 20 '17 13:03 casio

@casio Yes, adding DbStore backend is the way to go to get sessions to survive server restart.

smpallen99 avatar Mar 29 '17 13:03 smpallen99

@casio Did you implement the persisted storage? Would you like to share some details so that we don't have to reimplement the wheel?

johannesE avatar Aug 21 '17 17:08 johannesE

@johnhamelink

I have just implemented your code in Pheonix 1.3 and latest Coherence, I am getting the an error:

session_controller.ex:12: cannot import Coherence.Rememberable.hash/1 because it is undefined or private

acrolink avatar Oct 13 '17 16:10 acrolink