En este tutorial vamos a construir una autenticación básica en phoenix y proteger las rutas para usuarios autenticados

Creamos nuestra aplicacion, en mi caso voy a usar mysql, asi que estare pasando la flag al momento de crear el proyecto, lo mismo para el html


mix phx.new api_auth --database mysql --no-html

Nos aseguramos que todo este funcionando

iex -S mix phx.server

Obtendremos esta salida

Running ApiAuthWeb.Endpoint with cowboy 2.10.0 at 127.0.0.1:4000

Y tambien nos saldra este error relacionado con la base de datos

[error] MyXQL.Connection (#PID<0.4742.0>) failed to connect: ** (MyXQL.Error) (1045) (ER_ACCESS_DENIED_ERROR) Access denied for user 'root'@'localhost' (using password: NO)

Entramos al archivo config/dev.ex y modificamos nuestra conexion a mysql

config :api_auth, ApiAuth.Repo,
 username: "root",
 password: "",
 database: "api_auth_dev",
 hostname: "localhost",
 show_sensitive_data_on_connection_error: true,
 pool_size: 10

Actualizamos el fichero mix.exs y añadimos los modulos que necesitaremos

   {:json, "~> 1.2"},
   {:poison, "~> 3.1", override: true},
   {:comeonin, "~> 4.0"},
   {:bcrypt_elixir, "~> 1.0"},
   {:guardian, "~> 1.0"}

Luego las instalamos con el comando

mix deps.get

Creamos una pequeña migración para guardar algún usuario

mix phx.gen.schema User users email:string password:string 

Corremos la migración

mix ecto.migrate

Haremos un cambio en el modelo de User en lib/api_auth/user.ex

Definimos el changeset para validar algunos campos y encriptar la contraseña cada vez que se guarde un nuevo usuario y vamos a ocupar @derive para convertir el modelo a json

defmodule ApiAuth.User do
  use Ecto.Schema
  import Ecto.Changeset


  @derive {Poison.Encoder, only: [:email, :password]}
  schema "users" do
    field :email, :string
    field :password, :string
    timestamps()
  end
  
  @doc false
  def changeset(user, attrs) do
    user
    |> cast(attrs, [:email, :password])
    |> validate_required([:email, :password])
    |> put_password_hash
  end


  defp put_password_hash(changeset) do
     case changeset do
       %Ecto.Changeset{valid?: true, changes: %{password: password}}
         ->
           put_change(changeset, :password, Comeonin.Bcrypt.hashpwsalt(password))
        _ ->
           changeset
     end
  end
end

Con este cambio, vamos a iniciar la consola interactiva

iex -S mix

Importamos los alias que queremos usar: en nuestro caso, Repo y User

alias ApiAuth.{Repo, User}

Cremos un changeset para el modelo de User

changeset = User.changeset(%User{}, %{email: "triangulo@example.com", password: "123456"})

Nos va a retornar lo siguiente

#Ecto.Changeset<
 action: nil,
 changes: %{
  email: "triangulo@example.com",
  password: "$2b$12$9UI5Qv0IMfuiGZpC3Ljhn.9whg/wkmvZB56NzDD34YEwzu3QQfn5i"
 },
 errors: [],
 data: #ApiAuth.User<>,
 valid?: true
>

Ahora lo guardamos en la base de datos

Repo.insert(changeset)

Si todo sale bien, nos va a retornar el registro, podemos ver que el password ya ha sido encriptado

[debug] QUERY OK db=135.0ms idle=1711.8ms
INSERT INTO `users` (`email`,`password`,`inserted_at`,`updated_at`) VALUES (?,?,?,?) ["triangulo@example.com", "$2b$12$9UI5Qv0IMfuiGZpC3Ljhn.9whg/wkmvZB56NzDD34YEwzu3QQfn5i", ~N[2023-10-26 12:43:47], ~N[2023-10-26 12:43:47]]
{:ok,
 %ApiAuth.User{
  __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
  email: "triangulo@example.com",
  id: 2,
  inserted_at: ~N[2023-10-26 12:43:47],
  password: "$2b$12$9UI5Qv0IMfuiGZpC3Ljhn.9whg/wkmvZB56NzDD34YEwzu3QQfn5i",
  updated_at: ~N[2023-10-26 12:43:47]
 }}

En nuestro router.ex vamos a añadir el endpoint que estaremos ocupando, apuntando a nuestro controller, en mi caso AuthController y el metodo sera authenticate

scope "/api", ApiAuthWeb do
  pipe_through :api
  post "/auth-user", AuthController, :authenticate
 end

Ahora agregamos un modulo que se encargada de toda la logica de autenticación en lib/api_auth/accounts/accounts.ex

defmodule ApiAuth.Accounts do
 alias ApiAuth.Guardian
 alias ApiAuth.User
 alias ApiAuth.Repo
 import Comeonin.Bcrypt, only: [checkpw: 2, dummy_checkpw: 0]

 def token_sign_in(email, password) do
  case email_password_auth(email, password) do
   {:ok, user} ->
    Guardian.encode_and_sign(user)
   _ ->
   {:error, :error}
  end
 end

 defp email_password_auth(email, password) when is_binary(email) and is_binary(password) do
  with {:ok, user} <- get_by_email(email),
  do: verify_password(password, user)
 end

 defp get_by_email(email) when is_binary(email) do
  case Repo.get_by(User, email: email) do
   nil ->
     dummy_checkpw()
     {:error, "Login error"}
   user ->
     {:ok, user}
  end
 end

 defp verify_password(password, %User{} = user) when is_binary(password) do
  if checkpw(password, user.password) do
   {:ok, user}
  else
   {:error, :invalid_password}
  end
 end

 def get_user!(id), do: Repo.get!(User, id)

end

Vamos a añadir nuestro guardian, que sera el encargado de comprobar que el usuario este autenticado basado en el token lib/api_auth/guardian/guardian.ex

defmodule ApiAuth.Guardian do
  use Guardian, otp_app: :api_auth


  def subject_for_token(resource, _claims) do
    sub = to_string(resource.id)
    {:ok, sub}
  end


  def subject_for_token(_, _) do
    {:error, :reason_for_error}
  end


  def resource_from_claims(claims) do
    id = claims["sub"]
    resource = ApiAuth.Accounts.get_user!(id)
    {:ok,  resource}
  end


  def resource_from_claims(_claims) do
    {:error, :reason_for_error}
  end

end

Lo configuramos en config.ex podemos ejecutar

mix guardian.gen.secret

Para obtener la secret key

config :api_auth, ApiAuth.Guardian,
    issuer: "api_auth",
    secret_key: 
"EuBjJCflv1S0MUp1ZEy5SaltUZ5BTKww4v51J3gtjFreWje34HfVovYTVWvMZE7A"

Añadiremos un manejador de errores en lib/api_auth/handlers/auth_error_handler.ex

defmodule ApiAuth.AuthErrorHandler do
    import Plug.Conn

    def auth_error(conn, {type, _reason}, _opts) do
      body = Poison.encode!(%{error: to_string(type)})
      send_resp(conn, 401, body)
    end
end

Y un plug para el pipeline de la autenticación en lib/api_auth/plug/auth_pipeline.ex

defmodule ApiAuth.Guardian.AuthPipeline do
    use Guardian.Plug.Pipeline, otp_app: :ApiAuth,
    module: ApiAuth.Guardian,
    error_handler: ApiAuth.AuthErrorHandler

    plug Guardian.Plug.VerifyHeader, realm: "Bearer"
    plug Guardian.Plug.EnsureAuthenticated
    plug Guardian.Plug.LoadResource
end

Ahora en nuestro auth_controller.ex vamos a poner un método para autenticar el usuario

defmodule ApiAuthWeb.AuthController do
  use ApiAuthWeb, :controller

  alias ApiAuth.Accounts

  def authenticate(conn,  %{"email" => email, "password" => password}) do
    case Accounts.token_sign_in(email, password) do
      {:ok, token, _claims} ->
        send_resp conn, 200, Poison.encode!(%{token: token})
       _ ->
        send_resp conn, 200, Poison.encode!(%{error: :unauthorized})
    end
  end
end

Ahora al hacer un post al endpoint api/auth-user

{
	"email":"triangulo@example.com",
	"password":"123456"
}

Obtendremos la siguiente respuesta

{"token":"eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhcGlfYXV0aCIsImV4cCI6MTcwMDc2NzgwMCwiaWF0IjoxNjk4MzQ4NjAwLCJpc3MiOiJhcGlfYXV0aCIsImp0aSI6IjNiOGEwOWJlLWJhMGItNDk5Yi04NWYyLWM1NGY5MWE2MDQ3YSIsIm5iZiI6MTY5ODM0ODU5OSwic3ViIjoiMiIsInR5cCI6ImFjY2VzcyJ9.vML81OdcQIDMQzKIQPtTjKlw3_LgAhqCrZaj384ZftBLp8tStQ3HWHNazIHC9nXJvQPb_35d860FoBX1fSBs7Q"}

Repositorio del proyecto https://github.com/danielvkp/elixir_jwt


Autor: Daniel C. Rojas