Powered by AppSignal & Oban Pro

Trilha Phoenix · Autenticação de API

15_autenticacao_de_api.livemd

Trilha Phoenix · Autenticação de API

Mix.install(
  [
    {:phoenix, "~> 1.8.0"},
    {:phoenix_live_view, "~> 1.1"},
    {:phoenix_ecto, "~> 4.6"},
    {:ecto_sql, "~> 3.13"},
    {:postgrex, ">= 0.0.0"},
    {:bandit, "~> 1.5"},
    {:jason, "~> 1.4"},
    {:kino, "~> 0.14"},
    {:swoosh, "~> 1.16"},
    {:pbkdf2_elixir, "~> 2.0"}
  ],
  consolidate_protocols: false,
  config: [
    phoenix: [json_library: Jason, plug_init_mode: :runtime],
    swoosh: [api_client: false],
    hello: [
      {HelloWeb.Endpoint,
       [
         adapter: Bandit.PhoenixAdapter,
         http: [ip: {127, 0, 0, 1}, port: 4010],
         server: true,
         debug_errors: true,
         secret_key_base: String.duplicate("trilha-phoenix-", 5),
         render_errors: [formats: [html: HelloWeb.ErrorHTML, json: HelloWeb.ErrorJSON], layout: false],
         pubsub_server: Hello.PubSub
       ]},
      {Hello.Mailer, [adapter: Swoosh.Adapters.Local]}
    ]
  ]
)

