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