coherence
coherence copied to clipboard
Possible to use Coherence with SPA front-ends?
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?
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 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 one thing that would be nice is if the error could be taken from a view instead of being a string.
@johnhamelink What do you mean by taken from a view?
I have been working on moving all messages to gettext.
@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.
Would love to have this built in one day <3
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 Yes, adding DbStore backend is the way to go to get sessions to survive server restart.
@casio Did you implement the persisted storage? Would you like to share some details so that we don't have to reimplement the wheel?
@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