Powered by AppSignal & Oban Pro

Trilha Phoenix · mix phx.gen.auth

13_phx_gen_auth.livemd

Trilha Phoenix · mix phx.gen.auth

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    :ok
  end

  @doc "Esquece os cookies de um visitante só."
  def esquecer_visitante(quem) do
    :persistent_term.erase({__MODULE__, :cookies, quem})
    :persistent_term.erase({__MODULE__, :persistentes, quem})
    :ok
  end

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

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

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

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

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

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

    # Guarda quais cookies têm validade (max_age); os outros somem quando o navegador fecha.
    persistentes =
      Enum.reduce(resp.resp_cookies, persistentes(quem), fn {nome, opcoes}, acc ->
        if (opcoes[:max_age] || 0) > 0, do: MapSet.put(acc, nome), else: MapSet.delete(acc, nome)
      end)

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

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

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

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

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

        _ ->
          ""
      end

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

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

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

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

  # --- autenticação -----------------------------------------------------------------

  @doc """
  Faz `quem` entrar como `email` pelo caminho de verdade: cria a conta, se ela
  não existir, pede o link mágico (o e-mail vai para a caixa de entrada) e envia
  o formulário de confirmação com o token do link, como o botão da página faria.
  Com `lembrar: true`, marca a opção "stay logged in".
  """
  def entrar(quem, email, opcoes \\ []) do
    navegar(quem, :post, "/users/log-in", parametros_de_login(email, opcoes))
  end

  defp logar(quem, email, opcoes \\ []) do
    visitar(quem, :post, "/users/log-in", parametros_de_login(email, opcoes))
  end

  defp parametros_de_login(email, opcoes) do
    usuario =
      apply(Hello.Accounts, :get_user_by_email, [email]) ||
        case apply(Hello.Accounts, :register_user, [%{email: email}]) do
          {:ok, usuario} -> usuario
          {:error, changeset} -> raise "não deu para criar a conta #{email}: #{inspect(changeset.errors)}"
        end

    {:ok, _email} =
      apply(Hello.Accounts, :deliver_login_instructions, [usuario, &"http://localhost:4010/users/log-in/#{&1}"])

    token = email |> link_do_email() |> String.split("/") |> List.last()
    usuario = %{"token" => token}
    usuario = if opcoes[:lembrar], do: Map.put(usuario, "remember_me", "true"), else: usuario
    %{"user" => usuario}
  end

  @doc "Os cookies que o navegador de `quem` guarda agora."
  def mostrar_cookies(quem) do
    persistentes = persistentes(quem)

    linhas =
      for {nome, valor} <- cookies(quem) do
        validade = if nome in persistentes, do: "tem `max_age`: sobrevive ao fechar o navegador", else: "sem validade: some ao fechar o navegador"
        "| `#{nome}` | #{validade} | #{byte_size(valor)} bytes |"
      end

    if linhas == [] do
      Kino.Markdown.new("O navegador de #{quem} não tem nenhum cookie.")
    else
      Kino.Markdown.new(Enum.join(["**Cookies de #{quem}**", "", "| Cookie | Validade | Tamanho |", "| --- | --- | --- |" | linhas], "\n"))
    end
  end

  @doc "Simula fechar o navegador: os cookies sem validade (como o da sessão) somem; os que têm max_age ficam."
  def fechar_navegador(quem) do
    persistentes = persistentes(quem)
    restantes = cookies(quem) |> Enum.filter(fn {nome, _} -> nome in persistentes end) |> Map.new()
    :persistent_term.put({__MODULE__, :cookies, quem}, restantes)
    mostrar_cookies(quem)
  end

  defp persistentes(quem), do: :persistent_term.get({__MODULE__, :persistentes, quem}, MapSet.new())

  @doc "Move para trás, em `minutos`, a hora da última autenticação de todas as sessões de `email`."
  def envelhecer_sessoes(email, minutos) do
    sql(
      """
      UPDATE users_tokens SET authenticated_at = authenticated_at - make_interval(mins => $2)
      WHERE context = 'session' AND user_id = (SELECT id FROM users WHERE email = $1)
      """,
      [email, minutos]
    )

    consultar(
      """
      SELECT t.id, u.email, t.context, t.authenticated_at, t.inserted_at
      FROM users_tokens t JOIN users u ON u.id = t.user_id
      WHERE u.email = $1 AND t.context = 'session' ORDER BY t.id
      """,
      [email]
    )
  end

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

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

    quadro = Kino.Frame.new()

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

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

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

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

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

  defp perguntas do
    %{
      authn_authz: %{
        texto:
          "Bia entra com o e-mail e a senha certos e tenta abrir `/admin`, uma página só para administradores. A aplicação responde `403 Forbidden`. Qual das duas etapas recusou o pedido?",
        opcoes: [
          a: "A autenticação: a aplicação não sabe quem é a Bia.",
          b: "A autorização: a aplicação sabe quem é a Bia, e decidiu que ela não pode ver aquela página.",
          c: "As duas: sem autorização não há autenticação.",
          d: "Nenhuma: 403 é um erro do servidor."
        ],
        correta: :b,
        explicacao:
          "Autenticação responde \"quem é você?\"; autorização responde \"o que você pode fazer?\". A Bia provou quem é (a autenticação deu certo) e recebeu um não para aquele recurso. Por convenção, 401 indica que falta autenticação e 403 que a autorização foi negada."
      },
      liveview_ou_nao: %{
        texto:
          "O `phx.gen.auth` pergunta se você quer o sistema com LiveView ou só com controllers. O que muda entre as duas respostas?",
        opcoes: [
          a: "Tudo: as tabelas, o contexto e as rotas são diferentes.",
          b: "Só a camada web: as páginas são LiveViews ou controllers com templates. O contexto `Accounts`, os schemas, as tabelas e os caminhos das rotas são os mesmos.",
          c: "A versão com controllers não tem link mágico.",
          d: "A versão com LiveView não precisa de banco de dados."
        ],
        correta: :b,
        explicacao:
          "O próprio guia diz: as duas abordagens criam o mesmo contexto e os mesmos schemas, com as mesmas tabelas e os mesmos caminhos de rota. A trilha usa a versão com controllers (`--no-live`) porque o LiveView vem mais adiante; tudo o que este notebook mostra sobre `Accounts`, tokens e segurança vale para as duas."
      },
      token_hash: %{
        texto:
          "O e-mail de confirmação traz um link com um token, como `/users/log-in/QNqk3SOw...`. O que fica gravado na coluna `users_tokens.token` para esse link?",
        opcoes: [
          a: "O mesmo texto do link, para comparar quando a pessoa clicar.",
          b: "O hash SHA-256 dos bytes do token. Quem lê o banco não consegue montar o link a partir dele.",
          c: "Nada: o token só existe no e-mail.",
          d: "O token cifrado com a `secret_key_base`."
        ],
        correta: :b,
        explicacao:
          "`build_email_token/2` sorteia 32 bytes, manda ao usuário a versão em Base64 e grava só `:crypto.hash(:sha256, token)`. Ao clicar, a aplicação calcula o hash do que chegou e procura. Um vazamento de leitura do banco não entrega links válidos. Os tokens de sessão são diferentes: ficam guardados como vieram, porque só viajam dentro do cookie assinado."
      },
      responsabilidade: %{
        texto:
          "Sai uma versão nova do Phoenix com uma melhoria de segurança no código que o `phx.gen.auth` gera. Você atualiza a dependência `phoenix` no `mix.exs`. O que acontece com o `UserAuth` e o `Accounts` do seu projeto?",
        opcoes: [
          a: "São atualizados junto com a dependência.",
          b: "O `mix deps.get` pergunta se você quer aplicar a melhoria.",
          c: "Nada: o código gerado é seu. Cabe a você ler o CHANGELOG e trazer a mudança para o seu projeto.",
          d: "Param de compilar até você rodar o gerador de novo."
        ],
        correta: :c,
        explicacao:
          "O gerador copia o código para dentro da aplicação, em vez de deixá-lo numa biblioteca. A vantagem é a liberdade de mudar o que quiser; o preço é que nada se atualiza sozinho. O guia promete marcar com clareza, no `CHANGELOG.md` e nas notas de atualização, as mudanças de segurança."
      },
      sudo: %{
        texto:
          "Ana entrou pelo link mágico há 15 minutos, não fechou o navegador e agora abre `/users/settings`. O que acontece?",
        opcoes: [
          a: "A página abre normalmente: ela continua logada.",
          b: "Ela é mandada para `/users/log-in` com \"You must re-authenticate to access this page.\": as configurações exigem uma autenticação dos últimos 10 minutos.",
          c: "Ela é deslogada e perde a sessão.",
          d: "A página abre, mas sem o formulário de senha."
        ],
        correta: :b,
        explicacao:
          "O `UserSettingsController` tem `plug :require_sudo_mode`, que chama `Accounts.sudo_mode?(user, -10)`: a última autenticação (`authenticated_at`, guardado com o token da sessão) precisa ter sido nos últimos 10 minutos. Ana continua logada para o resto do site; só as ações sensíveis pedem que ela prove de novo que é ela."
      },
      troca_de_senha: %{
        texto:
          "Ana está logada no celular e no notebook. No notebook, ela troca a senha. O que acontece no celular?",
        opcoes: [
          a: "Nada: cada aparelho tem a sua sessão.",
          b: "O celular continua logado até o cookie expirar, em 14 dias.",
          c: "A sessão do celular deixa de valer: trocar a senha apaga todos os tokens da Ana, e na próxima requisição o celular é tratado como visitante.",
          d: "O celular recebe um e-mail pedindo a senha nova."
        ],
        correta: :c,
        explicacao:
          "`update_user_password/2` passa por `update_user_and_delete_all_tokens/1`, que apaga todos os tokens do usuário, inclusive os de sessão. O cookie do celular ainda traz um token, mas ele não existe mais em `users_tokens`. O notebook continua logado porque o controller chama `UserAuth.log_in_user/2` logo depois e cria uma sessão nova."
      },
      enumeracao: %{
        texto:
          "Alguém quer descobrir se `bia@example.com` tem conta. Qual destas telas, do jeito que foram geradas, entrega a resposta?",
        opcoes: [
          a: "O pedido de link mágico em \"Log in with email\".",
          b: "O login com e-mail e senha.",
          c: "O cadastro em `/users/register`.",
          d: "Nenhuma: o gerador protege todas."
        ],
        correta: :c,
        explicacao:
          "O pedido de link mágico responde sempre \"If your email is in our system...\", e o login com senha responde sempre \"Invalid email or password\". O cadastro, porém, mostra \"has already been taken\" quando o e-mail existe. O guia avisa: o código gerado não tenta se proteger de enumeração, porque a proteção muda muito de uma aplicação para outra."
      },
      pre_cadastro: %{
        texto:
          "Antes que a Gabi crie a conta dela, um atacante se cadastra com `gabi@example.com`. O que o atacante consegue fazer com essa conta?",
        opcoes: [
          a: "Entrar e definir uma senha, para usar depois que a Bia confirmar.",
          b: "Nada: entrar exige o link enviado para o e-mail da Gabi, e só quem entrou pode definir uma senha.",
          c: "Trocar o e-mail da conta para o dele.",
          d: "Entrar sem confirmação, porque a conta é nova."
        ],
        correta: :b,
        explicacao:
          "No 1.8, o cadastro só pede o e-mail. A única porta de entrada de uma conta não confirmada é o link mágico, que vai para a caixa da Gabi; e a senha só se define nas configurações, depois de entrar. Quando a Gabi clicar no link, `login_user_by_magic_link/1` confirma a conta e apaga todos os tokens que existiam."
      },
      citext: %{
        texto:
          "Ana se cadastrou como `ana@example.com` e definiu uma senha. Ela tenta entrar digitando `ANA@Example.COM` e a senha certa. O que acontece?",
        opcoes: [
          a: "Dá \"Invalid email or password\": o e-mail é outro.",
          b: "Ela entra: a coluna `email` é `citext`, e a comparação ignora maiúsculas e minúsculas.",
          c: "Ela entra, porque o controller converte o e-mail para minúsculas antes de buscar.",
          d: "Dá erro de banco de dados."
        ],
        correta: :b,
        explicacao:
          "Nenhuma linha do código Elixir converte o e-mail. Quem resolve é o tipo da coluna: `citext` (case-insensitive text) compara textos ignorando a caixa, e o índice único também. Por isso a migration começa com `CREATE EXTENSION IF NOT EXISTS citext`."
      }
    }
  end

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

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

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

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

  defp solucoes do
    %{
      senha: ~S'''
      # Na célula do Hello.Accounts.User, em validate_password/2:
      defp validate_password(changeset, opts) do
        changeset
        |> validate_required([:password])
        |> validate_length(:password, min: 12, max: 72)
        # Examples of additional password validation:
        # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character")
        # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character")
        |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character")
        |> maybe_hash_password(opts)
      end
      ''',
      painel: ~S'''
      # No router, no scope que passa por :require_authenticated_user:
      scope "/", HelloWeb do
        pipe_through [:browser, :require_authenticated_user]

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

        get "/painel", PainelController, :index
      end

      # No HelloWeb.PainelHTML:
      def index(assigns) do
        ~H"""
        <Layouts.app flash={@flash} current_scope={@current_scope}>
          <.header>Painel</.header>
          <p>Olá, {@current_scope.user.email}!</p>
        </Layouts.app>
        """
      end
      ''',
      seguranca: ~S'''
      # No router, um scope novo. A ordem importa: require_sudo_mode lê
      # current_scope.user, então require_authenticated_user vem antes.
      scope "/", HelloWeb do
        pipe_through [:browser, :require_authenticated_user, :require_sudo_mode]

        get "/painel/seguranca", PainelController, :seguranca
      end
      ''',
      email_pt: ~S'''
      # Na célula do Hello.Accounts.UserNotifier:
      defp deliver_confirmation_instructions(user, url) do
        deliver(user.email, "Confirme sua conta", """

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

        Olá, #{user.email}!

        Para confirmar a sua conta, acesse o endereço abaixo:

        #{url}

        Se você não criou uma conta, ignore este e-mail.

        ==============================
        """)
      end
      ''',
      brecho: ~S'''
      # Na célula do Hello.Accounts, no fim do módulo:
      ## Sessions

      @doc "Lists the user's active sessions, newest first."
      def list_user_sessions(%User{} = user) do
        Repo.all(
          from t in UserToken,
            where: t.user_id == ^user.id and t.context == "session",
            order_by: [desc: t.inserted_at, desc: t.id]
        )
      end

      @doc "Deletes one of the user's sessions."
      def delete_user_session(%User{} = user, id) do
        query = from t in UserToken, where: t.id == ^id and t.user_id == ^user.id and t.context == "session"

        case Repo.delete_all(query) do
          {1, _} -> :ok
          {0, _} -> {:error, :not_found}
        end
      end

      # No router, no scope com :require_authenticated_user:
      get "/users/sessions", UserSessionsController, :index
      delete "/users/sessions/:id", UserSessionsController, :delete

      # O controller e a view:
      defmodule HelloWeb.UserSessionsController do
        use HelloWeb, :controller

        alias Hello.Accounts

        def index(conn, _params) do
          sessions = Accounts.list_user_sessions(conn.assigns.current_scope.user)
          render(conn, :index, sessions: sessions, current_token: get_session(conn, :user_token))
        end

        def delete(conn, %{"id" => id}) do
          case Accounts.delete_user_session(conn.assigns.current_scope.user, id) do
            :ok ->
              conn |> put_flash(:info, "Sessão encerrada.") |> redirect(to: ~p"/users/sessions")

            {:error, :not_found} ->
              conn |> put_flash(:error, "Sessão não encontrada.") |> redirect(to: ~p"/users/sessions")
          end
        end
      end

      defmodule HelloWeb.UserSessionsHTML do
        use HelloWeb, :html

        def index(assigns) do
          ~H"""
          <Layouts.app flash={@flash} current_scope={@current_scope}>
            <.header>
              Suas sessões
              <:subtitle>Cada linha é um navegador onde você entrou.</:subtitle>
            </.header>

            <.table id="sessions" rows={@sessions}>
              <:col :let={session} label="Entrou em">{session.inserted_at}</:col>
              <:col :let={session} label="Última autenticação">{session.authenticated_at}</:col>
              <:col :let={session} label="">
                <span :if={session.token == @current_token}>Esta sessão</span>
              </:col>
              <:action :let={session}>
                <.link
                  :if={session.token != @current_token}
                  href={~p"/users/sessions/#{session.id}"}
                  method="delete"
                >
                  Encerrar
                </.link>
              </:action>
            </.table>
          </Layouts.app>
          """
        end
      end
      '''
    }
  end

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

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

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

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

  defp verificacoes do
    %{
      senha: [
        {"Uma senha só de letras recebe o erro \"at least one digit or punctuation character\".",
         fn ->
           case erros_de_senha("senhasemnenhumnumero") do
             [] ->
               {:erro, "A senha `senhasemnenhumnumero` foi aceita. Tire o `#` da linha do `validate_format` que pede um dígito ou pontuação e avalie a célula do `User`."}

             mensagens ->
               if Enum.any?(mensagens, &(&1 =~ "digit or punctuation")),
                 do: :ok,
                 else: {:erro, "Os erros foram #{inspect(mensagens)}. Use a mensagem do código gerado: `at least one digit or punctuation character`."}
           end
         end},
        {"Uma senha com número, como `senha com 1 número`, continua válida.",
         fn ->
           case erros_de_senha("senha com 1 número") do
             [] -> :ok
             mensagens -> {:erro, "A senha foi recusada com #{inspect(mensagens)}. A regra nova só deve exigir um dígito ou uma pontuação."}
           end
         end},
        {"As regras que já existiam continuam: `curta1` é curta demais.",
         fn ->
           if Enum.any?(erros_de_senha("curta1"), &(&1 =~ "at least 12")),
             do: :ok,
             else: {:erro, "`curta1` deveria receber `should be at least 12 character(s)`. O `validate_length` continua no `validate_password/2`?"}
         end}
      ],
      painel: [
        {"Um visitante sem login, em `GET /painel`, é mandado para `/users/log-in`.",
         fn ->
           esquecer_visitante("conferência anônima")

           case sem_seguir("conferência anônima", :get, "/painel") do
             {:ok, %{status: 302} = conn} ->
               if Plug.Conn.get_resp_header(conn, "location") == ["/users/log-in"],
                 do: :ok,
                 else: {:erro, "O redirect foi para #{inspect(Plug.Conn.get_resp_header(conn, "location"))}."}

             {:ok, %{status: 200}} ->
               {:erro, "A página abriu sem login. A rota está num `scope` que não passa por `:require_authenticated_user`."}

             {:ok, conn} ->
               {:erro, "A resposta veio com status `#{conn.status}`."}

             {:erro, erro} ->
               {:erro, explicar(erro)}
           end
         end},
        {"Quem entrou vê o próprio e-mail na página.",
         fn ->
           {:ok, _} = logar("conferência painel", "painel@example.com")

           case visitar("conferência painel", :get, "/painel") do
             {:ok, %{status: 200, resp_body: corpo}} ->
               if to_string(corpo) =~ "painel@example.com",
                 do: :ok,
                 else: {:erro, "A página abriu, mas não mostra `painel@example.com`. No template, o e-mail está em `@current_scope.user.email`."}

             {:ok, conn} ->
               {:erro, "A resposta final veio com status `#{conn.status}` em `#{conn.request_path}`."}

             {:erro, erro} ->
               {:erro, explicar(erro)}
           end
         end},
        {"Quem tenta abrir `/painel` sem login volta para `/painel` depois de entrar.",
         fn ->
           esquecer_visitante("conferência volta")
           sem_seguir("conferência volta", :get, "/painel")

           case logar("conferência volta", "painel@example.com") do
             {:ok, %{request_path: "/painel", status: 200}} -> :ok
             {:ok, conn} -> {:erro, "Depois do login, a navegação terminou em `#{conn.request_path}` (status #{conn.status})."}
             {:erro, erro} -> {:erro, explicar(erro)}
           end
         end}
      ],
      seguranca: [
        {"Logo depois de entrar, `GET /painel/seguranca` responde 200.",
         fn ->
           {:ok, _} = logar("conferência sudo", "sudo@example.com")

           case sem_seguir("conferência sudo", :get, "/painel/seguranca") do
             {:ok, %{status: 200}} -> :ok
             {:ok, %{status: 302} = conn} -> {:erro, "Foi redirecionado para #{inspect(Plug.Conn.get_resp_header(conn, "location"))} logo depois de entrar."}
             {:ok, conn} -> {:erro, "A resposta veio com status `#{conn.status}`."}
             {:erro, erro} -> {:erro, explicar(erro)}
           end
         end},
        {"Com a última autenticação de 11 minutos atrás, a página manda reautenticar.",
         fn ->
           sql(
             """
             UPDATE users_tokens SET authenticated_at = authenticated_at - interval '11 minutes'
             WHERE context = 'session' AND user_id = (SELECT id FROM users WHERE email = $1)
             """,
             ["sudo@example.com"]
           )

           case visitar("conferência sudo", :get, "/painel/seguranca") do
             {:ok, %{request_path: "/painel/seguranca", status: 200}} ->
               {:erro, "A página abriu com uma autenticação de 11 minutos atrás. Falta `:require_sudo_mode` na lista do `pipe_through`."}

             {:ok, %{request_path: "/users/log-in"} = conn} ->
               if conn.assigns.flash["error"] == "You must re-authenticate to access this page.",
                 do: :ok,
                 else: {:erro, "Foi parar no login com a flash #{inspect(conn.assigns.flash)}; quem manda reautenticar é o `:require_sudo_mode`."}

             {:ok, conn} ->
               {:erro, "A navegação terminou em `#{conn.request_path}`, com status `#{conn.status}`."}

             {:erro, erro} ->
               {:erro, explicar(erro)}
           end
         end},
        {"Um visitante sem login é mandado para o login com \"You must log in to access this page.\"",
         fn ->
           esquecer_visitante("conferência anônima")

           case visitar("conferência anônima", :get, "/painel/seguranca") do
             {:ok, %{request_path: "/users/log-in"} = conn} ->
               if conn.assigns.flash["error"] == "You must log in to access this page.",
                 do: :ok,
                 else: {:erro, "A flash foi #{inspect(conn.assigns.flash)}. O `:require_authenticated_user` vem antes do `:require_sudo_mode`?"}

             {:ok, conn} ->
               {:erro, "A navegação terminou em `#{conn.request_path}`, com status `#{conn.status}`."}

             {:erro, erro} ->
               {:erro,
                explicar(erro) <>
                  "\n\n  O `require_sudo_mode/2` lê `current_scope.user`; sem ninguém logado, o `current_scope` é `nil`. Ponha `:require_authenticated_user` antes dele."}
           end
         end}
      ],
      email_pt: [
        {"O cadastro manda o e-mail de confirmação em português, com o link.",
         fn ->
           email = "conferencia.pt@example.com"
           apagar_usuario(email)
           esquecer_visitante("conferência e-mail")

           try do
             case visitar("conferência e-mail", :post, "/users/register", %{"user" => %{"email" => email}}) do
               {:ok, _} ->
                 case email_para(email) do
                   nil ->
                     {:erro, "Nenhum e-mail chegou para `#{email}`."}

                   %{subject: "Confirmation instructions"} ->
                     {:erro, "O assunto ainda é `Confirmation instructions`. Traduza o assunto e o texto em `deliver_confirmation_instructions/2` e avalie a célula do `UserNotifier`."}

                   %{text_body: corpo} = enviado ->
                     cond do
                       not (corpo =~ "/users/log-in/") -> {:erro, "O texto não traz o link. Mantenha `\#{url}` no corpo do e-mail."}
                       corpo =~ "If you didn't create an account" -> {:erro, "O assunto mudou (`#{enviado.subject}`), mas o texto ainda está em inglês."}
                       true -> :ok
                     end
                 end

               {:erro, erro} ->
                 {:erro, explicar(erro)}
             end
           after
             apagar_usuario(email)
           end
         end},
        {"O e-mail de login de quem já confirmou a conta continua o mesmo.",
         fn ->
           email = "conferencia.pt@example.com"
           apagar_usuario(email)

           try do
             {:ok, usuario} = apply(Hello.Accounts, :register_user, [%{email: email}])
             usuario = %{usuario | confirmed_at: DateTime.utc_now(:second)}
             {:ok, _} = apply(Hello.Accounts, :deliver_login_instructions, [usuario, &"http://localhost:4010/users/log-in/#{&1}"])

             case email_para(email) do
               %{subject: "Log in instructions"} -> :ok
               outro -> {:erro, "Para uma conta confirmada, o assunto deveria continuar `Log in instructions`; veio `#{outro && outro.subject}`."}
             end
           after
             apagar_usuario(email)
           end
         end}
      ],
      brecho: [
        {"`Hello.Accounts` define `list_user_sessions/1` e `delete_user_session/2`.",
         fn -> exporta(Hello.Accounts, list_user_sessions: 1, delete_user_session: 2) end},
        {"Duas entradas da mesma conta, em dois navegadores, aparecem como duas sessões.",
         fn ->
           preparar_sessoes()
           usuario = apply(Hello.Accounts, :get_user_by_email, ["dona@brecho.example"])

           case apply(Hello.Accounts, :list_user_sessions, [usuario]) do
             [_, _] = sessoes ->
               if Enum.all?(sessoes, &(&1.context == "session")),
                 do: :ok,
                 else: {:erro, "A lista traz tokens que não são de sessão: #{inspect(Enum.map(sessoes, & &1.context))}. Filtre por `context == \"session\"`."}

             outra ->
               {:erro, "Esperadas 2 sessões, vieram #{length(List.wrap(outra))}. Filtre os tokens do usuário com `context == \"session\"`."}
           end
         end},
        {"`GET /users/sessions` lista as sessões e marca \"Esta sessão\" uma vez só.",
         fn ->
           preparar_sessoes()

           case sem_seguir("brechó celular", :get, "/users/sessions") do
             {:ok, %{status: 200, resp_body: corpo}} ->
               case length(String.split(to_string(corpo), "Esta sessão")) - 1 do
                 1 -> :ok
                 0 -> {:erro, "A página não marca a sessão atual com o texto `Esta sessão`. Compare `session.token` com `get_session(conn, :user_token)`."}
                 n -> {:erro, "`Esta sessão` aparece #{n} vezes; deveria marcar só a sessão de quem está vendo a página."}
               end

             {:ok, conn} ->
               {:erro, "A resposta veio com status `#{conn.status}`."}

             {:erro, erro} ->
               {:erro, explicar(erro)}
           end
         end},
        {"Encerrar a sessão do notebook pelo celular desloga o notebook.",
         fn ->
           preparar_sessoes()
           [_celular, notebook] = sessoes_de("dona@brecho.example")

           with {:ok, _} <- visitar("brechó celular", :delete, "/users/sessions/#{notebook}"),
                {:ok, conn} <- sem_seguir("brechó notebook", :get, "/users/sessions") do
             cond do
               coluna("SELECT id FROM users_tokens WHERE id = $1", [notebook]) != [] ->
                 {:erro, "O token #{notebook} continua em `users_tokens`. `delete_user_session/2` precisa apagá-lo."}

               conn.status == 302 and Plug.Conn.get_resp_header(conn, "location") == ["/users/log-in"] ->
                 :ok

               true ->
                 {:erro, "O token sumiu, mas o notebook recebeu status `#{conn.status}` em vez do redirect para o login."}
             end
           else
             {:erro, erro} -> {:erro, explicar(erro)}
           end
         end},
        {"Outra pessoa não consegue encerrar uma sessão que não é dela.",
         fn ->
           preparar_sessoes()
           [celular, _notebook] = sessoes_de("dona@brecho.example")
           {:ok, _} = logar("brechó intrusa", "intrusa@brecho.example")

           case visitar("brechó intrusa", :delete, "/users/sessions/#{celular}") do
             {:erro, %Ecto.NoResultsError{}} ->
               if coluna("SELECT id FROM users_tokens WHERE id = $1", [celular]) == [celular], do: :ok, else: {:erro, "A sessão de outra pessoa foi apagada."}

             _ ->
               if coluna("SELECT id FROM users_tokens WHERE id = $1", [celular]) == [celular],
                 do: :ok,
                 else: {:erro, "A intrusa apagou a sessão da dona. Em `delete_user_session/2`, filtre também por `user_id == ^user.id`."}
           end
         end},
        {"Um visitante sem login, em `/users/sessions`, é mandado para o login.",
         fn ->
           esquecer_visitante("conferência anônima")

           case sem_seguir("conferência anônima", :get, "/users/sessions") do
             {:ok, %{status: 302} = conn} ->
               if Plug.Conn.get_resp_header(conn, "location") == ["/users/log-in"], do: :ok, else: {:erro, "O redirect foi para outro lugar."}

             {:ok, conn} ->
               {:erro, "A resposta veio com status `#{conn.status}`. As rotas ficam no scope com `:require_authenticated_user`."}

             {:erro, erro} ->
               {:erro, explicar(erro)}
           end
         end}
      ]
    }
  end

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

  defp erros_de_senha(senha) do
    changeset =
      apply(Hello.Accounts.User, :password_changeset, [
        struct(Hello.Accounts.User),
        %{"password" => senha},
        [hash_password: false]
      ])

    for {:password, {mensagem, opcoes}} <- changeset.errors do
      Enum.reduce(opcoes, mensagem, fn {chave, valor}, acc -> String.replace(acc, "%{#{chave}}", fn _ -> to_string(valor) end) end)
    end
  end

  # Uma requisição com os cookies de `quem`, sem seguir o redirect.
  defp sem_seguir(quem, metodo, caminho, parametros \\ nil) do
    conn = Enum.reduce(cookies(quem), Phoenix.ConnTest.build_conn(), fn {nome, valor}, c -> Plug.Test.put_req_cookie(c, nome, valor) end)
    resultado = requisicao(metodo, caminho, parametros, conn)

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

    resultado
  end

  defp apagar_usuario(email), do: sql("DELETE FROM users WHERE email = $1", [email])

  defp preparar_sessoes do
    apagar_usuario("dona@brecho.example")
    apagar_usuario("intrusa@brecho.example")
    Enum.each(["brechó celular", "brechó notebook", "brechó intrusa"], &esquecer_visitante/1)
    {:ok, _} = logar("brechó celular", "dona@brecho.example")
    {:ok, _} = logar("brechó notebook", "dona@brecho.example")
    :ok
  end

  defp sessoes_de(email) do
    coluna(
      """
      SELECT t.id FROM users_tokens t JOIN users u ON u.id = t.user_id
      WHERE u.email = $1 AND t.context = 'session' ORDER BY t.id
      """,
      [email]
    )
  end

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Como usar este notebook

