Pular para o conteúdo principal

2 postagens marcadas com "auth"

Ver todas os Marcadores

Thiago Lages de Alencar
warning

Este post parte do princípio que o leitor viu o post anterior!

Além disto este post for reescrito pois o formato não estava do meu agrado (igual ao post anterior).

O repositório com exemplos continua vivo em:
https://github.com/thiagola92/learning-authentication

A lógica básica é a mesma que o post anterior, porém usando o email no lugar do username.

UI de login com email e password

O que temos de extra é que podemos utilizar o email do usuário para nos dar mais confiança que a pessoa acessando é o dono da conta.

Register

Client

Segue exatamente o mesmo passo a passo do post anterior (utilizando o email em vez de usuário).

Server

Segue exatamente o mesmo passo a passo do post anterior (utilizando o email em vez de usuário) porém não cadastramos o usuário ao final!

Carta representando um email

O motivo é bem simples: O email fornecido é realmente da pessoa que cadastrou a conta?

Imagina se nós terminassemos o cadastro acreditando naquele email... Dia seguinte aquele email poderia receber emails nosso, achar super esquisito e nos marcar como spam. Se isso acontecesse muito o Google iria acabar marcando nosso email corporativo como spam!

Para resolver esse problema basta cobrarmos uma prova que a pessoa é a dona do email. Podemos fazer isso enviando um email para ela e esperando que ela nos diga algo que possue no email.

note

Partindo do princípio que a pessoa dona do email é a única que deveria ter acesso ao email.

O modo clássico é gerar um código no servidor e enviar para o email da pessoa este código, agora ela precisa acessar o email e nos dizer o código para provar que ela conseguiu o código certo.

Visão geral do processo

Email é bom pois nós não precisamos ser dependentes de terceiros para utilizar (Gmail/Outlook/etc).

import smtplib
from email.message import EmailMessage

message = EmailMessage()
message["Subject"] = "Confirmation code"
message["From"] = "noreply@yourwebsite.com"
message["To"] = "user@example.com"
message.set_content("Your confirmation code is 123")

# I'm running my own email server for tests
with smtplib.SMTP("localhost", 8025) as s:
s.send_message(message)
info

Utilizei a biblioteca aiosmtpd para criar o server de test:
python -m aiosmtpd -n

Se tudo foi feito corretamente, todo email novo deve aparecer lá no formato:

---------- MESSAGE FOLLOWS ----------
Subject: Confirmation code
From: noreply@yourwebsite.com
To: user@example.com
Content: Your confirmation code is 123
X-Peer: ('127.0.0.1', 52020)

------------ END MESSAGE ------------

Porém o exemplo acima é inseguro, pois não utiliza a SLL (que ajuda a criar uma camada de segurança entre nós e o serviço). Hoje em dia isso é obrigatório para interagir com quase todos os serviços de email (eles não vão aceitar emails sem essa segurança).

import smtplib
from email.message import EmailMessage

message = EmailMessage()
message["Subject"] = "Confirmation code"
message["From"] = "noreply@yourwebsite.com"
message["To"] = "user@example.com"
message.set_content("Your confirmation code is 123")

with smtplib.SMTP("localhost", 8024) as s:
s.starttls()
s.send_message(message)
info

Levantei outro server de email e nele estou utilizando TLS.

Criei meu certificado auto-assinado:
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes -subj '/CN=localhost'

E utilizei ele durante a inicialização do server:
python -m aiosmtpd -n --tlscert cert.pem --tlskey key.pem -l localhost:8024

Então é isso? Podemos mandar email para provedores como Gmail? Nope.

Rosto chorando

Deve ser bem óbvio que embora este código funcione localmente, ele jamais passaria por qualquer segurança do nível de aplicação.

Ele possui TLS para proteger contra man-in-the-middle mas como que o provedor vai saber que nós somos os donos daquele email? Se nós botarmos noreply@gmail.com, nós temos que provar que temos acesso à aquela conta no Gmail.

A maneira clássica é logar na conta.