defmodule Trilha do
  @moduledoc """
  Apoio deste notebook: verifica a conexão com o PostgreSQL, mostra tabelas como
  o psql mostraria, registra migrations, faz requisições ao endpoint como um
  navegador faria, confere os exercícios e guarda as perguntas e as soluções.

  Não faz parte do Phoenix nem do Ecto. Você não precisa ler este módulo para seguir o guia.
  """

  @endpoint HelloWeb.Endpoint
  @router HelloWeb.Router
  @repo Hello.Repo

  # --- banco de dados ------------------------------------------------------------

  @doc "Tenta uma conexão com o PostgreSQL usando a configuração do Hello.Repo."
  def verificar_postgres(extra \\ []) do
    config = Keyword.merge(apply(@repo, :config, []), extra)
    onde = "`#{config[:username]}@#{config[:hostname]}:#{config[:port] || 5432}`"

    # Conecta ao banco "postgres", que sempre existe: o banco da trilha talvez ainda não tenha sido criado.
    resultado =
      try do
        Postgrex.Protocol.connect(Postgrex.Utils.default_opts(Keyword.put(config, :database, "postgres")))
      rescue
        erro -> {:error, erro}
      end

    case resultado do
      {:ok, estado} ->
        Postgrex.Protocol.disconnect(%RuntimeError{message: "fim da verificação"}, estado)
        Kino.Markdown.new("**Conectou** ao PostgreSQL em #{onde}. Pode seguir.")

      {:error, erro} ->
        Kino.Markdown.new(
          "**Não conectou** ao PostgreSQL em #{onde}. A mensagem do Postgrex:\n\n" <>
            cerca(Exception.message(erro), "") <> "\n\n" <> dica_conexao(erro)
        )
    end
  end

  defp dica_conexao(%Postgrex.Error{postgres: %{pg_code: "28P01"}}) do
    "**Senha recusada.** Confira `username` e `password` na célula `config/dev.exs`. " <>
      "Com autenticação por senha, o PostgreSQL dá esta mesma resposta quando o usuário não existe."
  end

  defp dica_conexao(%Postgrex.Error{postgres: %{pg_code: "28000", message: mensagem}}) do
    cond do
      mensagem =~ ~r/ident|peer/i ->
        "**O servidor não aceita senha para esta conexão.** Ele está configurado para autenticação `ident` ou `peer`, " <>
          "o padrão de algumas distribuições Linux, como o Fedora. O `LEIA-ME.md` da trilha mostra como trocar para senha no `pg_hba.conf`."

      mensagem =~ "does not exist" ->
        "**O usuário não existe no PostgreSQL.** Crie-o no `psql` ou troque `username` na célula `config/dev.exs`."

      true ->
        "**O servidor recusou a conexão.** Leia a mensagem acima e confira o `pg_hba.conf` do servidor."
    end
  end

  defp dica_conexao(%DBConnection.ConnectionError{message: mensagem}) do
    cond do
      mensagem =~ "econnrefused" ->
        "**Nada responde nesse endereço.** O servidor PostgreSQL está parado, ou escuta em outra porta. O `LEIA-ME.md` mostra como iniciá-lo."

      mensagem =~ "nxdomain" ->
        "**O nome do host não existe.** Confira `hostname` na célula `config/dev.exs`."

      true ->
        "**A conexão falhou antes de chegar ao PostgreSQL.** Confira `hostname` e `port` na célula `config/dev.exs`."
    end
  end

  defp dica_conexao(_erro), do: "Confira a configuração na célula `config/dev.exs`."

  @doc """
  Confere se o servidor tem a extensão citext, que a migration do phx.gen.auth
  cria. Algumas distribuições Linux a separam num pacote à parte.
  """
  def verificar_citext do
    case sql("SELECT default_version FROM pg_available_extensions WHERE name = 'citext'").rows do
      [[versao]] ->
        Kino.Markdown.new("O servidor tem a extensão `citext` (versão #{versao}). Pode seguir.")

      [] ->
        Kino.Markdown.new(
          "**O servidor não tem a extensão `citext`.** A migration do `phx.gen.auth` vai falhar com " <>
            "`extension \"citext\" is not available`. No Fedora, ela vem no pacote `postgresql-contrib`:\n\n" <>
            cerca("sudo dnf install postgresql-contrib\nsudo systemctl restart postgresql", "") <>
            "\n\nO `LEIA-ME.md` da trilha tem os detalhes. Depois de instalar, avalie esta célula de novo."
        )
    end
  end

  @doc "Apaga todas as tabelas do banco da trilha, inclusive schema_migrations, esquece os visitantes e esvazia a caixa de entrada."
  def recomecar do
    sql("DROP SCHEMA public CASCADE")
    sql("CREATE SCHEMA public")
    esquecer_visitantes()
    Swoosh.Adapters.Local.Storage.Memory.delete_all()

    Kino.Markdown.new(
      "O banco `#{apply(@repo, :config, [])[:database]}` está vazio: nenhuma tabela, nenhuma migration registrada."
    )
  end

  @doc "Registra uma migration com a versão que, no projeto, viria do nome do arquivo."
  def migracao(versao, modulo) do
    :persistent_term.put({__MODULE__, :migracoes}, Map.put(mapa_migracoes(), versao, modulo))
    nome = modulo |> Module.split() |> List.last() |> Macro.underscore()

    Kino.Markdown.new("No projeto, esta migration seria o arquivo `priv/repo/migrations/#{versao}_#{nome}.exs`.")
  end

  @doc "As migrations registradas, como pares {versão, módulo}, em ordem de versão."
  def migracoes, do: mapa_migracoes() |> Enum.sort()

  defp mapa_migracoes, do: :persistent_term.get({__MODULE__, :migracoes}, %{})

  @doc "Lista as tabelas e sequências do banco, como o comando \\d do psql."
  def relacoes do
    resultado =
      sql("""
      SELECT n.nspname AS "Schema", c.relname AS "Name",
             CASE c.relkind WHEN 'r' THEN 'table' WHEN 'S' THEN 'sequence' WHEN 'v' THEN 'view' END AS "Type",
             pg_get_userbyid(c.relowner) AS "Owner"
      FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
      WHERE n.nspname = 'public' AND c.relkind IN ('r', 'S', 'v')
      ORDER BY c.relname
      """)

    if resultado.rows == [] do
      Kino.Markdown.new("`\\d`: o banco não tem nenhuma tabela.")
    else
      Kino.Markdown.new("**`\\d`**\n\n" <> tabela_markdown(resultado))
    end
  end

  @doc "Mostra as colunas, os índices e as chaves estrangeiras de uma tabela, como o comando \\d TABELA do psql."
  def descrever(tabela) do
    colunas =
      sql(
        """
        SELECT a.attname AS "Column", format_type(a.atttypid, a.atttypmod) AS "Type",
               CASE WHEN a.attnotnull THEN 'not null' ELSE '' END AS "Nullable",
               coalesce(pg_get_expr(d.adbin, d.adrelid), '') AS "Default"
        FROM pg_attribute a
        LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
        WHERE a.attrelid = to_regclass($1) AND a.attnum > 0 AND NOT a.attisdropped
        ORDER BY a.attnum
        """,
        [tabela]
      )

    if colunas.rows == [] do
      Kino.Markdown.new("`\\d #{tabela}`: a tabela `#{tabela}` não existe.")
    else
      indices = sql("SELECT indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = $1 ORDER BY indexname", [tabela])
      lista = Enum.map_join(indices.rows, "\n", fn [definicao] -> "* `#{definicao}`" end)

      chaves =
        sql(
          """
          SELECT conname, pg_get_constraintdef(oid) FROM pg_constraint
          WHERE conrelid = to_regclass($1) AND contype = 'f' ORDER BY conname
          """,
          [tabela]
        )

      fks =
        if chaves.rows == [],
          do: "",
          else: "\n\n**Chaves estrangeiras**\n\n" <> Enum.map_join(chaves.rows, "\n", fn [nome, definicao] -> "* `#{nome}` #{definicao}" end)

      Kino.Markdown.new(
        "**`\\d #{tabela}`**\n\n" <>
          tabela_markdown(colunas) <> "\n\n**Índices**\n\n" <> if(lista == "", do: "nenhum", else: lista) <> fks
      )
    end
  end

  @doc "Executa um SQL e mostra o resultado como tabela."
  def consultar(consulta, parametros \\ []) do
    resultado = sql(consulta, parametros)
    Kino.Markdown.new(cerca(consulta, "sql") <> "\n\n" <> tabela_markdown(resultado))
  end

  defp sql(consulta, parametros \\ []) do
    apply(@repo, :query!, [consulta, parametros, [log: false]])
  end

  defp tabela_markdown(%{columns: colunas, rows: linhas}) do
    cabecalho = "| " <> Enum.join(colunas, " | ") <> " |"
    separador = "| " <> Enum.map_join(colunas, " | ", fn _ -> "---" end) <> " |"

    corpo =
      Enum.map(linhas, fn linha ->
        "| " <> Enum.map_join(linha, " | ", &celula/1) <> " |"
      end)

    Enum.join([cabecalho, separador | corpo], "\n")
  end

  defp celula(nil), do: ""
  defp celula(""), do: ""
  defp celula(valor) when is_binary(valor), do: "`" <> String.replace(valor, "|", "\\|") <> "`"
  defp celula(%Decimal{} = valor), do: "`" <> Decimal.to_string(valor) <> "`"
  defp celula(valor), do: "`" <> (valor |> inspect() |> String.replace("|", "\\|")) <> "`"

  # --- requisições -----------------------------------------------------------

  @doc "Despacha uma requisição para o endpoint, sem passar pela rede."
  def requisicao(metodo \\ :get, caminho, parametros \\ nil, conn \\ Phoenix.ConnTest.build_conn()) do
    descartar_respostas_antigas()

    conn = Phoenix.ConnTest.dispatch(conn, @endpoint, metodo, caminho, parametros)

    # Para NoRouteError (e só para ela), o Phoenix envia a página de erro e
    # devolve o conn sem relançar a exceção. Reconstruímos a exceção aqui.
    if conn.state == :sent and conn.status == nil do
      {:erro, Phoenix.Router.NoRouteError.exception(conn: conn, router: @router)}
    else
      {:ok, conn}
    end
  rescue
    erro -> {:erro, desembrulhar(erro)}
  end

  # Cada resposta enviada pelo adaptador de testes deixa duas mensagens no
  # processo que fez a requisição. Se ficarem acumuladas, o Phoenix acha que a
  # próxima página de erro já foi enviada e não a renderiza.
  defp descartar_respostas_antigas do
    receive do
      {ref, {status, cabecalhos, _corpo}} when is_reference(ref) and is_integer(status) and is_list(cabecalhos) ->
        descartar_respostas_antigas()

      {:plug_conn, :sent} ->
        descartar_respostas_antigas()
    after
      0 -> :ok
    end
  end

  defp desembrulhar(%Plug.Conn.WrapperError{reason: motivo}), do: desembrulhar(motivo)
  defp desembrulhar(erro), do: erro

  @doc "Faz a requisição e mostra o status, o content-type e o corpo da resposta."
  def mostrar(metodo \\ :get, caminho, parametros \\ nil) do
    titulo = "### #{metodo |> to_string() |> String.upcase()} #{caminho}"

    corpo =
      case requisicao(metodo, caminho, parametros) do
        {:ok, conn} ->
          tipo = conn |> Plug.Conn.get_resp_header("content-type") |> Enum.join()

          "**Status** `#{conn.status}` · **content-type** `#{tipo}`\n\n" <>
            cerca(to_string(conn.resp_body), if(tipo =~ "json", do: "json", else: "html"))

        {:erro, erro} ->
          "**A requisição levantou uma exceção:**\n\n" <> cerca(Exception.format_banner(:error, erro), "")
      end

    Kino.Markdown.new(titulo <> "\n\n" <> corpo)
  end

  # --- navegador ---------------------------------------------------------------

  @doc """
  Faz uma requisição como um navegador: envia os cookies que o visitante `quem`
  recebeu antes (a sessão, onde ficam a flash e o login) e segue os redirects.
  `quem` é um nome qualquer, como "ana"; cada nome tem os seus próprios cookies.
  Devolve `{:ok, conn}` com a última resposta, ou `{:erro, exceção}`.
  """
  def visitar(quem, metodo, caminho, parametros \\ nil) do
    quem |> seguir(metodo, caminho, parametros, []) |> List.last() |> elem(1)
  end

  @doc "Como `visitar/4`, mas mostra cada passo, a flash e o texto da página final."
  def navegar(quem, metodo \\ :get, caminho, parametros \\ nil) do
    passos = seguir(quem, metodo, caminho, parametros, [])

    linhas =
      passos
      |> Enum.with_index(1)
      |> Enum.map(fn {{requisicao, resultado}, i} -> linha_passo(i, requisicao, resultado) end)

    cabecalho = [
      "### #{quem}: #{rotulo(metodo, caminho)}",
      "",
      "| # | Requisição | Status | Redireciona para |",
      "| --- | --- | --- | --- |"
    ]

    Kino.Markdown.new(Enum.join(cabecalho ++ linhas, "\n") <> "\n\n" <> resumo_final(List.last(passos)))
  end

  @doc "Esquece os cookies de todos os visitantes, como se cada um abrisse um navegador novo."
  def esquecer_visitantes do
    for {{__MODULE__, :cookies, _} = chave, _} <- :persistent_term.get(), do: :persistent_term.erase(chave)
    :ok
  end

  defp seguir(quem, metodo, caminho, parametros, feitos) do
    conn = Enum.reduce(cookies(quem), Phoenix.ConnTest.build_conn(), fn {nome, valor}, c -> Plug.Test.put_req_cookie(c, nome, valor) end)
    resultado = requisicao(metodo, caminho, parametros, conn)
    feitos = feitos ++ [{rotulo(metodo, caminho), resultado}]

    with {:ok, resp} <- resultado do
      guardar_cookies(quem, resp)
    end

    with {:ok, resp} <- resultado,
         true <- resp.status in 300..399 and length(feitos) < 6,
         [destino] <- Plug.Conn.get_resp_header(resp, "location"),
         true <- String.starts_with?(destino, "/") do
      seguir(quem, :get, destino, nil, feitos)
    else
      _ -> feitos
    end
  end

  defp cookies(quem), do: :persistent_term.get({__MODULE__, :cookies, quem}, %{})

  defp guardar_cookies(quem, resp) do
    novos =
      Enum.reduce(resp.resp_cookies, cookies(quem), fn {nome, opcoes}, acc ->
        if opcoes[:max_age] == 0, do: Map.delete(acc, nome), else: Map.put(acc, nome, opcoes[:value])
      end)

    :persistent_term.put({__MODULE__, :cookies, quem}, novos)
  end

  defp rotulo(metodo, caminho), do: "#{metodo |> to_string() |> String.upcase()} #{caminho}"

  defp linha_passo(i, requisicao, {:ok, conn}) do
    destino = conn |> Plug.Conn.get_resp_header("location") |> List.first("")
    "| #{i} | `#{requisicao}` | `#{conn.status}` | #{if destino == "", do: "", else: "`#{destino}`"} |"
  end

  defp linha_passo(i, requisicao, {:erro, erro}) do
    "| #{i} | `#{requisicao}` | exceção: `#{erro.__struct__ |> inspect()}` | |"
  end

  defp resumo_final({_requisicao, {:ok, conn}}) do
    flash =
      case conn.assigns[:flash] do
        mapa when is_map(mapa) and map_size(mapa) > 0 ->
          "**Flash:** " <> Enum.map_join(mapa, " · ", fn {tipo, msg} -> "#{tipo}: \"#{msg}\"" end) <> "\n\n"

        _ ->
          ""
      end

    flash <> "**Texto da página** (sem as tags):\n\n" <> cerca(texto(to_string(conn.resp_body)), "")
  end

  defp resumo_final({_requisicao, {:erro, erro}}) do
    "**A requisição levantou uma exceção:**\n\n" <> cerca(Exception.format_banner(:error, erro), "")
  end

  @doc "O texto de uma página HTML, sem as tags: o que se lê no navegador."
  def texto(html) do
    conteudo =
      case Regex.run(~r{<main[^>]*>(.*)</main>}s, html) do
        [_, dentro] -> dentro
        nil -> html
      end

    conteudo
    |> String.replace(~r{<(script|style|head)[^>]*>.*?</\1>}s, "")
    |> String.replace(~r{<input[^>]*type="hidden"[^>]*>}, "")
    |> String.replace(~r{<option[^>]*\sselected[^>]*>}, "[x] ")
    |> String.replace(~r{<option[^>]*>}, "[ ] ")
    |> then(&Regex.replace(~r{<input[^>]*\svalue="([^"]*)"[^>]*>}, &1, fn _, valor -> if valor == "", do: " ", else: " [" <> valor <> "] " end))
    |> String.replace(~r{</?(p|div|li|tr|h\d|header|footer|ul|table|thead|tbody|form|label|select|option|section|br)[^>]*>}, "\n")
    |> String.replace(~r{</t[dh]>}, "  ")
    |> String.replace(~r{<[^>]+>}, "")
    |> String.replace(["&amp;", "<", ">", """, "'", "&nbsp;"], fn
      "&amp;" -> "&"
      "<" -> "<"
      ">" -> ">"
      """ -> "\""
      "'" -> "'"
      "&nbsp;" -> " "
    end)
    |> String.split("\n")
    |> Enum.map(&(&1 |> String.replace(~r/[ \t]+/, " ") |> String.trim()))
    |> Enum.reject(&(&1 == ""))
    |> Enum.join("\n")
  end

  defp cerca(texto, linguagem) do
    marca = String.duplicate("`", 3)
    marca <> linguagem <> "\n" <> String.trim(texto) <> "\n" <> marca
  end

  # --- curl ------------------------------------------------------------------------

  @doc """
  Roda um comando curl de verdade contra o servidor do notebook, por uma conexão
  TCP, e mostra a resposta como o curl mostraria. Entende -i, -X, -H e -d.
  """
  def curl(comando) do
    {requisicao, resposta} = executar_curl(comando)
    corpo = resumir_corpo(resposta)
    cabeca = resposta.bruta |> String.split("\n\n", parts: 2) |> hd()
    saida = if requisicao.incluir_cabecalhos, do: cabeca <> "\n\n" <> corpo, else: corpo
    Kino.Markdown.new(cerca("$ " <> String.trim(comando), "console") <> "\n\n" <> cerca(saida, ""))
  end

  # A página de depuração do Plug.Debugger tem dezenas de KB; mostramos só o começo.
  defp resumir_corpo(%{corpo: corpo} = resposta) when byte_size(corpo) > 3000 do
    tipo = resposta.cabecalhos |> List.keyfind("content-type", 0, {nil, ""}) |> elem(1)

    resumo =
      cond do
        tipo =~ "text/html" ->
          titulo =
            case Regex.run(~r{<title>(.*?)</title>}s, corpo) do
              [_, t] -> String.trim(t)
              nil -> "?"
            end
          "<!-- página HTML de #{byte_size(corpo)} bytes, título: #{titulo} -->"

        true ->
          corpo |> String.split("\n") |> Enum.take(12) |> Enum.join("\n")
      end

    resumo <> "\n[... #{byte_size(corpo)} bytes no total; o Trilha.curl mostra só o começo]"
  end

  defp resumir_corpo(%{corpo: corpo}), do: corpo

  @doc "Como curl/1, mas devolve %{status:, cabecalhos:, corpo:, json:} para conferências."
  def curl!(comando), do: comando |> executar_curl() |> elem(1)

  defp executar_curl(comando) do
    requisicao = interpretar_curl(comando)
    {requisicao, enviar_http(requisicao)}
  end

  defp interpretar_curl(comando) do
    palavras =
      Regex.scan(~r/'([^']*)'|"((?:[^"\\]|\\.)*)"|(\S+)/, String.replace(comando, "\\\n", " "))
      |> Enum.map(fn
        [_, simples] -> simples
        [_, "", duplas] -> String.replace(duplas, ~s(\\"), ~s("))
        [_, "", "", solta] -> solta
      end)
      |> Enum.drop_while(&(&1 in ["$", "curl"]))

    inicial = %{metodo: nil, url: nil, cabecalhos: [], dados: nil, incluir_cabecalhos: false}

    requisicao = ler_opcoes_curl(palavras, inicial)
    metodo = requisicao.metodo || if(requisicao.dados, do: "POST", else: "GET")
    %{requisicao | metodo: metodo}
  end

  defp ler_opcoes_curl([], acc), do: acc
  defp ler_opcoes_curl(["-H", cabecalho | resto], acc), do: ler_opcoes_curl(resto, %{acc | cabecalhos: acc.cabecalhos ++ [cabecalho]})
  defp ler_opcoes_curl(["-d", dados | resto], acc), do: ler_opcoes_curl(resto, %{acc | dados: dados})
  defp ler_opcoes_curl(["-X", metodo | resto], acc), do: ler_opcoes_curl(resto, %{acc | metodo: metodo})

  defp ler_opcoes_curl(["-" <> letras | resto], acc) when letras in ["i", "iX", "Xi"] do
    acc = %{acc | incluir_cabecalhos: true}

    case {String.contains?(letras, "X"), resto} do
      {true, [metodo | resto]} -> ler_opcoes_curl(resto, %{acc | metodo: metodo})
      {false, _} -> ler_opcoes_curl(resto, acc)
    end
  end

  defp ler_opcoes_curl([url | resto], acc), do: ler_opcoes_curl(resto, %{acc | url: url})

  defp enviar_http(requisicao) do
    porta = apply(@endpoint, :config, [:http])[:port]
    %URI{path: caminho, query: consulta} = URI.parse(requisicao.url)
    alvo = (caminho || "/") <> if(consulta, do: "?" <> consulta, else: "")
    corpo = requisicao.dados || ""

    tipo =
      if requisicao.dados && !Enum.any?(requisicao.cabecalhos, &(&1 =~ ~r/^content-type:/i)),
        do: ["Content-Type: application/x-www-form-urlencoded"],
        else: []

    # Como o curl: um cabeçalho passado com -H substitui o padrão de mesmo nome.
    padrao =
      Enum.reject(["User-Agent: curl/8", "Accept: */*"], fn linha ->
        nome = linha |> String.split(":") |> hd() |> String.downcase()
        Enum.any?(requisicao.cabecalhos, &(&1 |> String.split(":") |> hd() |> String.downcase() == nome))
      end)

    linhas =
      ["#{requisicao.metodo} #{alvo} HTTP/1.1", "Host: localhost:#{porta}"] ++
        padrao ++ requisicao.cabecalhos ++ tipo ++
        if(corpo != "", do: ["Content-Length: #{byte_size(corpo)}"], else: []) ++ ["Connection: close", "", corpo]

    socket =
      case :gen_tcp.connect(~c"127.0.0.1", porta, [:binary, active: false]) do
        {:ok, socket} ->
          socket

        {:error, motivo} ->
          raise "nada respondeu na porta #{porta} (#{inspect(motivo)}). Avalie a célula que inicia o HelloWeb.Endpoint."
      end

    :ok = :gen_tcp.send(socket, Enum.join(linhas, "\r\n"))
    bruta = receber_tudo(socket, "")
    :gen_tcp.close(socket)

    [cabeca, corpo_resposta] = String.split(bruta, "\r\n\r\n", parts: 2)
    [linha_status | linhas_cabecalho] = String.split(cabeca, "\r\n")
    [_, status | _] = String.split(linha_status, " ")

    cabecalhos =
      Enum.map(linhas_cabecalho, fn linha ->
        [nome, valor] = String.split(linha, ": ", parts: 2)
        {String.downcase(nome), valor}
      end)

    json =
      case Jason.decode(corpo_resposta) do
        {:ok, valor} -> valor
        _ -> nil
      end

    %{
      status: String.to_integer(status),
      cabecalhos: cabecalhos,
      corpo: corpo_resposta,
      json: json,
      bruta: String.replace(cabeca, "\r\n", "\n") <> "\n\n" <> corpo_resposta
    }
  end

  defp receber_tudo(socket, acc) do
    case :gen_tcp.recv(socket, 0, 5000) do
      {:ok, dados} -> receber_tudo(socket, acc <> dados)
      {:error, :closed} -> acc
    end
  end

  # --- e-mails ---------------------------------------------------------------------

  @doc """
  Os e-mails enviados pela aplicação, do mais novo ao mais antigo. Em
  desenvolvimento, o Swoosh guarda cada e-mail na memória em vez de enviá-lo;
  é a mesma caixa que aparece em http://localhost:4010/dev/mailbox.
  """
  def caixa_de_entrada do
    emails = Swoosh.Adapters.Local.Storage.Memory.all()

    if emails == [] do
      Kino.Markdown.new("A caixa de entrada está vazia.")
    else
      linhas =
        Enum.map(emails, fn email ->
          para = email.to |> Enum.map_join(", ", fn {_nome, endereco} -> endereco end)
          "| `#{para}` | #{email.subject} |"
        end)

      Kino.Markdown.new(Enum.join(["| Para | Assunto |", "| --- | --- |" | linhas], "\n"))
    end
  end

  @doc "O último e-mail enviado para `endereco`, com o texto inteiro."
  def ultimo_email(endereco) do
    case email_para(endereco) do
      nil -> Kino.Markdown.new("Nenhum e-mail para `#{endereco}`.")
      email -> Kino.Markdown.new("**#{email.subject}**\n\n" <> cerca(email.text_body, ""))
    end
  end

  @doc "O caminho do link (o que vem depois de http://localhost:4010) no último e-mail para `endereco`."
  def link_do_email(endereco) do
    with %{text_body: corpo} <- email_para(endereco),
         [url | _] <- Regex.run(~r{https?://\S+}, corpo) do
      %URI{path: caminho} = URI.parse(url)
      caminho
    else
      _ -> raise "nenhum e-mail com link para #{endereco}"
    end
  end

  defp email_para(endereco) do
    Swoosh.Adapters.Local.Storage.Memory.all()
    |> Enum.find(fn email -> Enum.any?(email.to, fn {_nome, para} -> para == endereco end) end)
  end

  # --- perguntas -------------------------------------------------------------

  @doc "Uma pergunta de múltipla escolha, respondida antes de ver a explicação."
  def pergunta(id) do
    %{texto: texto, opcoes: opcoes, correta: correta, explicacao: explicacao} = Map.fetch!(perguntas(), id)

    quadro = Kino.Frame.new()

    letras = Enum.map(opcoes, fn {letra, _texto} -> {letra, to_string(letra)} end)
    lista = Enum.map_join(opcoes, "\n", fn {letra, opcao} -> "* **#{letra})** #{opcao}" end)

    formulario =
      Kino.Control.form(
        [resposta: Kino.Input.select("Sua resposta", [{nil, "Escolha"} | letras])],
        submit: "Conferir"
      )

    Kino.listen(formulario, fn %{data: %{resposta: resposta}} ->
      retorno =
        cond do
          resposta == nil -> "Escolha uma opção antes de conferir."
          resposta == correta -> "**Certo.** " <> explicacao
          true -> "**Ainda não.** Releia o trecho acima e tente outra opção."
        end

      Kino.Frame.render(quadro, Kino.Markdown.new(retorno))
    end)

    Kino.Layout.grid([Kino.Markdown.new("**Antes de continuar:** " <> texto <> "\n\n" <> lista), formulario, quadro])
  end

  defp perguntas do
    %{
      sem_sessao: %{
        texto:
          "você entrou no navegador como Ana e, no mesmo computador, roda `curl http://localhost:4010/api/me`. O que a API sabe sobre quem fez essa requisição?",
        opcoes: [
          a: "Sabe que é a Ana, pelo cookie de sessão que o navegador recebeu no login.",
          b: "Nada: o curl não envia os cookies do navegador, e o pipeline `:api` nem lê a sessão.",
          c: "Sabe, porque o Phoenix lembra o último usuário que entrou.",
          d: "Sabe pelo endereço IP, que é o mesmo do navegador."
        ],
        correta: :b,
        explicacao:
          "Cookies pertencem ao navegador que os recebeu; o curl é outro programa e começa sem nenhum. E mesmo um cliente que mandasse o cookie não seria reconhecido: o pipeline `:api` só tem `plug :accepts`, sem `fetch_session` nem `fetch_current_scope_for_user`. Por isso `conn.assigns` não tem `:current_scope`, e o `MeController` quebra ao procurá-lo."
      },
      mesma_tabela: %{
        texto:
          "por que guardar os tokens de API na tabela `users_tokens`, que o `phx.gen.auth` já criou, em vez de criar uma tabela nova?",
        opcoes: [
          a: "Porque o Ecto só permite uma tabela de tokens por aplicação.",
          b: "Porque a tabela já tem o que um token precisa: o usuário, um contexto que separa os tipos de token, a data de criação para a validade, o e-mail de destino e funções prontas que geram e conferem tokens.",
          c: "Porque assim o token de API também serve de cookie de sessão.",
          d: "Por desempenho: o PostgreSQL consulta uma tabela só mais depressa."
        ],
        correta: :b,
        explicacao:
          "Cada linha de `users_tokens` tem `user_id`, `token`, `context`, `sent_to` e `inserted_at`. O contexto separa os tipos: `\"session\"`, `\"login\"`, `\"change:...\"` e, agora, `\"api-token\"`. A tabela também já participa das regras de segurança: a chave estrangeira apaga os tokens com o usuário, e trocar a senha apaga todos os tokens dele. Um token de API reaproveita tudo isso."
      },
      hash: %{
        texto:
          "um backup do banco vaza, com a tabela `users_tokens` inteira. Quem tem o backup consegue usar os tokens de API da Ana?",
        opcoes: [
          a: "Sim: os tokens estão na tabela.",
          b: "Não: a tabela guarda só o hash SHA-256 de cada token de API, e não dá para reconstruir o token a partir do hash.",
          c: "Só se também descobrir a senha da Ana.",
          d: "Sim, mas só durante 365 dias."
        ],
        correta: :b,
        explicacao:
          "Tokens de API são criados como os tokens de e-mail: `build_email_token/2` sorteia 32 bytes, entrega ao cliente esses bytes em Base64 e grava no banco só o hash. Para conferir, o servidor calcula o hash do que recebeu e procura. Quem tem só o hash não tem o que mandar no cabeçalho. Os tokens de sessão ficam sem hash porque só valem dentro de um cookie assinado com a `secret_key_base`, que não está no banco."
      },
      fetch_get: %{
        texto:
          "o guia chama a função de `fetch_user_by_api_token`, e não de `get_user_by_api_token`. Pela convenção do Elixir, o que o nome `fetch_*` promete?",
        opcoes: [
          a: "Que ela busca no banco; uma `get_*` buscaria na memória.",
          b: "Que ela devolve `{:ok, valor}` ou `:error`, como `Map.fetch/2`; uma `get_*` devolveria o valor ou `nil`, como `Map.get/2`.",
          c: "Que ela levanta uma exceção quando não acha nada.",
          d: "Nada: é questão de gosto."
        ],
        correta: :b,
        explicacao:
          "É a mesma convenção de `Map.fetch/2` e `Map.get/2`, ou `Keyword.fetch/2` e `Keyword.get/2`. As funções com `!` no fim, como `fetch!` e `get_user!`, levantam exceção. O guia escolhe `fetch_*` porque quer tratar os dois casos com status diferentes na API, e uma tupla casa bem com `with`."
      },
      email_muda: %{
        texto: "a Ana cria um token de API e depois troca o e-mail da conta. O que acontece com o token?",
        opcoes: [
          a: "Continua valendo: o token está ligado ao `user_id`, não ao e-mail.",
          b: "Para de valer: a consulta exige `token.sent_to == user.email`, e o `sent_to` do token guarda o e-mail antigo.",
          c: "É apagado da tabela quando o e-mail muda.",
          d: "Passa a valer para o e-mail novo."
        ],
        correta: :b,
        explicacao:
          "`build_email_token/2` grava o e-mail do momento em `sent_to`. A consulta de verificação compara esse campo com o e-mail atual do usuário. A linha continua na tabela, porque `update_user_email/2` só apaga os tokens de troca de e-mail, mas não serve mais. O guia diz isso numa frase: como é um token de e-mail, trocar o e-mail o expira."
      },
      senha_muda: %{
        texto: "e se a Ana trocar a senha, o que acontece com os tokens de API dela?",
        opcoes: [
          a: "Nada: senha e token de API são coisas separadas.",
          b: "São apagados, junto com todos os outros tokens dela: `update_user_password/2` chama `update_user_and_delete_all_tokens/1`.",
          c: "Só os tokens de sessão são apagados.",
          d: "Ganham mais 365 dias de validade."
        ],
        correta: :b,
        explicacao:
          "Trocar a senha é o gesto de quem acha que a conta pode ter sido invadida. Por isso o código gerado apaga todos os tokens do usuário, de qualquer contexto, e devolve a lista do que expirou. O guia de `mix phx.gen.auth` cita isso nas considerações de segurança. Os tokens de API entram na mesma limpeza porque moram na mesma tabela."
      },
      bearer_minusculo: %{
        texto:
          "um cliente manda o cabeçalho `authorization: bearer TOKEN`, com `bearer` todo em minúsculas. O plug do guia aceita?",
        opcoes: [
          a: "Não: o padrão do `with` só casa com `Bearer`.",
          b: "Sim: o padrão separa os 6 primeiros bytes, sejam quais forem, e o plug compara `String.downcase(bearer)` com `\"bearer\"`.",
          c: "Só se o token também estiver em minúsculas.",
          d: "Não, porque o nome do cabeçalho `authorization` precisa começar com maiúscula."
        ],
        correta: :b,
        explicacao:
          "`<<bearer::binary-size(6), \" \", token::binary>>` casa com quaisquer 6 bytes seguidos de um espaço. A linha seguinte do `with` aceita `Bearer`, `bearer` ou `BEARER`, porque o HTTP trata o nome do esquema de autenticação sem diferenciar maiúsculas. Os nomes de cabeçalho também: o Plug os entrega sempre em minúsculas, por isso o plug procura `\"authorization\"`."
      },
      halt: %{
        texto:
          "se o plug esquecesse o `halt()` depois do `send_resp(:unauthorized, ...)`, o que aconteceria numa requisição sem token?",
        opcoes: [
          a: "Nada de diferente: `send_resp` já encerra a requisição.",
          b: "Os plugs seguintes e o controller rodariam mesmo assim, sem usuário: `send_resp` envia a resposta, mas não interrompe o pipeline.",
          c: "O Phoenix chamaria `halt` sozinho ao ver o status 401.",
          d: "A requisição ficaria esperando para sempre."
        ],
        correta: :b,
        explicacao:
          "Um pipeline de plugs só para quando o `conn` está marcado com `halted: true`, que é o que `halt/1` faz. `send_resp/3` envia a resposta e marca o estado como `:sent`, mas o próximo plug é chamado do mesmo jeito. O código protegido rodaria para quem não tem token. Aqui ele quebraria por falta de `current_scope`; outro controller tentaria responder de novo e levantaria `Plug.Conn.AlreadySentError`."
      },
      rota_publica: %{
        texto:
          "a rota `POST /api/tokens` troca e-mail e senha por um token novo. Em que pipeline ela pode ficar?",
        opcoes: [
          a: "No `:api` do guia, junto com `GET /api/me`.",
          b: "Num pipeline sem o plug do token: quem ainda não tem token receberia 401 do `:api` do guia antes de chegar ao controller.",
          c: "No `:browser`, para aproveitar a sessão.",
          d: "Em qualquer um, porque o plug ignora requisições `POST`."
        ],
        correta: :b,
        explicacao:
          "O plug do guia recusa qualquer requisição sem token válido, e o cliente que pede um token ainda não tem um. A saída é separar: um pipeline `:api` só com `plug :accepts, [\"json\"]`, para as rotas públicas, e outro, aqui chamado `:api_auth`, com o plug do token, para as protegidas. Um `scope` pode passar pelos dois: `pipe_through [:api, :api_auth]`."
      }
    }
  end

  # --- soluções --------------------------------------------------------------

  @doc "Um botão que revela a solução de um exercício."
  def solucao(id) do
    quadro = Kino.Frame.new()
    botao = Kino.Control.button("Mostrar a solução")

    Kino.listen(botao, fn _evento ->
      Kino.Frame.render(quadro, Kino.Markdown.new(cerca(Map.fetch!(solucoes(), id), "elixir")))
    end)

    Kino.Layout.grid([botao, quadro])
  end

  defp solucoes do
    %{
      contexto: ~S'''
      # Na célula do Hello.Accounts, no lugar do comentário "Sua vez (notebook 15)":

        ## API

        @doc """
        Creates a new api token for a user.

        The token returned must be saved somewhere safe.
        This token cannot be recovered from the database.
        """
        def create_user_api_token(user) do
          {encoded_token, user_token} = UserToken.build_email_token(user, "api-token")
          Repo.insert!(user_token)
          encoded_token
        end

        @doc """
        Fetches the user by API token.
        """
        def fetch_user_by_api_token(token) do
          with {:ok, query} <- UserToken.verify_api_token_query(token),
               %User{} = user <- Repo.one(query) do
            {:ok, user}
          else
            _ -> :error
          end
        end
      ''',
      verificar: ~S'''
      # Na célula do Hello.Accounts.UserToken, junto dos outros atributos:

        @api_token_validity_in_days 365

      # e, no lugar do comentário "Sua vez (notebook 15)":

        @doc """
        Checks if the API token is valid and returns its underlying lookup query.

        The query returns the user found by the token, if any.

        The given token is valid if it matches its hashed counterpart in the
        database and the user email has not changed. This function also checks
        if the token is being used within 365 days.
        """
        def verify_api_token_query(token) do
          case Base.url_decode64(token, padding: false) do
            {:ok, decoded_token} ->
              hashed_token = :crypto.hash(@hash_algorithm, decoded_token)

              query =
                from token in by_token_and_context_query(hashed_token, "api-token"),
                  join: user in assoc(token, :user),
                  where:
                    token.inserted_at > ago(^@api_token_validity_in_days, "day") and
                      token.sent_to == user.email,
                  select: user

              {:ok, query}

            :error ->
              :error
          end
        end
      ''',
      plug: ~S'''
      # Na célula do HelloWeb.UserAuth, no lugar do comentário "Sua vez (notebook 15)":

        def fetch_current_scope_for_api_user(conn, _opts) do
          with [<<bearer::binary-size(6), " ", token::binary>>] <-
                 get_req_header(conn, "authorization"),
               true <- String.downcase(bearer) == "bearer",
               {:ok, user} <- Accounts.fetch_user_by_api_token(token) do
            assign(conn, :current_scope, Scope.for_user(user))
          else
            _ ->
              conn
              |> send_resp(:unauthorized, "No access for you")
              |> halt()
          end
        end

      # Na célula do router desta seção (avalie a do UserAuth antes):

        pipeline :api do
          plug :accepts, ["json"]
          plug :fetch_current_scope_for_api_user
        end
      ''',
      login_api: ~S'''
      defmodule HelloWeb.TokenController do
        use HelloWeb, :controller

        alias Hello.Accounts

        def create(conn, %{"email" => email, "password" => password}) do
          if user = Accounts.get_user_by_email_and_password(email, password) do
            token = Accounts.create_user_api_token(user)

            conn
            |> put_status(:created)
            |> json(%{data: %{token: token}})
          else
            conn
            |> put_status(:unauthorized)
            |> json(%{errors: %{detail: "Invalid email or password"}})
          end
        end
      end
      ''',
      revogar: ~S'''
      # Na célula do Hello.Accounts, junto das funções de API:

        def delete_user_api_tokens(user) do
          Repo.delete_all(from(UserToken, where: [user_id: ^user.id, context: "api-token"]))
          :ok
        end

      # No HelloWeb.TokenController, ao lado de create/2:

        def delete(conn, _params) do
          :ok = Accounts.delete_user_api_tokens(conn.assigns.current_scope.user)
          send_resp(conn, :no_content, "")
        end
      ''',
      brecho: ~S'''
      # priv/repo/migrations/20250625000000_add_vendedor_to_produtos.exs
      defmodule Hello.Repo.Migrations.AddVendedorToProdutos do
        use Ecto.Migration

        def change do
          alter table(:produtos) do
            add :vendedor_id, references(:users, on_delete: :nilify_all)
          end

          create index(:produtos, [:vendedor_id])
        end
      end

      Trilha.migracao(20250625000000, Hello.Repo.Migrations.AddVendedorToProdutos)
      Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)

      # lib/hello/vitrine/produto.ex: o schema ganha o vendedor
      defmodule Hello.Vitrine.Produto do
        use Ecto.Schema
        import Ecto.Changeset

        schema "produtos" do
          field :nome, :string
          field :preco_centavos, :integer
          field :vendido, :boolean, default: false
          belongs_to :vendedor, Hello.Accounts.User

          timestamps(type: :utc_datetime)
        end

        @doc false
        def changeset(produto, attrs) do
          produto
          |> cast(attrs, [:nome, :preco_centavos])
          |> validate_required([:nome, :preco_centavos])
          |> validate_number(:preco_centavos, greater_than: 0)
        end
      end

      # lib/hello/vitrine.ex: as funções com escopo (o resto como estava)
      defmodule Hello.Vitrine do
        import Ecto.Query, warn: false
        alias Hello.Repo

        alias Hello.Accounts.Scope
        alias Hello.Vitrine.Produto

        def listar_disponiveis do
          Repo.all(from p in Produto, where: not p.vendido, order_by: p.preco_centavos)
        end

        def buscar_produto!(id), do: Repo.get!(Produto, id)

        def cadastrar_produto(attrs) do
          %Produto{}
          |> Produto.changeset(attrs)
          |> Repo.insert()
        end

        def cadastrar_produto(%Scope{} = scope, attrs) do
          %Produto{vendedor_id: scope.user.id}
          |> Produto.changeset(attrs)
          |> Repo.insert()
        end

        def change_produto(%Produto{} = produto, attrs \\ %{}) do
          Produto.changeset(produto, attrs)
        end

        def vender(%Produto{id: id}) do
          consulta = from p in Produto, where: p.id == ^id and not p.vendido, select: p

          case Repo.update_all(consulta, set: [vendido: true, updated_at: DateTime.utc_now(:second)]) do
            {1, [produto]} -> {:ok, produto}
            {0, []} -> {:error, :ja_vendido}
          end
        end

        def vender(%Scope{} = scope, %Produto{} = produto) do
          if produto.vendedor_id == scope.user.id,
            do: vender(produto),
            else: {:error, :nao_e_seu}
        end
      end

      # lib/hello_web/controllers/produto_api_controller.ex
      defmodule HelloWeb.ProdutoAPIController do
        use HelloWeb, :controller

        alias Hello.Vitrine

        def index(conn, _params) do
          json(conn, %{data: Enum.map(Vitrine.listar_disponiveis(), &dados/1)})
        end

        def create(conn, %{"produto" => attrs}) do
          case Vitrine.cadastrar_produto(conn.assigns.current_scope, attrs) do
            {:ok, produto} ->
              conn |> put_status(:created) |> json(%{data: dados(produto)})

            {:error, changeset} ->
              erros = Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end)
              conn |> put_status(:unprocessable_entity) |> json(%{errors: erros})
          end
        end

        def vender(conn, %{"id" => id}) do
          case Vitrine.vender(conn.assigns.current_scope, Vitrine.buscar_produto!(id)) do
            {:ok, produto} ->
              json(conn, %{data: dados(produto)})

            {:error, :nao_e_seu} ->
              conn |> put_status(:forbidden) |> json(%{errors: %{detail: "Forbidden"}})

            {:error, :ja_vendido} ->
              conn |> put_status(:conflict) |> json(%{errors: %{detail: "Conflict"}})
          end
        end

        defp dados(produto) do
          Map.take(produto, [:id, :nome, :preco_centavos, :vendido, :vendedor_id])
        end
      end

      # No router: GET público, POST protegidos (o resto como estava)
      #
      #   scope "/api", HelloWeb do
      #     pipe_through :api
      #
      #     post "/tokens", TokenController, :create
      #     get "/produtos", ProdutoAPIController, :index
      #   end
      #
      #   scope "/api", HelloWeb do
      #     pipe_through [:api, :api_auth]
      #
      #     get "/me", MeController, :show
      #     delete "/tokens", TokenController, :delete
      #     post "/produtos", ProdutoAPIController, :create
      #     post "/produtos/:id/vender", ProdutoAPIController, :vender
      #   end
      '''
    }
  end

  # --- conferência dos exercícios ----------------------------------------------

  @doc "Confere um exercício consultando o banco e fazendo requisições de verdade."
  def conferir(id) do
    resultado =
      Enum.reduce_while(Map.fetch!(verificacoes(), id), [], fn {descricao, teste}, feitos ->
        case executar(teste) do
          :ok -> {:cont, ["- Passou: " <> descricao | feitos]}
          {:erro, dica} -> {:halt, ["- **Ainda não:** " <> descricao <> "\n\n  " <> dica | feitos]}
        end
      end)

    Kino.Markdown.new(resultado |> Enum.reverse() |> Enum.join("\n"))
  end

  defp executar(teste) do
    teste.()
  rescue
    erro -> {:erro, explicar(erro)}
  end

  defp verificacoes do
    %{
      contexto: [
        {"`Hello.Accounts` define `create_user_api_token/1` e `fetch_user_by_api_token/1`.",
         fn -> exporta(Hello.Accounts, create_user_api_token: 1, fetch_user_by_api_token: 1) end},
        {"`create_user_api_token/1` devolve o token como texto em Base64 para URLs, sem `=` no fim.",
         fn ->
           token = novo_token("ana@example.com")

           cond do
             not is_binary(token) ->
               {:erro,
                "A função devolveu `#{inspect(token, limit: 5)}`. Ela deve devolver o primeiro elemento da tupla de `UserToken.build_email_token/2`, o token codificado."}

             Base.url_decode64(token, padding: false) == :error ->
               {:erro, "`#{token}` não é Base64 para URLs. Devolva o `encoded_token`, não o `user_token`."}

             true ->
               :ok
           end
         end},
        {"A tabela `users_tokens` guarda o hash do token, com o contexto `\"api-token\"` e o e-mail da Ana em `sent_to`.",
         fn ->
           token = novo_token("ana@example.com")

           case sql("SELECT context, sent_to FROM users_tokens WHERE token = $1", [hash_do_token(token)]).rows do
             [["api-token", "ana@example.com"]] ->
               :ok

             [[contexto, para]] ->
               {:erro, "A linha existe, mas com contexto `#{contexto}` e `sent_to` `#{para}`. O contexto deve ser `\"api-token\"`."}

             [] ->
               {:erro, "Nenhuma linha de `users_tokens` tem o hash desse token. A função chama `Repo.insert!(user_token)`?"}
           end
         end}
      ],
      verificar: [
        {"`Hello.Accounts.UserToken` define `verify_api_token_query/1`.",
         fn -> exporta(Hello.Accounts.UserToken, verify_api_token_query: 1) end},
        {"Um token recém-criado devolve `{:ok, user}` com a Ana.",
         fn ->
           token = novo_token("ana@example.com")

           case buscar_por_token(token) do
             {:ok, %{email: "ana@example.com"}} -> :ok
             outro -> {:erro, "`fetch_user_by_api_token/1` devolveu `#{inspect(outro, limit: 5)}`."}
           end
         end},
        {"Tokens que não existem, ou que nem são Base64, devolvem `:error`, sem exceção.",
         fn ->
           falsos = ["invalid", "não é base64!", Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)]
           resultados = Enum.map(falsos, &buscar_por_token/1)

           if Enum.all?(resultados, &(&1 == :error)),
             do: :ok,
             else: {:erro, "Para #{inspect(falsos)}, veio `#{inspect(resultados, limit: 5)}`. Os três devem dar `:error`."}
         end},
        {"Um token criado há 366 dias não vale mais; um de 364 dias ainda vale.",
         fn ->
           velho = novo_token("ana@example.com")
           recente = novo_token("ana@example.com")
           envelhecer(velho, 366)
           envelhecer(recente, 364)

           case {buscar_por_token(velho), buscar_por_token(recente)} do
             {:error, {:ok, _}} ->
               :ok

             {{:ok, _}, _} ->
               {:erro,
                "O token de 366 dias ainda funciona. Confira a condição `token.inserted_at > ago(^@api_token_validity_in_days, \"day\")` e o atributo com 365."}

             {_, _} ->
               {:erro, "O token de 364 dias foi recusado. A validade deve ser de 365 dias."}
           end
         end},
        {"Um token cujo `sent_to` não é mais o e-mail do usuário não vale.",
         fn ->
           token = novo_token("ana@example.com")
           sql("UPDATE users_tokens SET sent_to = 'antigo@example.com' WHERE token = $1", [hash_do_token(token)])

           if buscar_por_token(token) == :error,
             do: :ok,
             else: {:erro, "O token continuou valendo. Falta a condição `token.sent_to == user.email` na consulta."}
         end}
      ],
      plug: [
        {"`HelloWeb.UserAuth` define `fetch_current_scope_for_api_user/2`.",
         fn -> exporta(HelloWeb.UserAuth, fetch_current_scope_for_api_user: 2) end},
        {"`GET /api/me` com o token da Ana responde 200 com o e-mail dela.",
         fn ->
           token = novo_token("ana@example.com")

           case curl!("curl -i -H 'Authorization: Bearer #{token}' http://localhost:4010/api/me") do
             %{status: 200, json: %{"data" => %{"email" => "ana@example.com"}}} ->
               :ok

             %{status: 500} ->
               {:erro,
                "A resposta foi 500: o `MeController` não achou `current_scope`. O plug está no pipeline `:api` da célula do router desta seção, e essa célula foi avaliada depois da do `UserAuth`?"}

             %{status: status, corpo: corpo} ->
               {:erro, "A resposta foi #{status}: `#{String.slice(corpo, 0, 200)}`."}
           end
         end},
        {"Sem o cabeçalho `authorization`, a resposta é 401 com o texto `No access for you`.",
         fn ->
           case curl!("curl -i http://localhost:4010/api/me") do
             %{status: 401, corpo: "No access for you"} -> :ok
             %{status: status, corpo: corpo} -> {:erro, "A resposta foi #{status}: `#{String.slice(corpo, 0, 200)}`."}
           end
         end},
        {"Com um token que não existe, a resposta também é 401.",
         fn ->
           falso = Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)

           case curl!("curl -i -H 'Authorization: Bearer #{falso}' http://localhost:4010/api/me") do
             %{status: 401} -> :ok
             %{status: status} -> {:erro, "A resposta foi #{status}. O plug deve recusar tokens que `fetch_user_by_api_token/1` não reconhece."}
           end
         end},
        {"O token da Bia mostra os dados da Bia.",
         fn ->
           token = novo_token("bia@example.com")

           case curl!("curl -i -H 'Authorization: Bearer #{token}' http://localhost:4010/api/me") do
             %{status: 200, json: %{"data" => %{"email" => "bia@example.com"}}} -> :ok
             %{status: status, corpo: corpo} -> {:erro, "A resposta foi #{status}: `#{String.slice(corpo, 0, 200)}`."}
           end
         end}
      ],
      login_api: [
        {"`POST /api/tokens` com o e-mail e a senha da Ana responde 201 com `data.token`.",
         fn ->
           case curl!(login_ana("hello world!")) do
             %{status: 201, json: %{"data" => %{"token" => token}}} when is_binary(token) ->
               :ok

             %{status: 401, corpo: "No access for you"} ->
               {:erro, "Quem respondeu foi o plug do token: a rota passa pelo pipeline `:api_auth`. Ela deve ficar no escopo público."}

             %{status: 404} ->
               {:erro, "A rota `POST /api/tokens` não existe. A célula do router desta seção foi avaliada? Ela depende do exercício do plug."}

             %{status: status, corpo: corpo} ->
               {:erro, "A resposta foi #{status}: `#{String.slice(corpo, 0, 300)}`."}
           end
         end},
        {"O token devolvido funciona em `GET /api/me`.",
         fn ->
           %{json: %{"data" => %{"token" => token}}} = curl!(login_ana("hello world!"))

           case curl!("curl -i -H 'Authorization: Bearer #{token}' http://localhost:4010/api/me") do
             %{status: 200, json: %{"data" => %{"email" => "ana@example.com"}}} -> :ok
             %{status: status} -> {:erro, "Com o token devolvido, `GET /api/me` respondeu #{status}. Devolva o resultado de `create_user_api_token/1`."}
           end
         end},
        {"Com a senha errada, a resposta é 401 e nenhum token novo é criado.",
         fn ->
           antes = contar_tokens_de_api()
           resposta = curl!(login_ana("senha errada"))
           depois = contar_tokens_de_api()

           cond do
             resposta.status != 401 -> {:erro, "Com a senha errada, a resposta foi #{resposta.status}."}
             depois != antes -> {:erro, "A resposta foi 401, mas um token foi criado mesmo assim."}
             true -> :ok
           end
         end}
      ],
      revogar: [
        {"`Hello.Accounts` define `delete_user_api_tokens/1`.",
         fn -> exporta(Hello.Accounts, delete_user_api_tokens: 1) end},
        {"`DELETE /api/tokens` com um token da Bia responde 204, e nenhum token de API da Bia funciona depois.",
         fn ->
           t1 = novo_token("bia@example.com")
           t2 = novo_token("bia@example.com")

           case curl!("curl -iX DELETE -H 'Authorization: Bearer #{t1}' http://localhost:4010/api/tokens") do
             %{status: 204} ->
               if buscar_por_token(t1) == :error and buscar_por_token(t2) == :error,
                 do: :ok,
                 else: {:erro, "A resposta foi 204, mas um token da Bia ainda funciona. Apague todas as linhas com contexto `\"api-token\"` dela."}

             %{status: status, corpo: corpo} ->
               {:erro, "A resposta foi #{status}: `#{String.slice(corpo, 0, 200)}`."}
           end
         end},
        {"Os tokens de API da Ana e o token de sessão da Bia continuam valendo.",
         fn ->
           da_ana = novo_token("ana@example.com")
           sessao_da_bia = apply(Hello.Accounts, :generate_user_session_token, [usuario("bia@example.com")])
           t = novo_token("bia@example.com")
           curl!("curl -iX DELETE -H 'Authorization: Bearer #{t}' http://localhost:4010/api/tokens")

           cond do
             buscar_por_token(da_ana) == :error ->
               {:erro, "O token da Ana parou de funcionar. Apague só os tokens do usuário que fez a requisição."}

             apply(Hello.Accounts, :get_user_by_session_token, [sessao_da_bia]) == nil ->
               {:erro, "O token de sessão da Bia foi apagado. Filtre pelo contexto `\"api-token\"`."}

             true ->
               :ok
           end
         end},
        {"`DELETE /api/tokens` sem token responde 401.",
         fn ->
           case curl!("curl -iX DELETE http://localhost:4010/api/tokens") do
             %{status: 401} -> :ok
             %{status: status} -> {:erro, "A resposta foi #{status}. A rota deve ficar no escopo protegido."}
           end
         end}
      ],
      brecho: [
        {"A tabela `produtos` tem a coluna `vendedor_id`, com chave estrangeira para `users`.",
         fn ->
           chaves =
             coluna(
               "SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conrelid = to_regclass('produtos') AND contype = 'f'"
             )

           cond do
             colunas("produtos", ["vendedor_id"]) != :ok -> colunas("produtos", ["vendedor_id"])
             not Enum.any?(chaves, &(&1 =~ "REFERENCES users")) -> {:erro, "`vendedor_id` existe, mas sem `references(:users)`."}
             true -> :ok
           end
         end},
        {"`GET /api/produtos`, sem token, responde 200 com as peças à venda.",
         fn ->
           limpando_brecho(fn ->
             cadastrar_direto("Conferência Lenço", 1500, "ana@example.com")

             case curl!("curl -i http://localhost:4010/api/produtos") do
               %{status: 200, json: %{"data" => lista}} when is_list(lista) ->
                 if Enum.any?(lista, &(&1["nome"] == "Conferência Lenço")),
                   do: :ok,
                   else: {:erro, "A lista não traz a peça cadastrada: `#{inspect(lista, limit: 5)}`."}

               %{status: status, corpo: corpo} ->
                 {:erro, "A resposta foi #{status}: `#{String.slice(corpo, 0, 200)}`. A rota deve ficar no escopo público."}
             end
           end)
         end},
        {"`POST /api/produtos` sem token responde 401; com o token da Ana responde 201 e grava a Ana como vendedora.",
         fn ->
           limpando_brecho(fn ->
             sem = curl!(cadastro_brecho(nil, "Conferência Saia", 3000))
             token = novo_token("ana@example.com")
             com = curl!(cadastro_brecho(token, "Conferência Saia", 3000))
             ana = usuario("ana@example.com")

             cond do
               sem.status != 401 ->
                 {:erro, "Sem token, a resposta foi #{sem.status}. O cadastro deve passar pelo pipeline com o plug do token."}

               com.status != 201 ->
                 {:erro, "Com o token da Ana, a resposta foi #{com.status}: `#{String.slice(com.corpo, 0, 200)}`."}

               coluna("SELECT vendedor_id FROM produtos WHERE nome = 'Conferência Saia'") != [ana.id] ->
                 {:erro, "A peça foi gravada, mas `vendedor_id` não é o id da Ana (#{ana.id})."}

               true ->
                 :ok
             end
           end)
         end},
        {"Um cadastro com preço zero responde 422.",
         fn ->
           limpando_brecho(fn ->
             token = novo_token("ana@example.com")

             case curl!(cadastro_brecho(token, "Conferência Grátis", 0)) do
               %{status: 422} -> :ok
               %{status: status} -> {:erro, "A resposta foi #{status}. Quando o changeset é inválido, responda 422."}
             end
           end)
         end},
        {"A Bia não vende a peça da Ana (403), e a peça continua à venda.",
         fn ->
           limpando_brecho(fn ->
             id = cadastrar_direto("Conferência Bota", 9000, "ana@example.com")
             token = novo_token("bia@example.com")

             case curl!(venda_brecho(token, id)) do
               %{status: 403} ->
                 if coluna("SELECT vendido FROM produtos WHERE id = $1", [id]) == [false],
                   do: :ok,
                   else: {:erro, "A resposta foi 403, mas a peça ficou marcada como vendida."}

               %{status: status} ->
                 {:erro, "A resposta foi #{status}. Só quem cadastrou a peça pode vendê-la: responda 403 para os outros."}
             end
           end)
         end},
        {"A Ana vende a peça dela (200, `vendido: true`); uma segunda venda responde 409.",
         fn ->
           limpando_brecho(fn ->
             id = cadastrar_direto("Conferência Casaco", 12000, "ana@example.com")
             token = novo_token("ana@example.com")
             primeira = curl!(venda_brecho(token, id))
             segunda = curl!(venda_brecho(token, id))

             cond do
               primeira.status != 200 -> {:erro, "A primeira venda respondeu #{primeira.status}: `#{String.slice(primeira.corpo, 0, 200)}`."}
               get_in(primeira.json, ["data", "vendido"]) != true -> {:erro, "A resposta da venda deve trazer `\"vendido\": true` em `data`."}
               segunda.status != 409 -> {:erro, "A segunda venda respondeu #{segunda.status}. Use `{:error, :ja_vendido}` da `Vitrine.vender/1` para responder 409."}
               true -> :ok
             end
           end)
         end},
        {"Depois da venda, `GET /api/produtos` não lista mais a peça.",
         fn ->
           limpando_brecho(fn ->
             id = cadastrar_direto("Conferência Chapéu", 2500, "ana@example.com")
             curl!(venda_brecho(novo_token("ana@example.com"), id))

             case curl!("curl -i http://localhost:4010/api/produtos") do
               %{status: 200, json: %{"data" => lista}} ->
                 if Enum.any?(lista, &(&1["nome"] == "Conferência Chapéu")),
                   do: {:erro, "A peça vendida ainda aparece na lista."},
                   else: :ok

               %{status: status} ->
                 {:erro, "A resposta foi #{status}."}
             end
           end)
         end}
      ]
    }
  end

  # --- ajudantes da conferência --------------------------------------------------

  defp exporta(modulo, funcoes) do
    faltando = Enum.reject(funcoes, fn {nome, aridade} -> Code.ensure_loaded?(modulo) and function_exported?(modulo, nome, aridade) end)

    if faltando == [],
      do: :ok,
      else: {:erro, "Falta definir em `#{inspect(modulo)}`: #{Enum.map_join(faltando, ", ", fn {n, a} -> "`#{n}/#{a}`" end)}."}
  end

  defp colunas(tabela, esperadas) do
    existentes = coluna("SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1", [tabela])

    cond do
      existentes == [] -> {:erro, "A tabela `#{tabela}` não existe. Defina a migration, registre-a e rode a célula do `mix ecto.migrate`."}
      (faltando = esperadas -- existentes) != [] -> {:erro, "A tabela `#{tabela}` não tem as colunas #{inspect(faltando)}."}
      true -> :ok
    end
  end

  defp coluna(consulta, parametros \\ []), do: consulta |> sql(parametros) |> Map.fetch!(:rows) |> Enum.map(&hd/1)

  defp usuario(email), do: apply(Hello.Accounts, :get_user_by_email, [email])

  defp novo_token(email), do: apply(Hello.Accounts, :create_user_api_token, [usuario(email)])

  defp buscar_por_token(token), do: apply(Hello.Accounts, :fetch_user_by_api_token, [token])

  defp hash_do_token(token), do: :crypto.hash(:sha256, Base.url_decode64!(token, padding: false))

  @doc "Muda a data de criação de um token de API, como se ele tivesse sido criado `dias` atrás."
  def envelhecer(token, dias) do
    data = NaiveDateTime.add(NaiveDateTime.utc_now(:second), -dias * 24 * 60 * 60)
    sql("UPDATE users_tokens SET inserted_at = $2 WHERE token = $1", [hash_do_token(token), data])
    :ok
  end

  defp contar_tokens_de_api do
    [[n]] = sql("SELECT count(*) FROM users_tokens WHERE context = 'api-token'").rows
    n
  end

  defp login_ana(senha) do
    corpo = Jason.encode!(%{email: "ana@example.com", password: senha})
    "curl -iX POST http://localhost:4010/api/tokens -H 'Content-Type: application/json' -d '#{corpo}'"
  end

  defp cadastro_brecho(token, nome, preco) do
    corpo = Jason.encode!(%{produto: %{nome: nome, preco_centavos: preco}})
    autorizacao = if token, do: "-H 'Authorization: Bearer #{token}' ", else: ""

    "curl -iX POST http://localhost:4010/api/produtos #{autorizacao}-H 'Content-Type: application/json' -d '#{corpo}'"
  end

  defp venda_brecho(token, id) do
    "curl -iX POST -H 'Authorization: Bearer #{token}' http://localhost:4010/api/produtos/#{id}/vender"
  end

  defp cadastrar_direto(nome, preco, email) do
    [[id]] =
      sql(
        """
        INSERT INTO produtos (nome, preco_centavos, vendido, vendedor_id, inserted_at, updated_at)
        VALUES ($1, $2, false, $3, now(), now()) RETURNING id
        """,
        [nome, preco, usuario(email).id]
      ).rows

    id
  end

  defp limpando_brecho(teste) do
    teste.()
  after
    if coluna("SELECT table_name FROM information_schema.tables WHERE table_name = 'produtos'") != [] do
      sql("DELETE FROM produtos WHERE nome LIKE 'Conferência%'")
    end
  end

  defp explicar(%Phoenix.Router.NoRouteError{} = erro) do
    "Etapa: **router**. `#{Exception.message(erro)}`. Adicione a rota na célula do router e avalie-a de novo."
  end

  defp explicar(%UndefinedFunctionError{module: modulo, function: funcao, arity: aridade}) do
    nome = inspect(modulo)

    cond do
      String.ends_with?(nome, "Controller") and funcao == :init ->
        "Etapa: **controller**. A rota aponta para `#{nome}`, mas esse módulo não existe. Defina-o e avalie a célula."

      String.ends_with?(nome, "Controller") ->
        "Etapa: **controller**. `#{nome}` existe, mas não define `#{funcao}/#{aridade}`."

      String.ends_with?(nome, "HTML") ->
        "Etapa: **view**. O `render` procurou `#{nome}.#{funcao}/#{aridade}` e não achou."

      true ->
        "`#{nome}.#{funcao}/#{aridade}` não existe. O módulo foi definido e a célula foi avaliada?"
    end
  end

  defp explicar(%Postgrex.Error{postgres: %{code: :undefined_table, message: mensagem}}) do
    "O PostgreSQL respondeu `#{mensagem}`. A migration que cria a tabela já rodou?"
  end

  defp explicar(%Ecto.ConstraintError{constraint: restricao}) do
    "O banco recusou a operação pela restrição `#{restricao}`, e o changeset não a declara: a violação virou exceção em vez de erro no changeset."
  end

  defp explicar(%Phoenix.ActionClauseError{}) do
    "Etapa: **controller**. Nenhuma cláusula da action casou com os parâmetros recebidos."
  end

  defp explicar(%MatchError{term: termo}) do
    "Um casamento de padrões falhou com o valor `#{inspect(termo, limit: 8)}`."
  end

  defp explicar(erro), do: "Levantou uma exceção:\n\n  `" <> Exception.format_banner(:error, erro) <> "`"
end

Como usar este notebook

Este notebook fecha o módulo 8 da trilha, sobre autenticação. Ele acompanha o guia API Authentication da documentação do Phoenix 1.8, na mesma ordem e com o link de cada seção. O notebook 13 gerou o sistema de login com mix phx.gen.auth, e o 14 mostrou como os escopos usam o usuário logado para proteger os dados. Aqui a aplicação ganha um segundo tipo de cliente.

Um navegador prova quem é com o cookie de sessão que recebeu no login. Um app de celular, um script ou outro servidor que conversa com a sua API não tem formulário de login nem cookie: ele manda, a cada requisição, um token num cabeçalho HTTP. O guia mostra como criar esses tokens reaproveitando a tabela de tokens que o phx.gen.auth já gerou, e como escrever o plug que os confere.

As convenções são as de sempre: uma célula equivale a um arquivo, as perguntas "Antes de continuar" vêm antes da explicação, e os erros fazem parte do conteúdo. Como no notebook 12, Trilha.curl/1 roda comandos curl de verdade contra o servidor do notebook e mostra a resposta como o curl mostraria. Os comandos com token são montados em Elixir, com o token no meio do texto. Copie o comando que aparece na saída para um terminal e ele funciona igual, enquanto o runtime estiver ligado.

A lista de dependências da primeira célula é a do notebook 13: além das de sempre, tem o swoosh, que envia os e-mails da autenticação, e o pbkdf2_elixir, que calcula o hash das senhas. Se você já abriu o 13 ou o 14, o Livebook reaproveita a instalação. Senão, a primeira avaliação instala tudo de novo, uma vez.

O notebook usa o PostgreSQL configurado no notebook 07 e, como o 13, precisa da extensão citext (no Fedora, o pacote postgresql-contrib). A célula Trilha.verificar_citext() confere. A aplicação fica em http://localhost:4010; se outro notebook da trilha estiver com o runtime ligado, desconecte-o antes. Avalie a primeira célula, no topo, antes de tudo.

A aplicação Hello dentro do notebook

config/dev.exs: a configuração do banco no ambiente de desenvolvimento, a mesma do notebook 07. Se o seu PostgreSQL usa outra senha ou outra porta, troque aqui e avalie a célula de novo.

Application.put_env(:hello, Hello.Repo,
  username: System.get_env("PGUSER", "postgres"),
  password: System.get_env("PGPASSWORD", "postgres"),
  hostname: System.get_env("PGHOST", "localhost"),
  port: String.to_integer(System.get_env("PGPORT", "5432")),
  database: "trilha_phoenix_dev",
  show_sensitive_data_on_connection_error: true,
  pool_size: 10,
  stacktrace: true
)

lib/hello/repo.ex

defmodule Hello.Repo do
  use Ecto.Repo,
    otp_app: :hello,
    adapter: Ecto.Adapters.Postgres
end

lib/hello_web.ex: como no projeto gerado, o bloco html_helpers importa os CoreComponents e dá o apelido JS ao Phoenix.LiveView.JS. Os templates gerados usam os dois.

defmodule HelloWeb do
  def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)

  def router do
    quote do
      use Phoenix.Router, helpers: false

      import Plug.Conn
      import Phoenix.Controller
      import Phoenix.LiveView.Router
    end
  end

  def controller do
    quote do
      use Phoenix.Controller, formats: [:html, :json]

      import Plug.Conn

      unquote(verified_routes())
    end
  end

  def html do
    quote do
      use Phoenix.Component

      import Phoenix.Controller,
        only: [get_csrf_token: 0, view_module: 1, view_template: 1]

      unquote(html_helpers())
    end
  end

  defp html_helpers do
    quote do
      import Phoenix.HTML
      import HelloWeb.CoreComponents

      alias Phoenix.LiveView.JS
      alias HelloWeb.Layouts

      unquote(verified_routes())
    end
  end

  def verified_routes do
    quote do
      use Phoenix.VerifiedRoutes,
        endpoint: HelloWeb.Endpoint,
        router: HelloWeb.Router,
        statics: HelloWeb.static_paths()
    end
  end

  defmacro __using__(which) when is_atom(which) do
    apply(__MODULE__, which, [])
  end