Este notebook abre o módulo de autenticação da trilha. Ele acompanha dois guias da documentação do Phoenix 1.8, na mesma ordem e com o link de cada seção: o curto Introduction to Auth e o mix phx.gen.auth. Os notebooks 14 (escopos) e 15 (autenticação de APIs) completam o módulo.

Nos notebooks 10 e 11, a autenticação era uma versão reduzida, que só pedia o e-mail. Aqui entra o sistema que o mix phx.gen.auth gera de verdade, sem cortes: cadastro com confirmação por e-mail, login por link mágico, senha opcional, "modo sudo" e o rastreamento de sessões no banco. O código das células é o que o gerador do Phoenix 1.8.16 escreveu num projeto real.

Três novidades em relação aos notebooks anteriores:

  • A instalação mudou. A lista de dependências ganhou o swoosh (envio de e-mails) e o pbkdf2_elixir (hash de senhas). A primeira avaliação da célula do topo faz uma instalação nova, que leva alguns minutos; os notebooks 14 e 15 usam a mesma lista e reaproveitam a instalação.
  • E-mails de mentira. Em desenvolvimento, o Swoosh guarda cada e-mail na memória em vez de enviá-lo. Você os lê em http://localhost:4010/dev/mailbox, como num projeto gerado, ou com Trilha.caixa_de_entrada() e Trilha.ultimo_email/1.
  • A extensão citext do PostgreSQL. A migration gerada precisa dela. No Fedora, ela vem no pacote postgresql-contrib, que não é instalado junto com o servidor. A seção "Começando" verifica isso e mostra o comando.