import smtplib
from email.message import EmailMessage

# Removed "From" because providers will use the email used in s.login()
message = EmailMessage()
message["Subject"] = "Confirmation code"
message["To"] = "user@example.com"
message.set_content("Your confirmation code is 123")

with smtplib.SMTP("localhost", 8024) as s:
s.starttls()
s.login("noreply@gmail.com", "password")
s.send_message(message)

Este código não vai funcionar!

Hoje em dia já sabemos que usuário e senha sozinhos não providênciam uma segurança forte durante a autenticação, por isso que Google nos cobra outros métodos de MFA para provar que nós somos nós mesmos durante os logins (telefone, passkeys, authenticators, ...).

Google requer que você explicitamente adicione uma senha para aquele "app" e mesmo assim ele não recomenda fazer isto!
Link para a resposta no StackOverflow

Bem, vamos parar por aqui pois eu apenas tenho interesse em ver o conceito de segurança com o email (não quero ensinar a se autenticar em diversos provedores com email).

Client 2

Existe duas opções aqui, a primeira é o server ter armazenado numa tabela temporária seu email, código enviado por email e hash da senha.

emailcodehash
thiagola92@email.com48935e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8
darklord@email.com38922d2c3f7eb9152d67258cd1068a64a746c130d4cca3f571bd28a86d7f7589aa25
juninho@email.com0283b7e94be513e96e8c45cd23d162275e5a12ebde9100a425c4ebcdd7fa4dcd897c

Neste caso basta o usuário enviar o código para o server validar a conta.

import httpx
from urllib.parse import urlencode

body = urlencode({"code": code})
headers = {"Content-Type": "application/x-www-form-urlencoded"}

httpx.post("http://127.0.0.1:8000/register/code", headers=headers, content=body)
note

Embora eu tenha feito como um requisição POST, não existe nenhuma obrigação de ser assim.

Uma maneira mais familiar é o usuário receber uma URL com o código/token para o usuário acessar e isto seria o suficiente para confirmar.

http://127.0.0.1:8000/register/{code}

A outra é ele não ter armazenado o hash e só esperar que você envie a senha novamente.

emailcode
thiagola92@email.com4893
darklord@email.com3892
juninho@email.com0283
import httpx
from urllib.parse import urlencode

body = urlencode({"email": email, "password": password, "code": code})
headers = {"Content-Type": "application/x-www-form-urlencoded"}

httpx.post("http://127.0.0.1:8000/register/code", headers=headers, content=body)
note

Em ambas as tabelas é normal de ter a data de criação do código, para que ele não fique válido para sempre (não queremos que ninguém chute todas as opções de código).

Server 2

Segue exatamente o mesmo passo a passo do post anterior (utilizando o email em vez de usuário).

Recovery

Client

A grande vantagem de ter email é adicionar um ponto de recuperação de senha.

Usuário se perguntando qual a senha

Dado que o usuário esqueceu a senha, ele só precisa requisitar o resete de senha da conta relacionada a aquele email.

import httpx
from urllib.parse import urlencode

body = urlencode({"email": email})
headers = {"Content-Type": "application/x-www-form-urlencoded"}

httpx.post("http://127.0.0.1:8000/recovery", headers=headers, content=body)

Como estamos falando do caso em que o usuário não lembra a senha, não podemos cobrar nenhuma autenticação... Em outras palavras, qualquer pessoa má intencionada pode ficar requisitando e resta ao server tomar cuidados para elas não conseguirem acesso.

Server

É a mesma ideia do login, queremos confirmar que é o dono da conta então mandamos para o email dele um código/token para ele utilizar na alteração da senha atual dele.

Lembrando que é importante botarmos tempo para o usuário fazer essa alteração e limite de tentativas, pois não queremos que usuários má intencionados usem força bruta para descobrir o código/token.

Client 2

Basicamente idêntico ao registrar, porém temos que fornecer a nova senha. Isto pode ser feito tudo em uma requisição ou em duas (acessar o URL com token e inserir a nova senha em um form).

Server 2