end

lib/hello_web/router.ex

defmodule HelloWeb.Router do
  use HelloWeb, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {HelloWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :home
  end
end

lib/hello_web/endpoint.ex: uma diferença em relação ao projeto. Lá, o Plug.Static serve o app.js compilado, que inclui o JavaScript do phoenix_html. Aqui não há compilação de assets, então o Plug.Static serve direto o arquivo phoenix_html.js que vem no pacote. É ele que faz os links com method="delete" e method="post" funcionarem no navegador.

defmodule HelloWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :hello

  @session_options [
    store: :cookie,
    key: "_hello_key",
    signing_salt: "trilha00",
    same_site: "Lax"
  ]

  plug Plug.Static,
    at: "/assets",
    from: {:phoenix_html, "priv/static"},
    only: ~w(phoenix_html.js)

  plug Plug.RequestId
  plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]

  plug Plug.Parsers,
    parsers: [:urlencoded, :multipart, :json],
    pass: ["*/*"],
    json_decoder: Phoenix.json_library()

  plug Plug.MethodOverride
  plug Plug.Head
  plug Plug.Session, @session_options
  plug HelloWeb.Router
end

lib/hello_web/components/core_components.ex: os componentes que um projeto novo traz e que os templates gerados usam: flash, button, input, header, table, list e icon. O código é o do arquivo gerado pelo Phoenix 1.8.16, com uma diferença: no projeto, as mensagens de erro passam pelo Gettext, que traduz textos; aqui, translate_error/1 só preenche os valores na mensagem.