As convenções são as de sempre: uma célula equivale a um arquivo, as perguntas "Antes de continuar" vêm antes da explicação, e os erros fazem parte do conteúdo. Como nos notebooks 08 a 12, Trilha.navegar(quem, metodo, caminho, parametros) faz requisições como um navegador: cada nome ("ana", "bia") tem os seus próprios cookies, e os redirects são seguidos. Neste notebook isso importa mais do que nunca, porque o login mora nos cookies.

A aplicação fica em http://localhost:4010. Se outro notebook da trilha estiver com o runtime ligado, desconecte-o antes.

A aplicação Hello dentro do notebook

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

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

lib/hello/repo.ex

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

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

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

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

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

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

      import Plug.Conn

      unquote(verified_routes())
    end
  end

  def html do
    quote do
      use Phoenix.Component

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

      unquote(html_helpers())
    end
  end

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

      alias Phoenix.LiveView.JS
      alias HelloWeb.Layouts

      unquote(verified_routes())
    end
  end

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

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

lib/hello_web/router.ex

defmodule HelloWeb.Router do
  use HelloWeb, :router

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

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

  scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :home
  end
end

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

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

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

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

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

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

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

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

defmodule HelloWeb.CoreComponents do
  use Phoenix.Component

  alias Phoenix.LiveView.JS

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  ## JS Commands

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

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

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

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

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