Valide o código/token!

Carimbo

Cada tentativa errada é um sinal de perigo, por isso é importante lembrar:

  • Invalidar o código/token depois de certo tempo
    • Por isto alguns tokens são grandes, pois nunca é possível chutar todas as possibilidades antes do tempo esgotar
  • Contar o número de tentativas de acertar o código/token para um email
    • Importante invalidar o código depois de certo número de tentativas, não queremos X máquinas tentando acertar o código

References

Thiago Lages de Alencar
warning

Eu não estava feliz que o post era muito "aqui o código de X/Y/Z", pois gerava paredão de texto. E quando escrevo estes posts, a intenção é reforçar conhecimento para no futuro eu conseguir voltar e relembrar do assunto (e não do código em específico).

O repositório com exemplos continua vivo em:
https://github.com/thiagola92/learning-authentication

Username com password é uma das maneiras mais velhas de se criar autenticação no seu sistema.

UI para login

Nós confiaremos que aquele usuário é o dono da conta se ele souber a senha que está relacionada àquele username. Note que falei "confiaremos" pois nunca podemos ter 100% de certeza, só estamos tentando reduzir a possibilidade de ser alguém indesejado na conta.

Register

Client

UI para registrar

Para entender como o registro de novos usuários funciona, podemos olhar como formulários em HTML funcionam.

<form action="./register">
</form>

Formulários coletam todos os dados dos elementos <input> dentro deles e enviam para o endereço especificado no atributo action. Tenha em mente que enviar quer dizer "mandar para aquele endpoint um request HTTP".

note

Caso você já tenha feito uma API REST em qualquer linguagem, você pode começar a notar detalhes familiares. Acontece que os navegadores não fazem nenhuma mágica, eles enviam o mesmo tipo de request que você está acostumado a lidar no backend de APIs REST.