defmodule HelloWeb.CoreComponents do
  use Phoenix.Component

  alias Phoenix.LiveView.JS

  @doc "Renders flash notices."
  attr :id, :string, doc: "the optional id of flash container"
  attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
  attr :title, :string, default: nil
  attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
  attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"

  slot :inner_block, doc: "the optional inner block that renders the flash message"

  def flash(assigns) do
    assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)

    ~H"""
    <div
      :if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
      id={@id}
      phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
      role="alert"
      class="toast toast-top toast-end z-50"
      {@rest}
    >
      <div class={[
        "alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
        @kind == :info && "alert-info",
        @kind == :error && "alert-error"
      ]}>
        <.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
        <.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
        <div>
          <p :if={@title} class="font-semibold">{@title}</p>
          <p>{msg}</p>
        </div>
        <div class="flex-1" />
        <button type="button" class="group self-start cursor-pointer" aria-label="close">
          <.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
        </button>
      </div>
    </div>
    """
  end

  @doc "Renders a button with navigation support."
  attr :rest, :global, include: ~w(href navigate patch method download name value disabled)
  attr :class, :any
  attr :variant, :string, values: ~w(primary)
  slot :inner_block, required: true

  def button(%{rest: rest} = assigns) do
    variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}

    assigns =
      assign_new(assigns, :class, fn ->
        ["btn", Map.fetch!(variants, assigns[:variant])]
      end)

    if rest[:href] || rest[:navigate] || rest[:patch] do
      ~H"""
      <.link class={@class} {@rest}>
        {render_slot(@inner_block)}
      </.link>
      """
    else
      ~H"""
      <button class={@class} {@rest}>
        {render_slot(@inner_block)}
      </button>
      """
    end
  end

  @doc "Renders an input with label and error messages."
  attr :id, :any, default: nil
  attr :name, :any
  attr :label, :string, default: nil
  attr :value, :any

  attr :type, :string,
    default: "text",
    values: ~w(checkbox color date datetime-local email file month number password
               search select tel text textarea time url week hidden)

  attr :field, Phoenix.HTML.FormField,
    doc: "a form field struct retrieved from the form, for example: @form[:email]"

  attr :errors, :list, default: []
  attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
  attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
  attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
  attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
  attr :class, :any, default: nil, doc: "the input class to use over defaults"
  attr :error_class, :any, default: nil, doc: "the input error class to use over defaults"

  attr :rest, :global,
    include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
                multiple pattern placeholder readonly required rows size step)

  def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
    errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []

    assigns
    |> assign(field: nil, id: assigns.id || field.id)
    |> assign(:errors, Enum.map(errors, &translate_error(&1)))
    |> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
    |> assign_new(:value, fn -> field.value end)
    |> input()
  end

  def input(%{type: "hidden"} = assigns) do
    ~H"""
    <input type="hidden" id={@id} name={@name} value={@value} {@rest} />
    """
  end

  def input(%{type: "checkbox"} = assigns) do
    assigns =
      assign_new(assigns, :checked, fn ->
        Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
      end)

    ~H"""
    <div class="fieldset mb-2">
      <label for={@id}>
        <input
          type="hidden"
          name={@name}
          value="false"
          disabled={@rest[:disabled]}
          form={@rest[:form]}
        />
        <span class="label">
          <input
            type="checkbox"
            id={@id}
            name={@name}
            value="true"
            checked={@checked}
            class={@class || "checkbox checkbox-sm"}
            {@rest}
          />{@label}
        </span>
      </label>
      <.error :for={msg <- @errors}>{msg}</.error>
    </div>
    """
  end

  def input(%{type: "select"} = assigns) do
    ~H"""
    <div class="fieldset mb-2">
      <label for={@id}>
        <span :if={@label} class="label mb-1">{@label}</span>
        <select
          id={@id}
          name={@name}
          class={[@class || "w-full select", @errors != [] && (@error_class || "select-error")]}
          multiple={@multiple}
          {@rest}
        >
          <option :if={@prompt} value="">{@prompt}</option>
          {Phoenix.HTML.Form.options_for_select(@options, @value)}
        </select>
      </label>
      <.error :for={msg <- @errors}>{msg}</.error>
    </div>
    """
  end

  def input(%{type: "textarea"} = assigns) do
    ~H"""
    <div class="fieldset mb-2">
      <label for={@id}>
        <span :if={@label} class="label mb-1">{@label}</span>
        <textarea
          id={@id}
          name={@name}
          class={[
            @class || "w-full textarea",
            @errors != [] && (@error_class || "textarea-error")
          ]}
          {@rest}
        >{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
      </label>
      <.error :for={msg <- @errors}>{msg}</.error>
    </div>
    """
  end

  # All other inputs text, datetime-local, url, password, etc. are handled here...
  def input(assigns) do
    ~H"""
    <div class="fieldset mb-2">
      <label for={@id}>
        <span :if={@label} class="label mb-1">{@label}</span>
        <input
          type={@type}
          name={@name}
          id={@id}
          value={Phoenix.HTML.Form.normalize_value(@type, @value)}
          class={[
            @class || "w-full input",
            @errors != [] && (@error_class || "input-error")
          ]}
          {@rest}
        />
      </label>
      <.error :for={msg <- @errors}>{msg}</.error>
    </div>
    """
  end

  # Helper used by inputs to generate form errors
  defp error(assigns) do
    ~H"""
    <p class="mt-1.5 flex gap-2 items-center text-sm text-error">
      <.icon name="hero-exclamation-circle" class="size-5" />
      {render_slot(@inner_block)}
    </p>
    """
  end

  @doc "Renders a header with title."
  slot :inner_block, required: true
  slot :subtitle
  slot :actions

  def header(assigns) do
    ~H"""
    <header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
      <div>
        <h1 class="text-lg font-semibold leading-8">
          {render_slot(@inner_block)}
        </h1>
        <p :if={@subtitle != []} class="text-sm text-base-content/70">
          {render_slot(@subtitle)}
        </p>
      </div>
      <div class="flex-none">{render_slot(@actions)}</div>
    </header>
    """
  end

  @doc "Renders a table with generic styling."
  attr :id, :string, required: true
  attr :rows, :list, required: true
  attr :row_id, :any, default: nil, doc: "the function for generating the row id"
  attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"

  attr :row_item, :any,
    default: &Function.identity/1,
    doc: "the function for mapping each row before calling the :col and :action slots"

  slot :col, required: true do
    attr :label, :string
  end

  slot :action, doc: "the slot for showing user actions in the last table column"

  def table(assigns) do
    assigns =
      with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
        assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
      end

    ~H"""
    <table class="table table-zebra">
      <thead>
        <tr>
          <th :for={col <- @col}>{col[:label]}</th>
          <th :if={@action != []}>
            <span class="sr-only">Actions</span>
          </th>
        </tr>
      </thead>
      <tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
        <tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
          <td
            :for={col <- @col}
            phx-click={@row_click && @row_click.(row)}
            class={@row_click && "hover:cursor-pointer"}
          >
            {render_slot(col, @row_item.(row))}
          </td>
          <td :if={@action != []} class="w-0 font-semibold">
            <div class="flex gap-4">
              <%= for action <- @action do %>
                {render_slot(action, @row_item.(row))}
              <% end %>
            </div>
          </td>
        </tr>
      </tbody>
    </table>
    """
  end

  @doc "Renders a data list."
  slot :item, required: true do
    attr :title, :string, required: true
  end

  def list(assigns) do
    ~H"""
    <ul class="list">
      <li :for={item <- @item} class="list-row">
        <div class="list-col-grow">
          <div class="font-bold">{item.title}</div>
          <div>{render_slot(item)}</div>
        </div>
      </li>
    </ul>
    """
  end

  @doc "Renders a [Heroicon](https://heroicons.com)."
  attr :name, :string, required: true
  attr :class, :any, default: "size-4"

  def icon(%{name: "hero-" <> _} = assigns) do
    ~H"""
    <span class={[@name, @class]} />
    """
  end

  ## JS Commands

  def show(js \\ %JS{}, selector) do
    JS.show(js,
      to: selector,
      time: 300,
      transition:
        {"transition-all ease-out duration-300",
         "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
         "opacity-100 translate-y-0 sm:scale-100"}
    )
  end

  def hide(js \\ %JS{}, selector) do
    JS.hide(js,
      to: selector,
      time: 200,
      transition:
        {"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
         "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
    )
  end

  @doc """
  Fills in the error message values. In the generated project this function
  translates the message with Gettext.
  """
  def translate_error({msg, opts}) do
    Enum.reduce(opts, msg, fn {key, value}, acc ->
      String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
    end)
  end

  @doc "Translates the errors for a field from a keyword list of errors."
  def translate_errors(errors, field) when is_list(errors) do
    for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
  end
end

lib/hello_web/components/layouts.ex: o root layout e o layout da aplicação. No projeto, o root layout carrega o app.css e o app.js compilados. Aqui ele carrega o Tailwind e o daisyUI direto de uma CDN, para que as páginas geradas tenham a mesma aparência no navegador, e o phoenix_html.js servido pelo endpoint. Sem internet, as páginas funcionam do mesmo jeito, só que sem estilo.

defmodule HelloWeb.Layouts do
  use HelloWeb, :html

  def root(assigns) do
    ~H"""
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <meta name="csrf-token" content={get_csrf_token()} />
        <title>Hello · Phoenix Framework</title>
        <link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
        <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4">
        </script>
        <script defer src={~p"/assets/phoenix_html.js"}>
        </script>
      </head>
      <body>
        {@inner_content}
      </body>
    </html>
    """
  end

  attr :flash, :map, required: true, doc: "the map of flash messages"
  attr :current_scope, :map, default: nil, doc: "the current scope"
  slot :inner_block, required: true

  def app(assigns) do
    ~H"""
    <header class="navbar px-4 sm:px-6 lg:px-8">
      <div class="flex-1">
        <a href="/" class="flex-1 flex w-fit items-center gap-2">
          <span class="text-sm font-semibold">Hello · Phoenix v{Application.spec(:phoenix, :vsn)}</span>
        </a>
      </div>
    </header>

    <main class="px-4 py-20 sm:px-6 lg:px-8">
      <div class="mx-auto max-w-2xl space-y-4">
        {render_slot(@inner_block)}
      </div>
    </main>

    <.flash_group flash={@flash} />
    """
  end

  attr :flash, :map, required: true, doc: "the map of flash messages"
  attr :id, :string, default: "flash-group", doc: "the optional id of flash container"

  def flash_group(assigns) do
    ~H"""
    <div id={@id} aria-live="polite">
      <.flash kind={:info} flash={@flash} />
      <.flash kind={:error} flash={@flash} />
    </div>
    """
  end
end

lib/hello_web/controllers/: as páginas de erro e a página inicial.

defmodule HelloWeb.ErrorHTML do
  def render(template, _assigns) do
    Phoenix.Controller.status_message_from_template(template)
  end
end

defmodule HelloWeb.ErrorJSON do
  def render(template, _assigns) do
    %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
  end
end

defmodule HelloWeb.PageController do
  use HelloWeb, :controller

  def home(conn, _params) do
    render(conn, :home)
  end
end

defmodule HelloWeb.PageHTML do
  use HelloWeb, :html

  def home(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <h1>Welcome to Phoenix!</h1>
    </Layouts.app>
    """
  end
end

Antes de começar: o PostgreSQL

Este notebook usa o mesmo PostgreSQL do notebook 07, com o banco trilha_phoenix_dev. A célula abaixo tenta uma conexão e, se falhar, traduz a mensagem em uma dica. Os comandos para instalar e configurar o PostgreSQL estão no LEIA-ME.md da trilha.

Trilha.verificar_postgres()

Crie o banco, se ainda não existir (é o mix ecto.create). {:error, :already_up} quer dizer que ele já existe, e serve do mesmo jeito.

Ecto.Adapters.Postgres.storage_up(Hello.Repo.config())

lib/hello/application.ex: a árvore de supervisão inicia o Repo, o Phoenix.PubSub e o endpoint. O PubSub é o sistema de mensagens do Phoenix; os contextos gerados com escopo o usam para avisar sobre mudanças nos dados.

{:ok, _} = Kino.start_child(Hello.Repo)
{:ok, _} = Kino.start_child({Phoenix.PubSub, name: Hello.PubSub})
{:ok, _} = Kino.start_child(HelloWeb.Endpoint)

Kino.Markdown.new("Repo conectado e aplicação no ar em <http://localhost:4010>.")

Comece do zero: a célula abaixo apaga todas as tabelas do banco trilha_phoenix_dev e esvazia a caixa de entrada dos e-mails, para que os números de id sejam os mesmos do texto. Avalie-a sempre que reabrir o notebook. Ela só mexe no banco da trilha.

Trilha.recomecar()

A migration da autenticação cria a extensão citext. Confira se o servidor a tem:

Trilha.verificar_citext()

A autenticação do notebook 13

Estas células são o código que mix phx.gen.auth Accounts User users --no-live --hashing-lib pbkdf2 gera no Phoenix 1.8.16, o mesmo do notebook 13. Para encurtar, os @doc gerados saíram de quase todos os módulos; os do UserToken ficaram, porque explicam justamente como os tokens são guardados, e este notebook depende disso. Três células têm um comentário "Sua vez (notebook 15)" no lugar onde os exercícios vão acrescentar código.

lib/hello/mailer.ex e a migration priv/repo/migrations/20250610120000_create_users_auth_tables.exs

defmodule Hello.Mailer do
  use Swoosh.Mailer, otp_app: :hello
end
defmodule Hello.Repo.Migrations.CreateUsersAuthTables do
  use Ecto.Migration

  def change do
    execute "CREATE EXTENSION IF NOT EXISTS citext", ""

    create table(:users) do
      add :email, :citext, null: false
      add :hashed_password, :string
      add :confirmed_at, :utc_datetime

      timestamps(type: :utc_datetime)
    end

    create unique_index(:users, [:email])

    create table(:users_tokens) do
      add :user_id, references(:users, on_delete: :delete_all), null: false
      add :token, :binary, null: false
      add :context, :string, null: false
      add :sent_to, :string
      add :authenticated_at, :utc_datetime

      timestamps(type: :utc_datetime, updated_at: false)
    end

    create index(:users_tokens, [:user_id])
    create unique_index(:users_tokens, [:context, :token])
  end
end

Trilha.migracao(20250610120000, Hello.Repo.Migrations.CreateUsersAuthTables)
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)

lib/hello/accounts/user.ex

defmodule Hello.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field :email, :string
    field :password, :string, virtual: true, redact: true
    field :hashed_password, :string, redact: true
    field :confirmed_at, :utc_datetime
    field :authenticated_at, :utc_datetime, virtual: true

    timestamps(type: :utc_datetime)
  end

  def email_changeset(user, attrs, opts \\ []) do
    user
    |> cast(attrs, [:email])
    |> validate_email(opts)
  end

  defp validate_email(changeset, opts) do
    changeset =
      changeset
      |> validate_required([:email])
      |> validate_format(:email, ~r/^[^@,;\s]+@[^@,;\s]+$/,
        message: "must have the @ sign and no spaces"
      )
      |> validate_length(:email, max: 160)

    if Keyword.get(opts, :validate_unique, true) do
      changeset
      |> unsafe_validate_unique(:email, Hello.Repo)
      |> unique_constraint(:email)
      |> validate_email_changed()
    else
      changeset
    end
  end

  defp validate_email_changed(changeset) do
    if get_field(changeset, :email) && get_change(changeset, :email) == nil do
      add_error(changeset, :email, "did not change")
    else
      changeset
    end
  end

  def password_changeset(user, attrs, opts \\ []) do
    user
    |> cast(attrs, [:password])
    |> validate_confirmation(:password, message: "does not match password")
    |> validate_password(opts)
  end

  defp validate_password(changeset, opts) do
    changeset
    |> validate_required([:password])
    |> validate_length(:password, min: 12, max: 72)
    # Examples of additional password validation:
    # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character")
    # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character")
    # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character")
    |> maybe_hash_password(opts)
  end

  defp maybe_hash_password(changeset, opts) do
    hash_password? = Keyword.get(opts, :hash_password, true)
    password = get_change(changeset, :password)

    if hash_password? && password && changeset.valid? do
      changeset
      # Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that
      # would keep the database transaction open longer and hurt performance.
      |> put_change(:hashed_password, Pbkdf2.hash_pwd_salt(password))
      |> delete_change(:password)
    else
      changeset
    end
  end

  def confirm_changeset(user) do
    now = DateTime.utc_now(:second)
    change(user, confirmed_at: now)
  end

  def valid_password?(%Hello.Accounts.User{hashed_password: hashed_password}, password)
      when is_binary(hashed_password) and byte_size(password) > 0 do
    Pbkdf2.verify_pass(password, hashed_password)
  end

  def valid_password?(_, _) do
    Pbkdf2.no_user_verify()
    false
  end
end

lib/hello/accounts/user_token.ex: o schema dos tokens. Leia os @doc de build_session_token/1 e de build_email_token/2: eles explicam por que alguns tokens são guardados com hash e outros não.

defmodule Hello.Accounts.UserToken do
  use Ecto.Schema
  import Ecto.Query
  alias Hello.Accounts.UserToken

  @hash_algorithm :sha256
  @rand_size 32

  # It is very important to keep the magic link token expiry short,
  # since someone with access to the email may take over the account.
  @magic_link_validity_in_minutes 15
  @change_email_validity_in_days 7
  @session_validity_in_days 14

  schema "users_tokens" do
    field :token, :binary
    field :context, :string
    field :sent_to, :string
    field :authenticated_at, :utc_datetime
    belongs_to :user, Hello.Accounts.User

    timestamps(type: :utc_datetime, updated_at: false)
  end

  @doc """
  Generates a token that will be stored in a signed place,
  such as session or cookie. As they are signed, those
  tokens do not need to be hashed.

  The reason why we store session tokens in the database, even
  though Phoenix already provides a session cookie, is because
  Phoenix's default session cookies are not persisted, they are
  simply signed and potentially encrypted. This means they are
  valid indefinitely, unless you change the signing/encryption
  salt.

  Therefore, storing them allows individual user
  sessions to be expired. The token system can also be extended
  to store additional data, such as the device used for logging in.
  You could then use this information to display all valid sessions
  and devices in the UI and allow users to explicitly expire any
  session they deem invalid.
  """
  def build_session_token(user) do
    token = :crypto.strong_rand_bytes(@rand_size)
    dt = user.authenticated_at || DateTime.utc_now(:second)
    {token, %UserToken{token: token, context: "session", user_id: user.id, authenticated_at: dt}}
  end

  @doc """
  Checks if the token is valid and returns its underlying lookup query.

  The query returns the user found by the token, if any, along with the token's creation time.

  The token is valid if it matches the value in the database and it has
  not expired (after @session_validity_in_days).
  """
  def verify_session_token_query(token) do
    query =
      from token in by_token_and_context_query(token, "session"),
        join: user in assoc(token, :user),
        where: token.inserted_at > ago(@session_validity_in_days, "day"),
        select: {%{user | authenticated_at: token.authenticated_at}, token.inserted_at}

    {:ok, query}
  end

  @doc """
  Builds a token and its hash to be delivered to the user's email.

  The non-hashed token is sent to the user email while the
  hashed part is stored in the database. The original token cannot be reconstructed,
  which means anyone with read-only access to the database cannot directly use
  the token in the application to gain access. Furthermore, if the user changes
  their email in the system, the tokens sent to the previous email are no longer
  valid.

  Users can easily adapt the existing code to provide other types of delivery methods,
  for example, by phone numbers.
  """
  def build_email_token(user, context) do
    build_hashed_token(user, context, user.email)
  end

  defp build_hashed_token(user, context, sent_to) do
    token = :crypto.strong_rand_bytes(@rand_size)
    hashed_token = :crypto.hash(@hash_algorithm, token)

    {Base.url_encode64(token, padding: false),
     %UserToken{
       token: hashed_token,
       context: context,
       sent_to: sent_to,
       user_id: user.id
     }}
  end

  @doc """
  Checks if the token is valid and returns its underlying lookup query.

  If found, the query returns a tuple of the form `{user, token}`.

  The given token is valid if it matches its hashed counterpart in the
  database. This function also checks whether the token has expired. The context
  of a magic link token is always "login".
  """
  def verify_magic_link_token_query(token) do
    case Base.url_decode64(token, padding: false) do
      {:ok, decoded_token} ->
        hashed_token = :crypto.hash(@hash_algorithm, decoded_token)

        query =
          from token in by_token_and_context_query(hashed_token, "login"),
            join: user in assoc(token, :user),
            where: token.inserted_at > ago(^@magic_link_validity_in_minutes, "minute"),
            where: token.sent_to == user.email,
            select: {user, token}

        {:ok, query}

      :error ->
        :error
    end
  end

  @doc """
  Checks if the token is valid and returns its underlying lookup query.

  The query returns the user_token found by the token, if any.

  This is used to validate requests to change the user
  email.
  The given token is valid if it matches its hashed counterpart in the
  database and if it has not expired (after @change_email_validity_in_days).
  The context must always start with "change:".
  """
  def verify_change_email_token_query(token, "change:" <> _ = context) do
    case Base.url_decode64(token, padding: false) do
      {:ok, decoded_token} ->
        hashed_token = :crypto.hash(@hash_algorithm, decoded_token)

        query =
          from token in by_token_and_context_query(hashed_token, context),
            where: token.inserted_at > ago(@change_email_validity_in_days, "day")

        {:ok, query}

      :error ->
        :error
    end
  end

  # Sua vez (notebook 15): verify_api_token_query/1 entra aqui.

  defp by_token_and_context_query(token, context) do
    from UserToken, where: [token: ^token, context: ^context]
  end
end

lib/hello/accounts/scope.ex e user_notifier.ex

defmodule Hello.Accounts.Scope do
  @moduledoc """
  Defines the scope of the caller to be used throughout the app.

  The `Hello.Accounts.Scope` allows public interfaces to receive
  information about the caller, such as if the call is initiated from an
  end-user, and if so, which user. Additionally, such a scope can carry fields
  such as "super user" or other privileges for use in authorization checks,
  or to ensure specific code paths can only be accessed for a given scope.

  It is useful for logging as well as for scoping pubsub subscriptions and
  broadcasts when a caller subscribes to an interface or performs a particular
  action.

  Feel free to extend the fields on this struct to fit the needs of
  growing application requirements.
  """

  alias Hello.Accounts.User

  defstruct user: nil

  @doc """
  Creates a scope for the given user.

  Returns nil if no user is given.
  """
  def for_user(%User{} = user) do
    %__MODULE__{user: user}
  end

  def for_user(nil), do: nil
end
defmodule Hello.Accounts.UserNotifier do
  import Swoosh.Email

  alias Hello.Mailer
  alias Hello.Accounts.User

  # Delivers the email using the application mailer.
  defp deliver(recipient, subject, body) do
    email =
      new()
      |> to(recipient)
      |> from({"Hello", "contact@example.com"})
      |> subject(subject)
      |> text_body(body)

    with {:ok, _metadata} <- Mailer.deliver(email) do
      {:ok, email}
    end
  end

  @doc """
  Deliver instructions to update a user email.
  """
  def deliver_update_email_instructions(user, url) do
    deliver(user.email, "Update email instructions", """

    ==============================

    Hi #{user.email},

    You can change your email by visiting the URL below:

    #{url}

    If you didn't request this change, please ignore this.

    ==============================
    """)
  end

  @doc """
  Deliver instructions to log in with a magic link.
  """
  def deliver_login_instructions(user, url) do
    case user do
      %User{confirmed_at: nil} -> deliver_confirmation_instructions(user, url)
      _ -> deliver_magic_link_instructions(user, url)
    end
  end

  defp deliver_magic_link_instructions(user, url) do
    deliver(user.email, "Log in instructions", """

    ==============================

    Hi #{user.email},

    You can log into your account by visiting the URL below:

    #{url}

    If you didn't request this email, please ignore this.

    ==============================
    """)
  end

  defp deliver_confirmation_instructions(user, url) do
    deliver(user.email, "Confirmation instructions", """

    ==============================

    Hi #{user.email},

    You can confirm your account by visiting the URL below:

    #{url}

    If you didn't create an account with us, please ignore this.

    ==============================
    """)
  end
end

lib/hello/accounts.ex: o contexto. O primeiro exercício acrescenta duas funções no fim, no lugar do comentário "Sua vez".

defmodule Hello.Accounts do
  @moduledoc """
  The Accounts context.
  """

  import Ecto.Query, warn: false
  alias Hello.Repo

  alias Hello.Accounts.{User, UserToken, UserNotifier}

  ## Database getters

  def get_user_by_email(email) when is_binary(email) do
    Repo.get_by(User, email: email)
  end

  def get_user_by_email_and_password(email, password)
      when is_binary(email) and is_binary(password) do
    user = Repo.get_by(User, email: email)
    if User.valid_password?(user, password), do: user
  end

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

  ## User registration

  def register_user(attrs) do
    %User{}
    |> User.email_changeset(attrs)
    |> Repo.insert()
  end

  ## Settings

  def sudo_mode?(user, minutes \\ -20)

  def sudo_mode?(%User{authenticated_at: ts}, minutes) when is_struct(ts, DateTime) do
    DateTime.after?(ts, DateTime.utc_now() |> DateTime.add(minutes, :minute))
  end

  def sudo_mode?(_user, _minutes), do: false

  def change_user_email(user, attrs \\ %{}, opts \\ []) do
    User.email_changeset(user, attrs, opts)
  end

  def update_user_email(user, token) do
    context = "change:#{user.email}"

    Repo.transact(fn ->
      with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
           %UserToken{sent_to: email} <- Repo.one(query),
           {:ok, user} <- Repo.update(User.email_changeset(user, %{email: email})),
           {_count, _result} <-
             Repo.delete_all(from(UserToken, where: [user_id: ^user.id, context: ^context])) do
        {:ok, user}
      else
        _ -> {:error, :transaction_aborted}
      end
    end)
  end

  def change_user_password(user, attrs \\ %{}, opts \\ []) do
    User.password_changeset(user, attrs, opts)
  end

  def update_user_password(user, attrs) do
    user
    |> User.password_changeset(attrs)
    |> update_user_and_delete_all_tokens()
  end

  ## Session

  def generate_user_session_token(user) do
    {token, user_token} = UserToken.build_session_token(user)
    Repo.insert!(user_token)
    token
  end

  def get_user_by_session_token(token) do
    {:ok, query} = UserToken.verify_session_token_query(token)
    Repo.one(query)
  end

  def get_user_by_magic_link_token(token) do
    with {:ok, query} <- UserToken.verify_magic_link_token_query(token),
         {user, _token} <- Repo.one(query) do
      user
    else
      _ -> nil
    end
  end

  def login_user_by_magic_link(token) do
    {:ok, query} = UserToken.verify_magic_link_token_query(token)

    case Repo.one(query) do
      # Prevent session fixation attacks by disallowing magic links for unconfirmed users with password
      {%User{confirmed_at: nil, hashed_password: hash}, _token} when not is_nil(hash) ->
        raise """
        magic link log in is not allowed for unconfirmed users with a password set!

        This cannot happen with the default implementation, which indicates that you
        might have adapted the code to a different use case. Please make sure to read the
        "Mixing magic link and password registration" section of `mix help phx.gen.auth`.
        """

      {%User{confirmed_at: nil} = user, _token} ->
        user
        |> User.confirm_changeset()
        |> update_user_and_delete_all_tokens()

      {user, token} ->
        Repo.delete!(token)
        {:ok, {user, []}}

      nil ->
        {:error, :not_found}
    end
  end

  def deliver_user_update_email_instructions(%User{} = user, current_email, update_email_url_fun)
      when is_function(update_email_url_fun, 1) do
    {encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}")

    Repo.insert!(user_token)
    UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
  end

  def deliver_login_instructions(%User{} = user, magic_link_url_fun)
      when is_function(magic_link_url_fun, 1) do
    {encoded_token, user_token} = UserToken.build_email_token(user, "login")
    Repo.insert!(user_token)
    UserNotifier.deliver_login_instructions(user, magic_link_url_fun.(encoded_token))
  end

  def delete_user_session_token(token) do
    Repo.delete_all(from(UserToken, where: [token: ^token, context: "session"]))
    :ok
  end

  ## API

  # Sua vez (notebook 15): create_user_api_token/1 e fetch_user_by_api_token/1 entram aqui.

  ## Token helper

  defp update_user_and_delete_all_tokens(changeset) do
    Repo.transact(fn ->
      with {:ok, user} <- Repo.update(changeset) do
        tokens_to_expire = Repo.all_by(UserToken, user_id: user.id)

        Repo.delete_all(from(t in UserToken, where: t.id in ^Enum.map(tokens_to_expire, & &1.id)))

        {:ok, {user, tokens_to_expire}}
      end
    end)
  end
end

lib/hello_web/router.ex, primeiro passo: as rotas da autenticação, ainda sem os plugs. O UserAuth, logo abaixo, usa ~p"/users/log-in", e o ~p confere cada caminho no router quando o módulo compila. O router completo, por sua vez, importa o UserAuth. Num projeto, o compilador resolve essa dependência circular sozinho; no notebook, as rotas vêm primeiro.

Esta célula avisa HelloWeb.UserRegistrationController.init/1 is undefined e o mesmo para os outros controllers: o router aponta para módulos que ainda não existem, e o aviso é só isso. Cada nova versão do router, mais abaixo, também avisa redefining module HelloWeb.Router, porque substitui a anterior.

defmodule HelloWeb.Router do
  use HelloWeb, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {HelloWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :home
  end

  # Em desenvolvimento: a caixa de entrada do Swoosh.
  scope "/dev" do
    pipe_through :browser

    forward "/mailbox", Plug.Swoosh.MailboxPreview
  end

  ## Authentication routes

  scope "/", HelloWeb do
    pipe_through :browser

    get "/users/register", UserRegistrationController, :new
    post "/users/register", UserRegistrationController, :create
  end

  scope "/", HelloWeb do
    pipe_through :browser

    get "/users/settings", UserSettingsController, :edit
    put "/users/settings", UserSettingsController, :update
    get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email
  end

  scope "/", HelloWeb do
    pipe_through :browser

    get "/users/log-in", UserSessionController, :new
    get "/users/log-in/:token", UserSessionController, :confirm
    post "/users/log-in", UserSessionController, :create
    delete "/users/log-out", UserSessionController, :delete
  end
end

lib/hello_web/user_auth.ex: os plugs da autenticação. O exercício do plug da API entra no fim, no lugar do comentário.

defmodule HelloWeb.UserAuth do
  use HelloWeb, :verified_routes

  import Plug.Conn
  import Phoenix.Controller

  alias Hello.Accounts
  alias Hello.Accounts.Scope

  # Make the remember me cookie valid for 14 days. This should match
  # the session validity setting in UserToken.
  @max_cookie_age_in_days 14
  @remember_me_cookie "_hello_web_user_remember_me"
  @remember_me_options [
    sign: true,
    max_age: @max_cookie_age_in_days * 24 * 60 * 60,
    same_site: "Lax"
  ]

  # How old the session token should be before a new one is issued. When a request is made
  # with a session token older than this value, then a new session token will be created
  # and the session and remember-me cookies (if set) will be updated with the new token.
  # Lowering this value will result in more tokens being created by active users. Increasing
  # it will result in less time before a session token expires for a user to get issued a new
  # token. This can be set to a value greater than `@max_cookie_age_in_days` to disable
  # the reissuing of tokens completely.
  @session_reissue_age_in_days 7

  def log_in_user(conn, user, params \\ %{}) do
    user_return_to = get_session(conn, :user_return_to)

    conn
    |> create_or_extend_session(user, params)
    |> delete_session(:user_return_to)
    |> redirect(to: user_return_to || signed_in_path(conn))
  end

  def log_out_user(conn) do
    user_token = get_session(conn, :user_token)
    user_token && Accounts.delete_user_session_token(user_token)

    if live_socket_id = get_session(conn, :live_socket_id) do
      HelloWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
    end

    conn
    |> renew_session(nil)
    |> delete_resp_cookie(@remember_me_cookie, @remember_me_options)
    |> redirect(to: ~p"/")
  end

  def fetch_current_scope_for_user(conn, _opts) do
    with {token, conn} <- ensure_user_token(conn),
         {user, token_inserted_at} <- Accounts.get_user_by_session_token(token) do
      conn
      |> assign(:current_scope, Scope.for_user(user))
      |> maybe_reissue_user_session_token(user, token_inserted_at)
    else
      nil -> assign(conn, :current_scope, Scope.for_user(nil))
    end
  end

  defp ensure_user_token(conn) do
    if token = get_session(conn, :user_token) do
      {token, conn}
    else
      conn = fetch_cookies(conn, signed: [@remember_me_cookie])

      if token = conn.cookies[@remember_me_cookie] do
        {token, conn |> put_token_in_session(token) |> put_session(:user_remember_me, true)}
      else
        nil
      end
    end
  end

  # Reissue the session token if it is older than the configured reissue age.
  defp maybe_reissue_user_session_token(conn, user, token_inserted_at) do
    token_age = DateTime.diff(DateTime.utc_now(:second), token_inserted_at, :day)

    if token_age >= @session_reissue_age_in_days do
      create_or_extend_session(conn, user, %{})
    else
      conn
    end
  end

  # This function is the one responsible for creating session tokens
  # and storing them safely in the session and cookies. It may be called
  # either when logging in, during sudo mode, or to renew a session which
  # will soon expire.
  #
  # When the session is created, rather than extended, the renew_session
  # function will clear the session to avoid fixation attacks. See the
  # renew_session function to customize this behaviour.
  defp create_or_extend_session(conn, user, params) do
    token = Accounts.generate_user_session_token(user)
    remember_me = get_session(conn, :user_remember_me)

    conn
    |> renew_session(user)
    |> put_token_in_session(token)
    |> maybe_write_remember_me_cookie(token, params, remember_me)
  end

  # Do not renew session if the user is already logged in
  # to prevent CSRF errors or data being lost in tabs that are still open
  defp renew_session(conn, user) when conn.assigns.current_scope.user.id == user.id do
    conn
  end

  # This function renews the session ID and erases the whole
  # session to avoid fixation attacks. If there is any data
  # in the session you may want to preserve after log in/log out,
  # you must explicitly fetch the session data before clearing
  # and then immediately set it after clearing, for example:
  #
  #     defp renew_session(conn, _user) do
  #       delete_csrf_token()
  #       preferred_locale = get_session(conn, :preferred_locale)
  #
  #       conn
  #       |> configure_session(renew: true)
  #       |> clear_session()
  #       |> put_session(:preferred_locale, preferred_locale)
  #     end
  #
  defp renew_session(conn, _user) do
    delete_csrf_token()

    conn
    |> configure_session(renew: true)
    |> clear_session()
  end

  defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}, _),
    do: write_remember_me_cookie(conn, token)

  defp maybe_write_remember_me_cookie(conn, token, _params, true),
    do: write_remember_me_cookie(conn, token)

  defp maybe_write_remember_me_cookie(conn, _token, _params, _), do: conn

  defp write_remember_me_cookie(conn, token) do
    conn
    |> put_session(:user_remember_me, true)
    |> put_resp_cookie(@remember_me_cookie, token, @remember_me_options)
  end

  defp put_token_in_session(conn, token) do
    put_session(conn, :user_token, token)
  end

  def require_sudo_mode(conn, _opts) do
    if Accounts.sudo_mode?(conn.assigns.current_scope.user, -10) do
      conn
    else
      conn
      |> put_flash(:error, "You must re-authenticate to access this page.")
      |> maybe_store_return_to()
      |> redirect(to: ~p"/users/log-in")
      |> halt()
    end
  end

  def redirect_if_user_is_authenticated(conn, _opts) do
    if conn.assigns.current_scope do
      conn
      |> redirect(to: signed_in_path(conn))
      |> halt()
    else
      conn
    end
  end

  defp signed_in_path(_conn), do: ~p"/"

  def require_authenticated_user(conn, _opts) do
    if conn.assigns.current_scope && conn.assigns.current_scope.user do
      conn
    else
      conn
      |> put_flash(:error, "You must log in to access this page.")
      |> maybe_store_return_to()
      |> redirect(to: ~p"/users/log-in")
      |> halt()
    end
  end

  defp maybe_store_return_to(%{method: "GET"} = conn) do
    put_session(conn, :user_return_to, current_path(conn))
  end

  defp maybe_store_return_to(conn), do: conn

  # Sua vez (notebook 15): fetch_current_scope_for_api_user/2 entra aqui.
end

Os controllers e as páginas da autenticação: cadastro, login e configurações, com os templates como funções.

defmodule HelloWeb.UserRegistrationController do
  use HelloWeb, :controller

  alias Hello.Accounts
  alias Hello.Accounts.User

  def new(conn, _params) do
    changeset = Accounts.change_user_email(%User{})
    render(conn, :new, changeset: changeset)
  end

  def create(conn, %{"user" => user_params}) do
    case Accounts.register_user(user_params) do
      {:ok, user} ->
        {:ok, _} =
          Accounts.deliver_login_instructions(
            user,
            &url(~p"/users/log-in/#{&1}")
          )

        conn
        |> put_flash(
          :info,
          "An email was sent to #{user.email}, please access it to confirm your account."
        )
        |> redirect(to: ~p"/users/log-in")

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, :new, changeset: changeset)
    end
  end
end
defmodule HelloWeb.UserRegistrationHTML do
  use HelloWeb, :html

  # user_registration_html/new.html.heex
  def new(assigns) do
    ~H"""
      <Layouts.app flash={@flash} current_scope={@current_scope}>
        <div class="mx-auto max-w-sm">
          <div class="text-center">
            <.header>
              Register for an account
              <:subtitle>
                Already registered?
                <.link navigate={~p"/users/log-in"} class="font-semibold text-brand hover:underline">
                  Log in
                </.link>
                to your account now.
              </:subtitle>
            </.header>
          </div>

          <.form :let={f} for={@changeset} action={~p"/users/register"}>
            <.input
              field={f[:email]}
              type="email"
              label="Email"
              autocomplete="username"
              spellcheck="false"
              required
              phx-mounted={JS.focus()}
            />

            <.button phx-disable-with="Creating account..." class="btn btn-primary w-full">
              Create an account
            </.button>
          </.form>
        </div>
      </Layouts.app>
    """
  end
end
defmodule HelloWeb.UserSessionController do
  use HelloWeb, :controller

  alias Hello.Accounts
  alias HelloWeb.UserAuth

  def new(conn, _params) do
    email = get_in(conn.assigns, [:current_scope, Access.key(:user), Access.key(:email)])
    form = Phoenix.Component.to_form(%{"email" => email}, as: "user")

    render(conn, :new, form: form)
  end

  # magic link login
  def create(conn, %{"user" => %{"token" => token} = user_params} = params) do
    info =
      case params do
        %{"_action" => "confirmed"} -> "User confirmed successfully."
        _ -> "Welcome back!"
      end

    case Accounts.login_user_by_magic_link(token) do
      {:ok, {user, _expired_tokens}} ->
        conn
        |> put_flash(:info, info)
        |> UserAuth.log_in_user(user, user_params)

      {:error, :not_found} ->
        conn
        |> put_flash(:error, "The link is invalid or it has expired.")
        |> render(:new, form: Phoenix.Component.to_form(%{}, as: "user"))
    end
  end

  # email + password login
  def create(conn, %{"user" => %{"email" => email, "password" => password} = user_params}) do
    if user = Accounts.get_user_by_email_and_password(email, password) do
      conn
      |> put_flash(:info, "Welcome back!")
      |> UserAuth.log_in_user(user, user_params)
    else
      form = Phoenix.Component.to_form(user_params, as: "user")

      # In order to prevent user enumeration attacks, don't disclose whether the email is registered.
      conn
      |> put_flash(:error, "Invalid email or password")
      |> render(:new, form: form)
    end
  end

  # magic link request
  def create(conn, %{"user" => %{"email" => email}}) do
    if user = Accounts.get_user_by_email(email) do
      Accounts.deliver_login_instructions(
        user,
        &url(~p"/users/log-in/#{&1}")
      )
    end

    info =
      "If your email is in our system, you will receive instructions for logging in shortly."

    conn
    |> put_flash(:info, info)
    |> redirect(to: ~p"/users/log-in")
  end

  def confirm(conn, %{"token" => token}) do
    if user = Accounts.get_user_by_magic_link_token(token) do
      form = Phoenix.Component.to_form(%{"token" => token}, as: "user")

      conn
      |> assign(:user, user)
      |> assign(:form, form)
      |> render(:confirm)
    else
      conn
      |> put_flash(:error, "Magic link is invalid or it has expired.")
      |> redirect(to: ~p"/users/log-in")
    end
  end

  def delete(conn, _params) do
    conn
    |> put_flash(:info, "Logged out successfully.")
    |> UserAuth.log_out_user()
  end
end
defmodule HelloWeb.UserSessionHTML do
  use HelloWeb, :html

  # user_session_html/confirm.html.heex
  def confirm(assigns) do
    ~H"""
      <Layouts.app flash={@flash} current_scope={@current_scope}>
        <div class="mx-auto max-w-sm">
          <div class="text-center">
            <.header>Welcome {@user.email}</.header>
          </div>

          <.form
            :if={!@user.confirmed_at}
            for={@form}
            id="confirmation_form"
            action={~p"/users/log-in?_action=confirmed"}
            phx-mounted={JS.focus_first()}
          >
            <input type="hidden" name={@form[:token].name} value={@form[:token].value} />
            <.button
              name={@form[:remember_me].name}
              value="true"
              phx-disable-with="Confirming..."
              class="btn btn-primary w-full"
            >
              Confirm and stay logged in
            </.button>
            <.button phx-disable-with="Confirming..." class="btn btn-primary btn-soft w-full mt-2">
              Confirm and log in only this time
            </.button>
          </.form>

          <.form
            :if={@user.confirmed_at}
            for={@form}
            id="login_form"
            action={~p"/users/log-in"}
            phx-mounted={JS.focus_first()}
          >
            <input type="hidden" name={@form[:token].name} value={@form[:token].value} />
            <%= if @current_scope do %>
              <.button variant="primary" phx-disable-with="Logging in..." class="btn btn-primary w-full">
                Log in
              </.button>
            <% else %>
              <.button
                name={@form[:remember_me].name}
                value="true"
                phx-disable-with="Logging in..."
                class="btn btn-primary w-full"
              >
                Keep me logged in on this device
              </.button>
              <.button phx-disable-with="Logging in..." class="btn btn-primary btn-soft w-full mt-2">
                Log me in only this time
              </.button>
            <% end %>
          </.form>

          <p :if={!@user.confirmed_at} class="alert alert-outline mt-8">
            Tip: If you prefer passwords, you can enable them in the user settings.
          </p>
        </div>
      </Layouts.app>
    """
  end

  # user_session_html/new.html.heex
  def new(assigns) do
    ~H"""
      <Layouts.app flash={@flash} current_scope={@current_scope}>
        <div class="mx-auto max-w-sm space-y-4">
          <div class="text-center">
            <.header>
              <p>Log in</p>
              <:subtitle>
                <%= if @current_scope do %>
                  You need to reauthenticate to perform sensitive actions on your account.
                <% else %>
                  Don't have an account? <.link
                    navigate={~p"/users/register"}
                    class="font-semibold text-brand hover:underline"
                    phx-no-format
                  >Sign up</.link> for an account now.
                <% end %>
              </:subtitle>
            </.header>
          </div>

          <div :if={local_mail_adapter?()} class="alert alert-info">
            <.icon name="hero-information-circle" class="size-6 shrink-0" />
            <div>
              <p>You are running the local mail adapter.</p>
              <p>
                To see sent emails, visit <.link href="/dev/mailbox" class="underline">the mailbox page</.link>.
              </p>
            </div>
          </div>

          <.form :let={f} for={@form} as={:user} id="login_form_magic" action={~p"/users/log-in"}>
            <.input
              readonly={!!@current_scope}
              field={f[:email]}
              type="email"
              label="Email"
              autocomplete="username"
              spellcheck="false"
              required
              phx-mounted={JS.focus()}
            />
            <.button class="btn btn-primary w-full">
              Log in with email <span aria-hidden="true">→</span>
            </.button>
          </.form>

          <div class="divider">or</div>

          <.form :let={f} for={@form} as={:user} id="login_form_password" action={~p"/users/log-in"}>
            <.input
              readonly={!!@current_scope}
              field={f[:email]}
              type="email"
              label="Email"
              autocomplete="username"
              spellcheck="false"
              required
            />
            <.input
              field={f[:password]}
              type="password"
              label="Password"
              autocomplete="current-password"
              spellcheck="false"
            />
            <.button class="btn btn-primary w-full" name={@form[:remember_me].name} value="true">
              Log in and stay logged in <span aria-hidden="true">→</span>
            </.button>
            <.button class="btn btn-primary btn-soft w-full mt-2">
              Log in only this time
            </.button>
          </.form>
        </div>
      </Layouts.app>
    """
  end

  defp local_mail_adapter? do
    Application.get_env(:hello, Hello.Mailer)[:adapter] == Swoosh.Adapters.Local
  end
end
defmodule HelloWeb.UserSettingsController do
  use HelloWeb, :controller

  alias Hello.Accounts
  alias HelloWeb.UserAuth

  import HelloWeb.UserAuth, only: [require_sudo_mode: 2]

  plug :require_sudo_mode
  plug :assign_email_and_password_changesets

  def edit(conn, _params) do
    render(conn, :edit)
  end

  def update(conn, %{"action" => "update_email"} = params) do
    %{"user" => user_params} = params
    user = conn.assigns.current_scope.user

    case Accounts.change_user_email(user, user_params) do
      %{valid?: true} = changeset ->
        Accounts.deliver_user_update_email_instructions(
          Ecto.Changeset.apply_action!(changeset, :insert),
          user.email,
          &url(~p"/users/settings/confirm-email/#{&1}")
        )

        conn
        |> put_flash(
          :info,
          "A link to confirm your email change has been sent to the new address."
        )
        |> redirect(to: ~p"/users/settings")

      changeset ->
        render(conn, :edit, email_changeset: %{changeset | action: :insert})
    end
  end

  def update(conn, %{"action" => "update_password"} = params) do
    %{"user" => user_params} = params
    user = conn.assigns.current_scope.user

    case Accounts.update_user_password(user, user_params) do
      {:ok, {user, _}} ->
        conn
        |> put_flash(:info, "Password updated successfully.")
        |> put_session(:user_return_to, ~p"/users/settings")
        |> UserAuth.log_in_user(user)

      {:error, changeset} ->
        render(conn, :edit, password_changeset: changeset)
    end
  end

  def confirm_email(conn, %{"token" => token}) do
    case Accounts.update_user_email(conn.assigns.current_scope.user, token) do
      {:ok, _user} ->
        conn
        |> put_flash(:info, "Email changed successfully.")
        |> redirect(to: ~p"/users/settings")

      {:error, _} ->
        conn
        |> put_flash(:error, "Email change link is invalid or it has expired.")
        |> redirect(to: ~p"/users/settings")
    end
  end

  defp assign_email_and_password_changesets(conn, _opts) do
    user = conn.assigns.current_scope.user

    conn
    |> assign(:email_changeset, Accounts.change_user_email(user))
    |> assign(:password_changeset, Accounts.change_user_password(user))
  end
end
defmodule HelloWeb.UserSettingsHTML do
  use HelloWeb, :html

  # user_settings_html/edit.html.heex
  def edit(assigns) do
    ~H"""
      <Layouts.app flash={@flash} current_scope={@current_scope}>
        <div class="text-center">
          <.header>
            Account Settings
            <:subtitle>Manage your account email address and password settings</:subtitle>
          </.header>
        </div>

        <.form :let={f} for={@email_changeset} action={~p"/users/settings"} id="update_email">
          <input type="hidden" name="action" value="update_email" />

          <.input
            field={f[:email]}
            type="email"
            label="Email"
            autocomplete="username"
            spellcheck="false"
            required
          />

          <.button variant="primary" phx-disable-with="Changing...">Change Email</.button>
        </.form>

        <div class="divider" />

        <.form :let={f} for={@password_changeset} action={~p"/users/settings"} id="update_password">
          <input type="hidden" name="action" value="update_password" />

          <.input
            field={f[:password]}
            type="password"
            label="New password"
            autocomplete="new-password"
            spellcheck="false"
            required
          />
          <.input
            field={f[:password_confirmation]}
            type="password"
            label="Confirm new password"
            autocomplete="new-password"
            spellcheck="false"
            required
          />
          <.button variant="primary" phx-disable-with="Changing...">
            Save Password
          </.button>
        </.form>
      </Layouts.app>
    """
  end
end

lib/hello_web/router.ex: o router gerado, com os plugs. A única diferença em relação ao do notebook 13 é o escopo /api no fim, com a rota GET /api/me, que a próxima seção explica. O aviso HelloWeb.MeController.init/1 is undefined é esperado: o controller vem nessa seção.

defmodule HelloWeb.Router do
  use HelloWeb, :router

  import HelloWeb.UserAuth

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {HelloWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
    plug :fetch_current_scope_for_user
  end

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :home
  end

  # Em desenvolvimento: a caixa de entrada do Swoosh.
  scope "/dev" do
    pipe_through :browser

    forward "/mailbox", Plug.Swoosh.MailboxPreview
  end

  ## Authentication routes

  scope "/", HelloWeb do
    pipe_through [:browser, :redirect_if_user_is_authenticated]

    get "/users/register", UserRegistrationController, :new
    post "/users/register", UserRegistrationController, :create
  end

  scope "/", HelloWeb do
    pipe_through [:browser, :require_authenticated_user]

    get "/users/settings", UserSettingsController, :edit
    put "/users/settings", UserSettingsController, :update
    get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email
  end

  scope "/", HelloWeb do
    pipe_through [:browser]

    get "/users/log-in", UserSessionController, :new
    get "/users/log-in/:token", UserSessionController, :confirm
    post "/users/log-in", UserSessionController, :create
    delete "/users/log-out", UserSessionController, :delete
  end

  scope "/api", HelloWeb do
    pipe_through :api

    get "/me", MeController, :show
  end
end

lib/hello_web/components/layouts.ex: o root layout ganha o menu que o phx.gen.auth injeta no root.html.heex.

defmodule HelloWeb.Layouts do
  use HelloWeb, :html

  def root(assigns) do
    ~H"""
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <meta name="csrf-token" content={get_csrf_token()} />
        <title>Hello · Phoenix Framework</title>
        <link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
        <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4">
        </script>
        <script defer src={~p"/assets/phoenix_html.js"}>
        </script>
      </head>
      <body>
        <ul class="menu menu-horizontal w-full relative z-10 flex items-center gap-4 px-4 sm:px-6 lg:px-8 justify-end">
          <%= if @current_scope do %>
            <li>
              {@current_scope.user.email}
            </li>
            <li>
              <.link href={~p"/users/settings"}>Settings</.link>
            </li>
            <li>
              <.link href={~p"/users/log-out"} method="delete">Log out</.link>
            </li>
          <% else %>
            <li>
              <.link href={~p"/users/register"}>Register</.link>
            </li>
            <li>
              <.link href={~p"/users/log-in"}>Log in</.link>
            </li>
          <% end %>
        </ul>
        {@inner_content}
      </body>
    </html>
    """
  end

  attr :flash, :map, required: true, doc: "the map of flash messages"
  attr :current_scope, :map, default: nil, doc: "the current scope"
  slot :inner_block, required: true

  def app(assigns) do
    ~H"""
    <header class="navbar px-4 sm:px-6 lg:px-8">
      <div class="flex-1">
        <a href="/" class="flex-1 flex w-fit items-center gap-2">
          <span class="text-sm font-semibold">Hello · Phoenix v{Application.spec(:phoenix, :vsn)}</span>
        </a>
      </div>
    </header>

    <main class="px-4 py-20 sm:px-6 lg:px-8">
      <div class="mx-auto max-w-2xl space-y-4">
        {render_slot(@inner_block)}
      </div>
    </main>

    <.flash_group flash={@flash} />
    """
  end

  attr :flash, :map, required: true, doc: "the map of flash messages"
  attr :id, :string, default: "flash-group", doc: "the optional id of flash container"

  def flash_group(assigns) do
    ~H"""
    <div id={@id} aria-live="polite">
      <.flash kind={:info} flash={@flash} />
      <.flash kind={:error} flash={@flash} />
    </div>
    """
  end
end

test/support/fixtures/accounts_fixtures.ex: os ajudantes de teste que o gerador cria. O notebook os usa para criar as contas de exemplo e, mais adiante, no teste do guia.

defmodule Hello.AccountsFixtures do
  @moduledoc """
  This module defines test helpers for creating
  entities via the `Hello.Accounts` context.
  """

  import Ecto.Query

  alias Hello.Accounts
  alias Hello.Accounts.Scope

  def unique_user_email, do: "user#{System.unique_integer()}@example.com"
  def valid_user_password, do: "hello world!"

  def valid_user_attributes(attrs \\ %{}) do
    Enum.into(attrs, %{
      email: unique_user_email()
    })
  end

  def unconfirmed_user_fixture(attrs \\ %{}) do
    {:ok, user} =
      attrs
      |> valid_user_attributes()
      |> Accounts.register_user()

    user
  end

  def user_fixture(attrs \\ %{}) do
    user = unconfirmed_user_fixture(attrs)

    token =
      extract_user_token(fn url ->
        Accounts.deliver_login_instructions(user, url)
      end)

    {:ok, {user, _expired_tokens}} =
      Accounts.login_user_by_magic_link(token)

    user
  end

  def user_scope_fixture do
    user = user_fixture()
    user_scope_fixture(user)
  end

  def user_scope_fixture(user) do
    Scope.for_user(user)
  end

  def set_password(user) do
    {:ok, {user, _expired_tokens}} =
      Accounts.update_user_password(user, %{password: valid_user_password()})

    user
  end

  def extract_user_token(fun) do
    {:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]")
    [_, token | _] = String.split(captured_email.text_body, "[TOKEN]")
    token
  end

  def override_token_authenticated_at(token, authenticated_at) when is_binary(token) do
    Hello.Repo.update_all(
      from(t in Accounts.UserToken,
        where: t.token == ^token
      ),
      set: [authenticated_at: authenticated_at]
    )
  end

  def generate_user_magic_link_token(user) do
    {encoded_token, user_token} = Accounts.UserToken.build_email_token(user, "login")
    Hello.Repo.insert!(user_token)
    {encoded_token, user_token.token}
  end

  def offset_user_token(token, amount_to_add, unit) do
    dt = DateTime.add(DateTime.utc_now(:second), amount_to_add, unit)

    Hello.Repo.update_all(
      from(ut in Accounts.UserToken, where: ut.token == ^token),
      set: [inserted_at: dt, authenticated_at: dt]
    )
  end
end

As contas de exemplo: a Ana e a Bia, já confirmadas e com senha. user_fixture/1 faz o caminho do link mágico, que confirma a conta; set_password/1 define a senha hello world!.

alias Hello.AccountsFixtures

ana = AccountsFixtures.user_fixture(%{email: "ana@example.com"}) |> AccountsFixtures.set_password()
bia = AccountsFixtures.user_fixture(%{email: "bia@example.com"}) |> AccountsFixtures.set_password()

Kino.Markdown.new(
  "Contas confirmadas, com a senha `#{AccountsFixtures.valid_user_password()}`: " <>
    "`#{ana.email}` (id #{ana.id}) e `#{bia.email}` (id #{bia.id})."
)

A API que vamos proteger

Uma API pequena, no estilo do notebook 12: GET /api/me devolve, em JSON, os dados de quem fez a requisição. O controller lê o usuário do escopo, conn.assigns.current_scope, como as páginas da autenticação fazem.

lib/hello_web/controllers/me_controller.ex e me_json.ex

defmodule HelloWeb.MeController do
  use HelloWeb, :controller

  def show(conn, _params) do
    render(conn, :show, user: conn.assigns.current_scope.user)
  end
end

defmodule HelloWeb.MeJSON do
  def show(%{user: user}) do
    %{data: %{id: user.id, email: user.email, confirmed_at: user.confirmed_at}}
  end
end
Trilha.pergunta(:sem_sessao)
Trilha.curl(~S"""
curl -i http://localhost:4010/api/me
""")

500 Internal Server Error, e o título da página de depuração diz KeyError: o controller procurou :current_scope em conn.assigns e não achou. O pipeline :api só tem plug :accepts, ["json"]. Nenhum plug dele lê a sessão ou descobre o usuário, e nem faria sentido ler a sessão: o curl, como um app de celular, não tem o cookie do navegador. A API precisa de outro jeito de saber quem está do outro lado. É o assunto do guia.

Tokens de API na tabela que já existe

Na documentação: API Authentication

O guia parte do mix phx.gen.auth Accounts User users e acrescenta autenticação por token. Como o gerador já criou uma tabela de tokens, ela vai guardar também os tokens de API, seguindo as boas práticas de segurança que o código gerado já aplica. O guia tem duas partes: funções novas no contexto e um plug.

Antes de mexer no código, veja a tabela. A Ana entra pelo formulário de login, com a senha:

Trilha.navegar("ana", :post, "/users/log-in", %{
  "user" => %{"email" => "ana@example.com", "password" => "hello world!"}
})
Trilha.descrever("users_tokens")
Trilha.consultar("""
SELECT t.id, u.email, t.context, t.sent_to, octet_length(t.token) AS bytes, t.inserted_at
FROM users_tokens t JOIN users u ON u.id = t.user_id
ORDER BY t.id
""")

Uma linha para a Ana, com o contexto session: é o token de sessão do login que acabou de acontecer. O token tem 32 bytes sorteados (@rand_size 32 no UserToken) e a coluna context diz para que ele serve. A coluna sent_to fica vazia em tokens de sessão; nos tokens enviados por e-mail ela guarda o endereço de destino. Não há linhas da Bia, nem os tokens de login por link mágico das duas: o link é apagado quando é usado, e set_password/1 apaga todos os tokens do usuário.

Trilha.pergunta(:mesma_tabela)

Os tokens de API vão morar aqui, com o contexto "api-token". Falta decidir como guardá-los. O UserToken tem dois jeitos, e os @doc explicam:

  • build_session_token/1 guarda o token como ele é. O token só vale dentro do cookie de sessão, que o Phoenix assina com a secret_key_base. Quem lê o banco não consegue forjar esse cookie.
  • build_email_token/2 guarda só o hash SHA-256 do token. O token em si viaja por e-mail, em Base64, e não pode ser reconstruído a partir do hash. Quem lê o banco não tem o que usar.

Um token de API se parece com o segundo caso: ele sai do servidor uma vez e passa a viver no cliente, num arquivo de configuração ou numa variável de ambiente. Por isso o guia o cria com build_email_token/2.

Trilha.pergunta(:hash)

Funções de API no contexto

Na documentação: Adding API functions to the context

O guia acrescenta duas funções ao Accounts: uma cria o token, a outra encontra o usuário a partir dele.

## API

@doc """
Creates a new api token for a user.

The token returned must be saved somewhere safe.
This token cannot be recovered from the database.
"""
def create_user_api_token(user) do
  {encoded_token, user_token} = UserToken.build_email_token(user, "api-token")
  Repo.insert!(user_token)
  encoded_token
end

@doc """
Fetches the user by API token.
"""
def fetch_user_by_api_token(token) do
  with {:ok, query} <- UserToken.verify_api_token_query(token),
       %User{} = user <- Repo.one(query) do
    {:ok, user}
  else
    _ -> :error
  end
end

create_user_api_token/1 usa build_email_token/2 com o contexto "api-token". A função devolve duas coisas: o token codificado, que vai para quem pediu, e a struct %UserToken{} com o hash, que vai para o banco. O token não pode ser recuperado depois; quem o recebe precisa guardá-lo.

Como é um token "de e-mail", build_email_token/2 também grava o e-mail atual do usuário em sent_to. O guia tira disso uma consequência: se o usuário trocar o e-mail, os tokens expiram. A seção seguinte mostra por quê.

Sua vez: as duas funções

Na célula do Hello.Accounts, lá em cima, troque o comentário "Sua vez (notebook 15)" pelas duas funções do guia e avalie a célula.

Trilha.solucao(:contexto)
Trilha.conferir(:contexto)

A conferência cria tokens e procura o hash de cada um na tabela; ela ainda não chama fetch_user_by_api_token/1. Essa função depende de UserToken.verify_api_token_query/1, que não existe.

Trilha.pergunta(:fetch_get)

O guia explica o nome: a API vai responder com status diferentes conforme o usuário seja encontrado ou não, então a função devolve {:ok, user} ou :error. Na convenção do Elixir, funções assim se chamam fetch_*; uma get_* devolveria nil, como get_user_by_email/1 faz.

Erro como conteúdo: o teste do guia

O guia sugere um teste para accounts_test.exs. Um notebook não tem mix test, mas tem o ExUnit, a biblioteca de testes do Elixir: ExUnit.start(autorun: false) liga o ExUnit sem rodar nada sozinho, o módulo define o teste, e ExUnit.run() roda. O use ExUnit.Case e o import Hello.AccountsFixtures são os mesmos do arquivo de teste gerado.

ExUnit.start(autorun: false)

defmodule Hello.AccountsApiTest do
  use ExUnit.Case

  alias Hello.Accounts
  import Hello.AccountsFixtures

  describe "create_user_api_token/1 and fetch_user_by_api_token/1" do
    test "creates and fetches by token" do
      user = user_fixture()
      token = Accounts.create_user_api_token(user)
      assert Accounts.fetch_user_by_api_token(token) == {:ok, user}
      assert Accounts.fetch_user_by_api_token("invalid") == :error
    end
  end
end

ExUnit.run()

Com as duas funções no lugar, o teste falha, como o guia avisa:

** (UndefinedFunctionError) function Hello.Accounts.UserToken.verify_api_token_query/1 is undefined or private. Did you mean:

      * verify_change_email_token_query/2
      * verify_magic_link_token_query/1
      * verify_session_token_query/1

A mensagem é a do guia, com Hello no lugar de Demo: o Elixir procura funções de nome parecido no módulo e as sugere. As três sugestões são as consultas que o UserToken já tem, uma por tipo de token. (Se você ainda não fez o exercício anterior, o erro é outro: Hello.Accounts.create_user_api_token/1 is undefined or private. E se já fez o próximo, o teste passa.)

O guia sugere que você tente corrigir o erro sozinho antes de ler a explicação. Olhe as três funções sugeridas no UserToken lá em cima: a nova vai ser parecida com uma delas.

A consulta que confere o token

O UserToken tem uma função de verificação para cada tipo de token. Falta a dos tokens de API. Quanto tempo um token desses deve valer depende da aplicação e de quão sensíveis são os dados; o guia escolhe 365 dias:

@doc """
Checks if the API token is valid and returns its underlying lookup query.

The query returns the user found by the token, if any.

The given token is valid if it matches its hashed counterpart in the
database and the user email has not changed. This function also checks
if the token is being used within 365 days.
"""
def verify_api_token_query(token) do
  case Base.url_decode64(token, padding: false) do
    {:ok, decoded_token} ->
      hashed_token = :crypto.hash(@hash_algorithm, decoded_token)

      query =
        from token in by_token_and_context_query(hashed_token, "api-token"),
          join: user in assoc(token, :user),
          where:
            token.inserted_at > ago(^@api_token_validity_in_days, "day") and
              token.sent_to == user.email,
          select: user

      {:ok, query}

    :error ->
      :error
  end
end

e um atributo novo no topo do módulo:

   @magic_link_validity_in_minutes 15
   @change_email_validity_in_days 7
   @session_validity_in_days 60
+  @api_token_validity_in_days 365

Leia a função na ordem em que ela trabalha:

  1. Base.url_decode64/2 desfaz o Base64. Um texto que não é Base64 válido dá :error, e fetch_user_by_api_token/1 devolve :error também, pelo else do with.
  2. :crypto.hash/2 calcula o SHA-256 dos bytes, com o mesmo @hash_algorithm que gerou o hash guardado.
  3. by_token_and_context_query/2 monta a consulta pelo hash e pelo contexto "api-token". Ela é defp no UserToken; a função nova pode usá-la porque fica no mesmo módulo.
  4. O join traz o usuário, e o where exige duas coisas: o token foi criado há menos de 365 dias, e o e-mail de destino (sent_to) ainda é o e-mail do usuário.

Uma diferença em relação ao guia: o trecho do diff mostra @session_validity_in_days 60, mas o 1.8.16 gera 14, como você vê na célula. Mude só o que o diff acrescenta.

Sua vez: verify_api_token_query/1

Na célula do Hello.Accounts.UserToken, acrescente o atributo @api_token_validity_in_days 365 junto dos outros e troque o comentário "Sua vez (notebook 15)" pela função. Avalie a célula e, depois, a do teste do guia, lá em cima: agora ele passa, com 1 test, 0 failures.

Trilha.solucao(:verificar)
Trilha.conferir(:verificar)

O token no banco

As células desta seção e da próxima usam as funções dos dois exercícios; sem elas, falham com UndefinedFunctionError. Crie um token para a Ana e compare o que ela recebe com o que o banco guarda:

ana = Hello.Accounts.get_user_by_email("ana@example.com")
token = Hello.Accounts.create_user_api_token(ana)
Trilha.consultar("""
SELECT id, user_id, context, sent_to, encode(token, 'hex') AS hash_em_hexadecimal
FROM users_tokens WHERE context = 'api-token' ORDER BY id DESC LIMIT 1
""")
token
|> Base.url_decode64!(padding: false)
|> then(&:crypto.hash(:sha256, &1))
|> Base.encode16(case: :lower)

O hash calculado aqui é o mesmo da coluna token. O banco guarda o resultado da conta, não o token: o token que a Ana recebeu, com 43 caracteres, não aparece em lugar nenhum da tabela.

Hello.Accounts.fetch_user_by_api_token(token)

Trocar o e-mail derruba o token

Trilha.pergunta(:email_muda)

Veja acontecer, com uma conta nova, a Carla, para não mexer na Ana. A troca de e-mail segue o caminho do código gerado: deliver_user_update_email_instructions/3 manda um link para o endereço novo, e update_user_email/2 aplica a troca quando recebe o token desse link.

carla = Hello.AccountsFixtures.user_fixture(%{email: "carla@example.com"})
token_da_carla = Hello.Accounts.create_user_api_token(carla)

antes = Hello.Accounts.fetch_user_by_api_token(token_da_carla)

novo = %{carla | email: "carla.nova@example.com"}
Hello.Accounts.deliver_user_update_email_instructions(
  novo,
  carla.email,
  &"#{HelloWeb.Endpoint.url()}/users/settings/confirm-email/#{&1}"
)

token_do_email = Trilha.link_do_email("carla.nova@example.com") |> String.split("/") |> List.last()
{:ok, carla} = Hello.Accounts.update_user_email(carla, token_do_email)

depois = Hello.Accounts.fetch_user_by_api_token(token_da_carla)

%{email_agora: carla.email, antes: antes, depois: depois}

Antes da troca, {:ok, user}; depois, :error. A linha do token continua na tabela, com o e-mail antigo:

Trilha.consultar(
  "SELECT id, context, sent_to FROM users_tokens WHERE user_id = $1 ORDER BY id",
  [carla.id]
)

update_user_email/2 só apagou o token da troca de e-mail, de contexto change:carla@example.com. O de API ficou, mas o sent_to dele não bate mais com users.email, e a consulta o ignora.

Trocar a senha também

Trilha.pergunta(:senha_muda)
token_da_carla = Hello.Accounts.create_user_api_token(carla)

{:ok, {carla, expirados}} =
  Hello.Accounts.update_user_password(carla, %{password: "uma senha nova e longa"})

%{
  tokens_expirados: Enum.map(expirados, & &1.context),
  token_de_api_agora: Hello.Accounts.fetch_user_by_api_token(token_da_carla)
}

update_user_password/2 devolve a lista dos tokens que apagou: os dois tokens de API da Carla, o desta célula e o da seção anterior, que já não valia por causa da troca de e-mail. É a regra que o guia de mix phx.gen.auth descreve: quando a senha muda, todos os tokens são apagados, e o usuário precisa entrar de novo em todos os dispositivos. Um cliente de API também.

A validade de 365 dias

Para testar a validade sem esperar um ano, Trilha.envelhecer/2 muda a coluna inserted_at de um token, como se ele tivesse sido criado tantos dias atrás:

velho = Hello.Accounts.create_user_api_token(ana)
Trilha.envelhecer(velho, 366)

Hello.Accounts.fetch_user_by_api_token(velho)

:error: o where exige token.inserted_at > ago(365, "day"). A linha continua na tabela. O código gerado não apaga tokens vencidos; num projeto, uma tarefa periódica pode limpar as linhas antigas.

O plug de autenticação da API

Na documentação: API authentication plug

Falta a API usar os tokens. O UserAuth gerado já tem plugs para o navegador, como fetch_current_scope_for_user. O guia acrescenta um para a API:

def fetch_current_scope_for_api_user(conn, _opts) do
  with [<<bearer::binary-size(6), " ", token::binary>>] <-
         get_req_header(conn, "authorization"),
       true <- String.downcase(bearer) == "bearer",
       {:ok, user} <- Accounts.fetch_user_by_api_token(token) do
    assign(conn, :current_scope, Scope.for_user(user))
  else
    _ ->
      conn
      |> send_resp(:unauthorized, "No access for you")
      |> halt()
  end
end

O cliente manda o token no cabeçalho authorization, no formato Bearer TOKEN, o padrão para tokens desse tipo. O with faz três verificações:

  1. get_req_header/2 devolve a lista de valores do cabeçalho. O padrão exige exatamente um valor, que comece com 6 bytes, um espaço e o resto, que é o token.
  2. Os 6 bytes, em minúsculas, devem ser bearer.
  3. fetch_user_by_api_token/1 deve achar o usuário.

Se tudo casa, o plug põe o escopo do usuário em conn.assigns.current_scope, o mesmo assign que o fetch_current_scope_for_user usa no navegador. O controller não precisa saber de onde o usuário veio. Se algo falha, a resposta é 401 Unauthorized com o texto No access for you, e halt/1 interrompe o pipeline.

Trilha.pergunta(:halt)

O halt/1 não é um detalhe. Este módulo de exemplo faz o mesmo que o plug, sem o halt, e com um plug "protegido" logo depois:

defmodule Exemplo.SemHalt do
  use Plug.Builder

  plug :recusar
  plug :dados_secretos

  def recusar(conn, _opts), do: send_resp(conn, :unauthorized, "No access for you")

  def dados_secretos(conn, _opts), do: send_resp(conn, 200, "o saldo da Ana")
end

Plug.Test.conn(:get, "/") |> Exemplo.SemHalt.call([])

Plug.Conn.AlreadySentError: o segundo plug rodou depois do 401 e tentou responder de novo. O cliente já tinha recebido o 401, mas o código protegido rodou para quem não tinha token. Com halt/1, o Plug.Builder para no primeiro plug.

Sua vez: o plug e o pipeline

Duas mudanças, nesta ordem:

  1. Na célula do HelloWeb.UserAuth, lá em cima, troque o comentário "Sua vez (notebook 15)" pela função do guia e avalie a célula. Os apelidos Accounts e Scope já estão no topo do módulo.
  2. Na célula do router logo abaixo, acrescente plug :fetch_current_scope_for_api_user ao pipeline :api, como o guia faz, e avalie.

Se o router for avaliado antes do UserAuth, a compilação falha com undefined function fetch_current_scope_for_api_user/2: o import HelloWeb.UserAuth só traz as funções que o módulo tem quando o router compila.

lib/hello_web/router.ex: o mesmo router, com o pipeline :api à espera do plug.

defmodule HelloWeb.Router do
  use HelloWeb, :router

  import HelloWeb.UserAuth

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {HelloWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
    plug :fetch_current_scope_for_user
  end

  pipeline :api do
    plug :accepts, ["json"]
    # Sua vez: o plug do token entra aqui.
  end

  scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :home
  end

  # Em desenvolvimento: a caixa de entrada do Swoosh.
  scope "/dev" do
    pipe_through :browser

    forward "/mailbox", Plug.Swoosh.MailboxPreview
  end

  ## Authentication routes

  scope "/", HelloWeb do
    pipe_through [:browser, :redirect_if_user_is_authenticated]

    get "/users/register", UserRegistrationController, :new
    post "/users/register", UserRegistrationController, :create
  end

  scope "/", HelloWeb do
    pipe_through [:browser, :require_authenticated_user]

    get "/users/settings", UserSettingsController, :edit
    put "/users/settings", UserSettingsController, :update
    get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email
  end

  scope "/", HelloWeb do
    pipe_through [:browser]

    get "/users/log-in", UserSessionController, :new
    get "/users/log-in/:token", UserSessionController, :confirm
    post "/users/log-in", UserSessionController, :create
    delete "/users/log-out", UserSessionController, :delete
  end

  scope "/api", HelloWeb do
    pipe_through :api

    get "/me", MeController, :show
  end
end
Trilha.solucao(:plug)
Trilha.conferir(:plug)

Agora, os pedidos com Trilha.curl/1. O token da Ana vai no meio do comando:

token = Hello.Accounts.create_user_api_token(ana)
Trilha.curl("curl -i -H 'Authorization: Bearer #{token}' http://localhost:4010/api/me")

200 OK, com os dados da Ana. A saída mostra o comando inteiro, com o token: copie-o para um terminal e a resposta é a mesma, enquanto o runtime estiver ligado.

Sem o cabeçalho:

Trilha.curl(~S"""
curl -i http://localhost:4010/api/me
""")

401 Unauthorized, com o corpo No access for you. A resposta não tem content-type: send_resp/3 manda o texto como está, e o Phoenix só põe o tipo quando quem responde é uma view, como o MeJSON, ou funções como json/2 e text/2.

Trilha.pergunta(:bearer_minusculo)

Um token que não existe, o esquema em minúsculas e um esquema diferente de Bearer:

falso = Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)
Trilha.curl("curl -i -H 'Authorization: Bearer #{falso}' http://localhost:4010/api/me")
token = Hello.Accounts.create_user_api_token(ana)
Trilha.curl("curl -i -H 'Authorization: bearer #{token}' http://localhost:4010/api/me")
Trilha.curl("curl -i -H 'Authorization: Basic #{Base.encode64("ana@example.com:hello world!")}' http://localhost:4010/api/me")

O falso recebe 401; o bearer minúsculo passa, com 200; o Basic, que é o esquema de usuário e senha do HTTP, recebe 401, porque os 6 primeiros bytes são Basic e não bearer.

A sua vez

Na documentação: Your turn

O guia termina deixando o fluxo completo por sua conta, porque ele depende da aplicação. Para um cliente JavaScript, o UserSessionController pode chamar create_user_api_token/1 e devolver o token em JSON. Para clientes de terceiros, a aplicação precisa de uma tela em que o usuário crie tokens e veja o resultado uma única vez. Os dois exercícios abaixo são a primeira ideia, numa versão de API: um login que devolve um token e uma rota que revoga os tokens.

Trilha.pergunta(:rota_publica)

lib/hello_web/router.ex: dois pipelines. O :api volta a ter só o accepts, e o plug do token vai para um pipeline novo, :api_auth. O escopo público tem a rota que entrega tokens; o protegido passa pelos dois pipelines.

defmodule HelloWeb.Router do
  use HelloWeb, :router

  import HelloWeb.UserAuth

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {HelloWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
    plug :fetch_current_scope_for_user
  end

  pipeline :api do
    plug :accepts, ["json"]
  end

  pipeline :api_auth do
    plug :fetch_current_scope_for_api_user
  end

  scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :home
  end

  # Em desenvolvimento: a caixa de entrada do Swoosh.
  scope "/dev" do
    pipe_through :browser

    forward "/mailbox", Plug.Swoosh.MailboxPreview
  end

  ## Authentication routes

  scope "/", HelloWeb do
    pipe_through [:browser, :redirect_if_user_is_authenticated]

    get "/users/register", UserRegistrationController, :new
    post "/users/register", UserRegistrationController, :create
  end

  scope "/", HelloWeb do
    pipe_through [:browser, :require_authenticated_user]

    get "/users/settings", UserSettingsController, :edit
    put "/users/settings", UserSettingsController, :update
    get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email
  end

  scope "/", HelloWeb do
    pipe_through [:browser]

    get "/users/log-in", UserSessionController, :new
    get "/users/log-in/:token", UserSessionController, :confirm
    post "/users/log-in", UserSessionController, :create
    delete "/users/log-out", UserSessionController, :delete
  end

  scope "/api", HelloWeb do
    pipe_through :api

    post "/tokens", TokenController, :create
  end

  scope "/api", HelloWeb do
    pipe_through [:api, :api_auth]

    get "/me", MeController, :show
    delete "/tokens", TokenController, :delete
  end
end

Se esta célula falhar com undefined function fetch_current_scope_for_api_user/2, faça antes o exercício do plug.

lib/hello_web/controllers/token_controller.ex: os dois exercícios a seguir escrevem as actions deste controller.

defmodule HelloWeb.TokenController do
  use HelloWeb, :controller

  # Sua vez: create/2 e delete/2.
end

Sua vez: um login que devolve um token

Escreva create/2, com um alias Hello.Accounts no topo do módulo. Ela recebe um JSON como {"email": "...", "password": "..."}. Se Accounts.get_user_by_email_and_password/2 encontrar o usuário, responda 201 Created com {"data": {"token": "..."}}; se não, 401 com uma mensagem de erro em JSON. Não diga se o e-mail existe: o UserSessionController gerado responde "Invalid email or password" nos dois casos, para não ajudar quem tenta descobrir quais e-mails estão cadastrados.

Trilha.solucao(:login_api)
Trilha.conferir(:login_api)

Com o controller pronto, um cliente faz o caminho completo: pede o token e o usa.

Trilha.curl(~S"""
curl -iX POST http://localhost:4010/api/tokens \
   -H 'Content-Type: application/json' \
   -d '{"email": "bia@example.com", "password": "hello world!"}'
""")
resposta =
  Trilha.curl!(~S"""
  curl -iX POST http://localhost:4010/api/tokens \
     -H 'Content-Type: application/json' \
     -d '{"email": "bia@example.com", "password": "hello world!"}'
  """)

token_da_bia = resposta.json["data"]["token"]
Trilha.curl("curl -i -H 'Authorization: Bearer #{token_da_bia}' http://localhost:4010/api/me")

Cada pedido cria um token novo: o servidor não tem como devolver um token antigo, porque só guarda o hash.

Sua vez: revogar os tokens

Um token vazou, ou o app foi desinstalado. O usuário precisa de um jeito de invalidar os tokens de API sem trocar a senha. Escreva:

  1. Accounts.delete_user_api_tokens/1, que recebe um usuário e apaga as linhas dele com o contexto "api-token", e devolve :ok. Ela fica na célula do Hello.Accounts, junto das funções de API. Não apague os tokens de sessão: o navegador da pessoa deve continuar logado.
  2. delete/2 no TokenController, que chama a função com o usuário do escopo e responde 204 No Content.

A rota DELETE /api/tokens já está no escopo protegido: só quem manda um token válido chega à action, e o escopo diz de quem são os tokens.

Trilha.solucao(:revogar)
Trilha.conferir(:revogar)

Uma última observação, que vale para qualquer API com token: o token é uma senha. Ele deve viajar só por HTTPS e ser guardado como se guarda uma senha. E um login por API, como o POST /api/tokens, merece um limite de tentativas, que o código gerado não traz.

Sem roteiro: a API do Brechó com token

O Brechó até aqui

A vitrine do Brechó, como o notebook 08 a deixou: a tabela produtos, o contexto Hello.Vitrine e as páginas /produtos. O router desta célula é o da seção anterior com as rotas da vitrine; como aquele, ele depende do exercício do plug.

priv/repo/migrations/20250320000000_create_produtos.exs (a do notebook 07)

defmodule Hello.Repo.Migrations.CreateProdutos do
  use Ecto.Migration

  def change do
    create table(:produtos) do
      add :nome, :string, null: false
      add :preco_centavos, :integer, null: false
      add :vendido, :boolean, default: false, null: false

      timestamps(type: :utc_datetime)
    end
  end
end

Trilha.migracao(20250320000000, Hello.Repo.Migrations.CreateProdutos)
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)

lib/hello/vitrine/produto.ex e lib/hello/vitrine.ex (o contexto do notebook 08)

defmodule Hello.Vitrine.Produto do
  use Ecto.Schema
  import Ecto.Changeset

  schema "produtos" do
    field :nome, :string
    field :preco_centavos, :integer
    field :vendido, :boolean, default: false

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(produto, attrs) do
    produto
    |> cast(attrs, [:nome, :preco_centavos])
    |> validate_required([:nome, :preco_centavos])
    |> validate_number(:preco_centavos, greater_than: 0)
  end
end

defmodule Hello.Vitrine do
  @moduledoc """
  A vitrine do Brechó: o que está à venda e a venda de cada peça.
  """

  import Ecto.Query, warn: false
  alias Hello.Repo

  alias Hello.Vitrine.Produto

  def listar_disponiveis do
    Repo.all(from p in Produto, where: not p.vendido, order_by: p.preco_centavos)
  end

  def buscar_produto!(id), do: Repo.get!(Produto, id)

  def cadastrar_produto(attrs) do
    %Produto{}
    |> Produto.changeset(attrs)
    |> Repo.insert()
  end

  def change_produto(%Produto{} = produto, attrs \\ %{}) do
    Produto.changeset(produto, attrs)
  end

  @doc """
  Marca a peça como vendida, se ninguém a vendeu antes.

  A verificação e a mudança acontecem num só UPDATE, então duas vendas
  simultâneas da mesma peça nunca dão certo ao mesmo tempo.
  """
  def vender(%Produto{id: id}) do
    consulta = from p in Produto, where: p.id == ^id and not p.vendido, select: p

    case Repo.update_all(consulta, set: [vendido: true, updated_at: DateTime.utc_now(:second)]) do
      {1, [produto]} -> {:ok, produto}
      {0, []} -> {:error, :ja_vendido}
    end
  end
end

lib/hello_web/router.ex: o router da seção anterior, com as rotas da vitrine no escopo do navegador.

defmodule HelloWeb.Router do
  use HelloWeb, :router

  import HelloWeb.UserAuth

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {HelloWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
    plug :fetch_current_scope_for_user
  end

  pipeline :api do
    plug :accepts, ["json"]
  end

  pipeline :api_auth do
    plug :fetch_current_scope_for_api_user
  end

  scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :home
    get "/produtos", ProdutoController, :index
    post "/produtos", ProdutoController, :create
    post "/produtos/:id/vender", ProdutoController, :vender
  end

  # Em desenvolvimento: a caixa de entrada do Swoosh.
  scope "/dev" do
    pipe_through :browser

    forward "/mailbox", Plug.Swoosh.MailboxPreview
  end

  ## Authentication routes

  scope "/", HelloWeb do
    pipe_through [:browser, :redirect_if_user_is_authenticated]

    get "/users/register", UserRegistrationController, :new
    post "/users/register", UserRegistrationController, :create
  end

  scope "/", HelloWeb do
    pipe_through [:browser, :require_authenticated_user]

    get "/users/settings", UserSettingsController, :edit
    put "/users/settings", UserSettingsController, :update
    get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email
  end

  scope "/", HelloWeb do
    pipe_through [:browser]

    get "/users/log-in", UserSessionController, :new
    get "/users/log-in/:token", UserSessionController, :confirm
    post "/users/log-in", UserSessionController, :create
    delete "/users/log-out", UserSessionController, :delete
  end

  scope "/api", HelloWeb do
    pipe_through :api

    post "/tokens", TokenController, :create
  end

  scope "/api", HelloWeb do
    pipe_through [:api, :api_auth]

    get "/me", MeController, :show
    delete "/tokens", TokenController, :delete
  end
end

lib/hello_web/controllers/produto_controller.ex e produto_html.ex: as páginas da vitrine, em http://localhost:4010/produtos.

defmodule HelloWeb.ProdutoController do
  use HelloWeb, :controller

  alias Hello.Vitrine

  def index(conn, _params) do
    render(conn, :index, produtos: Vitrine.listar_disponiveis())
  end

  def create(conn, %{"produto" => attrs}) do
    case Vitrine.cadastrar_produto(attrs) do
      {:ok, produto} ->
        conn |> put_status(201) |> json(%{id: produto.id})

      {:error, changeset} ->
        erros = Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end)
        conn |> put_status(422) |> json(%{errors: erros})
    end
  end

  def vender(conn, %{"id" => id}) do
    case Vitrine.vender(Vitrine.buscar_produto!(id)) do
      {:ok, produto} ->
        conn |> put_flash(:info, "#{produto.nome} vendido.") |> redirect(to: ~p"/produtos")

      {:error, :ja_vendido} ->
        conn |> put_flash(:error, "Esta peça já foi vendida.") |> redirect(to: ~p"/produtos")
    end
  end
end

defmodule HelloWeb.ProdutoHTML do
  use HelloWeb, :html

  def index(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <.header>Vitrine do Brechó</.header>
      <.table id="produtos" rows={@produtos}>
        <:col :let={produto} label="Peça">{produto.nome}</:col>
        <:col :let={produto} label="Preço">{produto.preco_centavos} centavos</:col>
        <:action :let={produto}>
          <.link href={~p"/produtos/#{produto}/vender"} method="post">Vender</.link>
        </:action>
      </.table>
    </Layouts.app>
    """
  end
end

O desafio

Abra a vitrine para outros programas, com uma API em que qualquer um consulta e só quem tem token mexe:

  • uma migration, versão 20250625000000, que acrescenta a produtos a coluna vendedor_id, com chave estrangeira para users; e belongs_to :vendedor, Hello.Accounts.User no schema Hello.Vitrine.Produto;
  • GET /api/produtos, pública: as peças à venda em JSON, em data, cada uma com id, nome, preco_centavos, vendido e vendedor_id;
  • POST /api/produtos, com token: cadastra a peça com {"produto": {"nome": ..., "preco_centavos": ...}} e grava como vendedor o dono do token; responde 201 com a peça, ou 422 com os erros do changeset;
  • POST /api/produtos/:id/vender, com token: só quem cadastrou a peça pode marcá-la como vendida. Responda 200 com a peça ("vendido": true), 403 para outro usuário e 409 se ela já foi vendida.

Algumas pistas, sem roteiro:

  • depois de mudar o schema, avalie de novo o Hello.Vitrine: ele usa %Produto{}, e uma struct compilada antes da mudança não tem o campo novo (o notebook 09 mostrou esse erro);
  • funções com escopo, como cadastrar_produto(scope, attrs) e vender(scope, produto), deixam o controller sem regra de negócio;
  • Vitrine.vender/1 já resolve a venda simultânea e devolve {:error, :ja_vendido};
  • as rotas novas entram em dois escopos do router, o público e o protegido.

Escreva o seu código na célula abaixo, ou em quantas células quiser.

# O seu código do desafio.
Trilha.conferir(:brecho)
Trilha.solucao(:brecho)

No seu projeto

Num projeto gerado com mix phx.gen.auth Accounts User users, os passos do guia são:

  1. Em lib/hello/accounts.ex, as funções create_user_api_token/1 e fetch_user_by_api_token/1.
  2. Em test/hello/accounts_test.exs, o describe do guia. Rode mix test test/hello/accounts_test.exs e veja o UndefinedFunctionError.
  3. Em lib/hello/accounts/user_token.ex, o atributo @api_token_validity_in_days 365 e a função verify_api_token_query/1. Rode o teste de novo.
  4. Em lib/hello_web/user_auth.ex, o plug fetch_current_scope_for_api_user/2; em lib/hello_web/router.ex, o plug no pipeline de API. Se a aplicação tiver rotas de API públicas, separe dois pipelines, como na seção "A sua vez".
  5. Para criar um token sem escrever tela nenhuma, use o iex:
$ iex -S mix
iex> user = Hello.Accounts.get_user_by_email("voce@example.com")
iex> Hello.Accounts.create_user_api_token(user)
"sTpJg7rt-KQ9gZ7xLMtn2keusGk9N2JpPwkXDx7LmHU"
  1. Com o servidor no ar (mix phx.server), teste no terminal, na porta 4000:
$ curl -i -H 'Authorization: Bearer SEU_TOKEN' http://localhost:4000/api/me
  1. O guia sugere testar o plug em test/hello_web/user_auth_test.exs, com os testes dos outros plugs como modelo. Num teste de controller, o token vai no cabeçalho com put_req_header(conn, "authorization", "Bearer " <> token).

Revisão

  • Um cliente de API não tem cookie de sessão; ele prova quem é mandando um token no cabeçalho authorization, no formato Bearer TOKEN, a cada requisição.
  • Os tokens de API moram na tabela users_tokens, com o contexto "api-token", e reaproveitam o que ela já faz: são ligados ao usuário, têm data de criação e são apagados quando a senha muda.
  • create_user_api_token/1 usa build_email_token/2: o cliente recebe o token em Base64 uma única vez, e o banco guarda só o hash SHA-256. Quem lê o banco não consegue usar os tokens.
  • verify_api_token_query/1 calcula o hash do token recebido e só o aceita se tiver menos de 365 dias e se o sent_to ainda for o e-mail do usuário. Trocar o e-mail ou a senha invalida os tokens.
  • fetch_* devolve {:ok, valor} ou :error; get_* devolve o valor ou nil.
  • fetch_current_scope_for_api_user/2 põe o mesmo current_scope que o login do navegador põe, então controllers e contextos não mudam. Quando o token falha, o plug responde 401 e chama halt/1; sem o halt, o resto do pipeline rodaria mesmo assim.
  • Rotas que entregam tokens precisam ficar fora do pipeline que exige token. Dois pipelines, :api e :api_auth, resolvem.
  • Um token é uma senha: só por HTTPS, guardado com cuidado, com um jeito de revogá-lo.

Este notebook fecha o módulo 8. O 13 gerou a autenticação e mostrou o que ela faz com a sessão, o link mágico, a senha e o modo sudo. O 14 mostrou como o escopo leva o usuário até as consultas, para que cada um veja só os próprios dados. Este estendeu a mesma autenticação para clientes que não são navegadores. Nos três, o código é seu: o gerador escreve uma vez, e daí em diante cada mudança, como as deste notebook, é feita à mão.