defmodule HelloWeb.Layouts do
  use HelloWeb, :html

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

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

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

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

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

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

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

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

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

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

defmodule HelloWeb.PageController do
  use HelloWeb, :controller

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

defmodule HelloWeb.PageHTML do
  use HelloWeb, :html

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

Antes de começar: o PostgreSQL

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

Trilha.verificar_postgres()

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

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

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

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

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

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

Trilha.recomecar()

Introdução à autenticação

Na documentação: Introduction to Auth

Autenticação (authentication, abreviada como authn) e autorização (authorization, authz) são dois conceitos centrais de segurança. Autenticação é verificar a identidade de um usuário ou de um sistema. Autorização é conceder ou negar acesso a recursos com base nessa identidade e nas permissões dela.

Trilha.pergunta(:authn_authz)

O Phoenix ajuda com as duas. Em geral, quem desenvolve usa o gerador mix phx.gen.auth para montar a autenticação e a base da autorização. Bibliotecas de terceiros, como o Ueberauth (login com Google, GitHub e outros provedores), podem complementar esse sistema ou substituí-lo.

A documentação tem três guias sobre o assunto, e cada um vira um notebook da trilha:

  • mix phx.gen.auth, este notebook: o gerador e as considerações de segurança;
  • Scopes, o notebook 14: o mecanismo que o Phoenix 1.8 introduziu para limitar o acesso aos dados de acordo com quem está pedindo;
  • API Authentication, o notebook 15: autenticação por token para APIs, construída sobre o código do gerador.

O gerador mix phx.gen.auth

Na documentação: mix phx.gen.auth

O mix phx.gen.auth gera, dentro da sua aplicação, um sistema de autenticação flexível e pronto. Assim você não perde tempo com a parte que toda aplicação tem e se concentra no problema que a sua aplicação resolve. O sistema gerado tem:

  • cadastro de usuário com confirmação da conta por e-mail;
  • login por link mágico (magic link), um link de uso único enviado por e-mail;
  • login por senha, opcional: cada pessoa decide se quer definir uma;
  • "modo sudo", ou autenticação privilegiada: antes de uma ação sensível, a pessoa precisa confirmar de novo quem é.

Começando

Na documentação: Getting started

O guia recomenda fazer um commit antes, porque o comando cria muitos arquivos. Na raiz do projeto:

$ mix phx.gen.auth Accounts User users

An authentication system can be created in two different ways:
- Using Phoenix.LiveView (default)
- Using Phoenix.Controller only

Do you want to create a LiveView based authentication system? [Y/n]

O primeiro argumento é o módulo do contexto; os outros dois são o módulo do schema e o nome no plural, que vira o nome da tabela. O comando acima gera um contexto Accounts com dois schemas dentro, User e UserToken. O contexto agrupa tudo o que diz respeito à autenticação; os nomes ficam a seu critério.

A pergunta é sobre a camada web.

Trilha.pergunta(:liveview_ou_nao)

O guia recomenda responder Y, pela experiência mais fluida do LiveView. A trilha responde n, porque o LiveView só chega num módulo adiante. Responder n equivale a passar --no-live. A trilha também passa --hashing-lib pbkdf2, assunto da seção "Hash de senhas" mais abaixo. O comando que gerou o código deste notebook, num projeto Phoenix 1.8.16 novo, foi:

$ mix phx.gen.auth Accounts User users --no-live --hashing-lib pbkdf2
* creating priv/repo/migrations/20261111020432_create_users_auth_tables.exs
* creating lib/hello/accounts/user_notifier.ex
* creating lib/hello/accounts/user.ex
* creating lib/hello/accounts/user_token.ex
* creating lib/hello_web/user_auth.ex
* creating test/hello_web/user_auth_test.exs
* creating lib/hello_web/controllers/user_session_controller.ex
* creating test/hello_web/controllers/user_session_controller_test.exs
* creating lib/hello/accounts/scope.ex
* creating lib/hello_web/controllers/user_registration_html/new.html.heex
* creating lib/hello_web/controllers/user_registration_controller.ex
* creating test/hello_web/controllers/user_registration_controller_test.exs
* creating lib/hello_web/controllers/user_registration_html.ex
* creating lib/hello_web/controllers/user_session_html.ex
* creating lib/hello_web/controllers/user_session_html/new.html.heex
* creating lib/hello_web/controllers/user_session_html/confirm.html.heex
* creating lib/hello_web/controllers/user_settings_html.ex
* creating lib/hello_web/controllers/user_settings_controller.ex
* creating lib/hello_web/controllers/user_settings_html/edit.html.heex
* creating test/hello_web/controllers/user_settings_controller_test.exs
* creating lib/hello/accounts.ex
* injecting lib/hello/accounts.ex
* creating test/hello/accounts_test.exs
* injecting test/hello/accounts_test.exs
* creating test/support/fixtures/accounts_fixtures.ex
* injecting test/support/fixtures/accounts_fixtures.ex
* injecting test/support/conn_case.ex
* injecting config/test.exs
* injecting config/config.exs
* injecting mix.exs
* injecting lib/hello_web/router.ex
* injecting lib/hello_web/router.ex - imports
* injecting lib/hello_web/router.ex - plug
* injecting lib/hello_web/components/layouts/root.html.heex
* injecting AGENTS.md

Please re-fetch your dependencies with the following command:

    $ mix deps.get

Remember to update your repository by running migrations:

    $ mix ecto.migrate

Once you are ready, visit "/users/register"
to create your account and then access "/dev/mailbox" to
see the account confirmation email.

Repare nas linhas injecting: além de criar arquivos, o gerador mexe em arquivos que já existiam. Em mix.exs, acrescenta a biblioteca de hash de senhas; em config/config.exs, registra o escopo user (o notebook 14 trata disso); em config/test.exs, deixa o hash rápido nos testes; no router e no root layout, acrescenta o que você verá mais abaixo. Cerca de metade das 2.700 linhas geradas são testes.

Como o gerador instalou uma dependência nova, o guia manda buscá-la e rodar a migration:

$ mix deps.get
$ mix ecto.migrate

Aqui, a dependência já veio na célula de instalação. Falta o banco. A migration cria a extensão citext do PostgreSQL; confira primeiro se o seu servidor a tem:

Trilha.verificar_citext()

Se a resposta for que o servidor não tem a extensão, rode os dois comandos que a célula mostra, num terminal, e avalie a célula de novo. A seção "Maiúsculas e minúsculas", perto do fim, explica para que ela serve.

priv/repo/migrations/20250610120000_create_users_auth_tables.exs: a migration gerada. A primeira linha cria a extensão citext; o segundo argumento de execute/2, vazio, é o que fazer no rollback (nada: a extensão fica). Como nos notebooks anteriores, a última linha registra a versão que, no projeto, viria do nome do arquivo.

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

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

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

      timestamps(type: :utc_datetime)
    end

    create unique_index(:users, [:email])

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

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

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

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

A migration cria duas tabelas. users guarda as contas:

Trilha.descrever("users")

email é do tipo citext, obrigatório e com índice único. hashed_password aceita NULL: no 1.8, a senha é opcional, e uma conta nova não tem nenhuma. confirmed_at fica vazio até a pessoa confirmar o e-mail.

users_tokens guarda os tokens: as sessões abertas, os links mágicos enviados e os pedidos de troca de e-mail. A coluna context diz de que tipo é cada um.

Trilha.descrever("users_tokens")

A chave estrangeira para users tem ON DELETE CASCADE: apagar uma conta apaga os tokens dela. O índice único em (context, token) garante que um token não se repete dentro do mesmo tipo.

O código gerado, peça por peça

O resto do comando são módulos Elixir. Eles vêm abaixo, um por célula, na ordem em que precisam ser compilados. As células são longas, mas vale a pena lê-las: o código gerado é seu, e saber o que ele faz é a primeira responsabilidade de quem o gera.

lib/hello/mailer.ex: o mailer da aplicação. Um projeto criado com mix phx.new já vem com ele; o phx.gen.auth só o usa. Em desenvolvimento, o adaptador Swoosh.Adapters.Local (configurado na célula de instalação) guarda os e-mails na memória.

