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=googlepede a senha e, só depois de aceita, grava osub. 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
/loginque aceitava e-mail, senão a porta continua aberta ao lado da nova.
Referência: https://developers.google.com/identity/gsi/web/guides/verify-google-id-token