Trilha Phoenix · Autenticação de API
Mix.install(
[
{:phoenix, "~> 1.8.0"},
{:phoenix_live_view, "~> 1.1"},
{:phoenix_ecto, "~> 4.6"},
{:ecto_sql, "~> 3.13"},
{:postgrex, ">= 0.0.0"},
{:bandit, "~> 1.5"},
{:jason, "~> 1.4"},
{:kino, "~> 0.14"},
{:swoosh, "~> 1.16"},
{:pbkdf2_elixir, "~> 2.0"}
],
consolidate_protocols: false,
config: [
phoenix: [json_library: Jason, plug_init_mode: :runtime],
swoosh: [api_client: false],
hello: [
{HelloWeb.Endpoint,
[
adapter: Bandit.PhoenixAdapter,
http: [ip: {127, 0, 0, 1}, port: 4010],
server: true,
debug_errors: true,
secret_key_base: String.duplicate("trilha-phoenix-", 5),
render_errors: [formats: [html: HelloWeb.ErrorHTML, json: HelloWeb.ErrorJSON], layout: false],
pubsub_server: Hello.PubSub
]},
{Hello.Mailer, [adapter: Swoosh.Adapters.Local]}
]
]
)
defmodule Trilha do
@moduledoc """
Apoio deste notebook: verifica a conexão com o PostgreSQL, mostra tabelas como
o psql mostraria, registra migrations, faz requisições ao endpoint como um
navegador faria, confere os exercícios e guarda as perguntas e as soluções.
Não faz parte do Phoenix nem do Ecto. Você não precisa ler este módulo para seguir o guia.
"""
@endpoint HelloWeb.Endpoint
@router HelloWeb.Router
@repo Hello.Repo
# --- banco de dados ------------------------------------------------------------
@doc "Tenta uma conexão com o PostgreSQL usando a configuração do Hello.Repo."
def verificar_postgres(extra \\ []) do
config = Keyword.merge(apply(@repo, :config, []), extra)
onde = "`#{config[:username]}@#{config[:hostname]}:#{config[:port] || 5432}`"
# Conecta ao banco "postgres", que sempre existe: o banco da trilha talvez ainda não tenha sido criado.
resultado =
try do
Postgrex.Protocol.connect(Postgrex.Utils.default_opts(Keyword.put(config, :database, "postgres")))
rescue
erro -> {:error, erro}
end
case resultado do
{:ok, estado} ->
Postgrex.Protocol.disconnect(%RuntimeError{message: "fim da verificação"}, estado)
Kino.Markdown.new("**Conectou** ao PostgreSQL em #{onde}. Pode seguir.")
{:error, erro} ->
Kino.Markdown.new(
"**Não conectou** ao PostgreSQL em #{onde}. A mensagem do Postgrex:\n\n" <>
cerca(Exception.message(erro), "") <> "\n\n" <> dica_conexao(erro)
)
end
end
defp dica_conexao(%Postgrex.Error{postgres: %{pg_code: "28P01"}}) do
"**Senha recusada.** Confira `username` e `password` na célula `config/dev.exs`. " <>
"Com autenticação por senha, o PostgreSQL dá esta mesma resposta quando o usuário não existe."
end
defp dica_conexao(%Postgrex.Error{postgres: %{pg_code: "28000", message: mensagem}}) do
cond do
mensagem =~ ~r/ident|peer/i ->
"**O servidor não aceita senha para esta conexão.** Ele está configurado para autenticação `ident` ou `peer`, " <>
"o padrão de algumas distribuições Linux, como o Fedora. O `LEIA-ME.md` da trilha mostra como trocar para senha no `pg_hba.conf`."
mensagem =~ "does not exist" ->
"**O usuário não existe no PostgreSQL.** Crie-o no `psql` ou troque `username` na célula `config/dev.exs`."
true ->
"**O servidor recusou a conexão.** Leia a mensagem acima e confira o `pg_hba.conf` do servidor."
end
end
defp dica_conexao(%DBConnection.ConnectionError{message: mensagem}) do
cond do
mensagem =~ "econnrefused" ->
"**Nada responde nesse endereço.** O servidor PostgreSQL está parado, ou escuta em outra porta. O `LEIA-ME.md` mostra como iniciá-lo."
mensagem =~ "nxdomain" ->
"**O nome do host não existe.** Confira `hostname` na célula `config/dev.exs`."
true ->
"**A conexão falhou antes de chegar ao PostgreSQL.** Confira `hostname` e `port` na célula `config/dev.exs`."
end
end
defp dica_conexao(_erro), do: "Confira a configuração na célula `config/dev.exs`."
@doc """
Confere se o servidor tem a extensão citext, que a migration do phx.gen.auth
cria. Algumas distribuições Linux a separam num pacote à parte.
"""
def verificar_citext do
case sql("SELECT default_version FROM pg_available_extensions WHERE name = 'citext'").rows do
[[versao]] ->
Kino.Markdown.new("O servidor tem a extensão `citext` (versão #{versao}). Pode seguir.")
[] ->
Kino.Markdown.new(
"**O servidor não tem a extensão `citext`.** A migration do `phx.gen.auth` vai falhar com " <>
"`extension \"citext\" is not available`. No Fedora, ela vem no pacote `postgresql-contrib`:\n\n" <>
cerca("sudo dnf install postgresql-contrib\nsudo systemctl restart postgresql", "") <>
"\n\nO `LEIA-ME.md` da trilha tem os detalhes. Depois de instalar, avalie esta célula de novo."
)
end
end
@doc "Apaga todas as tabelas do banco da trilha, inclusive schema_migrations, esquece os visitantes e esvazia a caixa de entrada."
def recomecar do
sql("DROP SCHEMA public CASCADE")
sql("CREATE SCHEMA public")
esquecer_visitantes()
Swoosh.Adapters.Local.Storage.Memory.delete_all()
Kino.Markdown.new(
"O banco `#{apply(@repo, :config, [])[:database]}` está vazio: nenhuma tabela, nenhuma migration registrada."
)
end
@doc "Registra uma migration com a versão que, no projeto, viria do nome do arquivo."
def migracao(versao, modulo) do
:persistent_term.put({__MODULE__, :migracoes}, Map.put(mapa_migracoes(), versao, modulo))
nome = modulo |> Module.split() |> List.last() |> Macro.underscore()
Kino.Markdown.new("No projeto, esta migration seria o arquivo `priv/repo/migrations/#{versao}_#{nome}.exs`.")
end
@doc "As migrations registradas, como pares {versão, módulo}, em ordem de versão."
def migracoes, do: mapa_migracoes() |> Enum.sort()
defp mapa_migracoes, do: :persistent_term.get({__MODULE__, :migracoes}, %{})
@doc "Lista as tabelas e sequências do banco, como o comando \\d do psql."
def relacoes do
resultado =
sql("""
SELECT n.nspname AS "Schema", c.relname AS "Name",
CASE c.relkind WHEN 'r' THEN 'table' WHEN 'S' THEN 'sequence' WHEN 'v' THEN 'view' END AS "Type",
pg_get_userbyid(c.relowner) AS "Owner"
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind IN ('r', 'S', 'v')
ORDER BY c.relname
""")
if resultado.rows == [] do
Kino.Markdown.new("`\\d`: o banco não tem nenhuma tabela.")
else
Kino.Markdown.new("**`\\d`**\n\n" <> tabela_markdown(resultado))
end
end
@doc "Mostra as colunas, os índices e as chaves estrangeiras de uma tabela, como o comando \\d TABELA do psql."
def descrever(tabela) do
colunas =
sql(
"""
SELECT a.attname AS "Column", format_type(a.atttypid, a.atttypmod) AS "Type",
CASE WHEN a.attnotnull THEN 'not null' ELSE '' END AS "Nullable",
coalesce(pg_get_expr(d.adbin, d.adrelid), '') AS "Default"
FROM pg_attribute a
LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
WHERE a.attrelid = to_regclass($1) AND a.attnum > 0 AND NOT a.attisdropped
ORDER BY a.attnum
""",
[tabela]
)
if colunas.rows == [] do
Kino.Markdown.new("`\\d #{tabela}`: a tabela `#{tabela}` não existe.")
else
indices = sql("SELECT indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = $1 ORDER BY indexname", [tabela])
lista = Enum.map_join(indices.rows, "\n", fn [definicao] -> "* `#{definicao}`" end)
chaves =
sql(
"""
SELECT conname, pg_get_constraintdef(oid) FROM pg_constraint
WHERE conrelid = to_regclass($1) AND contype = 'f' ORDER BY conname
""",
[tabela]
)
fks =
if chaves.rows == [],
do: "",
else: "\n\n**Chaves estrangeiras**\n\n" <> Enum.map_join(chaves.rows, "\n", fn [nome, definicao] -> "* `#{nome}` #{definicao}" end)
Kino.Markdown.new(
"**`\\d #{tabela}`**\n\n" <>
tabela_markdown(colunas) <> "\n\n**Índices**\n\n" <> if(lista == "", do: "nenhum", else: lista) <> fks
)
end
end
@doc "Executa um SQL e mostra o resultado como tabela."
def consultar(consulta, parametros \\ []) do
resultado = sql(consulta, parametros)
Kino.Markdown.new(cerca(consulta, "sql") <> "\n\n" <> tabela_markdown(resultado))
end
defp sql(consulta, parametros \\ []) do
apply(@repo, :query!, [consulta, parametros, [log: false]])
end
defp tabela_markdown(%{columns: colunas, rows: linhas}) do
cabecalho = "| " <> Enum.join(colunas, " | ") <> " |"
separador = "| " <> Enum.map_join(colunas, " | ", fn _ -> "---" end) <> " |"
corpo =
Enum.map(linhas, fn linha ->
"| " <> Enum.map_join(linha, " | ", &celula/1) <> " |"
end)
Enum.join([cabecalho, separador | corpo], "\n")
end
defp celula(nil), do: ""
defp celula(""), do: ""
defp celula(valor) when is_binary(valor), do: "`" <> String.replace(valor, "|", "\\|") <> "`"
defp celula(%Decimal{} = valor), do: "`" <> Decimal.to_string(valor) <> "`"
defp celula(valor), do: "`" <> (valor |> inspect() |> String.replace("|", "\\|")) <> "`"
# --- requisições -----------------------------------------------------------
@doc "Despacha uma requisição para o endpoint, sem passar pela rede."
def requisicao(metodo \\ :get, caminho, parametros \\ nil, conn \\ Phoenix.ConnTest.build_conn()) do
descartar_respostas_antigas()
conn = Phoenix.ConnTest.dispatch(conn, @endpoint, metodo, caminho, parametros)
# Para NoRouteError (e só para ela), o Phoenix envia a página de erro e
# devolve o conn sem relançar a exceção. Reconstruímos a exceção aqui.
if conn.state == :sent and conn.status == nil do
{:erro, Phoenix.Router.NoRouteError.exception(conn: conn, router: @router)}
else
{:ok, conn}
end
rescue
erro -> {:erro, desembrulhar(erro)}
end
# Cada resposta enviada pelo adaptador de testes deixa duas mensagens no
# processo que fez a requisição. Se ficarem acumuladas, o Phoenix acha que a
# próxima página de erro já foi enviada e não a renderiza.
defp descartar_respostas_antigas do
receive do
{ref, {status, cabecalhos, _corpo}} when is_reference(ref) and is_integer(status) and is_list(cabecalhos) ->
descartar_respostas_antigas()
{:plug_conn, :sent} ->
descartar_respostas_antigas()
after
0 -> :ok
end
end
defp desembrulhar(%Plug.Conn.WrapperError{reason: motivo}), do: desembrulhar(motivo)
defp desembrulhar(erro), do: erro
@doc "Faz a requisição e mostra o status, o content-type e o corpo da resposta."
def mostrar(metodo \\ :get, caminho, parametros \\ nil) do
titulo = "### #{metodo |> to_string() |> String.upcase()} #{caminho}"
corpo =
case requisicao(metodo, caminho, parametros) do
{:ok, conn} ->
tipo = conn |> Plug.Conn.get_resp_header("content-type") |> Enum.join()
"**Status** `#{conn.status}` · **content-type** `#{tipo}`\n\n" <>
cerca(to_string(conn.resp_body), if(tipo =~ "json", do: "json", else: "html"))
{:erro, erro} ->
"**A requisição levantou uma exceção:**\n\n" <> cerca(Exception.format_banner(:error, erro), "")
end
Kino.Markdown.new(titulo <> "\n\n" <> corpo)
end
# --- navegador ---------------------------------------------------------------
@doc """
Faz uma requisição como um navegador: envia os cookies que o visitante `quem`
recebeu antes (a sessão, onde ficam a flash e o login) e segue os redirects.
`quem` é um nome qualquer, como "ana"; cada nome tem os seus próprios cookies.
Devolve `{:ok, conn}` com a última resposta, ou `{:erro, exceção}`.
"""
def visitar(quem, metodo, caminho, parametros \\ nil) do
quem |> seguir(metodo, caminho, parametros, []) |> List.last() |> elem(1)
end
@doc "Como `visitar/4`, mas mostra cada passo, a flash e o texto da página final."
def navegar(quem, metodo \\ :get, caminho, parametros \\ nil) do
passos = seguir(quem, metodo, caminho, parametros, [])
linhas =
passos
|> Enum.with_index(1)
|> Enum.map(fn {{requisicao, resultado}, i} -> linha_passo(i, requisicao, resultado) end)
cabecalho = [
"### #{quem}: #{rotulo(metodo, caminho)}",
"",
"| # | Requisição | Status | Redireciona para |",
"| --- | --- | --- | --- |"
]
Kino.Markdown.new(Enum.join(cabecalho ++ linhas, "\n") <> "\n\n" <> resumo_final(List.last(passos)))
end
@doc "Esquece os cookies de todos os visitantes, como se cada um abrisse um navegador novo."
def esquecer_visitantes do
for {{__MODULE__, :cookies, _} = chave, _} <- :persistent_term.get(), do: :persistent_term.erase(chave)
:ok
end
defp seguir(quem, metodo, caminho, parametros, feitos) do
conn = Enum.reduce(cookies(quem), Phoenix.ConnTest.build_conn(), fn {nome, valor}, c -> Plug.Test.put_req_cookie(c, nome, valor) end)
resultado = requisicao(metodo, caminho, parametros, conn)
feitos = feitos ++ [{rotulo(metodo, caminho), resultado}]
with {:ok, resp} <- resultado do
guardar_cookies(quem, resp)
end
with {:ok, resp} <- resultado,
true <- resp.status in 300..399 and length(feitos) < 6,
[destino] <- Plug.Conn.get_resp_header(resp, "location"),
true <- String.starts_with?(destino, "/") do
seguir(quem, :get, destino, nil, feitos)
else
_ -> feitos
end
end
defp cookies(quem), do: :persistent_term.get({__MODULE__, :cookies, quem}, %{})
defp guardar_cookies(quem, resp) do
novos =
Enum.reduce(resp.resp_cookies, cookies(quem), fn {nome, opcoes}, acc ->
if opcoes[:max_age] == 0, do: Map.delete(acc, nome), else: Map.put(acc, nome, opcoes[:value])
end)
:persistent_term.put({__MODULE__, :cookies, quem}, novos)
end
defp rotulo(metodo, caminho), do: "#{metodo |> to_string() |> String.upcase()} #{caminho}"
defp linha_passo(i, requisicao, {:ok, conn}) do
destino = conn |> Plug.Conn.get_resp_header("location") |> List.first("")
"| #{i} | `#{requisicao}` | `#{conn.status}` | #{if destino == "", do: "", else: "`#{destino}`"} |"
end
defp linha_passo(i, requisicao, {:erro, erro}) do
"| #{i} | `#{requisicao}` | exceção: `#{erro.__struct__ |> inspect()}` | |"
end
defp resumo_final({_requisicao, {:ok, conn}}) do
flash =
case conn.assigns[:flash] do
mapa when is_map(mapa) and map_size(mapa) > 0 ->
"**Flash:** " <> Enum.map_join(mapa, " · ", fn {tipo, msg} -> "#{tipo}: \"#{msg}\"" end) <> "\n\n"
_ ->
""
end
flash <> "**Texto da página** (sem as tags):\n\n" <> cerca(texto(to_string(conn.resp_body)), "")
end
defp resumo_final({_requisicao, {:erro, erro}}) do
"**A requisição levantou uma exceção:**\n\n" <> cerca(Exception.format_banner(:error, erro), "")
end
@doc "O texto de uma página HTML, sem as tags: o que se lê no navegador."
def texto(html) do
conteudo =
case Regex.run(~r{<main[^>]*>(.*)</main>}s, html) do
[_, dentro] -> dentro
nil -> html
end
conteudo
|> String.replace(~r{<(script|style|head)[^>]*>.*?</\1>}s, "")
|> String.replace(~r{<input[^>]*type="hidden"[^>]*>}, "")
|> String.replace(~r{<option[^>]*\sselected[^>]*>}, "[x] ")
|> String.replace(~r{<option[^>]*>}, "[ ] ")
|> then(&Regex.replace(~r{<input[^>]*\svalue="([^"]*)"[^>]*>}, &1, fn _, valor -> if valor == "", do: " ", else: " [" <> valor <> "] " end))
|> String.replace(~r{</?(p|div|li|tr|h\d|header|footer|ul|table|thead|tbody|form|label|select|option|section|br)[^>]*>}, "\n")
|> String.replace(~r{</t[dh]>}, " ")
|> String.replace(~r{<[^>]+>}, "")
|> String.replace(["&", "<", ">", """, "'", " "], fn
"&" -> "&"
"<" -> "<"
">" -> ">"
""" -> "\""
"'" -> "'"
" " -> " "
end)
|> String.split("\n")
|> Enum.map(&(&1 |> String.replace(~r/[ \t]+/, " ") |> String.trim()))
|> Enum.reject(&(&1 == ""))
|> Enum.join("\n")
end
defp cerca(texto, linguagem) do
marca = String.duplicate("`", 3)
marca <> linguagem <> "\n" <> String.trim(texto) <> "\n" <> marca
end
# --- curl ------------------------------------------------------------------------
@doc """
Roda um comando curl de verdade contra o servidor do notebook, por uma conexão
TCP, e mostra a resposta como o curl mostraria. Entende -i, -X, -H e -d.
"""
def curl(comando) do
{requisicao, resposta} = executar_curl(comando)
corpo = resumir_corpo(resposta)
cabeca = resposta.bruta |> String.split("\n\n", parts: 2) |> hd()
saida = if requisicao.incluir_cabecalhos, do: cabeca <> "\n\n" <> corpo, else: corpo
Kino.Markdown.new(cerca("$ " <> String.trim(comando), "console") <> "\n\n" <> cerca(saida, ""))
end
# A página de depuração do Plug.Debugger tem dezenas de KB; mostramos só o começo.
defp resumir_corpo(%{corpo: corpo} = resposta) when byte_size(corpo) > 3000 do
tipo = resposta.cabecalhos |> List.keyfind("content-type", 0, {nil, ""}) |> elem(1)
resumo =
cond do
tipo =~ "text/html" ->
titulo =
case Regex.run(~r{<title>(.*?)</title>}s, corpo) do
[_, t] -> String.trim(t)
nil -> "?"
end
"<!-- página HTML de #{byte_size(corpo)} bytes, título: #{titulo} -->"
true ->
corpo |> String.split("\n") |> Enum.take(12) |> Enum.join("\n")
end
resumo <> "\n[... #{byte_size(corpo)} bytes no total; o Trilha.curl mostra só o começo]"
end
defp resumir_corpo(%{corpo: corpo}), do: corpo
@doc "Como curl/1, mas devolve %{status:, cabecalhos:, corpo:, json:} para conferências."
def curl!(comando), do: comando |> executar_curl() |> elem(1)
defp executar_curl(comando) do
requisicao = interpretar_curl(comando)
{requisicao, enviar_http(requisicao)}
end
defp interpretar_curl(comando) do
palavras =
Regex.scan(~r/'([^']*)'|"((?:[^"\\]|\\.)*)"|(\S+)/, String.replace(comando, "\\\n", " "))
|> Enum.map(fn
[_, simples] -> simples
[_, "", duplas] -> String.replace(duplas, ~s(\\"), ~s("))
[_, "", "", solta] -> solta
end)
|> Enum.drop_while(&(&1 in ["$", "curl"]))
inicial = %{metodo: nil, url: nil, cabecalhos: [], dados: nil, incluir_cabecalhos: false}
requisicao = ler_opcoes_curl(palavras, inicial)
metodo = requisicao.metodo || if(requisicao.dados, do: "POST", else: "GET")
%{requisicao | metodo: metodo}
end
defp ler_opcoes_curl([], acc), do: acc
defp ler_opcoes_curl(["-H", cabecalho | resto], acc), do: ler_opcoes_curl(resto, %{acc | cabecalhos: acc.cabecalhos ++ [cabecalho]})
defp ler_opcoes_curl(["-d", dados | resto], acc), do: ler_opcoes_curl(resto, %{acc | dados: dados})
defp ler_opcoes_curl(["-X", metodo | resto], acc), do: ler_opcoes_curl(resto, %{acc | metodo: metodo})
defp ler_opcoes_curl(["-" <> letras | resto], acc) when letras in ["i", "iX", "Xi"] do
acc = %{acc | incluir_cabecalhos: true}
case {String.contains?(letras, "X"), resto} do
{true, [metodo | resto]} -> ler_opcoes_curl(resto, %{acc | metodo: metodo})
{false, _} -> ler_opcoes_curl(resto, acc)
end
end
defp ler_opcoes_curl([url | resto], acc), do: ler_opcoes_curl(resto, %{acc | url: url})
defp enviar_http(requisicao) do
porta = apply(@endpoint, :config, [:http])[:port]
%URI{path: caminho, query: consulta} = URI.parse(requisicao.url)
alvo = (caminho || "/") <> if(consulta, do: "?" <> consulta, else: "")
corpo = requisicao.dados || ""
tipo =
if requisicao.dados && !Enum.any?(requisicao.cabecalhos, &(&1 =~ ~r/^content-type:/i)),
do: ["Content-Type: application/x-www-form-urlencoded"],
else: []
# Como o curl: um cabeçalho passado com -H substitui o padrão de mesmo nome.
padrao =
Enum.reject(["User-Agent: curl/8", "Accept: */*"], fn linha ->
nome = linha |> String.split(":") |> hd() |> String.downcase()
Enum.any?(requisicao.cabecalhos, &(&1 |> String.split(":") |> hd() |> String.downcase() == nome))
end)
linhas =
["#{requisicao.metodo} #{alvo} HTTP/1.1", "Host: localhost:#{porta}"] ++
padrao ++ requisicao.cabecalhos ++ tipo ++
if(corpo != "", do: ["Content-Length: #{byte_size(corpo)}"], else: []) ++ ["Connection: close", "", corpo]
socket =
case :gen_tcp.connect(~c"127.0.0.1", porta, [:binary, active: false]) do
{:ok, socket} ->
socket
{:error, motivo} ->
raise "nada respondeu na porta #{porta} (#{inspect(motivo)}). Avalie a célula que inicia o HelloWeb.Endpoint."
end
:ok = :gen_tcp.send(socket, Enum.join(linhas, "\r\n"))
bruta = receber_tudo(socket, "")
:gen_tcp.close(socket)
[cabeca, corpo_resposta] = String.split(bruta, "\r\n\r\n", parts: 2)
[linha_status | linhas_cabecalho] = String.split(cabeca, "\r\n")
[_, status | _] = String.split(linha_status, " ")
cabecalhos =
Enum.map(linhas_cabecalho, fn linha ->
[nome, valor] = String.split(linha, ": ", parts: 2)
{String.downcase(nome), valor}
end)
json =
case Jason.decode(corpo_resposta) do
{:ok, valor} -> valor
_ -> nil
end
%{
status: String.to_integer(status),
cabecalhos: cabecalhos,
corpo: corpo_resposta,
json: json,
bruta: String.replace(cabeca, "\r\n", "\n") <> "\n\n" <> corpo_resposta
}
end
defp receber_tudo(socket, acc) do
case :gen_tcp.recv(socket, 0, 5000) do
{:ok, dados} -> receber_tudo(socket, acc <> dados)
{:error, :closed} -> acc
end
end
# --- e-mails ---------------------------------------------------------------------
@doc """
Os e-mails enviados pela aplicação, do mais novo ao mais antigo. Em
desenvolvimento, o Swoosh guarda cada e-mail na memória em vez de enviá-lo;
é a mesma caixa que aparece em http://localhost:4010/dev/mailbox.
"""
def caixa_de_entrada do
emails = Swoosh.Adapters.Local.Storage.Memory.all()
if emails == [] do
Kino.Markdown.new("A caixa de entrada está vazia.")
else
linhas =
Enum.map(emails, fn email ->
para = email.to |> Enum.map_join(", ", fn {_nome, endereco} -> endereco end)
"| `#{para}` | #{email.subject} |"
end)
Kino.Markdown.new(Enum.join(["| Para | Assunto |", "| --- | --- |" | linhas], "\n"))
end
end
@doc "O último e-mail enviado para `endereco`, com o texto inteiro."
def ultimo_email(endereco) do
case email_para(endereco) do
nil -> Kino.Markdown.new("Nenhum e-mail para `#{endereco}`.")
email -> Kino.Markdown.new("**#{email.subject}**\n\n" <> cerca(email.text_body, ""))
end
end
@doc "O caminho do link (o que vem depois de http://localhost:4010) no último e-mail para `endereco`."
def link_do_email(endereco) do
with %{text_body: corpo} <- email_para(endereco),
[url | _] <- Regex.run(~r{https?://\S+}, corpo) do
%URI{path: caminho} = URI.parse(url)
caminho
else
_ -> raise "nenhum e-mail com link para #{endereco}"
end
end
defp email_para(endereco) do
Swoosh.Adapters.Local.Storage.Memory.all()
|> Enum.find(fn email -> Enum.any?(email.to, fn {_nome, para} -> para == endereco end) end)
end
# --- perguntas -------------------------------------------------------------
@doc "Uma pergunta de múltipla escolha, respondida antes de ver a explicação."
def pergunta(id) do
%{texto: texto, opcoes: opcoes, correta: correta, explicacao: explicacao} = Map.fetch!(perguntas(), id)
quadro = Kino.Frame.new()
letras = Enum.map(opcoes, fn {letra, _texto} -> {letra, to_string(letra)} end)
lista = Enum.map_join(opcoes, "\n", fn {letra, opcao} -> "* **#{letra})** #{opcao}" end)
formulario =
Kino.Control.form(
[resposta: Kino.Input.select("Sua resposta", [{nil, "Escolha"} | letras])],
submit: "Conferir"
)
Kino.listen(formulario, fn %{data: %{resposta: resposta}} ->
retorno =
cond do
resposta == nil -> "Escolha uma opção antes de conferir."
resposta == correta -> "**Certo.** " <> explicacao
true -> "**Ainda não.** Releia o trecho acima e tente outra opção."
end
Kino.Frame.render(quadro, Kino.Markdown.new(retorno))
end)
Kino.Layout.grid([Kino.Markdown.new("**Antes de continuar:** " <> texto <> "\n\n" <> lista), formulario, quadro])
end
defp perguntas do
%{
sem_sessao: %{
texto:
"você entrou no navegador como Ana e, no mesmo computador, roda `curl http://localhost:4010/api/me`. O que a API sabe sobre quem fez essa requisição?",
opcoes: [
a: "Sabe que é a Ana, pelo cookie de sessão que o navegador recebeu no login.",
b: "Nada: o curl não envia os cookies do navegador, e o pipeline `:api` nem lê a sessão.",
c: "Sabe, porque o Phoenix lembra o último usuário que entrou.",
d: "Sabe pelo endereço IP, que é o mesmo do navegador."
],
correta: :b,
explicacao:
"Cookies pertencem ao navegador que os recebeu; o curl é outro programa e começa sem nenhum. E mesmo um cliente que mandasse o cookie não seria reconhecido: o pipeline `:api` só tem `plug :accepts`, sem `fetch_session` nem `fetch_current_scope_for_user`. Por isso `conn.assigns` não tem `:current_scope`, e o `MeController` quebra ao procurá-lo."
},
mesma_tabela: %{
texto:
"por que guardar os tokens de API na tabela `users_tokens`, que o `phx.gen.auth` já criou, em vez de criar uma tabela nova?",
opcoes: [
a: "Porque o Ecto só permite uma tabela de tokens por aplicação.",
b: "Porque a tabela já tem o que um token precisa: o usuário, um contexto que separa os tipos de token, a data de criação para a validade, o e-mail de destino e funções prontas que geram e conferem tokens.",
c: "Porque assim o token de API também serve de cookie de sessão.",
d: "Por desempenho: o PostgreSQL consulta uma tabela só mais depressa."
],
correta: :b,
explicacao:
"Cada linha de `users_tokens` tem `user_id`, `token`, `context`, `sent_to` e `inserted_at`. O contexto separa os tipos: `\"session\"`, `\"login\"`, `\"change:...\"` e, agora, `\"api-token\"`. A tabela também já participa das regras de segurança: a chave estrangeira apaga os tokens com o usuário, e trocar a senha apaga todos os tokens dele. Um token de API reaproveita tudo isso."
},
hash: %{
texto:
"um backup do banco vaza, com a tabela `users_tokens` inteira. Quem tem o backup consegue usar os tokens de API da Ana?",
opcoes: [
a: "Sim: os tokens estão na tabela.",
b: "Não: a tabela guarda só o hash SHA-256 de cada token de API, e não dá para reconstruir o token a partir do hash.",
c: "Só se também descobrir a senha da Ana.",
d: "Sim, mas só durante 365 dias."
],
correta: :b,
explicacao:
"Tokens de API são criados como os tokens de e-mail: `build_email_token/2` sorteia 32 bytes, entrega ao cliente esses bytes em Base64 e grava no banco só o hash. Para conferir, o servidor calcula o hash do que recebeu e procura. Quem tem só o hash não tem o que mandar no cabeçalho. Os tokens de sessão ficam sem hash porque só valem dentro de um cookie assinado com a `secret_key_base`, que não está no banco."
},
fetch_get: %{
texto:
"o guia chama a função de `fetch_user_by_api_token`, e não de `get_user_by_api_token`. Pela convenção do Elixir, o que o nome `fetch_*` promete?",
opcoes: [
a: "Que ela busca no banco; uma `get_*` buscaria na memória.",
b: "Que ela devolve `{:ok, valor}` ou `:error`, como `Map.fetch/2`; uma `get_*` devolveria o valor ou `nil`, como `Map.get/2`.",
c: "Que ela levanta uma exceção quando não acha nada.",
d: "Nada: é questão de gosto."
],
correta: :b,
explicacao:
"É a mesma convenção de `Map.fetch/2` e `Map.get/2`, ou `Keyword.fetch/2` e `Keyword.get/2`. As funções com `!` no fim, como `fetch!` e `get_user!`, levantam exceção. O guia escolhe `fetch_*` porque quer tratar os dois casos com status diferentes na API, e uma tupla casa bem com `with`."
},
email_muda: %{
texto: "a Ana cria um token de API e depois troca o e-mail da conta. O que acontece com o token?",
opcoes: [
a: "Continua valendo: o token está ligado ao `user_id`, não ao e-mail.",
b: "Para de valer: a consulta exige `token.sent_to == user.email`, e o `sent_to` do token guarda o e-mail antigo.",
c: "É apagado da tabela quando o e-mail muda.",
d: "Passa a valer para o e-mail novo."
],
correta: :b,
explicacao:
"`build_email_token/2` grava o e-mail do momento em `sent_to`. A consulta de verificação compara esse campo com o e-mail atual do usuário. A linha continua na tabela, porque `update_user_email/2` só apaga os tokens de troca de e-mail, mas não serve mais. O guia diz isso numa frase: como é um token de e-mail, trocar o e-mail o expira."
},
senha_muda: %{
texto: "e se a Ana trocar a senha, o que acontece com os tokens de API dela?",
opcoes: [
a: "Nada: senha e token de API são coisas separadas.",
b: "São apagados, junto com todos os outros tokens dela: `update_user_password/2` chama `update_user_and_delete_all_tokens/1`.",
c: "Só os tokens de sessão são apagados.",
d: "Ganham mais 365 dias de validade."
],
correta: :b,
explicacao:
"Trocar a senha é o gesto de quem acha que a conta pode ter sido invadida. Por isso o código gerado apaga todos os tokens do usuário, de qualquer contexto, e devolve a lista do que expirou. O guia de `mix phx.gen.auth` cita isso nas considerações de segurança. Os tokens de API entram na mesma limpeza porque moram na mesma tabela."
},
bearer_minusculo: %{
texto:
"um cliente manda o cabeçalho `authorization: bearer TOKEN`, com `bearer` todo em minúsculas. O plug do guia aceita?",
opcoes: [
a: "Não: o padrão do `with` só casa com `Bearer`.",
b: "Sim: o padrão separa os 6 primeiros bytes, sejam quais forem, e o plug compara `String.downcase(bearer)` com `\"bearer\"`.",
c: "Só se o token também estiver em minúsculas.",
d: "Não, porque o nome do cabeçalho `authorization` precisa começar com maiúscula."
],
correta: :b,
explicacao:
"`<<bearer::binary-size(6), \" \", token::binary>>` casa com quaisquer 6 bytes seguidos de um espaço. A linha seguinte do `with` aceita `Bearer`, `bearer` ou `BEARER`, porque o HTTP trata o nome do esquema de autenticação sem diferenciar maiúsculas. Os nomes de cabeçalho também: o Plug os entrega sempre em minúsculas, por isso o plug procura `\"authorization\"`."
},
halt: %{
texto:
"se o plug esquecesse o `halt()` depois do `send_resp(:unauthorized, ...)`, o que aconteceria numa requisição sem token?",
opcoes: [
a: "Nada de diferente: `send_resp` já encerra a requisição.",
b: "Os plugs seguintes e o controller rodariam mesmo assim, sem usuário: `send_resp` envia a resposta, mas não interrompe o pipeline.",
c: "O Phoenix chamaria `halt` sozinho ao ver o status 401.",
d: "A requisição ficaria esperando para sempre."
],
correta: :b,
explicacao:
"Um pipeline de plugs só para quando o `conn` está marcado com `halted: true`, que é o que `halt/1` faz. `send_resp/3` envia a resposta e marca o estado como `:sent`, mas o próximo plug é chamado do mesmo jeito. O código protegido rodaria para quem não tem token. Aqui ele quebraria por falta de `current_scope`; outro controller tentaria responder de novo e levantaria `Plug.Conn.AlreadySentError`."
},
rota_publica: %{
texto:
"a rota `POST /api/tokens` troca e-mail e senha por um token novo. Em que pipeline ela pode ficar?",
opcoes: [
a: "No `:api` do guia, junto com `GET /api/me`.",
b: "Num pipeline sem o plug do token: quem ainda não tem token receberia 401 do `:api` do guia antes de chegar ao controller.",
c: "No `:browser`, para aproveitar a sessão.",
d: "Em qualquer um, porque o plug ignora requisições `POST`."
],
correta: :b,
explicacao:
"O plug do guia recusa qualquer requisição sem token válido, e o cliente que pede um token ainda não tem um. A saída é separar: um pipeline `:api` só com `plug :accepts, [\"json\"]`, para as rotas públicas, e outro, aqui chamado `:api_auth`, com o plug do token, para as protegidas. Um `scope` pode passar pelos dois: `pipe_through [:api, :api_auth]`."
}
}
end
# --- soluções --------------------------------------------------------------
@doc "Um botão que revela a solução de um exercício."
def solucao(id) do
quadro = Kino.Frame.new()
botao = Kino.Control.button("Mostrar a solução")
Kino.listen(botao, fn _evento ->
Kino.Frame.render(quadro, Kino.Markdown.new(cerca(Map.fetch!(solucoes(), id), "elixir")))
end)
Kino.Layout.grid([botao, quadro])
end
defp solucoes do
%{
contexto: ~S'''
# Na célula do Hello.Accounts, no lugar do comentário "Sua vez (notebook 15)":
## API
@doc """
Creates a new api token for a user.
The token returned must be saved somewhere safe.
This token cannot be recovered from the database.
"""
def create_user_api_token(user) do
{encoded_token, user_token} = UserToken.build_email_token(user, "api-token")
Repo.insert!(user_token)
encoded_token
end
@doc """
Fetches the user by API token.
"""
def fetch_user_by_api_token(token) do
with {:ok, query} <- UserToken.verify_api_token_query(token),
%User{} = user <- Repo.one(query) do
{:ok, user}
else
_ -> :error
end
end
''',
verificar: ~S'''
# Na célula do Hello.Accounts.UserToken, junto dos outros atributos:
@api_token_validity_in_days 365
# e, no lugar do comentário "Sua vez (notebook 15)":
@doc """
Checks if the API token is valid and returns its underlying lookup query.
The query returns the user found by the token, if any.
The given token is valid if it matches its hashed counterpart in the
database and the user email has not changed. This function also checks
if the token is being used within 365 days.
"""
def verify_api_token_query(token) do
case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} ->
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
query =
from token in by_token_and_context_query(hashed_token, "api-token"),
join: user in assoc(token, :user),
where:
token.inserted_at > ago(^@api_token_validity_in_days, "day") and
token.sent_to == user.email,
select: user
{:ok, query}
:error ->
:error
end
end
''',
plug: ~S'''
# Na célula do HelloWeb.UserAuth, no lugar do comentário "Sua vez (notebook 15)":
def fetch_current_scope_for_api_user(conn, _opts) do
with [<<bearer::binary-size(6), " ", token::binary>>] <-
get_req_header(conn, "authorization"),
true <- String.downcase(bearer) == "bearer",
{:ok, user} <- Accounts.fetch_user_by_api_token(token) do
assign(conn, :current_scope, Scope.for_user(user))
else
_ ->
conn
|> send_resp(:unauthorized, "No access for you")
|> halt()
end
end
# Na célula do router desta seção (avalie a do UserAuth antes):
pipeline :api do
plug :accepts, ["json"]
plug :fetch_current_scope_for_api_user
end
''',
login_api: ~S'''
defmodule HelloWeb.TokenController do
use HelloWeb, :controller
alias Hello.Accounts
def create(conn, %{"email" => email, "password" => password}) do
if user = Accounts.get_user_by_email_and_password(email, password) do
token = Accounts.create_user_api_token(user)
conn
|> put_status(:created)
|> json(%{data: %{token: token}})
else
conn
|> put_status(:unauthorized)
|> json(%{errors: %{detail: "Invalid email or password"}})
end
end
end
''',
revogar: ~S'''
# Na célula do Hello.Accounts, junto das funções de API:
def delete_user_api_tokens(user) do
Repo.delete_all(from(UserToken, where: [user_id: ^user.id, context: "api-token"]))
:ok
end
# No HelloWeb.TokenController, ao lado de create/2:
def delete(conn, _params) do
:ok = Accounts.delete_user_api_tokens(conn.assigns.current_scope.user)
send_resp(conn, :no_content, "")
end
''',
brecho: ~S'''
# priv/repo/migrations/20250625000000_add_vendedor_to_produtos.exs
defmodule Hello.Repo.Migrations.AddVendedorToProdutos do
use Ecto.Migration
def change do
alter table(:produtos) do
add :vendedor_id, references(:users, on_delete: :nilify_all)
end
create index(:produtos, [:vendedor_id])
end
end
Trilha.migracao(20250625000000, Hello.Repo.Migrations.AddVendedorToProdutos)
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)
# lib/hello/vitrine/produto.ex: o schema ganha o vendedor
defmodule Hello.Vitrine.Produto do
use Ecto.Schema
import Ecto.Changeset
schema "produtos" do
field :nome, :string
field :preco_centavos, :integer
field :vendido, :boolean, default: false
belongs_to :vendedor, Hello.Accounts.User
timestamps(type: :utc_datetime)
end
@doc false
def changeset(produto, attrs) do
produto
|> cast(attrs, [:nome, :preco_centavos])
|> validate_required([:nome, :preco_centavos])
|> validate_number(:preco_centavos, greater_than: 0)
end
end
# lib/hello/vitrine.ex: as funções com escopo (o resto como estava)
defmodule Hello.Vitrine do
import Ecto.Query, warn: false
alias Hello.Repo
alias Hello.Accounts.Scope
alias Hello.Vitrine.Produto
def listar_disponiveis do
Repo.all(from p in Produto, where: not p.vendido, order_by: p.preco_centavos)
end
def buscar_produto!(id), do: Repo.get!(Produto, id)
def cadastrar_produto(attrs) do
%Produto{}
|> Produto.changeset(attrs)
|> Repo.insert()
end
def cadastrar_produto(%Scope{} = scope, attrs) do
%Produto{vendedor_id: scope.user.id}
|> Produto.changeset(attrs)
|> Repo.insert()
end
def change_produto(%Produto{} = produto, attrs \\ %{}) do
Produto.changeset(produto, attrs)
end
def vender(%Produto{id: id}) do
consulta = from p in Produto, where: p.id == ^id and not p.vendido, select: p
case Repo.update_all(consulta, set: [vendido: true, updated_at: DateTime.utc_now(:second)]) do
{1, [produto]} -> {:ok, produto}
{0, []} -> {:error, :ja_vendido}
end
end
def vender(%Scope{} = scope, %Produto{} = produto) do
if produto.vendedor_id == scope.user.id,
do: vender(produto),
else: {:error, :nao_e_seu}
end
end
# lib/hello_web/controllers/produto_api_controller.ex
defmodule HelloWeb.ProdutoAPIController do
use HelloWeb, :controller
alias Hello.Vitrine
def index(conn, _params) do
json(conn, %{data: Enum.map(Vitrine.listar_disponiveis(), &dados/1)})
end
def create(conn, %{"produto" => attrs}) do
case Vitrine.cadastrar_produto(conn.assigns.current_scope, attrs) do
{:ok, produto} ->
conn |> put_status(:created) |> json(%{data: dados(produto)})
{:error, changeset} ->
erros = Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end)
conn |> put_status(:unprocessable_entity) |> json(%{errors: erros})
end
end
def vender(conn, %{"id" => id}) do
case Vitrine.vender(conn.assigns.current_scope, Vitrine.buscar_produto!(id)) do
{:ok, produto} ->
json(conn, %{data: dados(produto)})
{:error, :nao_e_seu} ->
conn |> put_status(:forbidden) |> json(%{errors: %{detail: "Forbidden"}})
{:error, :ja_vendido} ->
conn |> put_status(:conflict) |> json(%{errors: %{detail: "Conflict"}})
end
end
defp dados(produto) do
Map.take(produto, [:id, :nome, :preco_centavos, :vendido, :vendedor_id])
end
end
# No router: GET público, POST protegidos (o resto como estava)
#
# scope "/api", HelloWeb do
# pipe_through :api
#
# post "/tokens", TokenController, :create
# get "/produtos", ProdutoAPIController, :index
# end
#
# scope "/api", HelloWeb do
# pipe_through [:api, :api_auth]
#
# get "/me", MeController, :show
# delete "/tokens", TokenController, :delete
# post "/produtos", ProdutoAPIController, :create
# post "/produtos/:id/vender", ProdutoAPIController, :vender
# end
'''
}
end
# --- conferência dos exercícios ----------------------------------------------
@doc "Confere um exercício consultando o banco e fazendo requisições de verdade."
def conferir(id) do
resultado =
Enum.reduce_while(Map.fetch!(verificacoes(), id), [], fn {descricao, teste}, feitos ->
case executar(teste) do
:ok -> {:cont, ["- Passou: " <> descricao | feitos]}
{:erro, dica} -> {:halt, ["- **Ainda não:** " <> descricao <> "\n\n " <> dica | feitos]}
end
end)
Kino.Markdown.new(resultado |> Enum.reverse() |> Enum.join("\n"))
end
defp executar(teste) do
teste.()
rescue
erro -> {:erro, explicar(erro)}
end
defp verificacoes do
%{
contexto: [
{"`Hello.Accounts` define `create_user_api_token/1` e `fetch_user_by_api_token/1`.",
fn -> exporta(Hello.Accounts, create_user_api_token: 1, fetch_user_by_api_token: 1) end},
{"`create_user_api_token/1` devolve o token como texto em Base64 para URLs, sem `=` no fim.",
fn ->
token = novo_token("ana@example.com")
cond do
not is_binary(token) ->
{:erro,
"A função devolveu `#{inspect(token, limit: 5)}`. Ela deve devolver o primeiro elemento da tupla de `UserToken.build_email_token/2`, o token codificado."}
Base.url_decode64(token, padding: false) == :error ->
{:erro, "`#{token}` não é Base64 para URLs. Devolva o `encoded_token`, não o `user_token`."}
true ->
:ok
end
end},
{"A tabela `users_tokens` guarda o hash do token, com o contexto `\"api-token\"` e o e-mail da Ana em `sent_to`.",
fn ->
token = novo_token("ana@example.com")
case sql("SELECT context, sent_to FROM users_tokens WHERE token = $1", [hash_do_token(token)]).rows do
[["api-token", "ana@example.com"]] ->
:ok
[[contexto, para]] ->
{:erro, "A linha existe, mas com contexto `#{contexto}` e `sent_to` `#{para}`. O contexto deve ser `\"api-token\"`."}
[] ->
{:erro, "Nenhuma linha de `users_tokens` tem o hash desse token. A função chama `Repo.insert!(user_token)`?"}
end
end}
],
verificar: [
{"`Hello.Accounts.UserToken` define `verify_api_token_query/1`.",
fn -> exporta(Hello.Accounts.UserToken, verify_api_token_query: 1) end},
{"Um token recém-criado devolve `{:ok, user}` com a Ana.",
fn ->
token = novo_token("ana@example.com")
case buscar_por_token(token) do
{:ok, %{email: "ana@example.com"}} -> :ok
outro -> {:erro, "`fetch_user_by_api_token/1` devolveu `#{inspect(outro, limit: 5)}`."}
end
end},
{"Tokens que não existem, ou que nem são Base64, devolvem `:error`, sem exceção.",
fn ->
falsos = ["invalid", "não é base64!", Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)]
resultados = Enum.map(falsos, &buscar_por_token/1)
if Enum.all?(resultados, &(&1 == :error)),
do: :ok,
else: {:erro, "Para #{inspect(falsos)}, veio `#{inspect(resultados, limit: 5)}`. Os três devem dar `:error`."}
end},
{"Um token criado há 366 dias não vale mais; um de 364 dias ainda vale.",
fn ->
velho = novo_token("ana@example.com")
recente = novo_token("ana@example.com")
envelhecer(velho, 366)
envelhecer(recente, 364)
case {buscar_por_token(velho), buscar_por_token(recente)} do
{:error, {:ok, _}} ->
:ok
{{:ok, _}, _} ->
{:erro,
"O token de 366 dias ainda funciona. Confira a condição `token.inserted_at > ago(^@api_token_validity_in_days, \"day\")` e o atributo com 365."}
{_, _} ->
{:erro, "O token de 364 dias foi recusado. A validade deve ser de 365 dias."}
end
end},
{"Um token cujo `sent_to` não é mais o e-mail do usuário não vale.",
fn ->
token = novo_token("ana@example.com")
sql("UPDATE users_tokens SET sent_to = 'antigo@example.com' WHERE token = $1", [hash_do_token(token)])
if buscar_por_token(token) == :error,
do: :ok,
else: {:erro, "O token continuou valendo. Falta a condição `token.sent_to == user.email` na consulta."}
end}
],
plug: [
{"`HelloWeb.UserAuth` define `fetch_current_scope_for_api_user/2`.",
fn -> exporta(HelloWeb.UserAuth, fetch_current_scope_for_api_user: 2) end},
{"`GET /api/me` com o token da Ana responde 200 com o e-mail dela.",
fn ->
token = novo_token("ana@example.com")
case curl!("curl -i -H 'Authorization: Bearer #{token}' http://localhost:4010/api/me") do
%{status: 200, json: %{"data" => %{"email" => "ana@example.com"}}} ->
:ok
%{status: 500} ->
{:erro,
"A resposta foi 500: o `MeController` não achou `current_scope`. O plug está no pipeline `:api` da célula do router desta seção, e essa célula foi avaliada depois da do `UserAuth`?"}
%{status: status, corpo: corpo} ->
{:erro, "A resposta foi #{status}: `#{String.slice(corpo, 0, 200)}`."}
end
end},
{"Sem o cabeçalho `authorization`, a resposta é 401 com o texto `No access for you`.",
fn ->
case curl!("curl -i http://localhost:4010/api/me") do
%{status: 401, corpo: "No access for you"} -> :ok
%{status: status, corpo: corpo} -> {:erro, "A resposta foi #{status}: `#{String.slice(corpo, 0, 200)}`."}
end
end},
{"Com um token que não existe, a resposta também é 401.",
fn ->
falso = Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)
case curl!("curl -i -H 'Authorization: Bearer #{falso}' http://localhost:4010/api/me") do
%{status: 401} -> :ok
%{status: status} -> {:erro, "A resposta foi #{status}. O plug deve recusar tokens que `fetch_user_by_api_token/1` não reconhece."}
end
end},
{"O token da Bia mostra os dados da Bia.",
fn ->
token = novo_token("bia@example.com")
case curl!("curl -i -H 'Authorization: Bearer #{token}' http://localhost:4010/api/me") do
%{status: 200, json: %{"data" => %{"email" => "bia@example.com"}}} -> :ok
%{status: status, corpo: corpo} -> {:erro, "A resposta foi #{status}: `#{String.slice(corpo, 0, 200)}`."}
end
end}
],
login_api: [
{"`POST /api/tokens` com o e-mail e a senha da Ana responde 201 com `data.token`.",
fn ->
case curl!(login_ana("hello world!")) do
%{status: 201, json: %{"data" => %{"token" => token}}} when is_binary(token) ->
:ok
%{status: 401, corpo: "No access for you"} ->
{:erro, "Quem respondeu foi o plug do token: a rota passa pelo pipeline `:api_auth`. Ela deve ficar no escopo público."}
%{status: 404} ->
{:erro, "A rota `POST /api/tokens` não existe. A célula do router desta seção foi avaliada? Ela depende do exercício do plug."}
%{status: status, corpo: corpo} ->
{:erro, "A resposta foi #{status}: `#{String.slice(corpo, 0, 300)}`."}
end
end},
{"O token devolvido funciona em `GET /api/me`.",
fn ->
%{json: %{"data" => %{"token" => token}}} = curl!(login_ana("hello world!"))
case curl!("curl -i -H 'Authorization: Bearer #{token}' http://localhost:4010/api/me") do
%{status: 200, json: %{"data" => %{"email" => "ana@example.com"}}} -> :ok
%{status: status} -> {:erro, "Com o token devolvido, `GET /api/me` respondeu #{status}. Devolva o resultado de `create_user_api_token/1`."}
end
end},
{"Com a senha errada, a resposta é 401 e nenhum token novo é criado.",
fn ->
antes = contar_tokens_de_api()
resposta = curl!(login_ana("senha errada"))
depois = contar_tokens_de_api()
cond do
resposta.status != 401 -> {:erro, "Com a senha errada, a resposta foi #{resposta.status}."}
depois != antes -> {:erro, "A resposta foi 401, mas um token foi criado mesmo assim."}
true -> :ok
end
end}
],
revogar: [
{"`Hello.Accounts` define `delete_user_api_tokens/1`.",
fn -> exporta(Hello.Accounts, delete_user_api_tokens: 1) end},
{"`DELETE /api/tokens` com um token da Bia responde 204, e nenhum token de API da Bia funciona depois.",
fn ->
t1 = novo_token("bia@example.com")
t2 = novo_token("bia@example.com")
case curl!("curl -iX DELETE -H 'Authorization: Bearer #{t1}' http://localhost:4010/api/tokens") do
%{status: 204} ->
if buscar_por_token(t1) == :error and buscar_por_token(t2) == :error,
do: :ok,
else: {:erro, "A resposta foi 204, mas um token da Bia ainda funciona. Apague todas as linhas com contexto `\"api-token\"` dela."}
%{status: status, corpo: corpo} ->
{:erro, "A resposta foi #{status}: `#{String.slice(corpo, 0, 200)}`."}
end
end},
{"Os tokens de API da Ana e o token de sessão da Bia continuam valendo.",
fn ->
da_ana = novo_token("ana@example.com")
sessao_da_bia = apply(Hello.Accounts, :generate_user_session_token, [usuario("bia@example.com")])
t = novo_token("bia@example.com")
curl!("curl -iX DELETE -H 'Authorization: Bearer #{t}' http://localhost:4010/api/tokens")
cond do
buscar_por_token(da_ana) == :error ->
{:erro, "O token da Ana parou de funcionar. Apague só os tokens do usuário que fez a requisição."}
apply(Hello.Accounts, :get_user_by_session_token, [sessao_da_bia]) == nil ->
{:erro, "O token de sessão da Bia foi apagado. Filtre pelo contexto `\"api-token\"`."}
true ->
:ok
end
end},
{"`DELETE /api/tokens` sem token responde 401.",
fn ->
case curl!("curl -iX DELETE http://localhost:4010/api/tokens") do
%{status: 401} -> :ok
%{status: status} -> {:erro, "A resposta foi #{status}. A rota deve ficar no escopo protegido."}
end
end}
],
brecho: [
{"A tabela `produtos` tem a coluna `vendedor_id`, com chave estrangeira para `users`.",
fn ->
chaves =
coluna(
"SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conrelid = to_regclass('produtos') AND contype = 'f'"
)
cond do
colunas("produtos", ["vendedor_id"]) != :ok -> colunas("produtos", ["vendedor_id"])
not Enum.any?(chaves, &(&1 =~ "REFERENCES users")) -> {:erro, "`vendedor_id` existe, mas sem `references(:users)`."}
true -> :ok
end
end},
{"`GET /api/produtos`, sem token, responde 200 com as peças à venda.",
fn ->
limpando_brecho(fn ->
cadastrar_direto("Conferência Lenço", 1500, "ana@example.com")
case curl!("curl -i http://localhost:4010/api/produtos") do
%{status: 200, json: %{"data" => lista}} when is_list(lista) ->
if Enum.any?(lista, &(&1["nome"] == "Conferência Lenço")),
do: :ok,
else: {:erro, "A lista não traz a peça cadastrada: `#{inspect(lista, limit: 5)}`."}
%{status: status, corpo: corpo} ->
{:erro, "A resposta foi #{status}: `#{String.slice(corpo, 0, 200)}`. A rota deve ficar no escopo público."}
end
end)
end},
{"`POST /api/produtos` sem token responde 401; com o token da Ana responde 201 e grava a Ana como vendedora.",
fn ->
limpando_brecho(fn ->
sem = curl!(cadastro_brecho(nil, "Conferência Saia", 3000))
token = novo_token("ana@example.com")
com = curl!(cadastro_brecho(token, "Conferência Saia", 3000))
ana = usuario("ana@example.com")
cond do
sem.status != 401 ->
{:erro, "Sem token, a resposta foi #{sem.status}. O cadastro deve passar pelo pipeline com o plug do token."}
com.status != 201 ->
{:erro, "Com o token da Ana, a resposta foi #{com.status}: `#{String.slice(com.corpo, 0, 200)}`."}
coluna("SELECT vendedor_id FROM produtos WHERE nome = 'Conferência Saia'") != [ana.id] ->
{:erro, "A peça foi gravada, mas `vendedor_id` não é o id da Ana (#{ana.id})."}
true ->
:ok
end
end)
end},
{"Um cadastro com preço zero responde 422.",
fn ->
limpando_brecho(fn ->
token = novo_token("ana@example.com")
case curl!(cadastro_brecho(token, "Conferência Grátis", 0)) do
%{status: 422} -> :ok
%{status: status} -> {:erro, "A resposta foi #{status}. Quando o changeset é inválido, responda 422."}
end
end)
end},
{"A Bia não vende a peça da Ana (403), e a peça continua à venda.",
fn ->
limpando_brecho(fn ->
id = cadastrar_direto("Conferência Bota", 9000, "ana@example.com")
token = novo_token("bia@example.com")
case curl!(venda_brecho(token, id)) do
%{status: 403} ->
if coluna("SELECT vendido FROM produtos WHERE id = $1", [id]) == [false],
do: :ok,
else: {:erro, "A resposta foi 403, mas a peça ficou marcada como vendida."}
%{status: status} ->
{:erro, "A resposta foi #{status}. Só quem cadastrou a peça pode vendê-la: responda 403 para os outros."}
end
end)
end},
{"A Ana vende a peça dela (200, `vendido: true`); uma segunda venda responde 409.",
fn ->
limpando_brecho(fn ->
id = cadastrar_direto("Conferência Casaco", 12000, "ana@example.com")
token = novo_token("ana@example.com")
primeira = curl!(venda_brecho(token, id))
segunda = curl!(venda_brecho(token, id))
cond do
primeira.status != 200 -> {:erro, "A primeira venda respondeu #{primeira.status}: `#{String.slice(primeira.corpo, 0, 200)}`."}
get_in(primeira.json, ["data", "vendido"]) != true -> {:erro, "A resposta da venda deve trazer `\"vendido\": true` em `data`."}
segunda.status != 409 -> {:erro, "A segunda venda respondeu #{segunda.status}. Use `{:error, :ja_vendido}` da `Vitrine.vender/1` para responder 409."}
true -> :ok
end
end)
end},
{"Depois da venda, `GET /api/produtos` não lista mais a peça.",
fn ->
limpando_brecho(fn ->
id = cadastrar_direto("Conferência Chapéu", 2500, "ana@example.com")
curl!(venda_brecho(novo_token("ana@example.com"), id))
case curl!("curl -i http://localhost:4010/api/produtos") do
%{status: 200, json: %{"data" => lista}} ->
if Enum.any?(lista, &(&1["nome"] == "Conferência Chapéu")),
do: {:erro, "A peça vendida ainda aparece na lista."},
else: :ok
%{status: status} ->
{:erro, "A resposta foi #{status}."}
end
end)
end}
]
}
end
# --- ajudantes da conferência --------------------------------------------------
defp exporta(modulo, funcoes) do
faltando = Enum.reject(funcoes, fn {nome, aridade} -> Code.ensure_loaded?(modulo) and function_exported?(modulo, nome, aridade) end)
if faltando == [],
do: :ok,
else: {:erro, "Falta definir em `#{inspect(modulo)}`: #{Enum.map_join(faltando, ", ", fn {n, a} -> "`#{n}/#{a}`" end)}."}
end
defp colunas(tabela, esperadas) do
existentes = coluna("SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1", [tabela])
cond do
existentes == [] -> {:erro, "A tabela `#{tabela}` não existe. Defina a migration, registre-a e rode a célula do `mix ecto.migrate`."}
(faltando = esperadas -- existentes) != [] -> {:erro, "A tabela `#{tabela}` não tem as colunas #{inspect(faltando)}."}
true -> :ok
end
end
defp coluna(consulta, parametros \\ []), do: consulta |> sql(parametros) |> Map.fetch!(:rows) |> Enum.map(&hd/1)
defp usuario(email), do: apply(Hello.Accounts, :get_user_by_email, [email])
defp novo_token(email), do: apply(Hello.Accounts, :create_user_api_token, [usuario(email)])
defp buscar_por_token(token), do: apply(Hello.Accounts, :fetch_user_by_api_token, [token])
defp hash_do_token(token), do: :crypto.hash(:sha256, Base.url_decode64!(token, padding: false))
@doc "Muda a data de criação de um token de API, como se ele tivesse sido criado `dias` atrás."
def envelhecer(token, dias) do
data = NaiveDateTime.add(NaiveDateTime.utc_now(:second), -dias * 24 * 60 * 60)
sql("UPDATE users_tokens SET inserted_at = $2 WHERE token = $1", [hash_do_token(token), data])
:ok
end
defp contar_tokens_de_api do
[[n]] = sql("SELECT count(*) FROM users_tokens WHERE context = 'api-token'").rows
n
end
defp login_ana(senha) do
corpo = Jason.encode!(%{email: "ana@example.com", password: senha})
"curl -iX POST http://localhost:4010/api/tokens -H 'Content-Type: application/json' -d '#{corpo}'"
end
defp cadastro_brecho(token, nome, preco) do
corpo = Jason.encode!(%{produto: %{nome: nome, preco_centavos: preco}})
autorizacao = if token, do: "-H 'Authorization: Bearer #{token}' ", else: ""
"curl -iX POST http://localhost:4010/api/produtos #{autorizacao}-H 'Content-Type: application/json' -d '#{corpo}'"
end
defp venda_brecho(token, id) do
"curl -iX POST -H 'Authorization: Bearer #{token}' http://localhost:4010/api/produtos/#{id}/vender"
end
defp cadastrar_direto(nome, preco, email) do
[[id]] =
sql(
"""
INSERT INTO produtos (nome, preco_centavos, vendido, vendedor_id, inserted_at, updated_at)
VALUES ($1, $2, false, $3, now(), now()) RETURNING id
""",
[nome, preco, usuario(email).id]
).rows
id
end
defp limpando_brecho(teste) do
teste.()
after
if coluna("SELECT table_name FROM information_schema.tables WHERE table_name = 'produtos'") != [] do
sql("DELETE FROM produtos WHERE nome LIKE 'Conferência%'")
end
end
defp explicar(%Phoenix.Router.NoRouteError{} = erro) do
"Etapa: **router**. `#{Exception.message(erro)}`. Adicione a rota na célula do router e avalie-a de novo."
end
defp explicar(%UndefinedFunctionError{module: modulo, function: funcao, arity: aridade}) do
nome = inspect(modulo)
cond do
String.ends_with?(nome, "Controller") and funcao == :init ->
"Etapa: **controller**. A rota aponta para `#{nome}`, mas esse módulo não existe. Defina-o e avalie a célula."
String.ends_with?(nome, "Controller") ->
"Etapa: **controller**. `#{nome}` existe, mas não define `#{funcao}/#{aridade}`."
String.ends_with?(nome, "HTML") ->
"Etapa: **view**. O `render` procurou `#{nome}.#{funcao}/#{aridade}` e não achou."
true ->
"`#{nome}.#{funcao}/#{aridade}` não existe. O módulo foi definido e a célula foi avaliada?"
end
end
defp explicar(%Postgrex.Error{postgres: %{code: :undefined_table, message: mensagem}}) do
"O PostgreSQL respondeu `#{mensagem}`. A migration que cria a tabela já rodou?"
end
defp explicar(%Ecto.ConstraintError{constraint: restricao}) do
"O banco recusou a operação pela restrição `#{restricao}`, e o changeset não a declara: a violação virou exceção em vez de erro no changeset."
end
defp explicar(%Phoenix.ActionClauseError{}) do
"Etapa: **controller**. Nenhuma cláusula da action casou com os parâmetros recebidos."
end
defp explicar(%MatchError{term: termo}) do
"Um casamento de padrões falhou com o valor `#{inspect(termo, limit: 8)}`."
end
defp explicar(erro), do: "Levantou uma exceção:\n\n `" <> Exception.format_banner(:error, erro) <> "`"
end
Como usar este notebook
Este notebook fecha o módulo 8 da trilha, sobre autenticação. Ele acompanha o guia API Authentication da documentação do Phoenix 1.8, na mesma ordem e com o link de cada seção. O notebook 13 gerou o sistema de login com mix phx.gen.auth, e o 14 mostrou como os escopos usam o usuário logado para proteger os dados. Aqui a aplicação ganha um segundo tipo de cliente.
Um navegador prova quem é com o cookie de sessão que recebeu no login. Um app de celular, um script ou outro servidor que conversa com a sua API não tem formulário de login nem cookie: ele manda, a cada requisição, um token num cabeçalho HTTP. O guia mostra como criar esses tokens reaproveitando a tabela de tokens que o phx.gen.auth já gerou, e como escrever o plug que os confere.
As convenções são as de sempre: uma célula equivale a um arquivo, as perguntas "Antes de continuar" vêm antes da explicação, e os erros fazem parte do conteúdo. Como no notebook 12, Trilha.curl/1 roda comandos curl de verdade contra o servidor do notebook e mostra a resposta como o curl mostraria. Os comandos com token são montados em Elixir, com o token no meio do texto. Copie o comando que aparece na saída para um terminal e ele funciona igual, enquanto o runtime estiver ligado.
A lista de dependências da primeira célula é a do notebook 13: além das de sempre, tem o swoosh, que envia os e-mails da autenticação, e o pbkdf2_elixir, que calcula o hash das senhas. Se você já abriu o 13 ou o 14, o Livebook reaproveita a instalação. Senão, a primeira avaliação instala tudo de novo, uma vez.
O notebook usa o PostgreSQL configurado no notebook 07 e, como o 13, precisa da extensão citext (no Fedora, o pacote postgresql-contrib). A célula Trilha.verificar_citext() confere. A aplicação fica em http://localhost:4010; se outro notebook da trilha estiver com o runtime ligado, desconecte-o antes. Avalie a primeira célula, no topo, antes de tudo.
A aplicação Hello dentro do notebook
config/dev.exs: a configuração do banco no ambiente de desenvolvimento, a mesma do notebook 07. Se o seu PostgreSQL usa outra senha ou outra porta, troque aqui e avalie a célula de novo.
Application.put_env(:hello, Hello.Repo,
username: System.get_env("PGUSER", "postgres"),
password: System.get_env("PGPASSWORD", "postgres"),
hostname: System.get_env("PGHOST", "localhost"),
port: String.to_integer(System.get_env("PGPORT", "5432")),
database: "trilha_phoenix_dev",
show_sensitive_data_on_connection_error: true,
pool_size: 10,
stacktrace: true
)
lib/hello/repo.ex
defmodule Hello.Repo do
use Ecto.Repo,
otp_app: :hello,
adapter: Ecto.Adapters.Postgres
end
lib/hello_web.ex: como no projeto gerado, o bloco html_helpers importa os CoreComponents e dá o apelido JS ao Phoenix.LiveView.JS. Os templates gerados usam os dois.
defmodule HelloWeb do
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
def router do
quote do
use Phoenix.Router, helpers: false
import Plug.Conn
import Phoenix.Controller
import Phoenix.LiveView.Router
end
end
def controller do
quote do
use Phoenix.Controller, formats: [:html, :json]
import Plug.Conn
unquote(verified_routes())
end
end
def html do
quote do
use Phoenix.Component
import Phoenix.Controller,
only: [get_csrf_token: 0, view_module: 1, view_template: 1]
unquote(html_helpers())
end
end
defp html_helpers do
quote do
import Phoenix.HTML
import HelloWeb.CoreComponents
alias Phoenix.LiveView.JS
alias HelloWeb.Layouts
unquote(verified_routes())
end
end
def verified_routes do
quote do
use Phoenix.VerifiedRoutes,
endpoint: HelloWeb.Endpoint,
router: HelloWeb.Router,
statics: HelloWeb.static_paths()
end
end
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
end
lib/hello_web/router.ex
defmodule HelloWeb.Router do
use HelloWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {HelloWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
pipeline :api do
plug :accepts, ["json"]
end
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :home
end
end
lib/hello_web/endpoint.ex: uma diferença em relação ao projeto. Lá, o Plug.Static serve o app.js compilado, que inclui o JavaScript do phoenix_html. Aqui não há compilação de assets, então o Plug.Static serve direto o arquivo phoenix_html.js que vem no pacote. É ele que faz os links com method="delete" e method="post" funcionarem no navegador.
defmodule HelloWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :hello
@session_options [
store: :cookie,
key: "_hello_key",
signing_salt: "trilha00",
same_site: "Lax"
]
plug Plug.Static,
at: "/assets",
from: {:phoenix_html, "priv/static"},
only: ~w(phoenix_html.js)
plug Plug.RequestId
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Phoenix.json_library()
plug Plug.MethodOverride
plug Plug.Head
plug Plug.Session, @session_options
plug HelloWeb.Router
end
lib/hello_web/components/core_components.ex: os componentes que um projeto novo traz e que os templates gerados usam: flash, button, input, header, table, list e icon. O código é o do arquivo gerado pelo Phoenix 1.8.16, com uma diferença: no projeto, as mensagens de erro passam pelo Gettext, que traduz textos; aqui, translate_error/1 só preenche os valores na mensagem.
defmodule HelloWeb.CoreComponents do
use Phoenix.Component
alias Phoenix.LiveView.JS
@doc "Renders flash notices."
attr :id, :string, doc: "the optional id of flash container"
attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
attr :title, :string, default: nil
attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
slot :inner_block, doc: "the optional inner block that renders the flash message"
def flash(assigns) do
assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
~H"""
<div
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
id={@id}
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
role="alert"
class="toast toast-top toast-end z-50"
{@rest}
>
<div class={[
"alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
@kind == :info && "alert-info",
@kind == :error && "alert-error"
]}>
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
<.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
<div>
<p :if={@title} class="font-semibold">{@title}</p>
<p>{msg}</p>
</div>
<div class="flex-1" />
<button type="button" class="group self-start cursor-pointer" aria-label="close">
<.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
</button>
</div>
</div>
"""
end
@doc "Renders a button with navigation support."
attr :rest, :global, include: ~w(href navigate patch method download name value disabled)
attr :class, :any
attr :variant, :string, values: ~w(primary)
slot :inner_block, required: true
def button(%{rest: rest} = assigns) do
variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
assigns =
assign_new(assigns, :class, fn ->
["btn", Map.fetch!(variants, assigns[:variant])]
end)
if rest[:href] || rest[:navigate] || rest[:patch] do
~H"""
<.link class={@class} {@rest}>
{render_slot(@inner_block)}
</.link>
"""
else
~H"""
<button class={@class} {@rest}>
{render_slot(@inner_block)}
</button>
"""
end
end
@doc "Renders an input with label and error messages."
attr :id, :any, default: nil
attr :name, :any
attr :label, :string, default: nil
attr :value, :any
attr :type, :string,
default: "text",
values: ~w(checkbox color date datetime-local email file month number password
search select tel text textarea time url week hidden)
attr :field, Phoenix.HTML.FormField,
doc: "a form field struct retrieved from the form, for example: @form[:email]"
attr :errors, :list, default: []
attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
attr :class, :any, default: nil, doc: "the input class to use over defaults"
attr :error_class, :any, default: nil, doc: "the input error class to use over defaults"
attr :rest, :global,
include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
multiple pattern placeholder readonly required rows size step)
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
assigns
|> assign(field: nil, id: assigns.id || field.id)
|> assign(:errors, Enum.map(errors, &translate_error(&1)))
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|> assign_new(:value, fn -> field.value end)
|> input()
end
def input(%{type: "hidden"} = assigns) do
~H"""
<input type="hidden" id={@id} name={@name} value={@value} {@rest} />
"""
end
def input(%{type: "checkbox"} = assigns) do
assigns =
assign_new(assigns, :checked, fn ->
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
end)
~H"""
<div class="fieldset mb-2">
<label for={@id}>
<input
type="hidden"
name={@name}
value="false"
disabled={@rest[:disabled]}
form={@rest[:form]}
/>
<span class="label">
<input
type="checkbox"
id={@id}
name={@name}
value="true"
checked={@checked}
class={@class || "checkbox checkbox-sm"}
{@rest}
/>{@label}
</span>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
def input(%{type: "select"} = assigns) do
~H"""
<div class="fieldset mb-2">
<label for={@id}>
<span :if={@label} class="label mb-1">{@label}</span>
<select
id={@id}
name={@name}
class={[@class || "w-full select", @errors != [] && (@error_class || "select-error")]}
multiple={@multiple}
{@rest}
>
<option :if={@prompt} value="">{@prompt}</option>
{Phoenix.HTML.Form.options_for_select(@options, @value)}
</select>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
def input(%{type: "textarea"} = assigns) do
~H"""
<div class="fieldset mb-2">
<label for={@id}>
<span :if={@label} class="label mb-1">{@label}</span>
<textarea
id={@id}
name={@name}
class={[
@class || "w-full textarea",
@errors != [] && (@error_class || "textarea-error")
]}
{@rest}
>{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
# All other inputs text, datetime-local, url, password, etc. are handled here...
def input(assigns) do
~H"""
<div class="fieldset mb-2">
<label for={@id}>
<span :if={@label} class="label mb-1">{@label}</span>
<input
type={@type}
name={@name}
id={@id}
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
class={[
@class || "w-full input",
@errors != [] && (@error_class || "input-error")
]}
{@rest}
/>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
# Helper used by inputs to generate form errors
defp error(assigns) do
~H"""
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
<.icon name="hero-exclamation-circle" class="size-5" />
{render_slot(@inner_block)}
</p>
"""
end
@doc "Renders a header with title."
slot :inner_block, required: true
slot :subtitle
slot :actions
def header(assigns) do
~H"""
<header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
<div>
<h1 class="text-lg font-semibold leading-8">
{render_slot(@inner_block)}
</h1>
<p :if={@subtitle != []} class="text-sm text-base-content/70">
{render_slot(@subtitle)}
</p>
</div>
<div class="flex-none">{render_slot(@actions)}</div>
</header>
"""
end
@doc "Renders a table with generic styling."
attr :id, :string, required: true
attr :rows, :list, required: true
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
attr :row_item, :any,
default: &Function.identity/1,
doc: "the function for mapping each row before calling the :col and :action slots"
slot :col, required: true do
attr :label, :string
end
slot :action, doc: "the slot for showing user actions in the last table column"
def table(assigns) do
assigns =
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
end
~H"""
<table class="table table-zebra">
<thead>
<tr>
<th :for={col <- @col}>{col[:label]}</th>
<th :if={@action != []}>
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
<td
:for={col <- @col}
phx-click={@row_click && @row_click.(row)}
class={@row_click && "hover:cursor-pointer"}
>
{render_slot(col, @row_item.(row))}
</td>
<td :if={@action != []} class="w-0 font-semibold">
<div class="flex gap-4">
<%= for action <- @action do %>
{render_slot(action, @row_item.(row))}
<% end %>
</div>
</td>
</tr>
</tbody>
</table>
"""
end
@doc "Renders a data list."
slot :item, required: true do
attr :title, :string, required: true
end
def list(assigns) do
~H"""
<ul class="list">
<li :for={item <- @item} class="list-row">
<div class="list-col-grow">
<div class="font-bold">{item.title}</div>
<div>{render_slot(item)}</div>
</div>
</li>
</ul>
"""
end
@doc "Renders a [Heroicon](https://heroicons.com)."
attr :name, :string, required: true
attr :class, :any, default: "size-4"
def icon(%{name: "hero-" <> _} = assigns) do
~H"""
<span class={[@name, @class]} />
"""
end
## JS Commands
def show(js \\ %JS{}, selector) do
JS.show(js,
to: selector,
time: 300,
transition:
{"transition-all ease-out duration-300",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
"opacity-100 translate-y-0 sm:scale-100"}
)
end
def hide(js \\ %JS{}, selector) do
JS.hide(js,
to: selector,
time: 200,
transition:
{"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
)
end
@doc """
Fills in the error message values. In the generated project this function
translates the message with Gettext.
"""
def translate_error({msg, opts}) do
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
end)
end
@doc "Translates the errors for a field from a keyword list of errors."
def translate_errors(errors, field) when is_list(errors) do
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
end
end
lib/hello_web/components/layouts.ex: o root layout e o layout da aplicação. No projeto, o root layout carrega o app.css e o app.js compilados. Aqui ele carrega o Tailwind e o daisyUI direto de uma CDN, para que as páginas geradas tenham a mesma aparência no navegador, e o phoenix_html.js servido pelo endpoint. Sem internet, as páginas funcionam do mesmo jeito, só que sem estilo.
defmodule HelloWeb.Layouts do
use HelloWeb, :html
def root(assigns) do
~H"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<title>Hello · Phoenix Framework</title>
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4">
</script>
<script defer src={~p"/assets/phoenix_html.js"}>
</script>
</head>
<body>
{@inner_content}
</body>
</html>
"""
end
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :current_scope, :map, default: nil, doc: "the current scope"
slot :inner_block, required: true
def app(assigns) do
~H"""
<header class="navbar px-4 sm:px-6 lg:px-8">
<div class="flex-1">
<a href="/" class="flex-1 flex w-fit items-center gap-2">
<span class="text-sm font-semibold">Hello · Phoenix v{Application.spec(:phoenix, :vsn)}</span>
</a>
</div>
</header>
<main class="px-4 py-20 sm:px-6 lg:px-8">
<div class="mx-auto max-w-2xl space-y-4">
{render_slot(@inner_block)}
</div>
</main>
<.flash_group flash={@flash} />
"""
end
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
def flash_group(assigns) do
~H"""
<div id={@id} aria-live="polite">
<.flash kind={:info} flash={@flash} />
<.flash kind={:error} flash={@flash} />
</div>
"""
end
end
lib/hello_web/controllers/: as páginas de erro e a página inicial.
defmodule HelloWeb.ErrorHTML do
def render(template, _assigns) do
Phoenix.Controller.status_message_from_template(template)
end
end
defmodule HelloWeb.ErrorJSON do
def render(template, _assigns) do
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
end
end
defmodule HelloWeb.PageController do
use HelloWeb, :controller
def home(conn, _params) do
render(conn, :home)
end
end
defmodule HelloWeb.PageHTML do
use HelloWeb, :html
def home(assigns) do
~H"""
<Layouts.app flash={@flash}>
<h1>Welcome to Phoenix!</h1>
</Layouts.app>
"""
end
end
Antes de começar: o PostgreSQL
Este notebook usa o mesmo PostgreSQL do notebook 07, com o banco trilha_phoenix_dev. A célula abaixo tenta uma conexão e, se falhar, traduz a mensagem em uma dica. Os comandos para instalar e configurar o PostgreSQL estão no LEIA-ME.md da trilha.
Trilha.verificar_postgres()
Crie o banco, se ainda não existir (é o mix ecto.create). {:error, :already_up} quer dizer que ele já existe, e serve do mesmo jeito.
Ecto.Adapters.Postgres.storage_up(Hello.Repo.config())
lib/hello/application.ex: a árvore de supervisão inicia o Repo, o Phoenix.PubSub e o endpoint. O PubSub é o sistema de mensagens do Phoenix; os contextos gerados com escopo o usam para avisar sobre mudanças nos dados.
{:ok, _} = Kino.start_child(Hello.Repo)
{:ok, _} = Kino.start_child({Phoenix.PubSub, name: Hello.PubSub})
{:ok, _} = Kino.start_child(HelloWeb.Endpoint)
Kino.Markdown.new("Repo conectado e aplicação no ar em <http://localhost:4010>.")
Comece do zero: a célula abaixo apaga todas as tabelas do banco trilha_phoenix_dev e esvazia a caixa de entrada dos e-mails, para que os números de id sejam os mesmos do texto. Avalie-a sempre que reabrir o notebook. Ela só mexe no banco da trilha.
Trilha.recomecar()
A migration da autenticação cria a extensão citext. Confira se o servidor a tem:
Trilha.verificar_citext()
A autenticação do notebook 13
Estas células são o código que mix phx.gen.auth Accounts User users --no-live --hashing-lib pbkdf2 gera no Phoenix 1.8.16, o mesmo do notebook 13. Para encurtar, os @doc gerados saíram de quase todos os módulos; os do UserToken ficaram, porque explicam justamente como os tokens são guardados, e este notebook depende disso. Três células têm um comentário "Sua vez (notebook 15)" no lugar onde os exercícios vão acrescentar código.
lib/hello/mailer.ex e a migration priv/repo/migrations/20250610120000_create_users_auth_tables.exs
defmodule Hello.Mailer do
use Swoosh.Mailer, otp_app: :hello
end
defmodule Hello.Repo.Migrations.CreateUsersAuthTables do
use Ecto.Migration
def change do
execute "CREATE EXTENSION IF NOT EXISTS citext", ""
create table(:users) do
add :email, :citext, null: false
add :hashed_password, :string
add :confirmed_at, :utc_datetime
timestamps(type: :utc_datetime)
end
create unique_index(:users, [:email])
create table(:users_tokens) do
add :user_id, references(:users, on_delete: :delete_all), null: false
add :token, :binary, null: false
add :context, :string, null: false
add :sent_to, :string
add :authenticated_at, :utc_datetime
timestamps(type: :utc_datetime, updated_at: false)
end
create index(:users_tokens, [:user_id])
create unique_index(:users_tokens, [:context, :token])
end
end
Trilha.migracao(20250610120000, Hello.Repo.Migrations.CreateUsersAuthTables)
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)
lib/hello/accounts/user.ex
defmodule Hello.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
field :password, :string, virtual: true, redact: true
field :hashed_password, :string, redact: true
field :confirmed_at, :utc_datetime
field :authenticated_at, :utc_datetime, virtual: true
timestamps(type: :utc_datetime)
end
def email_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email])
|> validate_email(opts)
end
defp validate_email(changeset, opts) do
changeset =
changeset
|> validate_required([:email])
|> validate_format(:email, ~r/^[^@,;\s]+@[^@,;\s]+$/,
message: "must have the @ sign and no spaces"
)
|> validate_length(:email, max: 160)
if Keyword.get(opts, :validate_unique, true) do
changeset
|> unsafe_validate_unique(:email, Hello.Repo)
|> unique_constraint(:email)
|> validate_email_changed()
else
changeset
end
end
defp validate_email_changed(changeset) do
if get_field(changeset, :email) && get_change(changeset, :email) == nil do
add_error(changeset, :email, "did not change")
else
changeset
end
end
def password_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:password])
|> validate_confirmation(:password, message: "does not match password")
|> validate_password(opts)
end
defp validate_password(changeset, opts) do
changeset
|> validate_required([:password])
|> validate_length(:password, min: 12, max: 72)
# Examples of additional password validation:
# |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character")
# |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character")
# |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character")
|> maybe_hash_password(opts)
end
defp maybe_hash_password(changeset, opts) do
hash_password? = Keyword.get(opts, :hash_password, true)
password = get_change(changeset, :password)
if hash_password? && password && changeset.valid? do
changeset
# Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that
# would keep the database transaction open longer and hurt performance.
|> put_change(:hashed_password, Pbkdf2.hash_pwd_salt(password))
|> delete_change(:password)
else
changeset
end
end
def confirm_changeset(user) do
now = DateTime.utc_now(:second)
change(user, confirmed_at: now)
end
def valid_password?(%Hello.Accounts.User{hashed_password: hashed_password}, password)
when is_binary(hashed_password) and byte_size(password) > 0 do
Pbkdf2.verify_pass(password, hashed_password)
end
def valid_password?(_, _) do
Pbkdf2.no_user_verify()
false
end
end
lib/hello/accounts/user_token.ex: o schema dos tokens. Leia os @doc de build_session_token/1 e de build_email_token/2: eles explicam por que alguns tokens são guardados com hash e outros não.
defmodule Hello.Accounts.UserToken do
use Ecto.Schema
import Ecto.Query
alias Hello.Accounts.UserToken
@hash_algorithm :sha256
@rand_size 32
# It is very important to keep the magic link token expiry short,
# since someone with access to the email may take over the account.
@magic_link_validity_in_minutes 15
@change_email_validity_in_days 7
@session_validity_in_days 14
schema "users_tokens" do
field :token, :binary
field :context, :string
field :sent_to, :string
field :authenticated_at, :utc_datetime
belongs_to :user, Hello.Accounts.User
timestamps(type: :utc_datetime, updated_at: false)
end
@doc """
Generates a token that will be stored in a signed place,
such as session or cookie. As they are signed, those
tokens do not need to be hashed.
The reason why we store session tokens in the database, even
though Phoenix already provides a session cookie, is because
Phoenix's default session cookies are not persisted, they are
simply signed and potentially encrypted. This means they are
valid indefinitely, unless you change the signing/encryption
salt.
Therefore, storing them allows individual user
sessions to be expired. The token system can also be extended
to store additional data, such as the device used for logging in.
You could then use this information to display all valid sessions
and devices in the UI and allow users to explicitly expire any
session they deem invalid.
"""
def build_session_token(user) do
token = :crypto.strong_rand_bytes(@rand_size)
dt = user.authenticated_at || DateTime.utc_now(:second)
{token, %UserToken{token: token, context: "session", user_id: user.id, authenticated_at: dt}}
end
@doc """
Checks if the token is valid and returns its underlying lookup query.
The query returns the user found by the token, if any, along with the token's creation time.
The token is valid if it matches the value in the database and it has
not expired (after @session_validity_in_days).
"""
def verify_session_token_query(token) do
query =
from token in by_token_and_context_query(token, "session"),
join: user in assoc(token, :user),
where: token.inserted_at > ago(@session_validity_in_days, "day"),
select: {%{user | authenticated_at: token.authenticated_at}, token.inserted_at}
{:ok, query}
end
@doc """
Builds a token and its hash to be delivered to the user's email.
The non-hashed token is sent to the user email while the
hashed part is stored in the database. The original token cannot be reconstructed,
which means anyone with read-only access to the database cannot directly use
the token in the application to gain access. Furthermore, if the user changes
their email in the system, the tokens sent to the previous email are no longer
valid.
Users can easily adapt the existing code to provide other types of delivery methods,
for example, by phone numbers.
"""
def build_email_token(user, context) do
build_hashed_token(user, context, user.email)
end
defp build_hashed_token(user, context, sent_to) do
token = :crypto.strong_rand_bytes(@rand_size)
hashed_token = :crypto.hash(@hash_algorithm, token)
{Base.url_encode64(token, padding: false),
%UserToken{
token: hashed_token,
context: context,
sent_to: sent_to,
user_id: user.id
}}
end
@doc """
Checks if the token is valid and returns its underlying lookup query.
If found, the query returns a tuple of the form `{user, token}`.
The given token is valid if it matches its hashed counterpart in the
database. This function also checks whether the token has expired. The context
of a magic link token is always "login".
"""
def verify_magic_link_token_query(token) do
case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} ->
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
query =
from token in by_token_and_context_query(hashed_token, "login"),
join: user in assoc(token, :user),
where: token.inserted_at > ago(^@magic_link_validity_in_minutes, "minute"),
where: token.sent_to == user.email,
select: {user, token}
{:ok, query}
:error ->
:error
end
end
@doc """
Checks if the token is valid and returns its underlying lookup query.
The query returns the user_token found by the token, if any.
This is used to validate requests to change the user
email.
The given token is valid if it matches its hashed counterpart in the
database and if it has not expired (after @change_email_validity_in_days).
The context must always start with "change:".
"""
def verify_change_email_token_query(token, "change:" <> _ = context) do
case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} ->
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
query =
from token in by_token_and_context_query(hashed_token, context),
where: token.inserted_at > ago(@change_email_validity_in_days, "day")
{:ok, query}
:error ->
:error
end
end
# Sua vez (notebook 15): verify_api_token_query/1 entra aqui.
defp by_token_and_context_query(token, context) do
from UserToken, where: [token: ^token, context: ^context]
end
end
lib/hello/accounts/scope.ex e user_notifier.ex
defmodule Hello.Accounts.Scope do
@moduledoc """
Defines the scope of the caller to be used throughout the app.
The `Hello.Accounts.Scope` allows public interfaces to receive
information about the caller, such as if the call is initiated from an
end-user, and if so, which user. Additionally, such a scope can carry fields
such as "super user" or other privileges for use in authorization checks,
or to ensure specific code paths can only be accessed for a given scope.
It is useful for logging as well as for scoping pubsub subscriptions and
broadcasts when a caller subscribes to an interface or performs a particular
action.
Feel free to extend the fields on this struct to fit the needs of
growing application requirements.
"""
alias Hello.Accounts.User
defstruct user: nil
@doc """
Creates a scope for the given user.
Returns nil if no user is given.
"""
def for_user(%User{} = user) do
%__MODULE__{user: user}
end
def for_user(nil), do: nil
end
defmodule Hello.Accounts.UserNotifier do
import Swoosh.Email
alias Hello.Mailer
alias Hello.Accounts.User
# Delivers the email using the application mailer.
defp deliver(recipient, subject, body) do
email =
new()
|> to(recipient)
|> from({"Hello", "contact@example.com"})
|> subject(subject)
|> text_body(body)
with {:ok, _metadata} <- Mailer.deliver(email) do
{:ok, email}
end
end
@doc """
Deliver instructions to update a user email.
"""
def deliver_update_email_instructions(user, url) do
deliver(user.email, "Update email instructions", """
==============================
Hi #{user.email},
You can change your email by visiting the URL below:
#{url}
If you didn't request this change, please ignore this.
==============================
""")
end
@doc """
Deliver instructions to log in with a magic link.
"""
def deliver_login_instructions(user, url) do
case user do
%User{confirmed_at: nil} -> deliver_confirmation_instructions(user, url)
_ -> deliver_magic_link_instructions(user, url)
end
end
defp deliver_magic_link_instructions(user, url) do
deliver(user.email, "Log in instructions", """
==============================
Hi #{user.email},
You can log into your account by visiting the URL below:
#{url}
If you didn't request this email, please ignore this.
==============================
""")
end
defp deliver_confirmation_instructions(user, url) do
deliver(user.email, "Confirmation instructions", """
==============================
Hi #{user.email},
You can confirm your account by visiting the URL below:
#{url}
If you didn't create an account with us, please ignore this.
==============================
""")
end
end
lib/hello/accounts.ex: o contexto. O primeiro exercício acrescenta duas funções no fim, no lugar do comentário "Sua vez".
defmodule Hello.Accounts do
@moduledoc """
The Accounts context.
"""
import Ecto.Query, warn: false
alias Hello.Repo
alias Hello.Accounts.{User, UserToken, UserNotifier}
## Database getters
def get_user_by_email(email) when is_binary(email) do
Repo.get_by(User, email: email)
end
def get_user_by_email_and_password(email, password)
when is_binary(email) and is_binary(password) do
user = Repo.get_by(User, email: email)
if User.valid_password?(user, password), do: user
end
def get_user!(id), do: Repo.get!(User, id)
## User registration
def register_user(attrs) do
%User{}
|> User.email_changeset(attrs)
|> Repo.insert()
end
## Settings
def sudo_mode?(user, minutes \\ -20)
def sudo_mode?(%User{authenticated_at: ts}, minutes) when is_struct(ts, DateTime) do
DateTime.after?(ts, DateTime.utc_now() |> DateTime.add(minutes, :minute))
end
def sudo_mode?(_user, _minutes), do: false
def change_user_email(user, attrs \\ %{}, opts \\ []) do
User.email_changeset(user, attrs, opts)
end
def update_user_email(user, token) do
context = "change:#{user.email}"
Repo.transact(fn ->
with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
%UserToken{sent_to: email} <- Repo.one(query),
{:ok, user} <- Repo.update(User.email_changeset(user, %{email: email})),
{_count, _result} <-
Repo.delete_all(from(UserToken, where: [user_id: ^user.id, context: ^context])) do
{:ok, user}
else
_ -> {:error, :transaction_aborted}
end
end)
end
def change_user_password(user, attrs \\ %{}, opts \\ []) do
User.password_changeset(user, attrs, opts)
end
def update_user_password(user, attrs) do
user
|> User.password_changeset(attrs)
|> update_user_and_delete_all_tokens()
end
## Session
def generate_user_session_token(user) do
{token, user_token} = UserToken.build_session_token(user)
Repo.insert!(user_token)
token
end
def get_user_by_session_token(token) do
{:ok, query} = UserToken.verify_session_token_query(token)
Repo.one(query)
end
def get_user_by_magic_link_token(token) do
with {:ok, query} <- UserToken.verify_magic_link_token_query(token),
{user, _token} <- Repo.one(query) do
user
else
_ -> nil
end
end
def login_user_by_magic_link(token) do
{:ok, query} = UserToken.verify_magic_link_token_query(token)
case Repo.one(query) do
# Prevent session fixation attacks by disallowing magic links for unconfirmed users with password
{%User{confirmed_at: nil, hashed_password: hash}, _token} when not is_nil(hash) ->
raise """
magic link log in is not allowed for unconfirmed users with a password set!
This cannot happen with the default implementation, which indicates that you
might have adapted the code to a different use case. Please make sure to read the
"Mixing magic link and password registration" section of `mix help phx.gen.auth`.
"""
{%User{confirmed_at: nil} = user, _token} ->
user
|> User.confirm_changeset()
|> update_user_and_delete_all_tokens()
{user, token} ->
Repo.delete!(token)
{:ok, {user, []}}
nil ->
{:error, :not_found}
end
end
def deliver_user_update_email_instructions(%User{} = user, current_email, update_email_url_fun)
when is_function(update_email_url_fun, 1) do
{encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}")
Repo.insert!(user_token)
UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
end
def deliver_login_instructions(%User{} = user, magic_link_url_fun)
when is_function(magic_link_url_fun, 1) do
{encoded_token, user_token} = UserToken.build_email_token(user, "login")
Repo.insert!(user_token)
UserNotifier.deliver_login_instructions(user, magic_link_url_fun.(encoded_token))
end
def delete_user_session_token(token) do
Repo.delete_all(from(UserToken, where: [token: ^token, context: "session"]))
:ok
end
## API
# Sua vez (notebook 15): create_user_api_token/1 e fetch_user_by_api_token/1 entram aqui.
## Token helper
defp update_user_and_delete_all_tokens(changeset) do
Repo.transact(fn ->
with {:ok, user} <- Repo.update(changeset) do
tokens_to_expire = Repo.all_by(UserToken, user_id: user.id)
Repo.delete_all(from(t in UserToken, where: t.id in ^Enum.map(tokens_to_expire, & &1.id)))
{:ok, {user, tokens_to_expire}}
end
end)
end
end
lib/hello_web/router.ex, primeiro passo: as rotas da autenticação, ainda sem os plugs. O UserAuth, logo abaixo, usa ~p"/users/log-in", e o ~p confere cada caminho no router quando o módulo compila. O router completo, por sua vez, importa o UserAuth. Num projeto, o compilador resolve essa dependência circular sozinho; no notebook, as rotas vêm primeiro.
Esta célula avisa HelloWeb.UserRegistrationController.init/1 is undefined e o mesmo para os outros controllers: o router aponta para módulos que ainda não existem, e o aviso é só isso. Cada nova versão do router, mais abaixo, também avisa redefining module HelloWeb.Router, porque substitui a anterior.
defmodule HelloWeb.Router do
use HelloWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {HelloWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
pipeline :api do
plug :accepts, ["json"]
end
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :home
end
# Em desenvolvimento: a caixa de entrada do Swoosh.
scope "/dev" do
pipe_through :browser
forward "/mailbox", Plug.Swoosh.MailboxPreview
end
## Authentication routes
scope "/", HelloWeb do
pipe_through :browser
get "/users/register", UserRegistrationController, :new
post "/users/register", UserRegistrationController, :create
end
scope "/", HelloWeb do
pipe_through :browser
get "/users/settings", UserSettingsController, :edit
put "/users/settings", UserSettingsController, :update
get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email
end
scope "/", HelloWeb do
pipe_through :browser
get "/users/log-in", UserSessionController, :new
get "/users/log-in/:token", UserSessionController, :confirm
post "/users/log-in", UserSessionController, :create
delete "/users/log-out", UserSessionController, :delete
end
end
lib/hello_web/user_auth.ex: os plugs da autenticação. O exercício do plug da API entra no fim, no lugar do comentário.
defmodule HelloWeb.UserAuth do
use HelloWeb, :verified_routes
import Plug.Conn
import Phoenix.Controller
alias Hello.Accounts
alias Hello.Accounts.Scope
# Make the remember me cookie valid for 14 days. This should match
# the session validity setting in UserToken.
@max_cookie_age_in_days 14
@remember_me_cookie "_hello_web_user_remember_me"
@remember_me_options [
sign: true,
max_age: @max_cookie_age_in_days * 24 * 60 * 60,
same_site: "Lax"
]
# How old the session token should be before a new one is issued. When a request is made
# with a session token older than this value, then a new session token will be created
# and the session and remember-me cookies (if set) will be updated with the new token.
# Lowering this value will result in more tokens being created by active users. Increasing
# it will result in less time before a session token expires for a user to get issued a new
# token. This can be set to a value greater than `@max_cookie_age_in_days` to disable
# the reissuing of tokens completely.
@session_reissue_age_in_days 7
def log_in_user(conn, user, params \\ %{}) do
user_return_to = get_session(conn, :user_return_to)
conn
|> create_or_extend_session(user, params)
|> delete_session(:user_return_to)
|> redirect(to: user_return_to || signed_in_path(conn))
end
def log_out_user(conn) do
user_token = get_session(conn, :user_token)
user_token && Accounts.delete_user_session_token(user_token)
if live_socket_id = get_session(conn, :live_socket_id) do
HelloWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
end
conn
|> renew_session(nil)
|> delete_resp_cookie(@remember_me_cookie, @remember_me_options)
|> redirect(to: ~p"/")
end
def fetch_current_scope_for_user(conn, _opts) do
with {token, conn} <- ensure_user_token(conn),
{user, token_inserted_at} <- Accounts.get_user_by_session_token(token) do
conn
|> assign(:current_scope, Scope.for_user(user))
|> maybe_reissue_user_session_token(user, token_inserted_at)
else
nil -> assign(conn, :current_scope, Scope.for_user(nil))
end
end
defp ensure_user_token(conn) do
if token = get_session(conn, :user_token) do
{token, conn}
else
conn = fetch_cookies(conn, signed: [@remember_me_cookie])
if token = conn.cookies[@remember_me_cookie] do
{token, conn |> put_token_in_session(token) |> put_session(:user_remember_me, true)}
else
nil
end
end
end
# Reissue the session token if it is older than the configured reissue age.
defp maybe_reissue_user_session_token(conn, user, token_inserted_at) do
token_age = DateTime.diff(DateTime.utc_now(:second), token_inserted_at, :day)
if token_age >= @session_reissue_age_in_days do
create_or_extend_session(conn, user, %{})
else
conn
end
end
# This function is the one responsible for creating session tokens
# and storing them safely in the session and cookies. It may be called
# either when logging in, during sudo mode, or to renew a session which
# will soon expire.
#
# When the session is created, rather than extended, the renew_session
# function will clear the session to avoid fixation attacks. See the
# renew_session function to customize this behaviour.
defp create_or_extend_session(conn, user, params) do
token = Accounts.generate_user_session_token(user)
remember_me = get_session(conn, :user_remember_me)
conn
|> renew_session(user)
|> put_token_in_session(token)
|> maybe_write_remember_me_cookie(token, params, remember_me)
end
# Do not renew session if the user is already logged in
# to prevent CSRF errors or data being lost in tabs that are still open
defp renew_session(conn, user) when conn.assigns.current_scope.user.id == user.id do
conn
end
# This function renews the session ID and erases the whole
# session to avoid fixation attacks. If there is any data
# in the session you may want to preserve after log in/log out,
# you must explicitly fetch the session data before clearing
# and then immediately set it after clearing, for example:
#
# defp renew_session(conn, _user) do
# delete_csrf_token()
# preferred_locale = get_session(conn, :preferred_locale)
#
# conn
# |> configure_session(renew: true)
# |> clear_session()
# |> put_session(:preferred_locale, preferred_locale)
# end
#
defp renew_session(conn, _user) do
delete_csrf_token()
conn
|> configure_session(renew: true)
|> clear_session()
end
defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}, _),
do: write_remember_me_cookie(conn, token)
defp maybe_write_remember_me_cookie(conn, token, _params, true),
do: write_remember_me_cookie(conn, token)
defp maybe_write_remember_me_cookie(conn, _token, _params, _), do: conn
defp write_remember_me_cookie(conn, token) do
conn
|> put_session(:user_remember_me, true)
|> put_resp_cookie(@remember_me_cookie, token, @remember_me_options)
end
defp put_token_in_session(conn, token) do
put_session(conn, :user_token, token)
end
def require_sudo_mode(conn, _opts) do
if Accounts.sudo_mode?(conn.assigns.current_scope.user, -10) do
conn
else
conn
|> put_flash(:error, "You must re-authenticate to access this page.")
|> maybe_store_return_to()
|> redirect(to: ~p"/users/log-in")
|> halt()
end
end
def redirect_if_user_is_authenticated(conn, _opts) do
if conn.assigns.current_scope do
conn
|> redirect(to: signed_in_path(conn))
|> halt()
else
conn
end
end
defp signed_in_path(_conn), do: ~p"/"
def require_authenticated_user(conn, _opts) do
if conn.assigns.current_scope && conn.assigns.current_scope.user do
conn
else
conn
|> put_flash(:error, "You must log in to access this page.")
|> maybe_store_return_to()
|> redirect(to: ~p"/users/log-in")
|> halt()
end
end
defp maybe_store_return_to(%{method: "GET"} = conn) do
put_session(conn, :user_return_to, current_path(conn))
end
defp maybe_store_return_to(conn), do: conn
# Sua vez (notebook 15): fetch_current_scope_for_api_user/2 entra aqui.
end
Os controllers e as páginas da autenticação: cadastro, login e configurações, com os templates como funções.
defmodule HelloWeb.UserRegistrationController do
use HelloWeb, :controller
alias Hello.Accounts
alias Hello.Accounts.User
def new(conn, _params) do
changeset = Accounts.change_user_email(%User{})
render(conn, :new, changeset: changeset)
end
def create(conn, %{"user" => user_params}) do
case Accounts.register_user(user_params) do
{:ok, user} ->
{:ok, _} =
Accounts.deliver_login_instructions(
user,
&url(~p"/users/log-in/#{&1}")
)
conn
|> put_flash(
:info,
"An email was sent to #{user.email}, please access it to confirm your account."
)
|> redirect(to: ~p"/users/log-in")
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, :new, changeset: changeset)
end
end
end
defmodule HelloWeb.UserRegistrationHTML do
use HelloWeb, :html
# user_registration_html/new.html.heex
def new(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="mx-auto max-w-sm">
<div class="text-center">
<.header>
Register for an account
<:subtitle>
Already registered?
<.link navigate={~p"/users/log-in"} class="font-semibold text-brand hover:underline">
Log in
</.link>
to your account now.
</:subtitle>
</.header>
</div>
<.form :let={f} for={@changeset} action={~p"/users/register"}>
<.input
field={f[:email]}
type="email"
label="Email"
autocomplete="username"
spellcheck="false"
required
phx-mounted={JS.focus()}
/>
<.button phx-disable-with="Creating account..." class="btn btn-primary w-full">
Create an account
</.button>
</.form>
</div>
</Layouts.app>
"""
end
end
defmodule HelloWeb.UserSessionController do
use HelloWeb, :controller
alias Hello.Accounts
alias HelloWeb.UserAuth
def new(conn, _params) do
email = get_in(conn.assigns, [:current_scope, Access.key(:user), Access.key(:email)])
form = Phoenix.Component.to_form(%{"email" => email}, as: "user")
render(conn, :new, form: form)
end
# magic link login
def create(conn, %{"user" => %{"token" => token} = user_params} = params) do
info =
case params do
%{"_action" => "confirmed"} -> "User confirmed successfully."
_ -> "Welcome back!"
end
case Accounts.login_user_by_magic_link(token) do
{:ok, {user, _expired_tokens}} ->
conn
|> put_flash(:info, info)
|> UserAuth.log_in_user(user, user_params)
{:error, :not_found} ->
conn
|> put_flash(:error, "The link is invalid or it has expired.")
|> render(:new, form: Phoenix.Component.to_form(%{}, as: "user"))
end
end
# email + password login
def create(conn, %{"user" => %{"email" => email, "password" => password} = user_params}) do
if user = Accounts.get_user_by_email_and_password(email, password) do
conn
|> put_flash(:info, "Welcome back!")
|> UserAuth.log_in_user(user, user_params)
else
form = Phoenix.Component.to_form(user_params, as: "user")
# In order to prevent user enumeration attacks, don't disclose whether the email is registered.
conn
|> put_flash(:error, "Invalid email or password")
|> render(:new, form: form)
end
end
# magic link request
def create(conn, %{"user" => %{"email" => email}}) do
if user = Accounts.get_user_by_email(email) do
Accounts.deliver_login_instructions(
user,
&url(~p"/users/log-in/#{&1}")
)
end
info =
"If your email is in our system, you will receive instructions for logging in shortly."
conn
|> put_flash(:info, info)
|> redirect(to: ~p"/users/log-in")
end
def confirm(conn, %{"token" => token}) do
if user = Accounts.get_user_by_magic_link_token(token) do
form = Phoenix.Component.to_form(%{"token" => token}, as: "user")
conn
|> assign(:user, user)
|> assign(:form, form)
|> render(:confirm)
else
conn
|> put_flash(:error, "Magic link is invalid or it has expired.")
|> redirect(to: ~p"/users/log-in")
end
end
def delete(conn, _params) do
conn
|> put_flash(:info, "Logged out successfully.")
|> UserAuth.log_out_user()
end
end
defmodule HelloWeb.UserSessionHTML do
use HelloWeb, :html
# user_session_html/confirm.html.heex
def confirm(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="mx-auto max-w-sm">
<div class="text-center">
<.header>Welcome {@user.email}</.header>
</div>
<.form
:if={!@user.confirmed_at}
for={@form}
id="confirmation_form"
action={~p"/users/log-in?_action=confirmed"}
phx-mounted={JS.focus_first()}
>
<input type="hidden" name={@form[:token].name} value={@form[:token].value} />
<.button
name={@form[:remember_me].name}
value="true"
phx-disable-with="Confirming..."
class="btn btn-primary w-full"
>
Confirm and stay logged in
</.button>
<.button phx-disable-with="Confirming..." class="btn btn-primary btn-soft w-full mt-2">
Confirm and log in only this time
</.button>
</.form>
<.form
:if={@user.confirmed_at}
for={@form}
id="login_form"
action={~p"/users/log-in"}
phx-mounted={JS.focus_first()}
>
<input type="hidden" name={@form[:token].name} value={@form[:token].value} />
<%= if @current_scope do %>
<.button variant="primary" phx-disable-with="Logging in..." class="btn btn-primary w-full">
Log in
</.button>
<% else %>
<.button
name={@form[:remember_me].name}
value="true"
phx-disable-with="Logging in..."
class="btn btn-primary w-full"
>
Keep me logged in on this device
</.button>
<.button phx-disable-with="Logging in..." class="btn btn-primary btn-soft w-full mt-2">
Log me in only this time
</.button>
<% end %>
</.form>
<p :if={!@user.confirmed_at} class="alert alert-outline mt-8">
Tip: If you prefer passwords, you can enable them in the user settings.
</p>
</div>
</Layouts.app>
"""
end
# user_session_html/new.html.heex
def new(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="mx-auto max-w-sm space-y-4">
<div class="text-center">
<.header>
<p>Log in</p>
<:subtitle>
<%= if @current_scope do %>
You need to reauthenticate to perform sensitive actions on your account.
<% else %>
Don't have an account? <.link
navigate={~p"/users/register"}
class="font-semibold text-brand hover:underline"
phx-no-format
>Sign up</.link> for an account now.
<% end %>
</:subtitle>
</.header>
</div>
<div :if={local_mail_adapter?()} class="alert alert-info">
<.icon name="hero-information-circle" class="size-6 shrink-0" />
<div>
<p>You are running the local mail adapter.</p>
<p>
To see sent emails, visit <.link href="/dev/mailbox" class="underline">the mailbox page</.link>.
</p>
</div>
</div>
<.form :let={f} for={@form} as={:user} id="login_form_magic" action={~p"/users/log-in"}>
<.input
readonly={!!@current_scope}
field={f[:email]}
type="email"
label="Email"
autocomplete="username"
spellcheck="false"
required
phx-mounted={JS.focus()}
/>
<.button class="btn btn-primary w-full">
Log in with email <span aria-hidden="true">→</span>
</.button>
</.form>
<div class="divider">or</div>
<.form :let={f} for={@form} as={:user} id="login_form_password" action={~p"/users/log-in"}>
<.input
readonly={!!@current_scope}
field={f[:email]}
type="email"
label="Email"
autocomplete="username"
spellcheck="false"
required
/>
<.input
field={f[:password]}
type="password"
label="Password"
autocomplete="current-password"
spellcheck="false"
/>
<.button class="btn btn-primary w-full" name={@form[:remember_me].name} value="true">
Log in and stay logged in <span aria-hidden="true">→</span>
</.button>
<.button class="btn btn-primary btn-soft w-full mt-2">
Log in only this time
</.button>
</.form>
</div>
</Layouts.app>
"""
end
defp local_mail_adapter? do
Application.get_env(:hello, Hello.Mailer)[:adapter] == Swoosh.Adapters.Local
end
end
defmodule HelloWeb.UserSettingsController do
use HelloWeb, :controller
alias Hello.Accounts
alias HelloWeb.UserAuth
import HelloWeb.UserAuth, only: [require_sudo_mode: 2]
plug :require_sudo_mode
plug :assign_email_and_password_changesets
def edit(conn, _params) do
render(conn, :edit)
end
def update(conn, %{"action" => "update_email"} = params) do
%{"user" => user_params} = params
user = conn.assigns.current_scope.user
case Accounts.change_user_email(user, user_params) do
%{valid?: true} = changeset ->
Accounts.deliver_user_update_email_instructions(
Ecto.Changeset.apply_action!(changeset, :insert),
user.email,
&url(~p"/users/settings/confirm-email/#{&1}")
)
conn
|> put_flash(
:info,
"A link to confirm your email change has been sent to the new address."
)
|> redirect(to: ~p"/users/settings")
changeset ->
render(conn, :edit, email_changeset: %{changeset | action: :insert})
end
end
def update(conn, %{"action" => "update_password"} = params) do
%{"user" => user_params} = params
user = conn.assigns.current_scope.user
case Accounts.update_user_password(user, user_params) do
{:ok, {user, _}} ->
conn
|> put_flash(:info, "Password updated successfully.")
|> put_session(:user_return_to, ~p"/users/settings")
|> UserAuth.log_in_user(user)
{:error, changeset} ->
render(conn, :edit, password_changeset: changeset)
end
end
def confirm_email(conn, %{"token" => token}) do
case Accounts.update_user_email(conn.assigns.current_scope.user, token) do
{:ok, _user} ->
conn
|> put_flash(:info, "Email changed successfully.")
|> redirect(to: ~p"/users/settings")
{:error, _} ->
conn
|> put_flash(:error, "Email change link is invalid or it has expired.")
|> redirect(to: ~p"/users/settings")
end
end
defp assign_email_and_password_changesets(conn, _opts) do
user = conn.assigns.current_scope.user
conn
|> assign(:email_changeset, Accounts.change_user_email(user))
|> assign(:password_changeset, Accounts.change_user_password(user))
end
end
defmodule HelloWeb.UserSettingsHTML do
use HelloWeb, :html
# user_settings_html/edit.html.heex
def edit(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="text-center">
<.header>
Account Settings
<:subtitle>Manage your account email address and password settings</:subtitle>
</.header>
</div>
<.form :let={f} for={@email_changeset} action={~p"/users/settings"} id="update_email">
<input type="hidden" name="action" value="update_email" />
<.input
field={f[:email]}
type="email"
label="Email"
autocomplete="username"
spellcheck="false"
required
/>
<.button variant="primary" phx-disable-with="Changing...">Change Email</.button>
</.form>
<div class="divider" />
<.form :let={f} for={@password_changeset} action={~p"/users/settings"} id="update_password">
<input type="hidden" name="action" value="update_password" />
<.input
field={f[:password]}
type="password"
label="New password"
autocomplete="new-password"
spellcheck="false"
required
/>
<.input
field={f[:password_confirmation]}
type="password"
label="Confirm new password"
autocomplete="new-password"
spellcheck="false"
required
/>
<.button variant="primary" phx-disable-with="Changing...">
Save Password
</.button>
</.form>
</Layouts.app>
"""
end
end
lib/hello_web/router.ex: o router gerado, com os plugs. A única diferença em relação ao do notebook 13 é o escopo /api no fim, com a rota GET /api/me, que a próxima seção explica. O aviso HelloWeb.MeController.init/1 is undefined é esperado: o controller vem nessa seção.
defmodule HelloWeb.Router do
use HelloWeb, :router
import HelloWeb.UserAuth
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {HelloWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :fetch_current_scope_for_user
end
pipeline :api do
plug :accepts, ["json"]
end
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :home
end
# Em desenvolvimento: a caixa de entrada do Swoosh.
scope "/dev" do
pipe_through :browser
forward "/mailbox", Plug.Swoosh.MailboxPreview
end
## Authentication routes
scope "/", HelloWeb do
pipe_through [:browser, :redirect_if_user_is_authenticated]
get "/users/register", UserRegistrationController, :new
post "/users/register", UserRegistrationController, :create
end
scope "/", HelloWeb do
pipe_through [:browser, :require_authenticated_user]
get "/users/settings", UserSettingsController, :edit
put "/users/settings", UserSettingsController, :update
get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email
end
scope "/", HelloWeb do
pipe_through [:browser]
get "/users/log-in", UserSessionController, :new
get "/users/log-in/:token", UserSessionController, :confirm
post "/users/log-in", UserSessionController, :create
delete "/users/log-out", UserSessionController, :delete
end
scope "/api", HelloWeb do
pipe_through :api
get "/me", MeController, :show
end
end
lib/hello_web/components/layouts.ex: o root layout ganha o menu que o phx.gen.auth injeta no root.html.heex.
defmodule HelloWeb.Layouts do
use HelloWeb, :html
def root(assigns) do
~H"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<title>Hello · Phoenix Framework</title>
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4">
</script>
<script defer src={~p"/assets/phoenix_html.js"}>
</script>
</head>
<body>
<ul class="menu menu-horizontal w-full relative z-10 flex items-center gap-4 px-4 sm:px-6 lg:px-8 justify-end">
<%= if @current_scope do %>
<li>
{@current_scope.user.email}
</li>
<li>
<.link href={~p"/users/settings"}>Settings</.link>
</li>
<li>
<.link href={~p"/users/log-out"} method="delete">Log out</.link>
</li>
<% else %>
<li>
<.link href={~p"/users/register"}>Register</.link>
</li>
<li>
<.link href={~p"/users/log-in"}>Log in</.link>
</li>
<% end %>
</ul>
{@inner_content}
</body>
</html>
"""
end
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :current_scope, :map, default: nil, doc: "the current scope"
slot :inner_block, required: true
def app(assigns) do
~H"""
<header class="navbar px-4 sm:px-6 lg:px-8">
<div class="flex-1">
<a href="/" class="flex-1 flex w-fit items-center gap-2">
<span class="text-sm font-semibold">Hello · Phoenix v{Application.spec(:phoenix, :vsn)}</span>
</a>
</div>
</header>
<main class="px-4 py-20 sm:px-6 lg:px-8">
<div class="mx-auto max-w-2xl space-y-4">
{render_slot(@inner_block)}
</div>
</main>
<.flash_group flash={@flash} />
"""
end
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
def flash_group(assigns) do
~H"""
<div id={@id} aria-live="polite">
<.flash kind={:info} flash={@flash} />
<.flash kind={:error} flash={@flash} />
</div>
"""
end
end
test/support/fixtures/accounts_fixtures.ex: os ajudantes de teste que o gerador cria. O notebook os usa para criar as contas de exemplo e, mais adiante, no teste do guia.
defmodule Hello.AccountsFixtures do
@moduledoc """
This module defines test helpers for creating
entities via the `Hello.Accounts` context.
"""
import Ecto.Query
alias Hello.Accounts
alias Hello.Accounts.Scope
def unique_user_email, do: "user#{System.unique_integer()}@example.com"
def valid_user_password, do: "hello world!"
def valid_user_attributes(attrs \\ %{}) do
Enum.into(attrs, %{
email: unique_user_email()
})
end
def unconfirmed_user_fixture(attrs \\ %{}) do
{:ok, user} =
attrs
|> valid_user_attributes()
|> Accounts.register_user()
user
end
def user_fixture(attrs \\ %{}) do
user = unconfirmed_user_fixture(attrs)
token =
extract_user_token(fn url ->
Accounts.deliver_login_instructions(user, url)
end)
{:ok, {user, _expired_tokens}} =
Accounts.login_user_by_magic_link(token)
user
end
def user_scope_fixture do
user = user_fixture()
user_scope_fixture(user)
end
def user_scope_fixture(user) do
Scope.for_user(user)
end
def set_password(user) do
{:ok, {user, _expired_tokens}} =
Accounts.update_user_password(user, %{password: valid_user_password()})
user
end
def extract_user_token(fun) do
{:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]")
[_, token | _] = String.split(captured_email.text_body, "[TOKEN]")
token
end
def override_token_authenticated_at(token, authenticated_at) when is_binary(token) do
Hello.Repo.update_all(
from(t in Accounts.UserToken,
where: t.token == ^token
),
set: [authenticated_at: authenticated_at]
)
end
def generate_user_magic_link_token(user) do
{encoded_token, user_token} = Accounts.UserToken.build_email_token(user, "login")
Hello.Repo.insert!(user_token)
{encoded_token, user_token.token}
end
def offset_user_token(token, amount_to_add, unit) do
dt = DateTime.add(DateTime.utc_now(:second), amount_to_add, unit)
Hello.Repo.update_all(
from(ut in Accounts.UserToken, where: ut.token == ^token),
set: [inserted_at: dt, authenticated_at: dt]
)
end
end
As contas de exemplo: a Ana e a Bia, já confirmadas e com senha. user_fixture/1 faz o caminho do link mágico, que confirma a conta; set_password/1 define a senha hello world!.
alias Hello.AccountsFixtures
ana = AccountsFixtures.user_fixture(%{email: "ana@example.com"}) |> AccountsFixtures.set_password()
bia = AccountsFixtures.user_fixture(%{email: "bia@example.com"}) |> AccountsFixtures.set_password()
Kino.Markdown.new(
"Contas confirmadas, com a senha `#{AccountsFixtures.valid_user_password()}`: " <>
"`#{ana.email}` (id #{ana.id}) e `#{bia.email}` (id #{bia.id})."
)
A API que vamos proteger
Uma API pequena, no estilo do notebook 12: GET /api/me devolve, em JSON, os dados de quem fez a requisição. O controller lê o usuário do escopo, conn.assigns.current_scope, como as páginas da autenticação fazem.
lib/hello_web/controllers/me_controller.ex e me_json.ex
defmodule HelloWeb.MeController do
use HelloWeb, :controller
def show(conn, _params) do
render(conn, :show, user: conn.assigns.current_scope.user)
end
end
defmodule HelloWeb.MeJSON do
def show(%{user: user}) do
%{data: %{id: user.id, email: user.email, confirmed_at: user.confirmed_at}}
end
end
Trilha.pergunta(:sem_sessao)
Trilha.curl(~S"""
curl -i http://localhost:4010/api/me
""")
500 Internal Server Error, e o título da página de depuração diz KeyError: o controller procurou :current_scope em conn.assigns e não achou. O pipeline :api só tem plug :accepts, ["json"]. Nenhum plug dele lê a sessão ou descobre o usuário, e nem faria sentido ler a sessão: o curl, como um app de celular, não tem o cookie do navegador. A API precisa de outro jeito de saber quem está do outro lado. É o assunto do guia.
Tokens de API na tabela que já existe
Na documentação: API Authentication
O guia parte do mix phx.gen.auth Accounts User users e acrescenta autenticação por token. Como o gerador já criou uma tabela de tokens, ela vai guardar também os tokens de API, seguindo as boas práticas de segurança que o código gerado já aplica. O guia tem duas partes: funções novas no contexto e um plug.
Antes de mexer no código, veja a tabela. A Ana entra pelo formulário de login, com a senha:
Trilha.navegar("ana", :post, "/users/log-in", %{
"user" => %{"email" => "ana@example.com", "password" => "hello world!"}
})
Trilha.descrever("users_tokens")
Trilha.consultar("""
SELECT t.id, u.email, t.context, t.sent_to, octet_length(t.token) AS bytes, t.inserted_at
FROM users_tokens t JOIN users u ON u.id = t.user_id
ORDER BY t.id
""")
Uma linha para a Ana, com o contexto session: é o token de sessão do login que acabou de acontecer. O token tem 32 bytes sorteados (@rand_size 32 no UserToken) e a coluna context diz para que ele serve. A coluna sent_to fica vazia em tokens de sessão; nos tokens enviados por e-mail ela guarda o endereço de destino. Não há linhas da Bia, nem os tokens de login por link mágico das duas: o link é apagado quando é usado, e set_password/1 apaga todos os tokens do usuário.
Trilha.pergunta(:mesma_tabela)
Os tokens de API vão morar aqui, com o contexto "api-token". Falta decidir como guardá-los. O UserToken tem dois jeitos, e os @doc explicam:
build_session_token/1guarda o token como ele é. O token só vale dentro do cookie de sessão, que o Phoenix assina com asecret_key_base. Quem lê o banco não consegue forjar esse cookie.build_email_token/2guarda só o hash SHA-256 do token. O token em si viaja por e-mail, em Base64, e não pode ser reconstruído a partir do hash. Quem lê o banco não tem o que usar.
Um token de API se parece com o segundo caso: ele sai do servidor uma vez e passa a viver no cliente, num arquivo de configuração ou numa variável de ambiente. Por isso o guia o cria com build_email_token/2.
Trilha.pergunta(:hash)
Funções de API no contexto
Na documentação: Adding API functions to the context
O guia acrescenta duas funções ao Accounts: uma cria o token, a outra encontra o usuário a partir dele.
## API
@doc """
Creates a new api token for a user.
The token returned must be saved somewhere safe.
This token cannot be recovered from the database.
"""
def create_user_api_token(user) do
{encoded_token, user_token} = UserToken.build_email_token(user, "api-token")
Repo.insert!(user_token)
encoded_token
end
@doc """
Fetches the user by API token.
"""
def fetch_user_by_api_token(token) do
with {:ok, query} <- UserToken.verify_api_token_query(token),
%User{} = user <- Repo.one(query) do
{:ok, user}
else
_ -> :error
end
end
create_user_api_token/1 usa build_email_token/2 com o contexto "api-token". A função devolve duas coisas: o token codificado, que vai para quem pediu, e a struct %UserToken{} com o hash, que vai para o banco. O token não pode ser recuperado depois; quem o recebe precisa guardá-lo.
Como é um token "de e-mail", build_email_token/2 também grava o e-mail atual do usuário em sent_to. O guia tira disso uma consequência: se o usuário trocar o e-mail, os tokens expiram. A seção seguinte mostra por quê.
Sua vez: as duas funções
Na célula do Hello.Accounts, lá em cima, troque o comentário "Sua vez (notebook 15)" pelas duas funções do guia e avalie a célula.
Trilha.solucao(:contexto)
Trilha.conferir(:contexto)
A conferência cria tokens e procura o hash de cada um na tabela; ela ainda não chama fetch_user_by_api_token/1. Essa função depende de UserToken.verify_api_token_query/1, que não existe.
Trilha.pergunta(:fetch_get)
O guia explica o nome: a API vai responder com status diferentes conforme o usuário seja encontrado ou não, então a função devolve {:ok, user} ou :error. Na convenção do Elixir, funções assim se chamam fetch_*; uma get_* devolveria nil, como get_user_by_email/1 faz.
Erro como conteúdo: o teste do guia
O guia sugere um teste para accounts_test.exs. Um notebook não tem mix test, mas tem o ExUnit, a biblioteca de testes do Elixir: ExUnit.start(autorun: false) liga o ExUnit sem rodar nada sozinho, o módulo define o teste, e ExUnit.run() roda. O use ExUnit.Case e o import Hello.AccountsFixtures são os mesmos do arquivo de teste gerado.
ExUnit.start(autorun: false)
defmodule Hello.AccountsApiTest do
use ExUnit.Case
alias Hello.Accounts
import Hello.AccountsFixtures
describe "create_user_api_token/1 and fetch_user_by_api_token/1" do
test "creates and fetches by token" do
user = user_fixture()
token = Accounts.create_user_api_token(user)
assert Accounts.fetch_user_by_api_token(token) == {:ok, user}
assert Accounts.fetch_user_by_api_token("invalid") == :error
end
end
end
ExUnit.run()
Com as duas funções no lugar, o teste falha, como o guia avisa:
** (UndefinedFunctionError) function Hello.Accounts.UserToken.verify_api_token_query/1 is undefined or private. Did you mean:
* verify_change_email_token_query/2
* verify_magic_link_token_query/1
* verify_session_token_query/1
A mensagem é a do guia, com Hello no lugar de Demo: o Elixir procura funções de nome parecido no módulo e as sugere. As três sugestões são as consultas que o UserToken já tem, uma por tipo de token. (Se você ainda não fez o exercício anterior, o erro é outro: Hello.Accounts.create_user_api_token/1 is undefined or private. E se já fez o próximo, o teste passa.)
O guia sugere que você tente corrigir o erro sozinho antes de ler a explicação. Olhe as três funções sugeridas no UserToken lá em cima: a nova vai ser parecida com uma delas.
A consulta que confere o token
O UserToken tem uma função de verificação para cada tipo de token. Falta a dos tokens de API. Quanto tempo um token desses deve valer depende da aplicação e de quão sensíveis são os dados; o guia escolhe 365 dias:
@doc """
Checks if the API token is valid and returns its underlying lookup query.
The query returns the user found by the token, if any.
The given token is valid if it matches its hashed counterpart in the
database and the user email has not changed. This function also checks
if the token is being used within 365 days.
"""
def verify_api_token_query(token) do
case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} ->
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
query =
from token in by_token_and_context_query(hashed_token, "api-token"),
join: user in assoc(token, :user),
where:
token.inserted_at > ago(^@api_token_validity_in_days, "day") and
token.sent_to == user.email,
select: user
{:ok, query}
:error ->
:error
end
end
e um atributo novo no topo do módulo:
@magic_link_validity_in_minutes 15
@change_email_validity_in_days 7
@session_validity_in_days 60
+ @api_token_validity_in_days 365
Leia a função na ordem em que ela trabalha:
Base.url_decode64/2desfaz o Base64. Um texto que não é Base64 válido dá:error, efetch_user_by_api_token/1devolve:errortambém, peloelsedowith.:crypto.hash/2calcula o SHA-256 dos bytes, com o mesmo@hash_algorithmque gerou o hash guardado.by_token_and_context_query/2monta a consulta pelo hash e pelo contexto"api-token". Ela édefpnoUserToken; a função nova pode usá-la porque fica no mesmo módulo.- O
jointraz o usuário, e owhereexige duas coisas: o token foi criado há menos de 365 dias, e o e-mail de destino (sent_to) ainda é o e-mail do usuário.
Uma diferença em relação ao guia: o trecho do diff mostra @session_validity_in_days 60, mas o 1.8.16 gera 14, como você vê na célula. Mude só o que o diff acrescenta.
Sua vez: verify_api_token_query/1
Na célula do Hello.Accounts.UserToken, acrescente o atributo @api_token_validity_in_days 365 junto dos outros e troque o comentário "Sua vez (notebook 15)" pela função. Avalie a célula e, depois, a do teste do guia, lá em cima: agora ele passa, com 1 test, 0 failures.
Trilha.solucao(:verificar)
Trilha.conferir(:verificar)
O token no banco
As células desta seção e da próxima usam as funções dos dois exercícios; sem elas, falham com UndefinedFunctionError. Crie um token para a Ana e compare o que ela recebe com o que o banco guarda:
ana = Hello.Accounts.get_user_by_email("ana@example.com")
token = Hello.Accounts.create_user_api_token(ana)
Trilha.consultar("""
SELECT id, user_id, context, sent_to, encode(token, 'hex') AS hash_em_hexadecimal
FROM users_tokens WHERE context = 'api-token' ORDER BY id DESC LIMIT 1
""")
token
|> Base.url_decode64!(padding: false)
|> then(&:crypto.hash(:sha256, &1))
|> Base.encode16(case: :lower)
O hash calculado aqui é o mesmo da coluna token. O banco guarda o resultado da conta, não o token: o token que a Ana recebeu, com 43 caracteres, não aparece em lugar nenhum da tabela.
Hello.Accounts.fetch_user_by_api_token(token)
Trocar o e-mail derruba o token
Trilha.pergunta(:email_muda)
Veja acontecer, com uma conta nova, a Carla, para não mexer na Ana. A troca de e-mail segue o caminho do código gerado: deliver_user_update_email_instructions/3 manda um link para o endereço novo, e update_user_email/2 aplica a troca quando recebe o token desse link.
carla = Hello.AccountsFixtures.user_fixture(%{email: "carla@example.com"})
token_da_carla = Hello.Accounts.create_user_api_token(carla)
antes = Hello.Accounts.fetch_user_by_api_token(token_da_carla)
novo = %{carla | email: "carla.nova@example.com"}
Hello.Accounts.deliver_user_update_email_instructions(
novo,
carla.email,
&"#{HelloWeb.Endpoint.url()}/users/settings/confirm-email/#{&1}"
)
token_do_email = Trilha.link_do_email("carla.nova@example.com") |> String.split("/") |> List.last()
{:ok, carla} = Hello.Accounts.update_user_email(carla, token_do_email)
depois = Hello.Accounts.fetch_user_by_api_token(token_da_carla)
%{email_agora: carla.email, antes: antes, depois: depois}
Antes da troca, {:ok, user}; depois, :error. A linha do token continua na tabela, com o e-mail antigo:
Trilha.consultar(
"SELECT id, context, sent_to FROM users_tokens WHERE user_id = $1 ORDER BY id",
[carla.id]
)
update_user_email/2 só apagou o token da troca de e-mail, de contexto change:carla@example.com. O de API ficou, mas o sent_to dele não bate mais com users.email, e a consulta o ignora.
Trocar a senha também
Trilha.pergunta(:senha_muda)
token_da_carla = Hello.Accounts.create_user_api_token(carla)
{:ok, {carla, expirados}} =
Hello.Accounts.update_user_password(carla, %{password: "uma senha nova e longa"})
%{
tokens_expirados: Enum.map(expirados, & &1.context),
token_de_api_agora: Hello.Accounts.fetch_user_by_api_token(token_da_carla)
}
update_user_password/2 devolve a lista dos tokens que apagou: os dois tokens de API da Carla, o desta célula e o da seção anterior, que já não valia por causa da troca de e-mail. É a regra que o guia de mix phx.gen.auth descreve: quando a senha muda, todos os tokens são apagados, e o usuário precisa entrar de novo em todos os dispositivos. Um cliente de API também.
A validade de 365 dias
Para testar a validade sem esperar um ano, Trilha.envelhecer/2 muda a coluna inserted_at de um token, como se ele tivesse sido criado tantos dias atrás:
velho = Hello.Accounts.create_user_api_token(ana)
Trilha.envelhecer(velho, 366)
Hello.Accounts.fetch_user_by_api_token(velho)
:error: o where exige token.inserted_at > ago(365, "day"). A linha continua na tabela. O código gerado não apaga tokens vencidos; num projeto, uma tarefa periódica pode limpar as linhas antigas.
O plug de autenticação da API
Na documentação: API authentication plug
Falta a API usar os tokens. O UserAuth gerado já tem plugs para o navegador, como fetch_current_scope_for_user. O guia acrescenta um para a API:
def fetch_current_scope_for_api_user(conn, _opts) do
with [<<bearer::binary-size(6), " ", token::binary>>] <-
get_req_header(conn, "authorization"),
true <- String.downcase(bearer) == "bearer",
{:ok, user} <- Accounts.fetch_user_by_api_token(token) do
assign(conn, :current_scope, Scope.for_user(user))
else
_ ->
conn
|> send_resp(:unauthorized, "No access for you")
|> halt()
end
end
O cliente manda o token no cabeçalho authorization, no formato Bearer TOKEN, o padrão para tokens desse tipo. O with faz três verificações:
get_req_header/2devolve a lista de valores do cabeçalho. O padrão exige exatamente um valor, que comece com 6 bytes, um espaço e o resto, que é o token.- Os 6 bytes, em minúsculas, devem ser
bearer. fetch_user_by_api_token/1deve achar o usuário.
Se tudo casa, o plug põe o escopo do usuário em conn.assigns.current_scope, o mesmo assign que o fetch_current_scope_for_user usa no navegador. O controller não precisa saber de onde o usuário veio. Se algo falha, a resposta é 401 Unauthorized com o texto No access for you, e halt/1 interrompe o pipeline.
Trilha.pergunta(:halt)
O halt/1 não é um detalhe. Este módulo de exemplo faz o mesmo que o plug, sem o halt, e com um plug "protegido" logo depois:
defmodule Exemplo.SemHalt do
use Plug.Builder
plug :recusar
plug :dados_secretos
def recusar(conn, _opts), do: send_resp(conn, :unauthorized, "No access for you")
def dados_secretos(conn, _opts), do: send_resp(conn, 200, "o saldo da Ana")
end
Plug.Test.conn(:get, "/") |> Exemplo.SemHalt.call([])
Plug.Conn.AlreadySentError: o segundo plug rodou depois do 401 e tentou responder de novo. O cliente já tinha recebido o 401, mas o código protegido rodou para quem não tinha token. Com halt/1, o Plug.Builder para no primeiro plug.
Sua vez: o plug e o pipeline
Duas mudanças, nesta ordem:
- Na célula do
HelloWeb.UserAuth, lá em cima, troque o comentário "Sua vez (notebook 15)" pela função do guia e avalie a célula. Os apelidosAccountseScopejá estão no topo do módulo. - Na célula do router logo abaixo, acrescente
plug :fetch_current_scope_for_api_userao pipeline:api, como o guia faz, e avalie.
Se o router for avaliado antes do UserAuth, a compilação falha com undefined function fetch_current_scope_for_api_user/2: o import HelloWeb.UserAuth só traz as funções que o módulo tem quando o router compila.
lib/hello_web/router.ex: o mesmo router, com o pipeline :api à espera do plug.
defmodule HelloWeb.Router do
use HelloWeb, :router
import HelloWeb.UserAuth
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {HelloWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :fetch_current_scope_for_user
end
pipeline :api do
plug :accepts, ["json"]
# Sua vez: o plug do token entra aqui.
end
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :home
end
# Em desenvolvimento: a caixa de entrada do Swoosh.
scope "/dev" do
pipe_through :browser
forward "/mailbox", Plug.Swoosh.MailboxPreview
end
## Authentication routes
scope "/", HelloWeb do
pipe_through [:browser, :redirect_if_user_is_authenticated]
get "/users/register", UserRegistrationController, :new
post "/users/register", UserRegistrationController, :create
end
scope "/", HelloWeb do
pipe_through [:browser, :require_authenticated_user]
get "/users/settings", UserSettingsController, :edit
put "/users/settings", UserSettingsController, :update
get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email
end
scope "/", HelloWeb do
pipe_through [:browser]
get "/users/log-in", UserSessionController, :new
get "/users/log-in/:token", UserSessionController, :confirm
post "/users/log-in", UserSessionController, :create
delete "/users/log-out", UserSessionController, :delete
end
scope "/api", HelloWeb do
pipe_through :api
get "/me", MeController, :show
end
end
Trilha.solucao(:plug)
Trilha.conferir(:plug)
Agora, os pedidos com Trilha.curl/1. O token da Ana vai no meio do comando:
token = Hello.Accounts.create_user_api_token(ana)
Trilha.curl("curl -i -H 'Authorization: Bearer #{token}' http://localhost:4010/api/me")
200 OK, com os dados da Ana. A saída mostra o comando inteiro, com o token: copie-o para um terminal e a resposta é a mesma, enquanto o runtime estiver ligado.
Sem o cabeçalho:
Trilha.curl(~S"""
curl -i http://localhost:4010/api/me
""")
401 Unauthorized, com o corpo No access for you. A resposta não tem content-type: send_resp/3 manda o texto como está, e o Phoenix só põe o tipo quando quem responde é uma view, como o MeJSON, ou funções como json/2 e text/2.
Trilha.pergunta(:bearer_minusculo)
Um token que não existe, o esquema em minúsculas e um esquema diferente de Bearer:
falso = Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)
Trilha.curl("curl -i -H 'Authorization: Bearer #{falso}' http://localhost:4010/api/me")
token = Hello.Accounts.create_user_api_token(ana)
Trilha.curl("curl -i -H 'Authorization: bearer #{token}' http://localhost:4010/api/me")
Trilha.curl("curl -i -H 'Authorization: Basic #{Base.encode64("ana@example.com:hello world!")}' http://localhost:4010/api/me")
O falso recebe 401; o bearer minúsculo passa, com 200; o Basic, que é o esquema de usuário e senha do HTTP, recebe 401, porque os 6 primeiros bytes são Basic e não bearer.
A sua vez
Na documentação: Your turn
O guia termina deixando o fluxo completo por sua conta, porque ele depende da aplicação. Para um cliente JavaScript, o UserSessionController pode chamar create_user_api_token/1 e devolver o token em JSON. Para clientes de terceiros, a aplicação precisa de uma tela em que o usuário crie tokens e veja o resultado uma única vez. Os dois exercícios abaixo são a primeira ideia, numa versão de API: um login que devolve um token e uma rota que revoga os tokens.
Trilha.pergunta(:rota_publica)
lib/hello_web/router.ex: dois pipelines. O :api volta a ter só o accepts, e o plug do token vai para um pipeline novo, :api_auth. O escopo público tem a rota que entrega tokens; o protegido passa pelos dois pipelines.
defmodule HelloWeb.Router do
use HelloWeb, :router
import HelloWeb.UserAuth
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {HelloWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :fetch_current_scope_for_user
end
pipeline :api do
plug :accepts, ["json"]
end
pipeline :api_auth do
plug :fetch_current_scope_for_api_user
end
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :home
end
# Em desenvolvimento: a caixa de entrada do Swoosh.
scope "/dev" do
pipe_through :browser
forward "/mailbox", Plug.Swoosh.MailboxPreview
end
## Authentication routes
scope "/", HelloWeb do
pipe_through [:browser, :redirect_if_user_is_authenticated]
get "/users/register", UserRegistrationController, :new
post "/users/register", UserRegistrationController, :create
end
scope "/", HelloWeb do
pipe_through [:browser, :require_authenticated_user]
get "/users/settings", UserSettingsController, :edit
put "/users/settings", UserSettingsController, :update
get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email
end
scope "/", HelloWeb do
pipe_through [:browser]
get "/users/log-in", UserSessionController, :new
get "/users/log-in/:token", UserSessionController, :confirm
post "/users/log-in", UserSessionController, :create
delete "/users/log-out", UserSessionController, :delete
end
scope "/api", HelloWeb do
pipe_through :api
post "/tokens", TokenController, :create
end
scope "/api", HelloWeb do
pipe_through [:api, :api_auth]
get "/me", MeController, :show
delete "/tokens", TokenController, :delete
end
end
Se esta célula falhar com undefined function fetch_current_scope_for_api_user/2, faça antes o exercício do plug.
lib/hello_web/controllers/token_controller.ex: os dois exercícios a seguir escrevem as actions deste controller.
defmodule HelloWeb.TokenController do
use HelloWeb, :controller
# Sua vez: create/2 e delete/2.
end
Sua vez: um login que devolve um token
Escreva create/2, com um alias Hello.Accounts no topo do módulo. Ela recebe um JSON como {"email": "...", "password": "..."}. Se Accounts.get_user_by_email_and_password/2 encontrar o usuário, responda 201 Created com {"data": {"token": "..."}}; se não, 401 com uma mensagem de erro em JSON. Não diga se o e-mail existe: o UserSessionController gerado responde "Invalid email or password" nos dois casos, para não ajudar quem tenta descobrir quais e-mails estão cadastrados.
Trilha.solucao(:login_api)
Trilha.conferir(:login_api)
Com o controller pronto, um cliente faz o caminho completo: pede o token e o usa.
Trilha.curl(~S"""
curl -iX POST http://localhost:4010/api/tokens \
-H 'Content-Type: application/json' \
-d '{"email": "bia@example.com", "password": "hello world!"}'
""")
resposta =
Trilha.curl!(~S"""
curl -iX POST http://localhost:4010/api/tokens \
-H 'Content-Type: application/json' \
-d '{"email": "bia@example.com", "password": "hello world!"}'
""")
token_da_bia = resposta.json["data"]["token"]
Trilha.curl("curl -i -H 'Authorization: Bearer #{token_da_bia}' http://localhost:4010/api/me")
Cada pedido cria um token novo: o servidor não tem como devolver um token antigo, porque só guarda o hash.
Sua vez: revogar os tokens
Um token vazou, ou o app foi desinstalado. O usuário precisa de um jeito de invalidar os tokens de API sem trocar a senha. Escreva:
Accounts.delete_user_api_tokens/1, que recebe um usuário e apaga as linhas dele com o contexto"api-token", e devolve:ok. Ela fica na célula doHello.Accounts, junto das funções de API. Não apague os tokens de sessão: o navegador da pessoa deve continuar logado.delete/2noTokenController, que chama a função com o usuário do escopo e responde204 No Content.
A rota DELETE /api/tokens já está no escopo protegido: só quem manda um token válido chega à action, e o escopo diz de quem são os tokens.
Trilha.solucao(:revogar)
Trilha.conferir(:revogar)
Uma última observação, que vale para qualquer API com token: o token é uma senha. Ele deve viajar só por HTTPS e ser guardado como se guarda uma senha. E um login por API, como o POST /api/tokens, merece um limite de tentativas, que o código gerado não traz.
Sem roteiro: a API do Brechó com token
O Brechó até aqui
A vitrine do Brechó, como o notebook 08 a deixou: a tabela produtos, o contexto Hello.Vitrine e as páginas /produtos. O router desta célula é o da seção anterior com as rotas da vitrine; como aquele, ele depende do exercício do plug.
priv/repo/migrations/20250320000000_create_produtos.exs (a do notebook 07)
defmodule Hello.Repo.Migrations.CreateProdutos do
use Ecto.Migration
def change do
create table(:produtos) do
add :nome, :string, null: false
add :preco_centavos, :integer, null: false
add :vendido, :boolean, default: false, null: false
timestamps(type: :utc_datetime)
end
end
end
Trilha.migracao(20250320000000, Hello.Repo.Migrations.CreateProdutos)
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)
lib/hello/vitrine/produto.ex e lib/hello/vitrine.ex (o contexto do notebook 08)
defmodule Hello.Vitrine.Produto do
use Ecto.Schema
import Ecto.Changeset
schema "produtos" do
field :nome, :string
field :preco_centavos, :integer
field :vendido, :boolean, default: false
timestamps(type: :utc_datetime)
end
@doc false
def changeset(produto, attrs) do
produto
|> cast(attrs, [:nome, :preco_centavos])
|> validate_required([:nome, :preco_centavos])
|> validate_number(:preco_centavos, greater_than: 0)
end
end
defmodule Hello.Vitrine do
@moduledoc """
A vitrine do Brechó: o que está à venda e a venda de cada peça.
"""
import Ecto.Query, warn: false
alias Hello.Repo
alias Hello.Vitrine.Produto
def listar_disponiveis do
Repo.all(from p in Produto, where: not p.vendido, order_by: p.preco_centavos)
end
def buscar_produto!(id), do: Repo.get!(Produto, id)
def cadastrar_produto(attrs) do
%Produto{}
|> Produto.changeset(attrs)
|> Repo.insert()
end
def change_produto(%Produto{} = produto, attrs \\ %{}) do
Produto.changeset(produto, attrs)
end
@doc """
Marca a peça como vendida, se ninguém a vendeu antes.
A verificação e a mudança acontecem num só UPDATE, então duas vendas
simultâneas da mesma peça nunca dão certo ao mesmo tempo.
"""
def vender(%Produto{id: id}) do
consulta = from p in Produto, where: p.id == ^id and not p.vendido, select: p
case Repo.update_all(consulta, set: [vendido: true, updated_at: DateTime.utc_now(:second)]) do
{1, [produto]} -> {:ok, produto}
{0, []} -> {:error, :ja_vendido}
end
end
end
lib/hello_web/router.ex: o router da seção anterior, com as rotas da vitrine no escopo do navegador.
defmodule HelloWeb.Router do
use HelloWeb, :router
import HelloWeb.UserAuth
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {HelloWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :fetch_current_scope_for_user
end
pipeline :api do
plug :accepts, ["json"]
end
pipeline :api_auth do
plug :fetch_current_scope_for_api_user
end
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :home
get "/produtos", ProdutoController, :index
post "/produtos", ProdutoController, :create
post "/produtos/:id/vender", ProdutoController, :vender
end
# Em desenvolvimento: a caixa de entrada do Swoosh.
scope "/dev" do
pipe_through :browser
forward "/mailbox", Plug.Swoosh.MailboxPreview
end
## Authentication routes
scope "/", HelloWeb do
pipe_through [:browser, :redirect_if_user_is_authenticated]
get "/users/register", UserRegistrationController, :new
post "/users/register", UserRegistrationController, :create
end
scope "/", HelloWeb do
pipe_through [:browser, :require_authenticated_user]
get "/users/settings", UserSettingsController, :edit
put "/users/settings", UserSettingsController, :update
get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email
end
scope "/", HelloWeb do
pipe_through [:browser]
get "/users/log-in", UserSessionController, :new
get "/users/log-in/:token", UserSessionController, :confirm
post "/users/log-in", UserSessionController, :create
delete "/users/log-out", UserSessionController, :delete
end
scope "/api", HelloWeb do
pipe_through :api
post "/tokens", TokenController, :create
end
scope "/api", HelloWeb do
pipe_through [:api, :api_auth]
get "/me", MeController, :show
delete "/tokens", TokenController, :delete
end
end
lib/hello_web/controllers/produto_controller.ex e produto_html.ex: as páginas da vitrine, em http://localhost:4010/produtos.
defmodule HelloWeb.ProdutoController do
use HelloWeb, :controller
alias Hello.Vitrine
def index(conn, _params) do
render(conn, :index, produtos: Vitrine.listar_disponiveis())
end
def create(conn, %{"produto" => attrs}) do
case Vitrine.cadastrar_produto(attrs) do
{:ok, produto} ->
conn |> put_status(201) |> json(%{id: produto.id})
{:error, changeset} ->
erros = Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end)
conn |> put_status(422) |> json(%{errors: erros})
end
end
def vender(conn, %{"id" => id}) do
case Vitrine.vender(Vitrine.buscar_produto!(id)) do
{:ok, produto} ->
conn |> put_flash(:info, "#{produto.nome} vendido.") |> redirect(to: ~p"/produtos")
{:error, :ja_vendido} ->
conn |> put_flash(:error, "Esta peça já foi vendida.") |> redirect(to: ~p"/produtos")
end
end
end
defmodule HelloWeb.ProdutoHTML do
use HelloWeb, :html
def index(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>Vitrine do Brechó</.header>
<.table id="produtos" rows={@produtos}>
<:col :let={produto} label="Peça">{produto.nome}</:col>
<:col :let={produto} label="Preço">{produto.preco_centavos} centavos</:col>
<:action :let={produto}>
<.link href={~p"/produtos/#{produto}/vender"} method="post">Vender</.link>
</:action>
</.table>
</Layouts.app>
"""
end
end
O desafio
Abra a vitrine para outros programas, com uma API em que qualquer um consulta e só quem tem token mexe:
- uma migration, versão
20250625000000, que acrescenta aprodutosa colunavendedor_id, com chave estrangeira parausers; ebelongs_to :vendedor, Hello.Accounts.Userno schemaHello.Vitrine.Produto; GET /api/produtos, pública: as peças à venda em JSON, emdata, cada uma comid,nome,preco_centavos,vendidoevendedor_id;POST /api/produtos, com token: cadastra a peça com{"produto": {"nome": ..., "preco_centavos": ...}}e grava como vendedor o dono do token; responde 201 com a peça, ou 422 com os erros do changeset;POST /api/produtos/:id/vender, com token: só quem cadastrou a peça pode marcá-la como vendida. Responda 200 com a peça ("vendido": true), 403 para outro usuário e 409 se ela já foi vendida.
Algumas pistas, sem roteiro:
- depois de mudar o schema, avalie de novo o
Hello.Vitrine: ele usa%Produto{}, e uma struct compilada antes da mudança não tem o campo novo (o notebook 09 mostrou esse erro); - funções com escopo, como
cadastrar_produto(scope, attrs)evender(scope, produto), deixam o controller sem regra de negócio; Vitrine.vender/1já resolve a venda simultânea e devolve{:error, :ja_vendido};- as rotas novas entram em dois escopos do router, o público e o protegido.
Escreva o seu código na célula abaixo, ou em quantas células quiser.
# O seu código do desafio.
Trilha.conferir(:brecho)
Trilha.solucao(:brecho)
No seu projeto
Num projeto gerado com mix phx.gen.auth Accounts User users, os passos do guia são:
- Em
lib/hello/accounts.ex, as funçõescreate_user_api_token/1efetch_user_by_api_token/1. - Em
test/hello/accounts_test.exs, odescribedo guia. Rodemix test test/hello/accounts_test.exse veja oUndefinedFunctionError. - Em
lib/hello/accounts/user_token.ex, o atributo@api_token_validity_in_days 365e a funçãoverify_api_token_query/1. Rode o teste de novo. - Em
lib/hello_web/user_auth.ex, o plugfetch_current_scope_for_api_user/2; emlib/hello_web/router.ex, o plug no pipeline de API. Se a aplicação tiver rotas de API públicas, separe dois pipelines, como na seção "A sua vez". - Para criar um token sem escrever tela nenhuma, use o
iex:
$ iex -S mix
iex> user = Hello.Accounts.get_user_by_email("voce@example.com")
iex> Hello.Accounts.create_user_api_token(user)
"sTpJg7rt-KQ9gZ7xLMtn2keusGk9N2JpPwkXDx7LmHU"
- Com o servidor no ar (
mix phx.server), teste no terminal, na porta 4000:
$ curl -i -H 'Authorization: Bearer SEU_TOKEN' http://localhost:4000/api/me
- O guia sugere testar o plug em
test/hello_web/user_auth_test.exs, com os testes dos outros plugs como modelo. Num teste de controller, o token vai no cabeçalho comput_req_header(conn, "authorization", "Bearer " <> token).
Revisão
- Um cliente de API não tem cookie de sessão; ele prova quem é mandando um token no cabeçalho
authorization, no formatoBearer TOKEN, a cada requisição. - Os tokens de API moram na tabela
users_tokens, com o contexto"api-token", e reaproveitam o que ela já faz: são ligados ao usuário, têm data de criação e são apagados quando a senha muda. create_user_api_token/1usabuild_email_token/2: o cliente recebe o token em Base64 uma única vez, e o banco guarda só o hash SHA-256. Quem lê o banco não consegue usar os tokens.verify_api_token_query/1calcula o hash do token recebido e só o aceita se tiver menos de 365 dias e se osent_toainda for o e-mail do usuário. Trocar o e-mail ou a senha invalida os tokens.fetch_*devolve{:ok, valor}ou:error;get_*devolve o valor ounil.fetch_current_scope_for_api_user/2põe o mesmocurrent_scopeque o login do navegador põe, então controllers e contextos não mudam. Quando o token falha, o plug responde 401 e chamahalt/1; sem ohalt, o resto do pipeline rodaria mesmo assim.- Rotas que entregam tokens precisam ficar fora do pipeline que exige token. Dois pipelines,
:apie:api_auth, resolvem. - Um token é uma senha: só por HTTPS, guardado com cuidado, com um jeito de revogá-lo.
Este notebook fecha o módulo 8. O 13 gerou a autenticação e mostrou o que ela faz com a sessão, o link mágico, a senha e o modo sudo. O 14 mostrou como o escopo leva o usuário até as consultas, para que cada um veja só os próprios dados. Este estendeu a mesma autenticação para clientes que não são navegadores. Nos três, o código é seu: o gerador escreve uma vez, e daí em diante cada mudança, como as deste notebook, é feita à mão.