Trilha Phoenix · mix phx.gen.auth
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__, tipo, _} = chave, _} <- :persistent_term.get(), tipo in [:cookies, :persistentes] do
:persistent_term.erase(chave)
end
:ok
end
@doc "Esquece os cookies de um visitante só."
def esquecer_visitante(quem) do
:persistent_term.erase({__MODULE__, :cookies, quem})
:persistent_term.erase({__MODULE__, :persistentes, quem})
: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)
# Guarda quais cookies têm validade (max_age); os outros somem quando o navegador fecha.
persistentes =
Enum.reduce(resp.resp_cookies, persistentes(quem), fn {nome, opcoes}, acc ->
if (opcoes[:max_age] || 0) > 0, do: MapSet.put(acc, nome), else: MapSet.delete(acc, nome)
end)
:persistent_term.put({__MODULE__, :persistentes, quem}, persistentes)
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
# --- autenticação -----------------------------------------------------------------
@doc """
Faz `quem` entrar como `email` pelo caminho de verdade: cria a conta, se ela
não existir, pede o link mágico (o e-mail vai para a caixa de entrada) e envia
o formulário de confirmação com o token do link, como o botão da página faria.
Com `lembrar: true`, marca a opção "stay logged in".
"""
def entrar(quem, email, opcoes \\ []) do
navegar(quem, :post, "/users/log-in", parametros_de_login(email, opcoes))
end
defp logar(quem, email, opcoes \\ []) do
visitar(quem, :post, "/users/log-in", parametros_de_login(email, opcoes))
end
defp parametros_de_login(email, opcoes) do
usuario =
apply(Hello.Accounts, :get_user_by_email, [email]) ||
case apply(Hello.Accounts, :register_user, [%{email: email}]) do
{:ok, usuario} -> usuario
{:error, changeset} -> raise "não deu para criar a conta #{email}: #{inspect(changeset.errors)}"
end
{:ok, _email} =
apply(Hello.Accounts, :deliver_login_instructions, [usuario, &"http://localhost:4010/users/log-in/#{&1}"])
token = email |> link_do_email() |> String.split("/") |> List.last()
usuario = %{"token" => token}
usuario = if opcoes[:lembrar], do: Map.put(usuario, "remember_me", "true"), else: usuario
%{"user" => usuario}
end
@doc "Os cookies que o navegador de `quem` guarda agora."
def mostrar_cookies(quem) do
persistentes = persistentes(quem)
linhas =
for {nome, valor} <- cookies(quem) do
validade = if nome in persistentes, do: "tem `max_age`: sobrevive ao fechar o navegador", else: "sem validade: some ao fechar o navegador"
"| `#{nome}` | #{validade} | #{byte_size(valor)} bytes |"
end
if linhas == [] do
Kino.Markdown.new("O navegador de #{quem} não tem nenhum cookie.")
else
Kino.Markdown.new(Enum.join(["**Cookies de #{quem}**", "", "| Cookie | Validade | Tamanho |", "| --- | --- | --- |" | linhas], "\n"))
end
end
@doc "Simula fechar o navegador: os cookies sem validade (como o da sessão) somem; os que têm max_age ficam."
def fechar_navegador(quem) do
persistentes = persistentes(quem)
restantes = cookies(quem) |> Enum.filter(fn {nome, _} -> nome in persistentes end) |> Map.new()
:persistent_term.put({__MODULE__, :cookies, quem}, restantes)
mostrar_cookies(quem)
end
defp persistentes(quem), do: :persistent_term.get({__MODULE__, :persistentes, quem}, MapSet.new())
@doc "Move para trás, em `minutos`, a hora da última autenticação de todas as sessões de `email`."
def envelhecer_sessoes(email, minutos) do
sql(
"""
UPDATE users_tokens SET authenticated_at = authenticated_at - make_interval(mins => $2)
WHERE context = 'session' AND user_id = (SELECT id FROM users WHERE email = $1)
""",
[email, minutos]
)
consultar(
"""
SELECT t.id, u.email, t.context, t.authenticated_at, t.inserted_at
FROM users_tokens t JOIN users u ON u.id = t.user_id
WHERE u.email = $1 AND t.context = 'session' ORDER BY t.id
""",
[email]
)
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
%{
authn_authz: %{
texto:
"Bia entra com o e-mail e a senha certos e tenta abrir `/admin`, uma página só para administradores. A aplicação responde `403 Forbidden`. Qual das duas etapas recusou o pedido?",
opcoes: [
a: "A autenticação: a aplicação não sabe quem é a Bia.",
b: "A autorização: a aplicação sabe quem é a Bia, e decidiu que ela não pode ver aquela página.",
c: "As duas: sem autorização não há autenticação.",
d: "Nenhuma: 403 é um erro do servidor."
],
correta: :b,
explicacao:
"Autenticação responde \"quem é você?\"; autorização responde \"o que você pode fazer?\". A Bia provou quem é (a autenticação deu certo) e recebeu um não para aquele recurso. Por convenção, 401 indica que falta autenticação e 403 que a autorização foi negada."
},
liveview_ou_nao: %{
texto:
"O `phx.gen.auth` pergunta se você quer o sistema com LiveView ou só com controllers. O que muda entre as duas respostas?",
opcoes: [
a: "Tudo: as tabelas, o contexto e as rotas são diferentes.",
b: "Só a camada web: as páginas são LiveViews ou controllers com templates. O contexto `Accounts`, os schemas, as tabelas e os caminhos das rotas são os mesmos.",
c: "A versão com controllers não tem link mágico.",
d: "A versão com LiveView não precisa de banco de dados."
],
correta: :b,
explicacao:
"O próprio guia diz: as duas abordagens criam o mesmo contexto e os mesmos schemas, com as mesmas tabelas e os mesmos caminhos de rota. A trilha usa a versão com controllers (`--no-live`) porque o LiveView vem mais adiante; tudo o que este notebook mostra sobre `Accounts`, tokens e segurança vale para as duas."
},
token_hash: %{
texto:
"O e-mail de confirmação traz um link com um token, como `/users/log-in/QNqk3SOw...`. O que fica gravado na coluna `users_tokens.token` para esse link?",
opcoes: [
a: "O mesmo texto do link, para comparar quando a pessoa clicar.",
b: "O hash SHA-256 dos bytes do token. Quem lê o banco não consegue montar o link a partir dele.",
c: "Nada: o token só existe no e-mail.",
d: "O token cifrado com a `secret_key_base`."
],
correta: :b,
explicacao:
"`build_email_token/2` sorteia 32 bytes, manda ao usuário a versão em Base64 e grava só `:crypto.hash(:sha256, token)`. Ao clicar, a aplicação calcula o hash do que chegou e procura. Um vazamento de leitura do banco não entrega links válidos. Os tokens de sessão são diferentes: ficam guardados como vieram, porque só viajam dentro do cookie assinado."
},
responsabilidade: %{
texto:
"Sai uma versão nova do Phoenix com uma melhoria de segurança no código que o `phx.gen.auth` gera. Você atualiza a dependência `phoenix` no `mix.exs`. O que acontece com o `UserAuth` e o `Accounts` do seu projeto?",
opcoes: [
a: "São atualizados junto com a dependência.",
b: "O `mix deps.get` pergunta se você quer aplicar a melhoria.",
c: "Nada: o código gerado é seu. Cabe a você ler o CHANGELOG e trazer a mudança para o seu projeto.",
d: "Param de compilar até você rodar o gerador de novo."
],
correta: :c,
explicacao:
"O gerador copia o código para dentro da aplicação, em vez de deixá-lo numa biblioteca. A vantagem é a liberdade de mudar o que quiser; o preço é que nada se atualiza sozinho. O guia promete marcar com clareza, no `CHANGELOG.md` e nas notas de atualização, as mudanças de segurança."
},
sudo: %{
texto:
"Ana entrou pelo link mágico há 15 minutos, não fechou o navegador e agora abre `/users/settings`. O que acontece?",
opcoes: [
a: "A página abre normalmente: ela continua logada.",
b: "Ela é mandada para `/users/log-in` com \"You must re-authenticate to access this page.\": as configurações exigem uma autenticação dos últimos 10 minutos.",
c: "Ela é deslogada e perde a sessão.",
d: "A página abre, mas sem o formulário de senha."
],
correta: :b,
explicacao:
"O `UserSettingsController` tem `plug :require_sudo_mode`, que chama `Accounts.sudo_mode?(user, -10)`: a última autenticação (`authenticated_at`, guardado com o token da sessão) precisa ter sido nos últimos 10 minutos. Ana continua logada para o resto do site; só as ações sensíveis pedem que ela prove de novo que é ela."
},
troca_de_senha: %{
texto:
"Ana está logada no celular e no notebook. No notebook, ela troca a senha. O que acontece no celular?",
opcoes: [
a: "Nada: cada aparelho tem a sua sessão.",
b: "O celular continua logado até o cookie expirar, em 14 dias.",
c: "A sessão do celular deixa de valer: trocar a senha apaga todos os tokens da Ana, e na próxima requisição o celular é tratado como visitante.",
d: "O celular recebe um e-mail pedindo a senha nova."
],
correta: :c,
explicacao:
"`update_user_password/2` passa por `update_user_and_delete_all_tokens/1`, que apaga todos os tokens do usuário, inclusive os de sessão. O cookie do celular ainda traz um token, mas ele não existe mais em `users_tokens`. O notebook continua logado porque o controller chama `UserAuth.log_in_user/2` logo depois e cria uma sessão nova."
},
enumeracao: %{
texto:
"Alguém quer descobrir se `bia@example.com` tem conta. Qual destas telas, do jeito que foram geradas, entrega a resposta?",
opcoes: [
a: "O pedido de link mágico em \"Log in with email\".",
b: "O login com e-mail e senha.",
c: "O cadastro em `/users/register`.",
d: "Nenhuma: o gerador protege todas."
],
correta: :c,
explicacao:
"O pedido de link mágico responde sempre \"If your email is in our system...\", e o login com senha responde sempre \"Invalid email or password\". O cadastro, porém, mostra \"has already been taken\" quando o e-mail existe. O guia avisa: o código gerado não tenta se proteger de enumeração, porque a proteção muda muito de uma aplicação para outra."
},
pre_cadastro: %{
texto:
"Antes que a Gabi crie a conta dela, um atacante se cadastra com `gabi@example.com`. O que o atacante consegue fazer com essa conta?",
opcoes: [
a: "Entrar e definir uma senha, para usar depois que a Bia confirmar.",
b: "Nada: entrar exige o link enviado para o e-mail da Gabi, e só quem entrou pode definir uma senha.",
c: "Trocar o e-mail da conta para o dele.",
d: "Entrar sem confirmação, porque a conta é nova."
],
correta: :b,
explicacao:
"No 1.8, o cadastro só pede o e-mail. A única porta de entrada de uma conta não confirmada é o link mágico, que vai para a caixa da Gabi; e a senha só se define nas configurações, depois de entrar. Quando a Gabi clicar no link, `login_user_by_magic_link/1` confirma a conta e apaga todos os tokens que existiam."
},
citext: %{
texto:
"Ana se cadastrou como `ana@example.com` e definiu uma senha. Ela tenta entrar digitando `ANA@Example.COM` e a senha certa. O que acontece?",
opcoes: [
a: "Dá \"Invalid email or password\": o e-mail é outro.",
b: "Ela entra: a coluna `email` é `citext`, e a comparação ignora maiúsculas e minúsculas.",
c: "Ela entra, porque o controller converte o e-mail para minúsculas antes de buscar.",
d: "Dá erro de banco de dados."
],
correta: :b,
explicacao:
"Nenhuma linha do código Elixir converte o e-mail. Quem resolve é o tipo da coluna: `citext` (case-insensitive text) compara textos ignorando a caixa, e o índice único também. Por isso a migration começa com `CREATE EXTENSION IF NOT EXISTS citext`."
}
}
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
%{
senha: ~S'''
# Na célula do Hello.Accounts.User, em validate_password/2:
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
''',
painel: ~S'''
# No router, no scope que passa por :require_authenticated_user:
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
get "/painel", PainelController, :index
end
# No HelloWeb.PainelHTML:
def index(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<.header>Painel</.header>
<p>Olá, {@current_scope.user.email}!</p>
</Layouts.app>
"""
end
''',
seguranca: ~S'''
# No router, um scope novo. A ordem importa: require_sudo_mode lê
# current_scope.user, então require_authenticated_user vem antes.
scope "/", HelloWeb do
pipe_through [:browser, :require_authenticated_user, :require_sudo_mode]
get "/painel/seguranca", PainelController, :seguranca
end
''',
email_pt: ~S'''
# Na célula do Hello.Accounts.UserNotifier:
defp deliver_confirmation_instructions(user, url) do
deliver(user.email, "Confirme sua conta", """
==============================
Olá, #{user.email}!
Para confirmar a sua conta, acesse o endereço abaixo:
#{url}
Se você não criou uma conta, ignore este e-mail.
==============================
""")
end
''',
brecho: ~S'''
# Na célula do Hello.Accounts, no fim do módulo:
## Sessions
@doc "Lists the user's active sessions, newest first."
def list_user_sessions(%User{} = user) do
Repo.all(
from t in UserToken,
where: t.user_id == ^user.id and t.context == "session",
order_by: [desc: t.inserted_at, desc: t.id]
)
end
@doc "Deletes one of the user's sessions."
def delete_user_session(%User{} = user, id) do
query = from t in UserToken, where: t.id == ^id and t.user_id == ^user.id and t.context == "session"
case Repo.delete_all(query) do
{1, _} -> :ok
{0, _} -> {:error, :not_found}
end
end
# No router, no scope com :require_authenticated_user:
get "/users/sessions", UserSessionsController, :index
delete "/users/sessions/:id", UserSessionsController, :delete
# O controller e a view:
defmodule HelloWeb.UserSessionsController do
use HelloWeb, :controller
alias Hello.Accounts
def index(conn, _params) do
sessions = Accounts.list_user_sessions(conn.assigns.current_scope.user)
render(conn, :index, sessions: sessions, current_token: get_session(conn, :user_token))
end
def delete(conn, %{"id" => id}) do
case Accounts.delete_user_session(conn.assigns.current_scope.user, id) do
:ok ->
conn |> put_flash(:info, "Sessão encerrada.") |> redirect(to: ~p"/users/sessions")
{:error, :not_found} ->
conn |> put_flash(:error, "Sessão não encontrada.") |> redirect(to: ~p"/users/sessions")
end
end
end
defmodule HelloWeb.UserSessionsHTML do
use HelloWeb, :html
def index(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<.header>
Suas sessões
<:subtitle>Cada linha é um navegador onde você entrou.</:subtitle>
</.header>
<.table id="sessions" rows={@sessions}>
<:col :let={session} label="Entrou em">{session.inserted_at}</:col>
<:col :let={session} label="Última autenticação">{session.authenticated_at}</:col>
<:col :let={session} label="">
<span :if={session.token == @current_token}>Esta sessão</span>
</:col>
<:action :let={session}>
<.link
:if={session.token != @current_token}
href={~p"/users/sessions/#{session.id}"}
method="delete"
>
Encerrar
</.link>
</:action>
</.table>
</Layouts.app>
"""
end
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
%{
senha: [
{"Uma senha só de letras recebe o erro \"at least one digit or punctuation character\".",
fn ->
case erros_de_senha("senhasemnenhumnumero") do
[] ->
{:erro, "A senha `senhasemnenhumnumero` foi aceita. Tire o `#` da linha do `validate_format` que pede um dígito ou pontuação e avalie a célula do `User`."}
mensagens ->
if Enum.any?(mensagens, &(&1 =~ "digit or punctuation")),
do: :ok,
else: {:erro, "Os erros foram #{inspect(mensagens)}. Use a mensagem do código gerado: `at least one digit or punctuation character`."}
end
end},
{"Uma senha com número, como `senha com 1 número`, continua válida.",
fn ->
case erros_de_senha("senha com 1 número") do
[] -> :ok
mensagens -> {:erro, "A senha foi recusada com #{inspect(mensagens)}. A regra nova só deve exigir um dígito ou uma pontuação."}
end
end},
{"As regras que já existiam continuam: `curta1` é curta demais.",
fn ->
if Enum.any?(erros_de_senha("curta1"), &(&1 =~ "at least 12")),
do: :ok,
else: {:erro, "`curta1` deveria receber `should be at least 12 character(s)`. O `validate_length` continua no `validate_password/2`?"}
end}
],
painel: [
{"Um visitante sem login, em `GET /painel`, é mandado para `/users/log-in`.",
fn ->
esquecer_visitante("conferência anônima")
case sem_seguir("conferência anônima", :get, "/painel") do
{:ok, %{status: 302} = conn} ->
if Plug.Conn.get_resp_header(conn, "location") == ["/users/log-in"],
do: :ok,
else: {:erro, "O redirect foi para #{inspect(Plug.Conn.get_resp_header(conn, "location"))}."}
{:ok, %{status: 200}} ->
{:erro, "A página abriu sem login. A rota está num `scope` que não passa por `:require_authenticated_user`."}
{:ok, conn} ->
{:erro, "A resposta veio com status `#{conn.status}`."}
{:erro, erro} ->
{:erro, explicar(erro)}
end
end},
{"Quem entrou vê o próprio e-mail na página.",
fn ->
{:ok, _} = logar("conferência painel", "painel@example.com")
case visitar("conferência painel", :get, "/painel") do
{:ok, %{status: 200, resp_body: corpo}} ->
if to_string(corpo) =~ "painel@example.com",
do: :ok,
else: {:erro, "A página abriu, mas não mostra `painel@example.com`. No template, o e-mail está em `@current_scope.user.email`."}
{:ok, conn} ->
{:erro, "A resposta final veio com status `#{conn.status}` em `#{conn.request_path}`."}
{:erro, erro} ->
{:erro, explicar(erro)}
end
end},
{"Quem tenta abrir `/painel` sem login volta para `/painel` depois de entrar.",
fn ->
esquecer_visitante("conferência volta")
sem_seguir("conferência volta", :get, "/painel")
case logar("conferência volta", "painel@example.com") do
{:ok, %{request_path: "/painel", status: 200}} -> :ok
{:ok, conn} -> {:erro, "Depois do login, a navegação terminou em `#{conn.request_path}` (status #{conn.status})."}
{:erro, erro} -> {:erro, explicar(erro)}
end
end}
],
seguranca: [
{"Logo depois de entrar, `GET /painel/seguranca` responde 200.",
fn ->
{:ok, _} = logar("conferência sudo", "sudo@example.com")
case sem_seguir("conferência sudo", :get, "/painel/seguranca") do
{:ok, %{status: 200}} -> :ok
{:ok, %{status: 302} = conn} -> {:erro, "Foi redirecionado para #{inspect(Plug.Conn.get_resp_header(conn, "location"))} logo depois de entrar."}
{:ok, conn} -> {:erro, "A resposta veio com status `#{conn.status}`."}
{:erro, erro} -> {:erro, explicar(erro)}
end
end},
{"Com a última autenticação de 11 minutos atrás, a página manda reautenticar.",
fn ->
sql(
"""
UPDATE users_tokens SET authenticated_at = authenticated_at - interval '11 minutes'
WHERE context = 'session' AND user_id = (SELECT id FROM users WHERE email = $1)
""",
["sudo@example.com"]
)
case visitar("conferência sudo", :get, "/painel/seguranca") do
{:ok, %{request_path: "/painel/seguranca", status: 200}} ->
{:erro, "A página abriu com uma autenticação de 11 minutos atrás. Falta `:require_sudo_mode` na lista do `pipe_through`."}
{:ok, %{request_path: "/users/log-in"} = conn} ->
if conn.assigns.flash["error"] == "You must re-authenticate to access this page.",
do: :ok,
else: {:erro, "Foi parar no login com a flash #{inspect(conn.assigns.flash)}; quem manda reautenticar é o `:require_sudo_mode`."}
{:ok, conn} ->
{:erro, "A navegação terminou em `#{conn.request_path}`, com status `#{conn.status}`."}
{:erro, erro} ->
{:erro, explicar(erro)}
end
end},
{"Um visitante sem login é mandado para o login com \"You must log in to access this page.\"",
fn ->
esquecer_visitante("conferência anônima")
case visitar("conferência anônima", :get, "/painel/seguranca") do
{:ok, %{request_path: "/users/log-in"} = conn} ->
if conn.assigns.flash["error"] == "You must log in to access this page.",
do: :ok,
else: {:erro, "A flash foi #{inspect(conn.assigns.flash)}. O `:require_authenticated_user` vem antes do `:require_sudo_mode`?"}
{:ok, conn} ->
{:erro, "A navegação terminou em `#{conn.request_path}`, com status `#{conn.status}`."}
{:erro, erro} ->
{:erro,
explicar(erro) <>
"\n\n O `require_sudo_mode/2` lê `current_scope.user`; sem ninguém logado, o `current_scope` é `nil`. Ponha `:require_authenticated_user` antes dele."}
end
end}
],
email_pt: [
{"O cadastro manda o e-mail de confirmação em português, com o link.",
fn ->
email = "conferencia.pt@example.com"
apagar_usuario(email)
esquecer_visitante("conferência e-mail")
try do
case visitar("conferência e-mail", :post, "/users/register", %{"user" => %{"email" => email}}) do
{:ok, _} ->
case email_para(email) do
nil ->
{:erro, "Nenhum e-mail chegou para `#{email}`."}
%{subject: "Confirmation instructions"} ->
{:erro, "O assunto ainda é `Confirmation instructions`. Traduza o assunto e o texto em `deliver_confirmation_instructions/2` e avalie a célula do `UserNotifier`."}
%{text_body: corpo} = enviado ->
cond do
not (corpo =~ "/users/log-in/") -> {:erro, "O texto não traz o link. Mantenha `\#{url}` no corpo do e-mail."}
corpo =~ "If you didn't create an account" -> {:erro, "O assunto mudou (`#{enviado.subject}`), mas o texto ainda está em inglês."}
true -> :ok
end
end
{:erro, erro} ->
{:erro, explicar(erro)}
end
after
apagar_usuario(email)
end
end},
{"O e-mail de login de quem já confirmou a conta continua o mesmo.",
fn ->
email = "conferencia.pt@example.com"
apagar_usuario(email)
try do
{:ok, usuario} = apply(Hello.Accounts, :register_user, [%{email: email}])
usuario = %{usuario | confirmed_at: DateTime.utc_now(:second)}
{:ok, _} = apply(Hello.Accounts, :deliver_login_instructions, [usuario, &"http://localhost:4010/users/log-in/#{&1}"])
case email_para(email) do
%{subject: "Log in instructions"} -> :ok
outro -> {:erro, "Para uma conta confirmada, o assunto deveria continuar `Log in instructions`; veio `#{outro && outro.subject}`."}
end
after
apagar_usuario(email)
end
end}
],
brecho: [
{"`Hello.Accounts` define `list_user_sessions/1` e `delete_user_session/2`.",
fn -> exporta(Hello.Accounts, list_user_sessions: 1, delete_user_session: 2) end},
{"Duas entradas da mesma conta, em dois navegadores, aparecem como duas sessões.",
fn ->
preparar_sessoes()
usuario = apply(Hello.Accounts, :get_user_by_email, ["dona@brecho.example"])
case apply(Hello.Accounts, :list_user_sessions, [usuario]) do
[_, _] = sessoes ->
if Enum.all?(sessoes, &(&1.context == "session")),
do: :ok,
else: {:erro, "A lista traz tokens que não são de sessão: #{inspect(Enum.map(sessoes, & &1.context))}. Filtre por `context == \"session\"`."}
outra ->
{:erro, "Esperadas 2 sessões, vieram #{length(List.wrap(outra))}. Filtre os tokens do usuário com `context == \"session\"`."}
end
end},
{"`GET /users/sessions` lista as sessões e marca \"Esta sessão\" uma vez só.",
fn ->
preparar_sessoes()
case sem_seguir("brechó celular", :get, "/users/sessions") do
{:ok, %{status: 200, resp_body: corpo}} ->
case length(String.split(to_string(corpo), "Esta sessão")) - 1 do
1 -> :ok
0 -> {:erro, "A página não marca a sessão atual com o texto `Esta sessão`. Compare `session.token` com `get_session(conn, :user_token)`."}
n -> {:erro, "`Esta sessão` aparece #{n} vezes; deveria marcar só a sessão de quem está vendo a página."}
end
{:ok, conn} ->
{:erro, "A resposta veio com status `#{conn.status}`."}
{:erro, erro} ->
{:erro, explicar(erro)}
end
end},
{"Encerrar a sessão do notebook pelo celular desloga o notebook.",
fn ->
preparar_sessoes()
[_celular, notebook] = sessoes_de("dona@brecho.example")
with {:ok, _} <- visitar("brechó celular", :delete, "/users/sessions/#{notebook}"),
{:ok, conn} <- sem_seguir("brechó notebook", :get, "/users/sessions") do
cond do
coluna("SELECT id FROM users_tokens WHERE id = $1", [notebook]) != [] ->
{:erro, "O token #{notebook} continua em `users_tokens`. `delete_user_session/2` precisa apagá-lo."}
conn.status == 302 and Plug.Conn.get_resp_header(conn, "location") == ["/users/log-in"] ->
:ok
true ->
{:erro, "O token sumiu, mas o notebook recebeu status `#{conn.status}` em vez do redirect para o login."}
end
else
{:erro, erro} -> {:erro, explicar(erro)}
end
end},
{"Outra pessoa não consegue encerrar uma sessão que não é dela.",
fn ->
preparar_sessoes()
[celular, _notebook] = sessoes_de("dona@brecho.example")
{:ok, _} = logar("brechó intrusa", "intrusa@brecho.example")
case visitar("brechó intrusa", :delete, "/users/sessions/#{celular}") do
{:erro, %Ecto.NoResultsError{}} ->
if coluna("SELECT id FROM users_tokens WHERE id = $1", [celular]) == [celular], do: :ok, else: {:erro, "A sessão de outra pessoa foi apagada."}
_ ->
if coluna("SELECT id FROM users_tokens WHERE id = $1", [celular]) == [celular],
do: :ok,
else: {:erro, "A intrusa apagou a sessão da dona. Em `delete_user_session/2`, filtre também por `user_id == ^user.id`."}
end
end},
{"Um visitante sem login, em `/users/sessions`, é mandado para o login.",
fn ->
esquecer_visitante("conferência anônima")
case sem_seguir("conferência anônima", :get, "/users/sessions") do
{:ok, %{status: 302} = conn} ->
if Plug.Conn.get_resp_header(conn, "location") == ["/users/log-in"], do: :ok, else: {:erro, "O redirect foi para outro lugar."}
{:ok, conn} ->
{:erro, "A resposta veio com status `#{conn.status}`. As rotas ficam no scope com `:require_authenticated_user`."}
{:erro, erro} ->
{:erro, explicar(erro)}
end
end}
]
}
end
# --- ajudantes da conferência --------------------------------------------------
defp erros_de_senha(senha) do
changeset =
apply(Hello.Accounts.User, :password_changeset, [
struct(Hello.Accounts.User),
%{"password" => senha},
[hash_password: false]
])
for {:password, {mensagem, opcoes}} <- changeset.errors do
Enum.reduce(opcoes, mensagem, fn {chave, valor}, acc -> String.replace(acc, "%{#{chave}}", fn _ -> to_string(valor) end) end)
end
end
# Uma requisição com os cookies de `quem`, sem seguir o redirect.
defp sem_seguir(quem, metodo, caminho, parametros \\ nil) 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)
with {:ok, resposta} <- resultado do
guardar_cookies(quem, resposta)
end
resultado
end
defp apagar_usuario(email), do: sql("DELETE FROM users WHERE email = $1", [email])
defp preparar_sessoes do
apagar_usuario("dona@brecho.example")
apagar_usuario("intrusa@brecho.example")
Enum.each(["brechó celular", "brechó notebook", "brechó intrusa"], &esquecer_visitante/1)
{:ok, _} = logar("brechó celular", "dona@brecho.example")
{:ok, _} = logar("brechó notebook", "dona@brecho.example")
:ok
end
defp sessoes_de(email) do
coluna(
"""
SELECT t.id FROM users_tokens t JOIN users u ON u.id = t.user_id
WHERE u.email = $1 AND t.context = 'session' ORDER BY t.id
""",
[email]
)
end
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 coluna(consulta, parametros), do: consulta |> sql(parametros) |> Map.fetch!(:rows) |> Enum.map(&hd/1)
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 abre o módulo de autenticação da trilha. Ele acompanha dois guias da documentação do Phoenix 1.8, na mesma ordem e com o link de cada seção: o curto Introduction to Auth e o mix phx.gen.auth. Os notebooks 14 (escopos) e 15 (autenticação de APIs) completam o módulo.
Nos notebooks 10 e 11, a autenticação era uma versão reduzida, que só pedia o e-mail. Aqui entra o sistema que o mix phx.gen.auth gera de verdade, sem cortes: cadastro com confirmação por e-mail, login por link mágico, senha opcional, "modo sudo" e o rastreamento de sessões no banco. O código das células é o que o gerador do Phoenix 1.8.16 escreveu num projeto real.
Três novidades em relação aos notebooks anteriores:
- A instalação mudou. A lista de dependências ganhou o
swoosh(envio de e-mails) e opbkdf2_elixir(hash de senhas). A primeira avaliação da célula do topo faz uma instalação nova, que leva alguns minutos; os notebooks 14 e 15 usam a mesma lista e reaproveitam a instalação. - E-mails de mentira. Em desenvolvimento, o Swoosh guarda cada e-mail na memória em vez de enviá-lo. Você os lê em http://localhost:4010/dev/mailbox, como num projeto gerado, ou com
Trilha.caixa_de_entrada()eTrilha.ultimo_email/1. - A extensão
citextdo PostgreSQL. A migration gerada precisa dela. No Fedora, ela vem no pacotepostgresql-contrib, que não é instalado junto com o servidor. A seção "Começando" verifica isso e mostra o comando.
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 nos notebooks 08 a 12, Trilha.navegar(quem, metodo, caminho, parametros) faz requisições como um navegador: cada nome ("ana", "bia") tem os seus próprios cookies, e os redirects são seguidos. Neste notebook isso importa mais do que nunca, porque o login mora nos cookies.
A aplicação fica em http://localhost:4010. Se outro notebook da trilha estiver com o runtime ligado, desconecte-o antes.
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, 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()
Introdução à autenticação
Na documentação: Introduction to Auth
Autenticação (authentication, abreviada como authn) e autorização (authorization, authz) são dois conceitos centrais de segurança. Autenticação é verificar a identidade de um usuário ou de um sistema. Autorização é conceder ou negar acesso a recursos com base nessa identidade e nas permissões dela.
Trilha.pergunta(:authn_authz)
O Phoenix ajuda com as duas. Em geral, quem desenvolve usa o gerador mix phx.gen.auth para montar a autenticação e a base da autorização. Bibliotecas de terceiros, como o Ueberauth (login com Google, GitHub e outros provedores), podem complementar esse sistema ou substituí-lo.
A documentação tem três guias sobre o assunto, e cada um vira um notebook da trilha:
- mix phx.gen.auth, este notebook: o gerador e as considerações de segurança;
- Scopes, o notebook 14: o mecanismo que o Phoenix 1.8 introduziu para limitar o acesso aos dados de acordo com quem está pedindo;
- API Authentication, o notebook 15: autenticação por token para APIs, construída sobre o código do gerador.
O gerador mix phx.gen.auth
Na documentação: mix phx.gen.auth
O mix phx.gen.auth gera, dentro da sua aplicação, um sistema de autenticação flexível e pronto. Assim você não perde tempo com a parte que toda aplicação tem e se concentra no problema que a sua aplicação resolve. O sistema gerado tem:
- cadastro de usuário com confirmação da conta por e-mail;
- login por link mágico (magic link), um link de uso único enviado por e-mail;
- login por senha, opcional: cada pessoa decide se quer definir uma;
- "modo sudo", ou autenticação privilegiada: antes de uma ação sensível, a pessoa precisa confirmar de novo quem é.
Começando
Na documentação: Getting started
O guia recomenda fazer um commit antes, porque o comando cria muitos arquivos. Na raiz do projeto:
$ mix phx.gen.auth Accounts User users
An authentication system can be created in two different ways:
- Using Phoenix.LiveView (default)
- Using Phoenix.Controller only
Do you want to create a LiveView based authentication system? [Y/n]
O primeiro argumento é o módulo do contexto; os outros dois são o módulo do schema e o nome no plural, que vira o nome da tabela. O comando acima gera um contexto Accounts com dois schemas dentro, User e UserToken. O contexto agrupa tudo o que diz respeito à autenticação; os nomes ficam a seu critério.
A pergunta é sobre a camada web.
Trilha.pergunta(:liveview_ou_nao)
O guia recomenda responder Y, pela experiência mais fluida do LiveView. A trilha responde n, porque o LiveView só chega num módulo adiante. Responder n equivale a passar --no-live. A trilha também passa --hashing-lib pbkdf2, assunto da seção "Hash de senhas" mais abaixo. O comando que gerou o código deste notebook, num projeto Phoenix 1.8.16 novo, foi:
$ mix phx.gen.auth Accounts User users --no-live --hashing-lib pbkdf2
* creating priv/repo/migrations/20261111020432_create_users_auth_tables.exs
* creating lib/hello/accounts/user_notifier.ex
* creating lib/hello/accounts/user.ex
* creating lib/hello/accounts/user_token.ex
* creating lib/hello_web/user_auth.ex
* creating test/hello_web/user_auth_test.exs
* creating lib/hello_web/controllers/user_session_controller.ex
* creating test/hello_web/controllers/user_session_controller_test.exs
* creating lib/hello/accounts/scope.ex
* creating lib/hello_web/controllers/user_registration_html/new.html.heex
* creating lib/hello_web/controllers/user_registration_controller.ex
* creating test/hello_web/controllers/user_registration_controller_test.exs
* creating lib/hello_web/controllers/user_registration_html.ex
* creating lib/hello_web/controllers/user_session_html.ex
* creating lib/hello_web/controllers/user_session_html/new.html.heex
* creating lib/hello_web/controllers/user_session_html/confirm.html.heex
* creating lib/hello_web/controllers/user_settings_html.ex
* creating lib/hello_web/controllers/user_settings_controller.ex
* creating lib/hello_web/controllers/user_settings_html/edit.html.heex
* creating test/hello_web/controllers/user_settings_controller_test.exs
* creating lib/hello/accounts.ex
* injecting lib/hello/accounts.ex
* creating test/hello/accounts_test.exs
* injecting test/hello/accounts_test.exs
* creating test/support/fixtures/accounts_fixtures.ex
* injecting test/support/fixtures/accounts_fixtures.ex
* injecting test/support/conn_case.ex
* injecting config/test.exs
* injecting config/config.exs
* injecting mix.exs
* injecting lib/hello_web/router.ex
* injecting lib/hello_web/router.ex - imports
* injecting lib/hello_web/router.ex - plug
* injecting lib/hello_web/components/layouts/root.html.heex
* injecting AGENTS.md
Please re-fetch your dependencies with the following command:
$ mix deps.get
Remember to update your repository by running migrations:
$ mix ecto.migrate
Once you are ready, visit "/users/register"
to create your account and then access "/dev/mailbox" to
see the account confirmation email.
Repare nas linhas injecting: além de criar arquivos, o gerador mexe em arquivos que já existiam. Em mix.exs, acrescenta a biblioteca de hash de senhas; em config/config.exs, registra o escopo user (o notebook 14 trata disso); em config/test.exs, deixa o hash rápido nos testes; no router e no root layout, acrescenta o que você verá mais abaixo. Cerca de metade das 2.700 linhas geradas são testes.
Como o gerador instalou uma dependência nova, o guia manda buscá-la e rodar a migration:
$ mix deps.get
$ mix ecto.migrate
Aqui, a dependência já veio na célula de instalação. Falta o banco. A migration cria a extensão citext do PostgreSQL; confira primeiro se o seu servidor a tem:
Trilha.verificar_citext()
Se a resposta for que o servidor não tem a extensão, rode os dois comandos que a célula mostra, num terminal, e avalie a célula de novo. A seção "Maiúsculas e minúsculas", perto do fim, explica para que ela serve.
priv/repo/migrations/20250610120000_create_users_auth_tables.exs: a migration gerada. A primeira linha cria a extensão citext; o segundo argumento de execute/2, vazio, é o que fazer no rollback (nada: a extensão fica). Como nos notebooks anteriores, a última linha registra a versão que, no projeto, viria do nome do arquivo.
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)
A migration cria duas tabelas. users guarda as contas:
Trilha.descrever("users")
email é do tipo citext, obrigatório e com índice único. hashed_password aceita NULL: no 1.8, a senha é opcional, e uma conta nova não tem nenhuma. confirmed_at fica vazio até a pessoa confirmar o e-mail.
users_tokens guarda os tokens: as sessões abertas, os links mágicos enviados e os pedidos de troca de e-mail. A coluna context diz de que tipo é cada um.
Trilha.descrever("users_tokens")
A chave estrangeira para users tem ON DELETE CASCADE: apagar uma conta apaga os tokens dela. O índice único em (context, token) garante que um token não se repete dentro do mesmo tipo.
O código gerado, peça por peça
O resto do comando são módulos Elixir. Eles vêm abaixo, um por célula, na ordem em que precisam ser compilados. As células são longas, mas vale a pena lê-las: o código gerado é seu, e saber o que ele faz é a primeira responsabilidade de quem o gera.
lib/hello/mailer.ex: o mailer da aplicação. Um projeto criado com mix phx.new já vem com ele; o phx.gen.auth só o usa. Em desenvolvimento, o adaptador Swoosh.Adapters.Local (configurado na célula de instalação) guarda os e-mails na memória.
defmodule Hello.Mailer do
use Swoosh.Mailer, otp_app: :hello
end
lib/hello/accounts/user.ex: o schema dos usuários. Os detalhes que importam:
passwordé um campo virtual: existe na struct e no changeset, mas não tem coluna. O que vai para o banco éhashed_password. As duas têmredact: true, que esconde o valor eminspecte nos logs.authenticated_attambém é virtual. Ele vem do token da sessão, e é com ele que o modo sudo sabe quando foi a última autenticação.email_changeset/3valida o formato, o tamanho e a unicidade do e-mail;password_changeset/3exige de 12 a 72 caracteres e calcula o hash comPbkdf2.hash_pwd_salt/1, apagando a senha em texto do changeset.valid_password?/2compara a senha com o hash. Quando não há usuário ou senha, ela chamaPbkdf2.no_user_verify/0, que gasta o mesmo tempo de uma verificação de verdade. Sem isso, a resposta mais rápida entregaria quais e-mails existem.
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
@doc """
A user changeset for registering or changing the email.
It requires the email to change otherwise an error is added.
## Options
* `:validate_unique` - Set to false if you don't want to validate the
uniqueness of the email, useful when displaying live validations.
Defaults to `true`.
"""
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
@doc """
A user changeset for changing the password.
It is important to validate the length of the password, as long passwords may
be very expensive to hash for certain algorithms.
## Options
* `:hash_password` - Hashes the password so it can be stored securely
in the database and ensures the password field is cleared to prevent
leaks in the logs. If password hashing is not needed and clearing the
password field is not desired (like when using this changeset for
validations on a LiveView form), this option can be set to `false`.
Defaults to `true`.
"""
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
@doc """
Confirms the account by setting `confirmed_at`.
"""
def confirm_changeset(user) do
now = DateTime.utc_now(:second)
change(user, confirmed_at: now)
end
@doc """
Verifies the password.
If there is no user or the user doesn't have a password, we call
`Pbkdf2.no_user_verify/0` to avoid timing attacks.
"""
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
Veja o redact funcionando: um changeset com senha, inspecionado, não mostra a senha.
changeset =
Hello.Accounts.User.password_changeset(%Hello.Accounts.User{}, %{password: "curta"}, hash_password: false)
IO.inspect(changeset, label: "changeset")
changeset.errors
O inspect mostra password: "**redacted**" nas mudanças. O erro é should be at least %{count} character(s), com count: 12 nas opções: o changeset guarda a mensagem com o marcador, e quem troca %{count} por 12 é o translate_error/1 dos CoreComponents, na hora de mostrar o formulário.
Sua vez: uma regra a mais para a senha
O validate_password/2 gerado traz três exemplos de regras comentados. Na célula do Hello.Accounts.User, ative a que exige pelo menos um dígito ou uma pontuação (tire o # do começo da linha certa) e avalie a célula.
Trilha.solucao(:senha)
Trilha.conferir(:senha)
lib/hello/accounts/user_token.ex: o schema dos tokens. Há dois jeitos de criar um token:
build_session_token/1sorteia 32 bytes e os guarda como vieram, comcontext: "session". Esse token vai para a sessão, dentro do cookie assinado, e nunca aparece em texto para ninguém.build_email_token/2sorteia 32 bytes, manda a versão em Base64 para o e-mail e grava só o hash SHA-256, junto com o endereço de destino (sent_to). É usado para os links mágicos (context: "login") e para a troca de e-mail (context: "change:EMAIL_ANTIGO").
As funções verify_*_query montam as consultas que validam cada tipo de token, com o prazo de cada um: 15 minutos para o link mágico, 7 dias para a troca de e-mail, 14 dias para a sessão. O link mágico e a troca de e-mail também conferem sent_to == user.email: se o e-mail da conta mudou, os links mandados para o endereço antigo deixam de valer.
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
defp by_token_and_context_query(token, context) do
from UserToken, where: [token: ^token, context: ^context]
end
end
Trilha.pergunta(:token_hash)
lib/hello/accounts/scope.ex: o escopo, uma struct com o usuário atual. Ele precisa vir depois do User, porque casa com %User{}. O notebook 14 inteiro é sobre escopos.
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
lib/hello/accounts/user_notifier.ex: os três e-mails do sistema: confirmação de conta, link de login e troca de e-mail. São e-mails em texto puro, montados com Swoosh.Email. deliver_login_instructions/2 escolhe o texto: quem ainda não confirmou a conta recebe "Confirmation instructions"; quem já confirmou recebe "Log in instructions".
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, a API pública da autenticação. Vale ler com calma login_user_by_magic_link/1, que trata três casos, e update_user_and_delete_all_tokens/1, que apaga todos os tokens de uma conta ao confirmá-la ou ao trocar a senha.
defmodule Hello.Accounts do
@moduledoc """
The Accounts context.
"""
import Ecto.Query, warn: false
alias Hello.Repo
alias Hello.Accounts.{User, UserToken, UserNotifier}
## Database getters
@doc """
Gets a user by email.
## Examples
iex> get_user_by_email("foo@example.com")
%User{}
iex> get_user_by_email("unknown@example.com")
nil
"""
def get_user_by_email(email) when is_binary(email) do
Repo.get_by(User, email: email)
end
@doc """
Gets a user by email and password.
## Examples
iex> get_user_by_email_and_password("foo@example.com", "correct_password")
%User{}
iex> get_user_by_email_and_password("foo@example.com", "invalid_password")
nil
"""
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
@doc """
Gets a single user.
Raises `Ecto.NoResultsError` if the User does not exist.
## Examples
iex> get_user!(123)
%User{}
iex> get_user!(456)
** (Ecto.NoResultsError)
"""
def get_user!(id), do: Repo.get!(User, id)
## User registration
@doc """
Registers a user.
## Examples
iex> register_user(%{field: value})
{:ok, %User{}}
iex> register_user(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def register_user(attrs) do
%User{}
|> User.email_changeset(attrs)
|> Repo.insert()
end
## Settings
@doc """
Checks whether the user is in sudo mode.
The user is in sudo mode when the last authentication was done no further
than 20 minutes ago. The limit can be given as second argument in minutes.
"""
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
@doc """
Returns an `%Ecto.Changeset{}` for changing the user email.
See `Hello.Accounts.User.email_changeset/3` for a list of supported options.
## Examples
iex> change_user_email(user)
%Ecto.Changeset{data: %User{}}
"""
def change_user_email(user, attrs \\ %{}, opts \\ []) do
User.email_changeset(user, attrs, opts)
end
@doc """
Updates the user email using the given token.
If the token matches, the user email is updated and the token is deleted.
"""
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
@doc """
Returns an `%Ecto.Changeset{}` for changing the user password.
See `Hello.Accounts.User.password_changeset/3` for a list of supported options.
## Examples
iex> change_user_password(user)
%Ecto.Changeset{data: %User{}}
"""
def change_user_password(user, attrs \\ %{}, opts \\ []) do
User.password_changeset(user, attrs, opts)
end
@doc """
Updates the user password.
Returns a tuple with the updated user, as well as a list of expired tokens.
## Examples
iex> update_user_password(user, %{password: ...})
{:ok, {%User{}, [...]}}
iex> update_user_password(user, %{password: "too short"})
{:error, %Ecto.Changeset{}}
"""
def update_user_password(user, attrs) do
user
|> User.password_changeset(attrs)
|> update_user_and_delete_all_tokens()
end
## Session
@doc """
Generates a session token.
"""
def generate_user_session_token(user) do
{token, user_token} = UserToken.build_session_token(user)
Repo.insert!(user_token)
token
end
@doc """
Gets the user with the given signed token.
If the token is valid `{user, token_inserted_at}` is returned, otherwise `nil` is returned.
"""
def get_user_by_session_token(token) do
{:ok, query} = UserToken.verify_session_token_query(token)
Repo.one(query)
end
@doc """
Gets the user with the given magic link token.
"""
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
@doc """
Logs the user in by magic link.
There are three cases to consider:
1. The user has already confirmed their email. They are logged in
and the magic link is expired.
2. The user has not confirmed their email and no password is set.
In this case, the user gets confirmed, logged in, and all tokens -
including session ones - are expired. In theory, no other tokens
exist but we delete all of them for best security practices.
3. The user has not confirmed their email but a password is set.
This cannot happen in the default implementation but may be the
source of security pitfalls. See the "Mixing magic link and password registration" section of
`mix help phx.gen.auth`.
"""
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
@doc ~S"""
Delivers the update email instructions to the given user.
## Examples
iex> deliver_user_update_email_instructions(user, current_email, &url(~p"/users/settings/confirm-email/#{&1}"))
{:ok, %{to: ..., body: ...}}
"""
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
@doc """
Delivers the magic link login instructions to the given user.
"""
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
@doc """
Deletes the signed token with the given context.
"""
def delete_user_session_token(token) do
Repo.delete_all(from(UserToken, where: [token: ^token, context: "session"]))
:ok
end
## 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/user_auth.ex: os plugs da camada web. log_in_user/3 cria a sessão e grava o token nela; fetch_current_scope_for_user/2 lê o token da sessão (ou do cookie "remember me") e põe o current_scope nos assigns; require_authenticated_user/2, require_sudo_mode/2 e redirect_if_user_is_authenticated/2 barram quem não deve passar. A seção "Proibindo o acesso" volta a eles.
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
@doc """
Logs the user in.
Redirects to the session's `:user_return_to` path
or falls back to the `signed_in_path/1`.
"""
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
@doc """
Logs the user out.
It clears all session data for safety. See renew_session.
"""
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
@doc """
Authenticates the user by looking into the session and remember me token.
Will reissue the session token if it is older than the configured age.
"""
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
@doc """
Plug for routes that require sudo mode.
"""
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
@doc """
Plug for routes that require the user to not be authenticated.
"""
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"/"
@doc """
Plug for routes that require the user to be authenticated.
"""
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
end
Esta célula compila com dois avisos no route path for HelloWeb.Router matches "/users/log-in", um em require_sudo_mode/2 e outro em require_authenticated_user/2. O UserAuth usa ~p com uma rota que o router ainda não tem; o router novo, logo abaixo, precisa do UserAuth pronto para importá-lo. No projeto, os dois compilam juntos e os avisos não aparecem. Aqui, eles somem quando você reavaliar esta célula depois do router.
lib/hello_web/router.ex: o gerador acrescentou ao router o import HelloWeb.UserAuth, o plug :fetch_current_scope_for_user no fim da pipeline :browser e três scopes com as rotas de autenticação, cada um com a sua lista de plugs. A caixa de entrada /dev/mailbox vem do mix phx.new: no projeto, ela fica dentro de um if Application.compile_env(:hello, :dev_routes), que só vale em desenvolvimento; aqui ela está direto no router, porque o notebook roda sempre em desenvolvimento.
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
end
lib/hello_web/components/layouts.ex: o gerador injeta no root.html.heex o menu do usuário, no alto da página: o e-mail, "Settings" e "Log out" para quem entrou; "Register" e "Log in" para quem não entrou.
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
lib/hello_web/controllers/: três controllers, cada um com a sua view e os seus templates (aqui como funções, uma por arquivo .heex, como nos notebooks anteriores). UserRegistrationController cuida do cadastro; UserSessionController, do login (por link e por senha), da página de confirmação e do logout; UserSettingsController, da troca de e-mail e de senha.
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
Usando a autenticação
Com tudo compilado, o guia sugere iniciar o servidor e experimentar. Abra http://localhost:4010 no navegador: os links "Register" e "Log in" apareceram no alto da página. As células abaixo fazem o mesmo caminho com o navegador simulado, para você ver cada requisição.
O cadastro só pede o e-mail:
Trilha.navegar("ana", :get, "/users/register")
Trilha.navegar("ana", :post, "/users/register", %{"user" => %{"email" => "ana@example.com"}})
O cadastro não faz login. Ele cria a conta, ainda não confirmada, manda um e-mail e redireciona para a página de login com a flash "An email was sent to ana@example.com, please access it to confirm your account." A página de login avisa que a aplicação está usando o adaptador local de e-mail e aponta para a caixa de entrada.
No log dessa célula aparece uma linha [debug] do Postgrex: a consulta "uses unknown oid(s) ... forcing us to reload type information from the database". É a primeira consulta à coluna citext, um tipo que não existia quando o Repo se conectou. O próprio Postgrex explica: é o esperado depois de uma migration.
Trilha.caixa_de_entrada()
Trilha.ultimo_email("ana@example.com")
O mesmo e-mail está em http://localhost:4010/dev/mailbox. O link leva à página de confirmação:
Trilha.navegar("ana", :get, Trilha.link_do_email("ana@example.com"))
Abrir o link não faz login: a página só mostra dois botões, "Confirm and stay logged in" e "Confirm and log in only this time". Isso é proposital. Alguns programas de e-mail e antivírus abrem os links sozinhos para inspecioná-los; se um GET fizesse login, eles gastariam o link. O login acontece no POST do formulário, que leva o token num campo escondido. A célula abaixo envia o formulário como o botão "Confirm and log in only this time" faria:
token = "ana@example.com" |> Trilha.link_do_email() |> String.split("/") |> List.last()
Trilha.navegar("ana", :post, "/users/log-in?_action=confirmed", %{"user" => %{"token" => token}})
A flash é "User confirmed successfully." e a página inicial mostra o menu com ana@example.com. Veja o que ficou em users_tokens:
Trilha.consultar("SELECT id, user_id, context, sent_to, authenticated_at, inserted_at FROM users_tokens ORDER BY id")
Sobrou um só token, de contexto session. O token login do link foi apagado: como a conta não estava confirmada, login_user_by_magic_link/1 confirmou a conta e apagou todos os tokens dela antes de criar a sessão. Se você enviar o mesmo formulário de novo, o link não vale mais:
Trilha.navegar("ana", :post, "/users/log-in", %{"user" => %{"token" => token}})
A resposta é a página de login com "The link is invalid or it has expired.". Ela traz o aviso "You need to reauthenticate to perform sensitive actions on your account." porque Ana continua logada: o link é que não serve mais.
A senha, opcional
A página de confirmação sugeria: "If you prefer passwords, you can enable them in the user settings." As configurações ficam em /users/settings:
Trilha.navegar("ana", :get, "/users/settings")
Trilha.navegar("ana", :put, "/users/settings", %{
"action" => "update_password",
"user" => %{"password" => "uma senha bem longa, 1", "password_confirmation" => "uma senha bem longa, 1"}
})
"Password updated successfully." Agora Ana pode sair e entrar com e-mail e senha:
Trilha.navegar("ana", :delete, "/users/log-out")
Trilha.navegar("ana", :post, "/users/log-in", %{"user" => %{"email" => "ana@example.com", "password" => "uma senha bem longa, 1"}})
"Remember me"
A página de login tem dois botões para a senha: "Log in and stay logged in" envia remember_me=true; "Log in only this time" não envia. A diferença está nos cookies. Bia entra lembrando, e Caio entra só desta vez:
Trilha.entrar("bia", "bia@example.com", lembrar: true)
Trilha.entrar("caio", "caio@example.com")
Trilha.entrar/3 faz o caminho de verdade: cria a conta, se precisar, pede o link mágico e envia o formulário de confirmação, como você fez com a Ana. Compare os cookies dos dois:
Trilha.mostrar_cookies("bia")
Trilha.mostrar_cookies("caio")
Os dois têm _hello_key, o cookie da sessão, que não tem validade: o navegador o apaga quando fecha. Só a Bia tem _hello_web_user_remember_me, assinado, com max_age de 14 dias e o mesmo token da sessão. Feche os dois navegadores e volte às configurações:
Kino.Layout.grid([Trilha.fechar_navegador("bia"), Trilha.fechar_navegador("caio")])
Trilha.navegar("caio", :get, "/users/settings")
Trilha.navegar("bia", :get, "/users/settings")
Caio volta a ser visitante e cai no login. Bia continua logada: fetch_current_scope_for_user/2 não achou token na sessão, procurou no cookie "remember me", achou, e o pôs de volta na sessão.
Trocar o e-mail
Ana quer trocar o endereço. O formulário de e-mail das configurações envia action=update_email:
Trilha.navegar("ana", :put, "/users/settings", %{"action" => "update_email", "user" => %{"email" => "ana.nova@example.com"}})
O e-mail ainda não mudou. A aplicação mandou um link para o endereço novo, para provar que ele é da Ana:
Trilha.ultimo_email("ana.nova@example.com")
Trilha.consultar("SELECT id, user_id, context, sent_to FROM users_tokens WHERE context LIKE 'change:%'")
O contexto do token guarda o endereço antigo (change:ana@example.com), e sent_to, o novo. Ao clicar no link, a troca só acontece se o token bater com os dois:
Trilha.navegar("ana", :get, Trilha.link_do_email("ana.nova@example.com"))
"Email changed successfully." Para os próximos exemplos, troque de volta pelo mesmo caminho:
Trilha.visitar("ana", :put, "/users/settings", %{"action" => "update_email", "user" => %{"email" => "ana@example.com"}})
Trilha.navegar("ana", :get, Trilha.link_do_email("ana@example.com"))
Responsabilidades de quem gera o código
Na documentação: Developer responsibilities
Trilha.pergunta(:responsabilidade)
Como o Phoenix gera esse código dentro da aplicação, em vez de mantê-lo numa biblioteca, você tem liberdade total para adaptá-lo. O preço é um só: o código gerado não é atualizado depois. Quando a saída do mix phx.gen.auth melhorar, cabe a você decidir se traz a mudança para o seu projeto. As mudanças de segurança e as outras importantes são marcadas com clareza no CHANGELOG.md e nas notas de atualização do Phoenix.
Notas sobre o código gerado
Na documentação: Generated code
Proibindo o acesso
Na documentação: Forbidding access
O HelloWeb.UserAuth traz um punhado de plugs:
fetch_current_scope_for_userbusca o usuário atual, se houver, e o guarda no assign:current_scope. Ele roda em toda requisição da pipeline:browser; sem usuário, ocurrent_scopeénil;require_authenticated_userdeve vir depois do anterior e exige um usuário autenticado; sem ele, redireciona para o login e guarda o caminho pedido para voltar depois;redirect_if_user_is_authenticatedserve para as poucas páginas que quem já entrou não deve ver, como o cadastro (só existe na versão com controllers);require_sudo_modeprotege as páginas com operações sensíveis e exige uma autenticação recente.
O router usa cada um num scope diferente. Para proteger uma página nova, basta pôr a rota no scope certo.
Sua vez: uma página só para quem entrou
A célula abaixo tem um PainelController pronto, com duas actions. Faça GET /painel funcionar só para quem entrou:
- na célula do router, lá em cima, acrescente
get "/painel", PainelController, :indexaoscopeque passa por:require_authenticated_user, e avalie a célula; - na célula abaixo, troque o "Olá!" do template
indexpor uma saudação com o e-mail de quem está logado, que está em@current_scope.user.email, e avalie a célula.
defmodule HelloWeb.PainelController do
use HelloWeb, :controller
def index(conn, _params) do
render(conn, :index)
end
def seguranca(conn, _params) do
render(conn, :seguranca)
end
end
defmodule HelloWeb.PainelHTML do
use HelloWeb, :html
def index(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<.header>Painel</.header>
<p>Olá!</p>
</Layouts.app>
"""
end
def seguranca(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<.header>Segurança</.header>
<p>Aqui ficariam as ações sensíveis: apagar a conta, gerar chaves, exportar dados.</p>
</Layouts.app>
"""
end
end
Trilha.solucao(:painel)
Trilha.conferir(:painel)
Veja o caminho de quem chega sem login e depois entra:
Trilha.navegar("dani", :get, "/painel")
Trilha.entrar("dani", "dani@example.com")
Depois do login, Dani voltou para /painel, e não para a página inicial: require_authenticated_user/2 guardou o caminho na sessão (:user_return_to), e log_in_user/3 o usou.
Modo sudo
Trilha.pergunta(:sudo)
O UserSettingsController começa com plug :require_sudo_mode. O plug chama Accounts.sudo_mode?(user, -10), que confere se authenticated_at está dentro dos últimos 10 minutos. Esse valor vem do token da sessão e é gravado quando a pessoa entra (pelo link ou pela senha). Ficar logado não basta; é preciso ter se autenticado há pouco.
Para ver sem esperar 10 minutos, Trilha.envelhecer_sessoes/2 move para trás o authenticated_at das sessões de uma conta. Ana entrou há pouco; agora a última autenticação dela foi há 11 minutos:
Trilha.envelhecer_sessoes("ana@example.com", 11)
Trilha.navegar("ana", :get, "/users/settings")
Ana continua logada (o menu ainda mostra o e-mail dela), mas as configurações a mandaram reautenticar, com "You must re-authenticate to access this page." A página de login muda o texto para "You need to reauthenticate to perform sensitive actions on your account." e trava o campo de e-mail. Ana pede um link (o primeiro formulário da página):
Trilha.navegar("ana", :post, "/users/log-in", %{"user" => %{"email" => "ana@example.com"}})
Trilha.ultimo_email("ana@example.com")
Como a conta já está confirmada, o e-mail agora é "Log in instructions". A página do link mostra um só botão, "Log in", porque Ana já está logada:
Trilha.navegar("ana", :get, Trilha.link_do_email("ana@example.com"))
Enviando o formulário:
token = "ana@example.com" |> Trilha.link_do_email() |> String.split("/") |> List.last()
Trilha.navegar("ana", :post, "/users/log-in", %{"user" => %{"token" => token}})
Ana volta direto para /users/settings, com um token de sessão novo e um authenticated_at de agora. O token antigo continua em users_tokens até expirar; só a troca de senha (ou o logout) apaga sessões.
Sua vez: uma ação sensível
A action seguranca do PainelController deve exigir o modo sudo. Na célula do router, crie um scope novo que passe por [:browser, :require_authenticated_user, :require_sudo_mode] e ponha nele get "/painel/seguranca", PainelController, :seguranca. Pense na ordem dos plugs.
Trilha.solucao(:seguranca)
Trilha.conferir(:seguranca)
Escopos
Na documentação: Scopes
O Hello.Accounts.Scope gerado é uma struct com um campo, user. O plug fetch_current_scope_for_user o guarda no assign :current_scope, que fica disponível nos controllers e nas LiveViews. Conforme a aplicação cresce, o escopo pode carregar mais informação: as equipes, empresas ou organizações de que o usuário faz parte, permissões, o endereço IP da requisição.
O gerador também registrou o escopo em config/config.exs, como escopo padrão:
config :hello, :scopes,
user: [
default: true,
module: Hello.Accounts.Scope,
assign_key: :current_scope,
access_path: [:user, :id],
schema_key: :user_id,
schema_type: :id,
schema_table: :users,
test_data_fixture: Hello.AccountsFixtures,
test_setup_helper: :register_and_log_in_user
]
A partir daí, os outros geradores (phx.gen.html, phx.gen.json, phx.gen.live, phx.gen.context) passam o escopo do controller para as funções do contexto e filtram os dados pelo usuário. Você viu isso no carrinho do notebook 10. O notebook 14 explica cada opção e mostra como criar escopos novos.
Hash de senhas
Na documentação: Password hashing
O mecanismo padrão é o bcrypt em sistemas Unix e o pbkdf2 no Windows. Os dois seguem a interface Comeonin, por isso o código gerado muda pouco de um para o outro. A opção --hashing-lib escolhe a biblioteca:
bcrypt: bcrypt_elixir;pbkdf2: pbkdf2_elixir;argon2: argon2_elixir.
O guia recomenda considerar o argon2, o mais robusto dos três; a desvantagem é que ele consome bastante CPU e memória, e exige servidores mais fortes. A trilha usa o pbkdf2 por um motivo prático: bcrypt_elixir e argon2_elixir compilam código C quando são instalados, e o pbkdf2_elixir é Elixir puro, que instala em qualquer máquina com o Livebook. Com --hashing-lib pbkdf2, o que muda no código gerado é só isto: Pbkdf2.hash_pwd_salt/1, Pbkdf2.verify_pass/2 e Pbkdf2.no_user_verify/0 no User, a dependência no mix.exs e a linha config :pbkdf2_elixir, :rounds, 1 no config/test.exs.
Um hash de senha é lento de propósito. Veja quanto tempo leva:
{microssegundos, hash} = :timer.tc(fn -> Pbkdf2.hash_pwd_salt("uma senha bem longa, 1") end)
IO.puts("#{div(microssegundos, 1000)} ms")
hash
O resultado guarda tudo o que a verificação precisa: o algoritmo (pbkdf2-sha512), o número de rodadas (160000), o sal e o hash. Um computador que testa senhas por força bruta leva o mesmo tempo para cada tentativa. Nos testes, rounds: 1 torna o hash instantâneo; em produção, nunca.
Notificadores
Na documentação: Notifiers
O guia diz que o código gerado não está integrado a nenhum sistema de SMS ou e-mail: é sua responsabilidade fazer essa integração. Na prática, num projeto criado com mix phx.new, o UserNotifier já usa o mailer do Swoosh, e em desenvolvimento os e-mails aparecem em /dev/mailbox. Em produção, você troca o adaptador Swoosh.Adapters.Local por um serviço de verdade (SMTP, Amazon SES, Postmark e outros) na configuração.
O texto dos e-mails também é seu.
Sua vez: o e-mail em português
Na célula do Hello.Accounts.UserNotifier, traduza o e-mail de confirmação (deliver_confirmation_instructions/2): o assunto e o texto. Mantenha #{url} no corpo, senão o link some. Avalie a célula e cadastre alguém para ver o resultado:
Trilha.navegar("eva", :post, "/users/register", %{"user" => %{"email" => "eva@example.com"}})
Trilha.ultimo_email("eva@example.com")
Trilha.solucao(:email_pt)
Trilha.conferir(:email_pt)
Testes concorrentes
Na documentação: Concurrent tests
Os testes gerados (accounts_test.exs, user_auth_test.exs e os dos três controllers) rodam em paralelo quando o banco permite, que é o caso do PostgreSQL. O módulo de testes da trilha volta a eles; por enquanto, saiba que mix test logo depois do gerador deve passar inteiro.
Considerações de segurança
Na documentação: Security considerations
Sessões rastreadas
Na documentação: Tracking sessions
Todas as sessões e todos os tokens ficam numa tabela à parte. Com isso dá para saber quantas sessões cada conta tem abertas, e até mostrar isso ao usuário. O comentário do build_session_token/1, lá em cima, explica o motivo: o cookie de sessão do Phoenix é só assinado, e valeria para sempre se não houvesse um registro no banco para apagar.
Trilha.pergunta(:troca_de_senha)
Fábio entra no celular e no notebook. São dois navegadores, com dois cookies, e duas linhas em users_tokens:
Trilha.entrar("fábio no celular", "fabio@example.com")
Trilha.entrar("fábio no notebook", "fabio@example.com")
Trilha.consultar("""
SELECT t.id, t.context, t.authenticated_at
FROM users_tokens t JOIN users u ON u.id = t.user_id
WHERE u.email = 'fabio@example.com'
ORDER BY t.id
""")
No notebook, ele define uma senha:
Trilha.navegar("fábio no notebook", :put, "/users/settings", %{
"action" => "update_password",
"user" => %{"password" => "outra senha bem longa, 2", "password_confirmation" => "outra senha bem longa, 2"}
})
Trilha.consultar("""
SELECT t.id, t.context, t.authenticated_at
FROM users_tokens t JOIN users u ON u.id = t.user_id
WHERE u.email = 'fabio@example.com'
ORDER BY t.id
""")
As duas sessões antigas sumiram, e há uma nova, a que o controller criou para o notebook logo depois da troca. O celular, que ainda tem o cookie antigo, perdeu a sessão:
Trilha.navegar("fábio no celular", :get, "/users/settings")
O guia resume: sempre que a senha muda, todos os tokens são apagados, e a pessoa precisa entrar de novo em todos os aparelhos. O mesmo acontece quando uma conta é confirmada.
Ataques de enumeração de usuários
Na documentação: User Enumeration attacks
Um ataque de enumeração permite a alguém descobrir se um e-mail está cadastrado. O código gerado não tenta se proteger disso.
Trilha.pergunta(:enumeracao)
Três tentativas com o e-mail da Bia, que existe, feitas por um visitante qualquer:
Trilha.navegar("curioso", :post, "/users/register", %{"user" => %{"email" => "bia@example.com"}})
Trilha.navegar("curioso", :post, "/users/log-in", %{"user" => %{"email" => "bia@example.com"}})
Trilha.navegar("curioso", :post, "/users/log-in", %{"user" => %{"email" => "bia@example.com", "password" => "um chute qualquer"}})
O cadastro responde "has already been taken", e isso entrega a existência da conta. O pedido de link responde a mesma frase para qualquer e-mail ("If your email is in our system..."), e o login com senha responde sempre "Invalid email or password"; o comentário no UserSessionController diz que é de propósito.
Se a sua aplicação precisa se proteger de enumeração, o guia avisa que os fluxos mudam muito de uma aplicação para outra, e que é preciso equilibrar segurança e experiência do usuário. Avisa também sobre ataques de tempo: cadastrar uma conta nova grava no banco e envia e-mail, o que demora mais do que recusar um e-mail repetido. Medindo o tempo das respostas, alguém pode descobrir os e-mails cadastrados. Isso vale para todas as rotas que enviam e-mail ou notificação. O Pbkdf2.no_user_verify/0 do valid_password?/2 existe exatamente para fechar essa porta no login com senha.
Confirmação e pré-cadastro de credenciais
Na documentação: Confirmation and credential pre-stuffing attacks
Num ataque de pré-cadastro, alguém cria uma conta com o e-mail de outra pessoa antes dela, define uma senha e espera: quando a dona do e-mail confirmar a conta, o atacante já tem a senha. O código gerado tem confirmação de conta por e-mail e impede o uso da aplicação por quem não confirmou.
Trilha.pergunta(:pre_cadastro)
O atacante cadastra o e-mail da Gabi e tenta entrar com uma senha:
Trilha.navegar("atacante", :post, "/users/register", %{"user" => %{"email" => "gabi@example.com"}})
Trilha.navegar("atacante", :post, "/users/log-in", %{"user" => %{"email" => "gabi@example.com", "password" => "a senha do atacante"}})
Não há senha nenhuma na conta, e não há como definir uma sem entrar. O e-mail de confirmação foi para a caixa da Gabi.
O login_user_by_magic_link/1 ainda se protege de um caso que o código gerado nunca produz: uma conta não confirmada com senha. Se você mudar o fluxo e permitir isso (por exemplo, pedindo a senha no cadastro), o link mágico se recusa a funcionar. Para ver, a célula abaixo põe uma senha direto no banco, na conta não confirmada da Gabi, e tenta usar o link que ela recebeu:
Trilha.consultar("UPDATE users SET hashed_password = 'qualquer coisa' WHERE email = 'gabi@example.com' RETURNING email, confirmed_at")
token = "gabi@example.com" |> Trilha.link_do_email() |> String.split("/") |> List.last()
Hello.Accounts.login_user_by_magic_link(token)
RuntimeError: "magic link log in is not allowed for unconfirmed users with a password set!". A mensagem aponta a seção "Mixing magic link and password registration" de mix help phx.gen.auth, que explica como misturar os dois cadastros sem abrir essa brecha. Desfaça a mudança:
Trilha.consultar("UPDATE users SET hashed_password = NULL WHERE email = 'gabi@example.com' RETURNING email, hashed_password")
Maiúsculas e minúsculas
Na documentação: Case sensitiveness
Trilha.pergunta(:citext)
A busca por e-mail não diferencia maiúsculas de minúsculas. No MySQL e no MSSQL isso já é o padrão; no SQLite3, o gerador usa COLLATE NOCASE na coluna; no PostgreSQL, usa a extensão citext. Ana definiu uma senha mais acima:
Trilha.navegar("ana maiúscula", :post, "/users/log-in", %{"user" => %{"email" => "ANA@Example.COM", "password" => "uma senha bem longa, 1"}})
Hello.Accounts.get_user_by_email("ANA@EXAMPLE.COM")
O email volta como foi gravado, ana@example.com: o citext guarda o texto original e só ignora a caixa ao comparar. Repare também que o inspect não mostra hashed_password, por causa do redact: true.
O citext faz parte do PostgreSQL e costuma vir junto com ele. Algumas distribuições o separam num pacote à parte; nesse caso, a migration falha, e a solução é instalar o pacote de extensões. O guia o chama de postgres-contrib; no Fedora, o nome é postgresql-contrib.
Recursos adicionais
Na documentação: Additional resources
O Phoenix 1.8 trouxe os links mágicos e o modo sudo, e tirou do cadastro a senha obrigatória. Quem gerou a autenticação no Phoenix 1.7 ou antes não precisa migrar; o guia recomenda ao menos criar o seu próprio escopo, como no notebook 14. Para quem quiser migrar, o guia aponta dois pull requests de referência (um para a versão com LiveView, outro para a com controllers) e um cuidado: deixar a coluna hashed_password opcional e apagar a senha das contas ainda não confirmadas, para fechar a brecha de pré-cadastro.
A seção termina com links sobre a implementação original: o post de José Valim que apresentou a solução, o post sobre a versão com LiveView e a especificação original.
Sem roteiro: as sessões da minha conta no Brechó
No Brechó, quem vende entra pelo celular, pelo computador de casa, pelo da loja. A seção "Sessões rastreadas" mostrou que cada entrada é uma linha em users_tokens. O guia sugere: dá até para mostrar essa informação ao usuário. Faça isso:
- Na célula do
Hello.Accounts, acrescente:list_user_sessions(user), que devolve os tokens de sessão (context == "session") do usuário, do mais novo ao mais antigo;delete_user_session(user, id), que apaga a sessãoidsó se ela for do usuário, devolvendo:okou{:error, :not_found}.
- No router, no
scopecom:require_authenticated_user, as rotasget "/users/sessions"edelete "/users/sessions/:id", para umHelloWeb.UserSessionsController(no plural, para não confundir com oUserSessionControllerdo login). - O controller e a view, na célula abaixo: a página lista as sessões, com a hora em que cada uma começou e a da última autenticação, marca com o texto Esta sessão a sessão de quem está vendo (compare
session.tokencomget_session(conn, :user_token)) e tem um link "Encerrar" para as outras, commethod="delete".
# lib/hello_web/controllers/user_sessions_controller.ex e user_sessions_html.ex
Para testar à mão: Trilha.entrar/2 com dois nomes e o mesmo e-mail, depois Trilha.navegar/4 em /users/sessions com cada um.
Trilha.solucao(:brecho)
Trilha.conferir(:brecho)
No seu projeto
- Se você usa Fedora, instale as extensões do PostgreSQL, uma vez:
sudo dnf install postgresql-contribesudo systemctl restart postgresql. - Faça um commit, como o guia recomenda.
- Rode
mix phx.gen.auth Accounts User users. RespondaYpara a versão com LiveView ounpara a com controllers; quem ainda não estudou LiveView pode começar pela segunda, que é a deste notebook. Sem compilador C na máquina, acrescente--hashing-lib pbkdf2. mix deps.getemix ecto.migrate.mix test: os testes gerados devem passar, sem nenhuma mudança.mix phx.server, abra http://localhost:4000, cadastre-se e leia o e-mail em http://localhost:4000/dev/mailbox.- Leia os arquivos gerados com a lista deste notebook do lado:
accounts.ex,user.ex,user_token.ex,user_auth.exe o router.
| No notebook | No projeto |
|---|---|
célula de instalação com swoosh e pbkdf2_elixir |
o gerador acrescenta a biblioteca de hash ao mix.exs; o Swoosh já vem do mix phx.new |
{Hello.Mailer, adapter: Swoosh.Adapters.Local} na instalação |
config :hello, Hello.Mailer, adapter: Swoosh.Adapters.Local em config/config.exs |
forward "/mailbox" direto no router |
dentro do if Application.compile_env(:hello, :dev_routes) |
Trilha.caixa_de_entrada() |
http://localhost:4000/dev/mailbox |
Trilha.envelhecer_sessoes/2 |
esperar 10 minutos |
Revisão
- Authn e authz: autenticação diz quem é a pessoa; autorização diz o que ela pode fazer. O
phx.gen.authcuida da primeira e prepara a segunda com os escopos. - O gerador:
mix phx.gen.auth Accounts User userscria o contextoAccounts, os schemasUsereUserToken, oUserAuthcom os plugs, três controllers e muitos testes. LiveView ou controllers só muda a camada web. - Contas sem senha: o cadastro pede só o e-mail; a primeira entrada é pelo link mágico, que confirma a conta. A senha é opcional e fica nas configurações.
- Tokens: os de e-mail são guardados como hash e valem 15 minutos (login) ou 7 dias (troca de e-mail); os de sessão valem 14 dias e vivem no cookie assinado. O cookie "remember me" sobrevive ao fechar do navegador.
- Plugs:
fetch_current_scope_for_user,require_authenticated_user,redirect_if_user_is_authenticatederequire_sudo_mode. Proteger uma página é pôr a rota noscopecom o plug certo, na ordem certa. - Segurança: sessões rastreadas e apagadas a cada troca de senha; enumeração possível no cadastro; confirmação obrigatória contra o pré-cadastro;
citextpara e-mails sem diferença de caixa. - Responsabilidade: o código é seu, e não se atualiza sozinho.
Próximo notebook: 14_escopos.livemd, sobre o guia Scopes.