Programação Por Equipe DevBras

Capa do artigo: Login com Google no servidor: CSRF, token e conta

Login com Google no servidor: CSRF, token e conta

Login com Google feito no servidor: receba o credential por POST, confira o CSRF e o token, ache a conta pelo sub e vincule o e-mail só quando for seguro.

Este é o código de login com Google que mais aparece em projeto pequeno, e ele entrega a conta de qualquer cliente para quem souber usar o DevTools:

// o padrão que abre a porta: o front decide quem é a pessoa
const dados = JSON.parse(atob(resposta.credential.split(".")[1]));
await fetch("/login", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ email: dados.email })
});

O botão funciona, o nome aparece na tela, o painel abre. Só que o servidor recebeu um e-mail num JSON e acreditou. Ninguém precisa do Google para repetir esse pedido:

curl -i https://loja.devbras.dev/login \
  -H "Content-Type: application/json" \
  -d '{"email":"qualquer.cliente@gmail.com"}'

A correção não é esconder melhor o e-mail nem ofuscar o JavaScript. É mudar quem decide: o navegador só transporta o ID token assinado pelo Google, e o servidor confere esse token, encontra a conta pelo sub e cria a sessão. Abaixo está o fluxo completo num endpoint Python, sem framework, testado com oito pedidos, do e-mail solto até a conta que alguém criou antes do dono de verdade.

Deixe o Google postar direto no servidor

O jeito mais simples de tirar o front da decisão é não ter callback em JavaScript. Com data-login_uri e sem data-callback, o Google Identity Services envia o token por POST para o seu endpoint, como descreve a referência de atributos HTML:

<script src="https://accounts.google.com/gsi/client" async></script>

<div id="g_id_onload"
     data-client_id="1234567890-web.apps.googleusercontent.com"
     data-login_uri="https://loja.devbras.dev/login/google">
</div>
<div class="g_id_signin" data-type="standard"></div>

O endereço precisa estar cadastrado como URI de redirecionamento autorizada no cliente OAuth. O pedido chega como formulário, com dois campos: credential, que é o ID token, e g_csrf_token, um valor aleatório que também vem num cookie com o mesmo nome. O Google pede que o servidor confira esse par antes de qualquer outra coisa. É o padrão double-submit cookie: um site de terceiros consegue fazer o navegador enviar um formulário para você, mas não consegue ler nem escrever o cookie do seu domínio para colocar o mesmo valor no corpo.

O endpoint, em três portas

O exemplo usa WSGI puro e SQLite em memória para caber num arquivo; a ordem das checagens é a mesma em Flask, Django ou qualquer outra linguagem.

def app(environ, responder):
    if environ["PATH_INFO"] != "/login/google" or environ["REQUEST_METHOD"] != "POST":
        responder("404 Not Found", []); return [b""]
    tamanho = int(environ.get("CONTENT_LENGTH") or 0)
    form = parse_qs(environ["wsgi.input"].read(tamanho).decode())
    biscoitos = cookies.SimpleCookie(environ.get("HTTP_COOKIE", ""))

    csrf_form = form.get("g_csrf_token", [""])[0]
    csrf_cookie = biscoitos["g_csrf_token"].value if "g_csrf_token" in biscoitos else ""
    if not csrf_form or not secrets.compare_digest(csrf_form, csrf_cookie):
        responder("400 Bad Request", []); return [b"csrf"]

    try:
        info = validar.conferir(form.get("credential", [""])[0])
    except Exception:
        responder("401 Unauthorized", []); return [b"token recusado"]

    usuario_id, acao = achar_conta(info)
    if usuario_id is None:
        responder("303 See Other", [("Location", "/entrar?vincular=google")])
        return [acao.encode()]

Primeira porta: CSRF, com secrets.compare_digest para não vazar tempo de comparação. Segunda: o token, conferido pela função conferir, que chama a biblioteca oficial google-auth com a lista de client IDs do app. Essa função e os testes que provam cada checagem (outro app, vencido, emissor falso, chave intrusa, payload alterado) estão no guia para testar a validação do ID token com tokens falsos. Terceira: achar a conta. A sessão é um identificador aleatório num cookie HttpOnly; o ID token não é guardado.

Achar a conta pelo sub, e o e-mail só com cuidado

O Google é explícito na página de verificação do token: use só o campo sub como identificador único do usuário. O e-mail de uma conta Google pode mudar com o tempo; o sub não.

O caso difícil é o primeiro login com Google de quem já tem conta com senha. A mesma página diz quando dá para confiar no e-mail do token: quando ele termina em @gmail.com, ou quando email_verified é verdadeiro e o campo hd está presente (conta Google Workspace). Nesses dois casos o Google é a autoridade sobre o endereço e você pode vincular sem pedir a senha. Fora deles, por exemplo uma conta Google comum criada com rh@padaria.com.br, o Google não responde por aquele endereço e a vinculação exige a senha atual.