defmodule Hello.Mailer do
  use Swoosh.Mailer, otp_app: :hello
end

lib/hello/accounts/user.ex: o schema dos usuários. Os detalhes que importam:

  • password é um campo virtual: existe na struct e no changeset, mas não tem coluna. O que vai para o banco é hashed_password. As duas têm redact: true, que esconde o valor em inspect e nos logs.
  • authenticated_at também é virtual. Ele vem do token da sessão, e é com ele que o modo sudo sabe quando foi a última autenticação.
  • email_changeset/3 valida o formato, o tamanho e a unicidade do e-mail; password_changeset/3 exige de 12 a 72 caracteres e calcula o hash com Pbkdf2.hash_pwd_salt/1, apagando a senha em texto do changeset.
  • valid_password?/2 compara a senha com o hash. Quando não há usuário ou senha, ela chama Pbkdf2.no_user_verify/0, que gasta o mesmo tempo de uma verificação de verdade. Sem isso, a resposta mais rápida entregaria quais e-mails existem.
defmodule Hello.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset

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

    timestamps(type: :utc_datetime)
  end

  @doc """
  A user changeset for registering or changing the email.

  It requires the email to change otherwise an error is added.

  ## Options

    * `:validate_unique` - Set to false if you don't want to validate the
      uniqueness of the email, useful when displaying live validations.
      Defaults to `true`.
  """
  def email_changeset(user, attrs, opts \\ []) do
    user
    |> cast(attrs, [:email])
    |> validate_email(opts)
  end

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

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

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

  @doc """
  A user changeset for changing the password.

  It is important to validate the length of the password, as long passwords may
  be very expensive to hash for certain algorithms.

  ## Options

    * `:hash_password` - Hashes the password so it can be stored securely
      in the database and ensures the password field is cleared to prevent
      leaks in the logs. If password hashing is not needed and clearing the
      password field is not desired (like when using this changeset for
      validations on a LiveView form), this option can be set to `false`.
      Defaults to `true`.
  """
  def password_changeset(user, attrs, opts \\ []) do
    user
    |> cast(attrs, [:password])
    |> validate_confirmation(:password, message: "does not match password")
    |> validate_password(opts)
  end

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

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

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

  @doc """
  Confirms the account by setting `confirmed_at`.
  """
  def confirm_changeset(user) do
    now = DateTime.utc_now(:second)
    change(user, confirmed_at: now)
  end

  @doc """
  Verifies the password.

  If there is no user or the user doesn't have a password, we call
  `Pbkdf2.no_user_verify/0` to avoid timing attacks.
  """
  def valid_password?(%Hello.Accounts.User{hashed_password: hashed_password}, password)
      when is_binary(hashed_password) and byte_size(password) > 0 do
    Pbkdf2.verify_pass(password, hashed_password)
  end

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

Veja o redact funcionando: um changeset com senha, inspecionado, não mostra a senha.

changeset =
  Hello.Accounts.User.password_changeset(%Hello.Accounts.User{}, %{password: "curta"}, hash_password: false)

IO.inspect(changeset, label: "changeset")
changeset.errors

O inspect mostra password: "**redacted**" nas mudanças. O erro é should be at least %{count} character(s), com count: 12 nas opções: o changeset guarda a mensagem com o marcador, e quem troca %{count} por 12 é o translate_error/1 dos CoreComponents, na hora de mostrar o formulário.

Sua vez: uma regra a mais para a senha