Como eu estou escrevendo isto ao mesmo tempo que testo o código, eu vou trocar o endpoint para a minha API (http://127.0.0.1:8000/register).

<form action="http://127.0.0.1:8000/register">
</form>

Nós precisamos adicionar o input do tipo submit pois ele é utilizado para engatilhar o envio.

<form action="http://127.0.0.1:8000/register">
<input type="submit" value="register">
</form>

Se você clicar no botão de registrar, você mandara um request GET para o endpoint register, com nenhuma informação pois não existe nenhum campo input que segure informação.

No nosso caso precisamos de um campo para o username e outro para o password:

<form action="http://127.0.0.1:8000/register">
<input type="text" name="username" value="username"><br>
<input type="password" name="password" value="password"><br>
<input type="submit" value="register">
</form>

Note que utilizamos os tipos text e password apenas para dar o comportamento correto no navegador. O importante mesmo é o atributo name pois ele define o nome a qual o valor vai estar relacionado quando enviado.

Se clicarmos no botão de registrar, enviaremos as informações na URL:
/register?username=username&password=password

Por padrão o formulário envia um request GET, o que é ótimo se você quiser compartilhar URL com alguém ou salvar no favoritos.

Porém não é nada seguro quando estamos falando de informação sensível como a senha do usuário! Neste caso queremos enviar no corpo do request POST (onde não fica visível a qualquer pessoa olhando a tela do seu computador).

Podemos especificar o método utilizado no request atráves do atributo method:

<form action="http://127.0.0.1:8000/register" method="post">
<input type="text" name="username" value="username"><br>
<input type="password" name="password" value="password"><br>
<input type="submit" value="register">
</form>

Agora ao clicar no botão de registrar, enviaremos as informações no corpo do request e a URL para qual você vai ser direcionado não vai conter seus dados (/register).

Corpo do request: username=username&password=password

note

Note que no final das contas é o mesmo formato porém em lugares diferentes.

Se você tiver uma API, conseguirá ver que ambos possuem o campo content-type da requisição com o valor application/x-www-form-urlencoded.

Tudo isto poderia ser reproduzido em python com:

import httpx
from urllib.parse import urlencode

body = urlencode({"username": username, "password": password})
headers = {"Content-Type": "application/x-www-form-urlencoded"}

httpx.post("http://127.0.0.1:8000/register", headers=headers, content=body)
note

Eu continuarei mostrando o código Python equivalente ao que seria feito pelo navegador/server pois acho que da uma visão lógica de como as coisas são feitas por baixo dos panos.

Server

Okay, seu server recebeu a requisição de cadastro do usuário. O que fazer agora? Validações!

Robo lendo um papel

O conteúdo da requisição está no formato esperado?

Podemos verificar se o campo content-type está com application/x-www-form-urlencoded.

if request.headers.get("Content-Type") != "application/x-www-form-urlencoded":
raise Exception("Invalid body format")

Claro que isso não impede o usuário de formatar o conteúdo incorretamente, porém agora podemos assumir que ele errou o formato e enviarmos uma mensagem de erro coerente com o problema.

try:
body = await request.body()
body = body.decode()
fields = parse_qs(body)
except ValueError:
raise Exception("Body incorrectly formatted")

E se o usuário esquecer um dos campos? Sim, precisamos validar isto também.

if "username" not in fields or "password" not in fields:
return Exception("Missing username or password")

E se o usuário tiver caracters inválido?
E se a senha tiver caracters inválido?
E se o usuário já existir no banco?
...

Acho que você já entendeu que validação é importante.
Agora vamos falar de storage!

Database

Como se armazena o username?
Igual a qualquer outro campo texto...

Como se armazena a senha?
Não se armazena senha...

Pode parecer estranho a primeira vista mas não precisamos armazenar a senha para conferir se alguém nos deus a senha correta.

Existem funções hash criptográficas que produzem saídas com propriedades que nos ajudam a conferir se a senha de um usuário está correta.

Propriedades que nos interessão nessas funções:

  • Dada uma entrada de bytes, sempre produz a mesma saída de bytes
    • Nada de especial aqui, apenas está garantindo que não é afetado por outro fatores aleatórios (tempo, temperatura, etc)
  • Não existe função que reverte a operação
    • Em outras palavras, tendo a saída de bytes da função você não consegue saber a entrada que foi dada para a função hash (sem ir chutando todas as possibilidades)
  • Qualquer mudança na entrada de bytes gera uma saída de bytes muito diferente
    • A ideia é que as pessoas não devem saber que as entradas são parecidas a partir da saída

Diversas funções hash criptográficas existem, cada uma com o próprio algoritmo.
No nosso caso vamos utilizar o algoritmo sha256 para os exemplos!

>>> sha256(entrada)
saida
note

Segurança é algo que muda com o tempo, então funções hash criptográficas seguras de antigamente podem já não ser mais seguras.

Estou usando a função hash que usa o algoritmo sha256 apenas de exemplo, não estou considerando se é segura ou não para o ano atual.

entradasaída
abcba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
abda52d159f262b2c6ddb724a61840befc36eb30c88877a4030b65cbe86298449c9
ABCb5d4045c3f466fa91fe2cc6abe79232a1a57cdf104f7a26e716e0a1e2789df78
123a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3
  • Se você der a mesma entrada, vai receber a mesma saída
  • Olhando a saída você não sabe a entrada
  • A mudanaça de um bit entre "abc" e "abd" mudou totalmente a saída

Não sei se ficou claro, mas isso é perfeito para podermos conferir se alguém acertou a senha.

Assim que um usuário registra no serviço e nos da senha, podemos calcular o hash da senha (saída da função hash para a senha) para armazenar.

>>> sha256(b"password").hexdigest()
'5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8'

>>> sha256(b"mega_password").hexdigest()
'2d2c3f7eb9152d67258cd1068a64a746c130d4cca3f571bd28a86d7f7589aa25'

>>> sha256(b"senha").hexdigest()
'b7e94be513e96e8c45cd23d162275e5a12ebde9100a425c4ebcdd7fa4dcd897c'
usernamehash
thiagola925e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8
darklord2d2c3f7eb9152d67258cd1068a64a746c130d4cca3f571bd28a86d7f7589aa25
juninhob7e94be513e96e8c45cd23d162275e5a12ebde9100a425c4ebcdd7fa4dcd897c

Quando alguém for logar no nosso serviço, a pessoa vai inserir a senha e nós vamos conferir se o hash dessa senha é igual ao que temos no banco.

  • Se for igual, a pessoa sabe a senha e nós podemos autorizar o acesso ao serviço
  • Se não for igual, a pessoa não sabe a senha e nós devemos negar o acesso ao serviço

Qual a vantagem de armazenar assim?
Se algum hacker acessar nosso banco, ele não vai conseguir saber a senha das pessoas.

Mesmo que a pessoa saiba que meu hash é:
5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8
Se ela tentar o hash como senha, o hash do hash vai ser totalmente diferente.

>>> sha256(b"5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8").hexdigest()
'113459eb7bb31bddee85ade5230d6ad5d8b2fb52879e00a84ff6ae1067a210d3'

Então é isso? Temos que armazenar o hash da senha? Nope.

Face saying nope

O que acontece quando dois usuários possuem a mesma senha?

usernamehash
thiagola925e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8
darklord2d2c3f7eb9152d67258cd1068a64a746c130d4cca3f571bd28a86d7f7589aa25
juninhob7e94be513e96e8c45cd23d162275e5a12ebde9100a425c4ebcdd7fa4dcd897c
mmiguel2d2c3f7eb9152d67258cd1068a64a746c130d4cca3f571bd28a86d7f7589aa25

Isso quer dizer que se um hacker advinhar a senha de mmiguel, ele também sabe a senha de darklord.

Para previnir isso é normal adicionar salt às senhas, ou seja, concatenar bytes extras na senha antes de fazer o hash.

Assim que o usuário criar a conta, criaremos um salt para ele com uma função que gera X bytes aleatórios (os.urandom(X)):

# No caso vamos gerar 10 bytes aleatórios.
salt = os.urandom(10)
usernamesalt
thiagola92cea3a49482094b096acb
darklordb9f195b7ce55a51fdc0d
juninho885cfd855add75c08a7a
mmiguel10cc25d0c8adcbc44e9a

Iremos inserir na função hash a concatenação de senha + salt.

>>> sha256(b"password" + b"cea3a49482094b096acb").hexdigest()
'a15760e9639bc64e8908d5d2109900dcbc508e6fc5fa7b161e5149fcdaa04eee'

>>> sha256(b"mega_password" + b"b9f195b7ce55a51fdc0d").hexdigest()
'8c7044c8e42efebbf7d19027db0a00c8d2242197a0e001d5d0d977cccfaa020c'

>>> sha256(b"senha" + b"885cfd855add75c08a7a").hexdigest()
'f51f77c4575127dbf8906c60b7b1c80e01772be217c8a1c9df68e4f527b7f8eb'

>>> sha256(b"mega_password" + b"10cc25d0c8adcbc44e9a").hexdigest()
'bbb70b912376b0c266d90cd3ebd1ffa99f6c439a50ed6481bb809b99fd93d300'
usernamesalthash
thiagola92cea3a49482094b096acba15760e9639bc64e8908d5d2109900dcbc508e6fc5fa7b161e5149fcdaa04eee
darklordb9f195b7ce55a51fdc0d8c7044c8e42efebbf7d19027db0a00c8d2242197a0e001d5d0d977cccfaa020c
juninho885cfd855add75c08a7af51f77c4575127dbf8906c60b7b1c80e01772be217c8a1c9df68e4f527b7f8eb
mmiguel10cc25d0c8adcbc44e9abbb70b912376b0c266d90cd3ebd1ffa99f6c439a50ed6481bb809b99fd93d300

Agora se um hacker tentar todas as possibilidades de senhas para o mmiguel e um dia acertar, ele jamais vai ser que é a mesma senha para darklord.

E se o salt gerado for o mesmo para ambos?

Uma maneira de solucionar isto é verificar se já existe alguém com o mesmo hash e salt.
Caso sim, gere um novo salt...

warning

Minha intenção aqui era explicar o conceito.

É importante olhar a documentação da sua linguagem/biblioteca para saber a melhor função hash a se usar!

Por exemplo, python já disponibiliza uma função que já concatena senha e salt, e depois faz o hash (hashlib.scrypt()). Então não existe necessidade de fazer cada etapa dita neste post.

Login

Client

UI para login

Quando falando de website, a maneira para logar é a mesma de se registrar:

import httpx
from urllib.parse import urlencode

body = urlencode({"username": username, "password": password})
headers = {"Content-Type": "application/x-www-form-urlencoded"}

httpx.post("http://127.0.0.1:8000/login", headers=headers, content=body)

E o server responde enviando cookies para que o browser use nas próximas requisições de conteúdo.

Note que esse request utiliza o body para enviar username e password, o que deixa o body ocupado por dados relacionados ao login (ao menos que você queira fazer uma bagunça e misturar login com pedido de dados).

Outra maneira conhecida por APIs é passar a autentição no headers pelo campo Authorization:

import httpx
import base64

credentials = f"{username}:{password}"
credentials = credentials.encode()
credentials = base64.b64encode(credentials)
credentials = credentials.decode()

response = httpx.get("http://127.0.0.1:8000/", headers={"Authorization": f"Basic {credentials}"})

Note que nesse modelo nós botamos como valor do Authroization o esquema e depois a credencial:
<scheme> <credential>

No nosso caso o esquema é Basic, onde quer dizer que vamos passar as credenciais em base 64 e concatenadas com :.
<username>:<password>

A vantagem desta maneira é que em uma requisição nós já conseguiriamos nos autenticar e aplicar a operação desejada na API.

note

Essa maneira não é incentivada pois passar em toda requisição username e password é arriscado, basta uma pessoa má intencionado conseguir acesso a uma mensagem para obter sua senha.

Passar em uma requisição para obter um token/cookie é válido, pois toda requisição seguinte nós utilizariamos esse token/cookie para sermos validados no server.

Server

Okay, seu servidor recebeu o request de logar... O que fazer? Validações! (novamente)

Robo lendo um papel com os olhos estressados

Se estivermos falando da mesma maneira utilizada por websites, então as validações são bem parecidas com as de registrar!

if request.headers.get("Content-Type") != "application/x-www-form-urlencoded":
raise Exception("Invalid body format")
try:
body = await request.body()
body = body.decode()
fields = parse_qs(body)
except ValueError:
raise Exception("Body incorrectly formatted")
if "username" not in fields or "password" not in fields:
return Exception("Missing username or password")

Se estivermos falando da maneira das APIs, temos que adaptar ao outro local onde o username e password estão mas nada complicado!

if not request.headers.get("Authorization"):
raise Exception("Missing authorization")
auth = conn.headers["Authorization"]
scheme, credentials = auth.split()

if scheme.lower() != "basic":
raise Exception("Incorrect authorization protocol")
try:
credentials = base64.b64decode(credentials)
credentials = credentials.decode()
username, _, password = credentials.partition(":")
except ValueError:
raise Exception("Credentials incorrectly formatted")

Chegou a hora da verdade.
Quem mandou a requisição é o usuário verdadeiro da conta?

Pessoa com o lazer de uma arma na cabeça

Bem... Ele não vai ser o usuário verdadeiro se o usuário nem existir né?

Podemos verificar isso tentando pegar do banco o salt e hash.

salt, hash = get_user_auth(username)
if not salt or not hash:
raise Exception("Invalid user")

Agora vem a parte fácil... Validar se a senha que ele passou está certa! Basta calcular o hash usando a senha que nos passaram.

if sha256(password + salt).hexdigest() != hash:
raise Exception("Wrong password")

Fim!

Principalmente porque não pretendo cobrir a fundo assuntos como cookies e tokens. Mas tudo coberto já da um ótimo início para entender autenticação/login/cadastro/etc.

References