def google_responde_pelo_email(info):
    # Gmail, ou conta Workspace com e-mail verificado (hd presente)
    return info.get("email_verified") and (
        info["email"].endswith("@gmail.com") or "hd" in info)

def achar_conta(info):
    linha = db.execute("SELECT id FROM usuarios WHERE google_sub = ?",
                       (info["sub"],)).fetchone()
    if linha:
        return linha[0], "entrou"
    linha = db.execute("SELECT id, email_verificado FROM usuarios WHERE email = ?",
                       (info["email"],)).fetchone()
    if linha and google_responde_pelo_email(info):
        usuario_id, verificado = linha
        if not verificado:
            # ninguém confirmou esse e-mail aqui: a senha e as sessões podem
            # ser de quem pré-criou a conta. Saem as duas.
            db.execute("UPDATE usuarios SET senha_hash = NULL WHERE id = ?", (usuario_id,))
            db.execute("DELETE FROM sessoes WHERE usuario_id = ?", (usuario_id,))
        db.execute("UPDATE usuarios SET google_sub = ?, email_verificado = 1 WHERE id = ?",
                   (info["sub"], usuario_id))
        return usuario_id, "vinculou"
    if linha:
        return None, "pedir-senha"
    cur = db.execute("""INSERT INTO usuarios (email, google_sub, email_verificado)
                        VALUES (?, ?, ?)""",
                     (info["email"], info["sub"], int(bool(info.get("email_verified")))))
    return cur.lastrowid, "criou"

O trecho com email_verificado fecha um ataque descrito em 2022 por Avinash Sudhodanan e Andrew Paverd num estudo sobre criação de contas que encontrou o problema em pelo menos 35 de 75 serviços populares. É a pré-tomada de conta (pre-account hijacking), em que o atacante se cadastra com senha usando o e-mail da vítima, antes dela, e nunca confirma o endereço. Meses depois a vítima entra com Google, o sistema vincula pelo e-mail, e o atacante continua com a senha e com a sessão que deixou aberta. Por isso, quando a vinculação acontece numa conta cujo e-mail nunca foi confirmado no seu sistema, a senha é apagada e as sessões são derrubadas. A dona de verdade cria uma senha nova depois, se quiser.

Os oito pedidos do teste

O teste sobe o endpoint numa porta local, usa tokens assinados por uma chave de teste (a mesma técnica do guia de validação) e prepara três contas: uma com Gmail confirmado, outra com Gmail pré-cadastrado por um intruso, que tem sessão aberta, e uma de domínio próprio. Resultado:

400 csrf                                     só o e-mail, como o front antigo
400 csrf                                     csrf diferente do cookie
401 token recusado                           token forjado
303 criou           /painel                  conta nova
303 entrou          /painel                  mesma conta de novo
303 vinculou        /painel                  Gmail que já tinha senha
303 vinculou        /painel                  Gmail pré-cadastrado por outro
303 pedir-senha     /entrar?vincular=google  domínio próprio sem hd
[('ana@gmail.com', 'x', 1), ('bia@gmail.com', None, 1), ('rh@padaria.com.br', 'x', 0), ('cliente@gmail.com', None, 1)]
sessões do intruso: 0

Na ordem: o pedido no formato do front antigo, só com o e-mail, nem chega à parte do token; CSRF diferente do cookie é barrado; token assinado com outra chave volta 401; o primeiro login cria conta, o segundo encontra pelo sub; o Gmail confirmado é vinculado mantendo a senha; o Gmail pré-cadastrado é vinculado sem a senha do intruso, e a sessão dele some; o domínio próprio sem hd vai para a tela que pede a senha.

A coluna google_sub é UNIQUE: se dois pedidos simultâneos tentarem gravar o mesmo sub em contas diferentes, o banco recusa o segundo, em vez de deixar uma conta Google apontando para duas pessoas.

O que ainda depende de você

  • A tela de /entrar?vincular=google pede a senha e, só depois de aceita, grava o sub. O fluxo de conta com várias identidades está em entrar com Google sem tomar a conta de ninguém.
  • Sessão criada assim precisa de logout de verdade no servidor, não só apagar o cookie; veja sair de todos os aparelhos.
  • O pedido antigo com JSON precisa morrer junto: remova a rota /login que aceitava e-mail, senão a porta continua aberta ao lado da nova.


Avalie este artigo

Referência: https://developers.google.com/identity/gsi/web/guides/verify-google-id-token