O validate_password/2 gerado traz três exemplos de regras comentados. Na célula do Hello.Accounts.User, ative a que exige pelo menos um dígito ou uma pontuação (tire o # do começo da linha certa) e avalie a célula.

Trilha.solucao(:senha)
Trilha.conferir(:senha)

lib/hello/accounts/user_token.ex: o schema dos tokens. Há dois jeitos de criar um token:

  • build_session_token/1 sorteia 32 bytes e os guarda como vieram, com context: "session". Esse token vai para a sessão, dentro do cookie assinado, e nunca aparece em texto para ninguém.
  • build_email_token/2 sorteia 32 bytes, manda a versão em Base64 para o e-mail e grava só o hash SHA-256, junto com o endereço de destino (sent_to). É usado para os links mágicos (context: "login") e para a troca de e-mail (context: "change:EMAIL_ANTIGO").

As funções verify_*_query montam as consultas que validam cada tipo de token, com o prazo de cada um: 15 minutos para o link mágico, 7 dias para a troca de e-mail, 14 dias para a sessão. O link mágico e a troca de e-mail também conferem sent_to == user.email: se o e-mail da conta mudou, os links mandados para o endereço antigo deixam de valer.

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

  @hash_algorithm :sha256
  @rand_size 32

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

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

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

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

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

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

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

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

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

    {:ok, query}
  end

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

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

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

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

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

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

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

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

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

        {:ok, query}

      :error ->
        :error
    end
  end

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

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

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

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

        {:ok, query}

      :error ->
        :error
    end
  end

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

lib/hello/accounts/scope.ex: o escopo, uma struct com o usuário atual. Ele precisa vir depois do User, porque casa com %User{}. O notebook 14 inteiro é sobre escopos.

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

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

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

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

  alias Hello.Accounts.User

  defstruct user: nil

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

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

  def for_user(nil), do: nil
end

lib/hello/accounts/user_notifier.ex: os três e-mails do sistema: confirmação de conta, link de login e troca de e-mail. São e-mails em texto puro, montados com Swoosh.Email. deliver_login_instructions/2 escolhe o texto: quem ainda não confirmou a conta recebe "Confirmation instructions"; quem já confirmou recebe "Log in instructions".

defmodule Hello.Accounts.UserNotifier do
  import Swoosh.Email

  alias Hello.Mailer
  alias Hello.Accounts.User

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

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

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

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

    Hi #{user.email},

    You can change your email by visiting the URL below:

    #{url}

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

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

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

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

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

    Hi #{user.email},

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

    #{url}

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

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

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

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

    Hi #{user.email},

    You can confirm your account by visiting the URL below:

    #{url}

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

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

lib/hello/accounts.ex: o contexto, a API pública da autenticação. Vale ler com calma login_user_by_magic_link/1, que trata três casos, e update_user_and_delete_all_tokens/1, que apaga todos os tokens de uma conta ao confirmá-la ou ao trocar a senha.

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

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

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

  ## Database getters

  @doc """
  Gets a user by email.

  ## Examples

      iex> get_user_by_email("foo@example.com")
      %User{}

      iex> get_user_by_email("unknown@example.com")
      nil

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

  @doc """
  Gets a user by email and password.

  ## Examples

      iex> get_user_by_email_and_password("foo@example.com", "correct_password")
      %User{}

      iex> get_user_by_email_and_password("foo@example.com", "invalid_password")
      nil

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

  @doc """
  Gets a single user.

  Raises `Ecto.NoResultsError` if the User does not exist.

  ## Examples

      iex> get_user!(123)
      %User{}

      iex> get_user!(456)
      ** (Ecto.NoResultsError)

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

  ## User registration

  @doc """
  Registers a user.

  ## Examples

      iex> register_user(%{field: value})
      {:ok, %User{}}

      iex> register_user(%{field: bad_value})
      {:error, %Ecto.Changeset{}}

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

  ## Settings

  @doc """
  Checks whether the user is in sudo mode.

  The user is in sudo mode when the last authentication was done no further
  than 20 minutes ago. The limit can be given as second argument in minutes.
  """
  def sudo_mode?(user, minutes \\ -20)

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

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

  @doc """
  Returns an `%Ecto.Changeset{}` for changing the user email.

  See `Hello.Accounts.User.email_changeset/3` for a list of supported options.

  ## Examples

      iex> change_user_email(user)
      %Ecto.Changeset{data: %User{}}

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

  @doc """
  Updates the user email using the given token.

  If the token matches, the user email is updated and the token is deleted.
  """
  def update_user_email(user, token) do
    context = "change:#{user.email}"

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

  @doc """
  Returns an `%Ecto.Changeset{}` for changing the user password.

  See `Hello.Accounts.User.password_changeset/3` for a list of supported options.

  ## Examples

      iex> change_user_password(user)
      %Ecto.Changeset{data: %User{}}

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

  @doc """
  Updates the user password.

  Returns a tuple with the updated user, as well as a list of expired tokens.

  ## Examples

      iex> update_user_password(user, %{password: ...})
      {:ok, {%User{}, [...]}}

      iex> update_user_password(user, %{password: "too short"})
      {:error, %Ecto.Changeset{}}

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

  ## Session

  @doc """
  Generates a session token.
  """
  def generate_user_session_token(user) do
    {token, user_token} = UserToken.build_session_token(user)
    Repo.insert!(user_token)
    token
  end

  @doc """
  Gets the user with the given signed token.

  If the token is valid `{user, token_inserted_at}` is returned, otherwise `nil` is returned.
  """
  def get_user_by_session_token(token) do
    {:ok, query} = UserToken.verify_session_token_query(token)
    Repo.one(query)
  end

  @doc """
  Gets the user with the given magic link token.
  """
  def get_user_by_magic_link_token(token) do
    with {:ok, query} <- UserToken.verify_magic_link_token_query(token),
         {user, _token} <- Repo.one(query) do
      user
    else
      _ -> nil
    end
  end

  @doc """
  Logs the user in by magic link.

  There are three cases to consider:

  1. The user has already confirmed their email. They are logged in
     and the magic link is expired.

  2. The user has not confirmed their email and no password is set.
     In this case, the user gets confirmed, logged in, and all tokens -
     including session ones - are expired. In theory, no other tokens
     exist but we delete all of them for best security practices.

  3. The user has not confirmed their email but a password is set.
     This cannot happen in the default implementation but may be the
     source of security pitfalls. See the "Mixing magic link and password registration" section of
     `mix help phx.gen.auth`.
  """
  def login_user_by_magic_link(token) do
    {:ok, query} = UserToken.verify_magic_link_token_query(token)

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

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

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

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

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

  @doc ~S"""
  Delivers the update email instructions to the given user.

  ## Examples

      iex> deliver_user_update_email_instructions(user, current_email, &url(~p"/users/settings/confirm-email/#{&1}"))
      {:ok, %{to: ..., body: ...}}

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

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

  @doc """
  Delivers the magic link login instructions to the given user.
  """
  def deliver_login_instructions(%User{} = user, magic_link_url_fun)
      when is_function(magic_link_url_fun, 1) do
    {encoded_token, user_token} = UserToken.build_email_token(user, "login")
    Repo.insert!(user_token)
    UserNotifier.deliver_login_instructions(user, magic_link_url_fun.(encoded_token))
  end

  @doc """
  Deletes the signed token with the given context.
  """
  def delete_user_session_token(token) do
    Repo.delete_all(from(UserToken, where: [token: ^token, context: "session"]))
    :ok
  end

  ## Token helper

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

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

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

lib/hello_web/user_auth.ex: os plugs da camada web. log_in_user/3 cria a sessão e grava o token nela; fetch_current_scope_for_user/2 lê o token da sessão (ou do cookie "remember me") e põe o current_scope nos assigns; require_authenticated_user/2, require_sudo_mode/2 e redirect_if_user_is_authenticated/2 barram quem não deve passar. A seção "Proibindo o acesso" volta a eles.

defmodule HelloWeb.UserAuth do
  use HelloWeb, :verified_routes

  import Plug.Conn
  import Phoenix.Controller

  alias Hello.Accounts
  alias Hello.Accounts.Scope

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

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

  @doc """
  Logs the user in.

  Redirects to the session's `:user_return_to` path
  or falls back to the `signed_in_path/1`.
  """
  def log_in_user(conn, user, params \\ %{}) do
    user_return_to = get_session(conn, :user_return_to)

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

  @doc """
  Logs the user out.

  It clears all session data for safety. See renew_session.
  """
  def log_out_user(conn) do
    user_token = get_session(conn, :user_token)
    user_token && Accounts.delete_user_session_token(user_token)

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

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

  @doc """
  Authenticates the user by looking into the session and remember me token.

  Will reissue the session token if it is older than the configured age.
  """
  def fetch_current_scope_for_user(conn, _opts) do
    with {token, conn} <- ensure_user_token(conn),
         {user, token_inserted_at} <- Accounts.get_user_by_session_token(token) do
      conn
      |> assign(:current_scope, Scope.for_user(user))
      |> maybe_reissue_user_session_token(user, token_inserted_at)
    else
      nil -> assign(conn, :current_scope, Scope.for_user(nil))
    end
  end

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  @doc """
  Plug for routes that require the user to not be authenticated.
  """
  def redirect_if_user_is_authenticated(conn, _opts) do
    if conn.assigns.current_scope do
      conn
      |> redirect(to: signed_in_path(conn))
      |> halt()
    else
      conn
    end
  end

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

  @doc """
  Plug for routes that require the user to be authenticated.
  """
  def require_authenticated_user(conn, _opts) do
    if conn.assigns.current_scope && conn.assigns.current_scope.user do
      conn
    else
      conn
      |> put_flash(:error, "You must log in to access this page.")
      |> maybe_store_return_to()
      |> redirect(to: ~p"/users/log-in")
      |> halt()
    end
  end

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

  defp maybe_store_return_to(conn), do: conn
end

Esta célula compila com dois avisos no route path for HelloWeb.Router matches "/users/log-in", um em require_sudo_mode/2 e outro em require_authenticated_user/2. O UserAuth usa ~p com uma rota que o router ainda não tem; o router novo, logo abaixo, precisa do UserAuth pronto para importá-lo. No projeto, os dois compilam juntos e os avisos não aparecem. Aqui, eles somem quando você reavaliar esta célula depois do router.

lib/hello_web/router.ex: o gerador acrescentou ao router o import HelloWeb.UserAuth, o plug :fetch_current_scope_for_user no fim da pipeline :browser e três scopes com as rotas de autenticação, cada um com a sua lista de plugs. A caixa de entrada /dev/mailbox vem do mix phx.new: no projeto, ela fica dentro de um if Application.compile_env(:hello, :dev_routes), que só vale em desenvolvimento; aqui ela está direto no router, porque o notebook roda sempre em desenvolvimento.

defmodule HelloWeb.Router do
  use HelloWeb, :router

  import HelloWeb.UserAuth

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

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

  scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :home
  end


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

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

  ## Authentication routes

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

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

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

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

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

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

lib/hello_web/components/layouts.ex: o gerador injeta no root.html.heex o menu do usuário, no alto da página: o e-mail, "Settings" e "Log out" para quem entrou; "Register" e "Log in" para quem não entrou.

defmodule HelloWeb.Layouts do
  use HelloWeb, :html

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

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

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

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

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

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

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

lib/hello_web/controllers/: três controllers, cada um com a sua view e os seus templates (aqui como funções, uma por arquivo .heex, como nos notebooks anteriores). UserRegistrationController cuida do cadastro; UserSessionController, do login (por link e por senha), da página de confirmação e do logout; UserSettingsController, da troca de e-mail e de senha.

defmodule HelloWeb.UserRegistrationController do
  use HelloWeb, :controller

  alias Hello.Accounts
  alias Hello.Accounts.User

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

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

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

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

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

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

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

  alias Hello.Accounts
  alias HelloWeb.UserAuth

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  alias Hello.Accounts
  alias HelloWeb.UserAuth

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

  plug :require_sudo_mode
  plug :assign_email_and_password_changesets

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

      <div class="divider" />

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

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

Usando a autenticação

Com tudo compilado, o guia sugere iniciar o servidor e experimentar. Abra http://localhost:4010 no navegador: os links "Register" e "Log in" apareceram no alto da página. As células abaixo fazem o mesmo caminho com o navegador simulado, para você ver cada requisição.

O cadastro só pede o e-mail:

Trilha.navegar("ana", :get, "/users/register")
Trilha.navegar("ana", :post, "/users/register", %{"user" => %{"email" => "ana@example.com"}})

O cadastro não faz login. Ele cria a conta, ainda não confirmada, manda um e-mail e redireciona para a página de login com a flash "An email was sent to ana@example.com, please access it to confirm your account." A página de login avisa que a aplicação está usando o adaptador local de e-mail e aponta para a caixa de entrada.

No log dessa célula aparece uma linha [debug] do Postgrex: a consulta "uses unknown oid(s) ... forcing us to reload type information from the database". É a primeira consulta à coluna citext, um tipo que não existia quando o Repo se conectou. O próprio Postgrex explica: é o esperado depois de uma migration.

Trilha.caixa_de_entrada()
Trilha.ultimo_email("ana@example.com")

O mesmo e-mail está em http://localhost:4010/dev/mailbox. O link leva à página de confirmação:

Trilha.navegar("ana", :get, Trilha.link_do_email("ana@example.com"))

Abrir o link não faz login: a página só mostra dois botões, "Confirm and stay logged in" e "Confirm and log in only this time". Isso é proposital. Alguns programas de e-mail e antivírus abrem os links sozinhos para inspecioná-los; se um GET fizesse login, eles gastariam o link. O login acontece no POST do formulário, que leva o token num campo escondido. A célula abaixo envia o formulário como o botão "Confirm and log in only this time" faria:

token = "ana@example.com" |> Trilha.link_do_email() |> String.split("/") |> List.last()

Trilha.navegar("ana", :post, "/users/log-in?_action=confirmed", %{"user" => %{"token" => token}})

A flash é "User confirmed successfully." e a página inicial mostra o menu com ana@example.com. Veja o que ficou em users_tokens:

Trilha.consultar("SELECT id, user_id, context, sent_to, authenticated_at, inserted_at FROM users_tokens ORDER BY id")

Sobrou um só token, de contexto session. O token login do link foi apagado: como a conta não estava confirmada, login_user_by_magic_link/1 confirmou a conta e apagou todos os tokens dela antes de criar a sessão. Se você enviar o mesmo formulário de novo, o link não vale mais:

Trilha.navegar("ana", :post, "/users/log-in", %{"user" => %{"token" => token}})

A resposta é a página de login com "The link is invalid or it has expired.". Ela traz o aviso "You need to reauthenticate to perform sensitive actions on your account." porque Ana continua logada: o link é que não serve mais.

A senha, opcional

A página de confirmação sugeria: "If you prefer passwords, you can enable them in the user settings." As configurações ficam em /users/settings:

Trilha.navegar("ana", :get, "/users/settings")
Trilha.navegar("ana", :put, "/users/settings", %{
  "action" => "update_password",
  "user" => %{"password" => "uma senha bem longa, 1", "password_confirmation" => "uma senha bem longa, 1"}
})

"Password updated successfully." Agora Ana pode sair e entrar com e-mail e senha:

Trilha.navegar("ana", :delete, "/users/log-out")
Trilha.navegar("ana", :post, "/users/log-in", %{"user" => %{"email" => "ana@example.com", "password" => "uma senha bem longa, 1"}})

"Remember me"

A página de login tem dois botões para a senha: "Log in and stay logged in" envia remember_me=true; "Log in only this time" não envia. A diferença está nos cookies. Bia entra lembrando, e Caio entra só desta vez:

Trilha.entrar("bia", "bia@example.com", lembrar: true)
Trilha.entrar("caio", "caio@example.com")

Trilha.entrar/3 faz o caminho de verdade: cria a conta, se precisar, pede o link mágico e envia o formulário de confirmação, como você fez com a Ana. Compare os cookies dos dois:

Trilha.mostrar_cookies("bia")
Trilha.mostrar_cookies("caio")

Os dois têm _hello_key, o cookie da sessão, que não tem validade: o navegador o apaga quando fecha. Só a Bia tem _hello_web_user_remember_me, assinado, com max_age de 14 dias e o mesmo token da sessão. Feche os dois navegadores e volte às configurações:

Kino.Layout.grid([Trilha.fechar_navegador("bia"), Trilha.fechar_navegador("caio")])
Trilha.navegar("caio", :get, "/users/settings")
Trilha.navegar("bia", :get, "/users/settings")

Caio volta a ser visitante e cai no login. Bia continua logada: fetch_current_scope_for_user/2 não achou token na sessão, procurou no cookie "remember me", achou, e o pôs de volta na sessão.

Trocar o e-mail

Ana quer trocar o endereço. O formulário de e-mail das configurações envia action=update_email:

Trilha.navegar("ana", :put, "/users/settings", %{"action" => "update_email", "user" => %{"email" => "ana.nova@example.com"}})

O e-mail ainda não mudou. A aplicação mandou um link para o endereço novo, para provar que ele é da Ana:

Trilha.ultimo_email("ana.nova@example.com")
Trilha.consultar("SELECT id, user_id, context, sent_to FROM users_tokens WHERE context LIKE 'change:%'")

O contexto do token guarda o endereço antigo (change:ana@example.com), e sent_to, o novo. Ao clicar no link, a troca só acontece se o token bater com os dois:

Trilha.navegar("ana", :get, Trilha.link_do_email("ana.nova@example.com"))

"Email changed successfully." Para os próximos exemplos, troque de volta pelo mesmo caminho:

Trilha.visitar("ana", :put, "/users/settings", %{"action" => "update_email", "user" => %{"email" => "ana@example.com"}})
Trilha.navegar("ana", :get, Trilha.link_do_email("ana@example.com"))

Responsabilidades de quem gera o código

Na documentação: Developer responsibilities

Trilha.pergunta(:responsabilidade)

Como o Phoenix gera esse código dentro da aplicação, em vez de mantê-lo numa biblioteca, você tem liberdade total para adaptá-lo. O preço é um só: o código gerado não é atualizado depois. Quando a saída do mix phx.gen.auth melhorar, cabe a você decidir se traz a mudança para o seu projeto. As mudanças de segurança e as outras importantes são marcadas com clareza no CHANGELOG.md e nas notas de atualização do Phoenix.

Notas sobre o código gerado

Na documentação: Generated code

Proibindo o acesso

Na documentação: Forbidding access

O HelloWeb.UserAuth traz um punhado de plugs:

  • fetch_current_scope_for_user busca o usuário atual, se houver, e o guarda no assign :current_scope. Ele roda em toda requisição da pipeline :browser; sem usuário, o current_scope é nil;
  • require_authenticated_user deve vir depois do anterior e exige um usuário autenticado; sem ele, redireciona para o login e guarda o caminho pedido para voltar depois;
  • redirect_if_user_is_authenticated serve para as poucas páginas que quem já entrou não deve ver, como o cadastro (só existe na versão com controllers);
  • require_sudo_mode protege as páginas com operações sensíveis e exige uma autenticação recente.

O router usa cada um num scope diferente. Para proteger uma página nova, basta pôr a rota no scope certo.

Sua vez: uma página só para quem entrou

A célula abaixo tem um PainelController pronto, com duas actions. Faça GET /painel funcionar só para quem entrou:

  1. na célula do router, lá em cima, acrescente get "/painel", PainelController, :index ao scope que passa por :require_authenticated_user, e avalie a célula;
  2. na célula abaixo, troque o "Olá!" do template index por uma saudação com o e-mail de quem está logado, que está em @current_scope.user.email, e avalie a célula.
defmodule HelloWeb.PainelController do
  use HelloWeb, :controller

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

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

defmodule HelloWeb.PainelHTML do
  use HelloWeb, :html

  def index(assigns) do
    ~H"""
    <Layouts.app flash={@flash} current_scope={@current_scope}>
      <.header>Painel</.header>
      <p>Olá!</p>
    </Layouts.app>
    """
  end

  def seguranca(assigns) do
    ~H"""
    <Layouts.app flash={@flash} current_scope={@current_scope}>
      <.header>Segurança</.header>
      <p>Aqui ficariam as ações sensíveis: apagar a conta, gerar chaves, exportar dados.</p>
    </Layouts.app>
    """
  end
end
Trilha.solucao(:painel)
Trilha.conferir(:painel)

Veja o caminho de quem chega sem login e depois entra:

Trilha.navegar("dani", :get, "/painel")
Trilha.entrar("dani", "dani@example.com")

Depois do login, Dani voltou para /painel, e não para a página inicial: require_authenticated_user/2 guardou o caminho na sessão (:user_return_to), e log_in_user/3 o usou.

Modo sudo

Trilha.pergunta(:sudo)

O UserSettingsController começa com plug :require_sudo_mode. O plug chama Accounts.sudo_mode?(user, -10), que confere se authenticated_at está dentro dos últimos 10 minutos. Esse valor vem do token da sessão e é gravado quando a pessoa entra (pelo link ou pela senha). Ficar logado não basta; é preciso ter se autenticado há pouco.

Para ver sem esperar 10 minutos, Trilha.envelhecer_sessoes/2 move para trás o authenticated_at das sessões de uma conta. Ana entrou há pouco; agora a última autenticação dela foi há 11 minutos:

Trilha.envelhecer_sessoes("ana@example.com", 11)
Trilha.navegar("ana", :get, "/users/settings")

Ana continua logada (o menu ainda mostra o e-mail dela), mas as configurações a mandaram reautenticar, com "You must re-authenticate to access this page." A página de login muda o texto para "You need to reauthenticate to perform sensitive actions on your account." e trava o campo de e-mail. Ana pede um link (o primeiro formulário da página):

Trilha.navegar("ana", :post, "/users/log-in", %{"user" => %{"email" => "ana@example.com"}})
Trilha.ultimo_email("ana@example.com")

Como a conta já está confirmada, o e-mail agora é "Log in instructions". A página do link mostra um só botão, "Log in", porque Ana já está logada:

Trilha.navegar("ana", :get, Trilha.link_do_email("ana@example.com"))

Enviando o formulário:

token = "ana@example.com" |> Trilha.link_do_email() |> String.split("/") |> List.last()

Trilha.navegar("ana", :post, "/users/log-in", %{"user" => %{"token" => token}})

Ana volta direto para /users/settings, com um token de sessão novo e um authenticated_at de agora. O token antigo continua em users_tokens até expirar; só a troca de senha (ou o logout) apaga sessões.

Sua vez: uma ação sensível

A action seguranca do PainelController deve exigir o modo sudo. Na célula do router, crie um scope novo que passe por [:browser, :require_authenticated_user, :require_sudo_mode] e ponha nele get "/painel/seguranca", PainelController, :seguranca. Pense na ordem dos plugs.

Trilha.solucao(:seguranca)
Trilha.conferir(:seguranca)

Escopos

Na documentação: Scopes

O Hello.Accounts.Scope gerado é uma struct com um campo, user. O plug fetch_current_scope_for_user o guarda no assign :current_scope, que fica disponível nos controllers e nas LiveViews. Conforme a aplicação cresce, o escopo pode carregar mais informação: as equipes, empresas ou organizações de que o usuário faz parte, permissões, o endereço IP da requisição.

O gerador também registrou o escopo em config/config.exs, como escopo padrão:

config :hello, :scopes,
  user: [
    default: true,
    module: Hello.Accounts.Scope,
    assign_key: :current_scope,
    access_path: [:user, :id],
    schema_key: :user_id,
    schema_type: :id,
    schema_table: :users,
    test_data_fixture: Hello.AccountsFixtures,
    test_setup_helper: :register_and_log_in_user
  ]

A partir daí, os outros geradores (phx.gen.html, phx.gen.json, phx.gen.live, phx.gen.context) passam o escopo do controller para as funções do contexto e filtram os dados pelo usuário. Você viu isso no carrinho do notebook 10. O notebook 14 explica cada opção e mostra como criar escopos novos.

Hash de senhas

Na documentação: Password hashing

O mecanismo padrão é o bcrypt em sistemas Unix e o pbkdf2 no Windows. Os dois seguem a interface Comeonin, por isso o código gerado muda pouco de um para o outro. A opção --hashing-lib escolhe a biblioteca:

O guia recomenda considerar o argon2, o mais robusto dos três; a desvantagem é que ele consome bastante CPU e memória, e exige servidores mais fortes. A trilha usa o pbkdf2 por um motivo prático: bcrypt_elixir e argon2_elixir compilam código C quando são instalados, e o pbkdf2_elixir é Elixir puro, que instala em qualquer máquina com o Livebook. Com --hashing-lib pbkdf2, o que muda no código gerado é só isto: Pbkdf2.hash_pwd_salt/1, Pbkdf2.verify_pass/2 e Pbkdf2.no_user_verify/0 no User, a dependência no mix.exs e a linha config :pbkdf2_elixir, :rounds, 1 no config/test.exs.

Um hash de senha é lento de propósito. Veja quanto tempo leva:

{microssegundos, hash} = :timer.tc(fn -> Pbkdf2.hash_pwd_salt("uma senha bem longa, 1") end)

IO.puts("#{div(microssegundos, 1000)} ms")
hash

O resultado guarda tudo o que a verificação precisa: o algoritmo (pbkdf2-sha512), o número de rodadas (160000), o sal e o hash. Um computador que testa senhas por força bruta leva o mesmo tempo para cada tentativa. Nos testes, rounds: 1 torna o hash instantâneo; em produção, nunca.

Notificadores

Na documentação: Notifiers

O guia diz que o código gerado não está integrado a nenhum sistema de SMS ou e-mail: é sua responsabilidade fazer essa integração. Na prática, num projeto criado com mix phx.new, o UserNotifier já usa o mailer do Swoosh, e em desenvolvimento os e-mails aparecem em /dev/mailbox. Em produção, você troca o adaptador Swoosh.Adapters.Local por um serviço de verdade (SMTP, Amazon SES, Postmark e outros) na configuração.

O texto dos e-mails também é seu.

Sua vez: o e-mail em português

Na célula do Hello.Accounts.UserNotifier, traduza o e-mail de confirmação (deliver_confirmation_instructions/2): o assunto e o texto. Mantenha #{url} no corpo, senão o link some. Avalie a célula e cadastre alguém para ver o resultado:

Trilha.navegar("eva", :post, "/users/register", %{"user" => %{"email" => "eva@example.com"}})
Trilha.ultimo_email("eva@example.com")
Trilha.solucao(:email_pt)
Trilha.conferir(:email_pt)

Testes concorrentes

Na documentação: Concurrent tests

Os testes gerados (accounts_test.exs, user_auth_test.exs e os dos três controllers) rodam em paralelo quando o banco permite, que é o caso do PostgreSQL. O módulo de testes da trilha volta a eles; por enquanto, saiba que mix test logo depois do gerador deve passar inteiro.

Considerações de segurança

Na documentação: Security considerations

Sessões rastreadas

Na documentação: Tracking sessions

Todas as sessões e todos os tokens ficam numa tabela à parte. Com isso dá para saber quantas sessões cada conta tem abertas, e até mostrar isso ao usuário. O comentário do build_session_token/1, lá em cima, explica o motivo: o cookie de sessão do Phoenix é só assinado, e valeria para sempre se não houvesse um registro no banco para apagar.

Trilha.pergunta(:troca_de_senha)

Fábio entra no celular e no notebook. São dois navegadores, com dois cookies, e duas linhas em users_tokens:

Trilha.entrar("fábio no celular", "fabio@example.com")
Trilha.entrar("fábio no notebook", "fabio@example.com")

Trilha.consultar("""
SELECT t.id, t.context, t.authenticated_at
FROM users_tokens t JOIN users u ON u.id = t.user_id
WHERE u.email = 'fabio@example.com'
ORDER BY t.id
""")

No notebook, ele define uma senha:

Trilha.navegar("fábio no notebook", :put, "/users/settings", %{
  "action" => "update_password",
  "user" => %{"password" => "outra senha bem longa, 2", "password_confirmation" => "outra senha bem longa, 2"}
})
Trilha.consultar("""
SELECT t.id, t.context, t.authenticated_at
FROM users_tokens t JOIN users u ON u.id = t.user_id
WHERE u.email = 'fabio@example.com'
ORDER BY t.id
""")

As duas sessões antigas sumiram, e há uma nova, a que o controller criou para o notebook logo depois da troca. O celular, que ainda tem o cookie antigo, perdeu a sessão:

Trilha.navegar("fábio no celular", :get, "/users/settings")

O guia resume: sempre que a senha muda, todos os tokens são apagados, e a pessoa precisa entrar de novo em todos os aparelhos. O mesmo acontece quando uma conta é confirmada.

Ataques de enumeração de usuários

Na documentação: User Enumeration attacks

Um ataque de enumeração permite a alguém descobrir se um e-mail está cadastrado. O código gerado não tenta se proteger disso.

Trilha.pergunta(:enumeracao)

Três tentativas com o e-mail da Bia, que existe, feitas por um visitante qualquer:

Trilha.navegar("curioso", :post, "/users/register", %{"user" => %{"email" => "bia@example.com"}})
Trilha.navegar("curioso", :post, "/users/log-in", %{"user" => %{"email" => "bia@example.com"}})
Trilha.navegar("curioso", :post, "/users/log-in", %{"user" => %{"email" => "bia@example.com", "password" => "um chute qualquer"}})

O cadastro responde "has already been taken", e isso entrega a existência da conta. O pedido de link responde a mesma frase para qualquer e-mail ("If your email is in our system..."), e o login com senha responde sempre "Invalid email or password"; o comentário no UserSessionController diz que é de propósito.

Se a sua aplicação precisa se proteger de enumeração, o guia avisa que os fluxos mudam muito de uma aplicação para outra, e que é preciso equilibrar segurança e experiência do usuário. Avisa também sobre ataques de tempo: cadastrar uma conta nova grava no banco e envia e-mail, o que demora mais do que recusar um e-mail repetido. Medindo o tempo das respostas, alguém pode descobrir os e-mails cadastrados. Isso vale para todas as rotas que enviam e-mail ou notificação. O Pbkdf2.no_user_verify/0 do valid_password?/2 existe exatamente para fechar essa porta no login com senha.

Confirmação e pré-cadastro de credenciais

Na documentação: Confirmation and credential pre-stuffing attacks

Num ataque de pré-cadastro, alguém cria uma conta com o e-mail de outra pessoa antes dela, define uma senha e espera: quando a dona do e-mail confirmar a conta, o atacante já tem a senha. O código gerado tem confirmação de conta por e-mail e impede o uso da aplicação por quem não confirmou.

Trilha.pergunta(:pre_cadastro)

O atacante cadastra o e-mail da Gabi e tenta entrar com uma senha:

Trilha.navegar("atacante", :post, "/users/register", %{"user" => %{"email" => "gabi@example.com"}})
Trilha.navegar("atacante", :post, "/users/log-in", %{"user" => %{"email" => "gabi@example.com", "password" => "a senha do atacante"}})

Não há senha nenhuma na conta, e não há como definir uma sem entrar. O e-mail de confirmação foi para a caixa da Gabi.

O login_user_by_magic_link/1 ainda se protege de um caso que o código gerado nunca produz: uma conta não confirmada com senha. Se você mudar o fluxo e permitir isso (por exemplo, pedindo a senha no cadastro), o link mágico se recusa a funcionar. Para ver, a célula abaixo põe uma senha direto no banco, na conta não confirmada da Gabi, e tenta usar o link que ela recebeu:

Trilha.consultar("UPDATE users SET hashed_password = 'qualquer coisa' WHERE email = 'gabi@example.com' RETURNING email, confirmed_at")
token = "gabi@example.com" |> Trilha.link_do_email() |> String.split("/") |> List.last()

Hello.Accounts.login_user_by_magic_link(token)

RuntimeError: "magic link log in is not allowed for unconfirmed users with a password set!". A mensagem aponta a seção "Mixing magic link and password registration" de mix help phx.gen.auth, que explica como misturar os dois cadastros sem abrir essa brecha. Desfaça a mudança:

Trilha.consultar("UPDATE users SET hashed_password = NULL WHERE email = 'gabi@example.com' RETURNING email, hashed_password")

Maiúsculas e minúsculas

Na documentação: Case sensitiveness

Trilha.pergunta(:citext)

A busca por e-mail não diferencia maiúsculas de minúsculas. No MySQL e no MSSQL isso já é o padrão; no SQLite3, o gerador usa COLLATE NOCASE na coluna; no PostgreSQL, usa a extensão citext. Ana definiu uma senha mais acima:

Trilha.navegar("ana maiúscula", :post, "/users/log-in", %{"user" => %{"email" => "ANA@Example.COM", "password" => "uma senha bem longa, 1"}})
Hello.Accounts.get_user_by_email("ANA@EXAMPLE.COM")

O email volta como foi gravado, ana@example.com: o citext guarda o texto original e só ignora a caixa ao comparar. Repare também que o inspect não mostra hashed_password, por causa do redact: true.

O citext faz parte do PostgreSQL e costuma vir junto com ele. Algumas distribuições o separam num pacote à parte; nesse caso, a migration falha, e a solução é instalar o pacote de extensões. O guia o chama de postgres-contrib; no Fedora, o nome é postgresql-contrib.

Recursos adicionais

Na documentação: Additional resources

O Phoenix 1.8 trouxe os links mágicos e o modo sudo, e tirou do cadastro a senha obrigatória. Quem gerou a autenticação no Phoenix 1.7 ou antes não precisa migrar; o guia recomenda ao menos criar o seu próprio escopo, como no notebook 14. Para quem quiser migrar, o guia aponta dois pull requests de referência (um para a versão com LiveView, outro para a com controllers) e um cuidado: deixar a coluna hashed_password opcional e apagar a senha das contas ainda não confirmadas, para fechar a brecha de pré-cadastro.

A seção termina com links sobre a implementação original: o post de José Valim que apresentou a solução, o post sobre a versão com LiveView e a especificação original.

Sem roteiro: as sessões da minha conta no Brechó

No Brechó, quem vende entra pelo celular, pelo computador de casa, pelo da loja. A seção "Sessões rastreadas" mostrou que cada entrada é uma linha em users_tokens. O guia sugere: dá até para mostrar essa informação ao usuário. Faça isso:

  1. Na célula do Hello.Accounts, acrescente:
    • list_user_sessions(user), que devolve os tokens de sessão (context == "session") do usuário, do mais novo ao mais antigo;
    • delete_user_session(user, id), que apaga a sessão id só se ela for do usuário, devolvendo :ok ou {:error, :not_found}.
  2. No router, no scope com :require_authenticated_user, as rotas get "/users/sessions" e delete "/users/sessions/:id", para um HelloWeb.UserSessionsController (no plural, para não confundir com o UserSessionController do login).
  3. O controller e a view, na célula abaixo: a página lista as sessões, com a hora em que cada uma começou e a da última autenticação, marca com o texto Esta sessão a sessão de quem está vendo (compare session.token com get_session(conn, :user_token)) e tem um link "Encerrar" para as outras, com method="delete".
# lib/hello_web/controllers/user_sessions_controller.ex e user_sessions_html.ex

Para testar à mão: Trilha.entrar/2 com dois nomes e o mesmo e-mail, depois Trilha.navegar/4 em /users/sessions com cada um.

Trilha.solucao(:brecho)
Trilha.conferir(:brecho)

No seu projeto

  1. Se você usa Fedora, instale as extensões do PostgreSQL, uma vez: sudo dnf install postgresql-contrib e sudo systemctl restart postgresql.
  2. Faça um commit, como o guia recomenda.
  3. Rode mix phx.gen.auth Accounts User users. Responda Y para a versão com LiveView ou n para a com controllers; quem ainda não estudou LiveView pode começar pela segunda, que é a deste notebook. Sem compilador C na máquina, acrescente --hashing-lib pbkdf2.
  4. mix deps.get e mix ecto.migrate.
  5. mix test: os testes gerados devem passar, sem nenhuma mudança.
  6. mix phx.server, abra http://localhost:4000, cadastre-se e leia o e-mail em http://localhost:4000/dev/mailbox.
  7. Leia os arquivos gerados com a lista deste notebook do lado: accounts.ex, user.ex, user_token.ex, user_auth.ex e o router.
No notebook No projeto
célula de instalação com swoosh e pbkdf2_elixir o gerador acrescenta a biblioteca de hash ao mix.exs; o Swoosh já vem do mix phx.new
{Hello.Mailer, adapter: Swoosh.Adapters.Local} na instalação config :hello, Hello.Mailer, adapter: Swoosh.Adapters.Local em config/config.exs
forward "/mailbox" direto no router dentro do if Application.compile_env(:hello, :dev_routes)
Trilha.caixa_de_entrada() http://localhost:4000/dev/mailbox
Trilha.envelhecer_sessoes/2 esperar 10 minutos

Revisão

  • Authn e authz: autenticação diz quem é a pessoa; autorização diz o que ela pode fazer. O phx.gen.auth cuida da primeira e prepara a segunda com os escopos.
  • O gerador: mix phx.gen.auth Accounts User users cria o contexto Accounts, os schemas User e UserToken, o UserAuth com os plugs, três controllers e muitos testes. LiveView ou controllers só muda a camada web.
  • Contas sem senha: o cadastro pede só o e-mail; a primeira entrada é pelo link mágico, que confirma a conta. A senha é opcional e fica nas configurações.
  • Tokens: os de e-mail são guardados como hash e valem 15 minutos (login) ou 7 dias (troca de e-mail); os de sessão valem 14 dias e vivem no cookie assinado. O cookie "remember me" sobrevive ao fechar do navegador.
  • Plugs: fetch_current_scope_for_user, require_authenticated_user, redirect_if_user_is_authenticated e require_sudo_mode. Proteger uma página é pôr a rota no scope com o plug certo, na ordem certa.
  • Segurança: sessões rastreadas e apagadas a cada troca de senha; enumeração possível no cadastro; confirmação obrigatória contra o pré-cadastro; citext para e-mails sem diferença de caixa.
  • Responsabilidade: o código é seu, e não se atualiza sozinho.

Próximo notebook: 14_escopos.livemd, sobre o guia Scopes.