
Autenticação em grupo confiável com SMS e Express
Tempo de leitura: 12 minutos
Introdução
Trabalhar sozinho em um projeto pode, às vezes, ser tedioso e solitário. É por isso que colaborar com amigos pode tornar o processo muito mais agradável! No entanto, marcar encontros e encontrar horários convenientes para todos pode ser um incômodo. Felizmente, há uma solução: criar um aplicativo web que permita que vocês trabalhem juntos online de forma integrada.
Este tutorial irá orientá-lo na criação de um fluxo de autenticação simples usando a Verify API da Vonage. Isso permitirá que seus amigos acessem com segurança o aplicativo colaborativo sem precisar lidar com políticas complexas de senha ou criptografia. Em vez disso, você utilizará os números de telefone deles e o sistema de verificação da Vonage para autenticá-los e mantê-los conectados por meio de cookies de sessão.
tl;dr: Se você quiser pular essa parte e implantar o aplicativo imediatamente, pode encontrar todo o código necessário hospedado no GitHub.
Pré-requisitos
Antes de começarmos, certifique-se de ter:
Node.js e npm instalados
Algum conhecimento básico sobre Express.js e SQLite
Um número virtual da Vonage com recursos de SMS
Se quiser, você pode usar uma ferramenta como o ngrok para expor publicamente seu servidor de desenvolvimento local durante os testes.
Configuração
Comece criando um novo projeto Node.js e instalando os pacotes necessários:
Isso irá adicionar o Express, suporte ao banco de dados SQLite3, middleware para sessões/cookies e a biblioteca cliente da API da Vonage.
Em seguida, crie um .env arquivo e adicione suas credenciais da Vonage e seu número virtual:
Você também vai querer gerar uma chave secreta para o seu armazenamento de sessão:
Defina quaisquer outras variáveis de ambiente de que você possa precisar, como um código de convite, domínio do projeto, ID do aplicativo Vonage etc.
Configurar o Express
No arquivo principal do servidor (por exemplo, app.js), inclua o Express e configure o servidor básico:
const express = require('express');
const app = express();
const port = 3000;
app.listen(port, () => {
console.log(`Server running on port ${port}`);
});
Em seguida, adicione middleware para analisar solicitações JSON, gerenciar sessões e habilitar o armazenamento de sessões no SQLite3:
// parse client requests in JSON
app.use(express.json());
// install packages to do session management
const session = require('express-session');
const SQLiteStore = require('connect-sqlite3')(session);
// configure automatic session storage in SQLite db
app.use(require('cookie-parser')());
app.use(session({
store: new SQLiteStore,
secret: process.env.SESH_SECRET || 'your-secret-key-here', // Provide a secret option
resave: false,
saveUninitialized: false,
cookie: { maxAge: 7 * 24 * 60 * 60 * 1000 } // 1 week
}));Não se esqueça de inicializar o objeto do SDK do servidor da Vonage com suas credenciais de API:
const Vonage = require('@vonage/server-sdk');
const vonage = new Vonage({
apiKey: process.env.API_KEY,
apiSecret: process.env.API_SECRET,
applicationId: process.env.APP_ID,
privateKey: process.env.PRIVATE_KEY_PATH,
}); Configurando o banco de dados SQLite
Você precisará de algumas tabelas SQLite para armazenar os dados dos usuários e as sessões durante o fluxo de autenticação:
const sqlite3 = require('sqlite3').verbose();
const db = new sqlite3.Database(':memory:');
db.serialize(function(){
if (!exists) {
db.run('CREATE TABLE Sessions (phone NUMERIC, id TEXT)');
db.run('CREATE TABLE allowlist (phone NUMERIC, username TEXT)');
db.run('CREATE TABLE Authors (username TEXT)');
}
});A Sessions tabela armazena temporariamente os números de telefone dos usuários e os IDs de solicitação da Vonage durante a verificação. As Allowlist registra as inscrições permitidas, opcionalmente com um nome de usuário pré-aprovado. Authors é a lista permanente de nomes de usuário autenticados.
Gerenciar rotas
Rotas para suas vistas
Seu servidor já possui uma rota para a página principal em /. Abaixo dela, você pode adicionar mais duas rotas: uma para a página de cadastro ou login dos usuários e outra para a página de administração:
// View Routes
app.get('/', function(request, response) {
response.sendFile(__dirname + '/views/index.html');
});
app.get('/signup', function(request, response) {
response.sendFile(__dirname + '/views/signup.html');
});
app.get('/admin', function(request, response) {
if (isAdmin(request.session)) {
response.sendFile(__dirname + '/views/admin.html');
} else {
response.sendFile(__dirname + '/views/index.html');
}
});Para começar a gerenciar o acesso às funcionalidades de administração, você também deve declarar a isAdmin função referenciada na sua /admin rota. Ela dividirá sua lista de administradores .env em um array e procurará uma correspondência exata com o nome de usuário na sessão atual:
function isAdmin(sesh) {
let admins = process.env.ADMINS.split(',');
return admins.includes(sesh.username);
} O Endpoint de Administração
A primeira etapa do fluxo de trabalho para adicionar um usuário é que um administrador insira o número de telefone dele em uma lista de permissões. Como a mesma pessoa pode querer fazer login a partir de diferentes dispositivos (o que exigiria cookies de sessão adicionais) ou sua sessão pode expirar, o administrador pode, opcionalmente, associar o número de telefone a um nome de usuário já existente.
Para começar, declare o endpoint em /invite e adicione uma verificação de segurança para garantir que essa pessoa ainda seja um administrador:
app.post('/invite', function(request, response) {
if (!isAdmin(request.session)) {
response.status(500).send({message: "Sorry, you're not an admin"});
return;
}
});Se a pessoa que está tentando adicionar um convite não for um administrador, a solicitação deve falhar.
A próxima ação da função será obter o número de telefone da solicitação. Após uma verificação superficial de validade, ele é adicionado à lista de permissões. Se o administrador tiver especificado um nome de usuário, este também será adicionado:
app.post('/invite', function(request, response) {
if (!isAdmin(request.session)) {
...
}
let phone = request.body.phone;
if (!isNaN(phone)) {
if (request.body.username) {
db.run('INSERT INTO Allowlist (phone, username) VALUES ($phone, $user)', {
$phone: phone,
$user: request.body.username
});
} else {
db.run('INSERT INTO Allowlist (phone) VALUES ($phone)', {
$phone: phone
});
}
}
});Depois que o novo número de telefone for adicionado à lista de permissões, a última coisa a fazer é enviar uma mensagem de texto com o convite ao novo usuário. A mensagem será enviada pelo número de telefone que você salvou em .env, e o usuário receberá o código de convite atual para responder por mensagem de texto.
Você poderia pular essa etapa completamente e enviar diretamente o PIN de verificação. No entanto, isso permite que você forneça qualquer informação contextual que possa ser útil para o usuário, como o link de cadastro. Como os PINs do Verify têm validade de apenas cinco minutos, isso também ajuda a garantir que o PIN do destinatário não expire antes que ele o veja:
app.post('/invite', function(request, response) {
if (!isAdmin(request.session)) {
...
}
let phone = request.body.phone;
if (!isNaN(phone)) {
if (request.body.username) {
...
} else {
...
}
vonage.messages.send(
new SMS(
`Please reply to this message with "${process.env.INVITE_CODE}" to get your PIN.`,
phone,
process.env.APP_NUM,
),
);
}
}); O ponto de conexão do Webhook
Antes de poder receber mensagens de texto com a Messages API do Vonage, precisamos configurar as definições do nosso aplicativo no painel do Vonage. Vamos adquirir um número virtual e definir a URL do webhook de entrada, na configuração do número, como nossa URL local do ngrok, que expõe nosso servidor Node.js.
Por exemplo, podemos definir a URL do webhook de entrada como algo como http://1234abc.ngrok.io/answer para enviar mensagens recebidas para nosso endpoint local /answer.
Com o seu número de telefone configurado, você pode adicionar a lógica para o endpoint do webhook. Você verificará se a mensagem de texto contém o código de convite atual e se o número de telefone de origem está na lista de permissões. Se essas condições forem atendidas, você enviará uma solicitação de verificação e salvará o número de telefone e o ID recebidos na resposta no seu banco de dados de sessões:
app.post('/answer', function(request, response) {
let from = request.body.from;
if (request.body.text === process.env.INVITE_CODE) {
db.all('SELECT * from Allowlist WHERE phone = $from',
{$from: from},
function(err, rows) {
if (rows.length) {
vonage.verify.request({
number: from,
brand: process.env.PROJECT_DOMAIN
}, (err, result) => {
db.run('INSERT INTO Sessions (phone, id) VALUES ($phone, $id)', {
$phone: from,
$id: result.request_id
});
response.status(204).end();
});
}
});
}
});
Desta vez, o novo usuário receberá uma mensagem de texto gerada automaticamente pelo Vonage Verify contendo seu PIN. Você forneceu o número de telefone do seu aplicativo e o domínio no ngrok para identificação, mas, fora isso, o texto é padrão. É preciso haver algo a que o usuário possa responder nessa mensagem. Com um registro disso armazenado, vamos aguardar que ele conclua a etapa final por meio do aplicativo web.
O endpoint de cadastro ou login
O novo usuário enviará seu número de telefone, nome de usuário e PIN pelo cliente web. Apenas o nome de usuário será armazenado. Os demais valores servem para o processo de autenticação e, caso o login seja bem-sucedido, nós os removeremos do banco de dados.
Adicione um novo /login endpoint ao seu servidor e, como primeiro passo, faça uma validação rápida do nome de usuário. O exemplo aqui permite apenas caracteres básicos, o que pode ser suficiente para seus objetivos, ou talvez você queira um conjunto mais robusto de opções. Com a validação concluída, você encontrará a sessão correspondente ao número de telefone fornecido:
app.post('/login', function(request, response) {
let allowed = RegExp('[A-Za-z0-9_-]+');
let username = request.body.username;
if (!allowed.test(username)) {
return;
}
db.each('SELECT * FROM Sessions WHERE phone = $phone', {
$phone: request.body.phone
}, function(error, sesh) {
});
});Na função de retorno que fornece a linha da sessão, você fará outra verificação do nome de usuário: desta vez, para verificar se ele já está em uso e, em caso afirmativo, se esse número de telefone está autorizado a fazer login com ele:
app.post('/login', function(request, response) {
...
db.each('SELECT * FROM Sessions WHERE phone = $phone',{
$phone: request.body.phone
}, function(error, sesh) {
let broken = false;
db.all('SELECT * FROM Authors WHERE username = $user', {
$user: username
}, function(err, rows) {
if (rows.length) {
db.all('SELECT * FROM Allowlist WHERE username = $user AND phone = $phone', {
$user: username,
$phone: sesh.phone
}, function(e, r) {
if (e || !r.length) broken = true;
});
}
});
if (!broken) {
}
});
});Se todas as verificações do nome de usuário forem bem-sucedidas, você usará um sinalizador para confirmar que está tudo certo para continuar e que o PIN recebido do cliente está correto para essa solicitação de verificação. Se estiver, você receberá um status 0 na resposta e poderá, então, excluir com segurança a sessão e os registros da lista de permissões que utilizou durante esse processo. A última etapa é inserir o nome de usuário na sessão:
app.post('/login', function (request, response) {
let allowed = RegExp('[A-Za-z0-9_-]+');
let username = request.body.username;
if (!allowed.test(username)) {
response.status(500).send({ message: 'Please use basic characters for your username' });
return;
}
db.each('SELECT * FROM Sessions WHERE phone = $phone', {
$phone: request.body.phone
}, function (error, sesh) {
db.all('SELECT * FROM Authors WHERE username = $user', { $user: username }, function (err, rows) {
if (rows.length) {
db.all('SELECT * FROM Allowlist WHERE username = $user AND phone = $phone', {
$user: username,
$phone: sesh.phone
}, function (e, r) {
if (e || !r.length) {
response.status(500).send({ message: 'Please choose a different username' });
return;
}
});
}
vonage.verify.check(sesh.id, request.body.pin)
.then(result => {
if (result && result.status === '0') {
db.serialize(function () {
db.run('INSERT INTO Authors (username) VALUES ($user)', {
$user: username
});
db.run('DELETE FROM Allowlist WHERE phone = $phone', {
$phone: sesh.phone
});
db.run('DELETE FROM Sessions WHERE phone = $phone', {
$phone: sesh.phone
});
});
request.session.username = username;
response.status(200).send({ message: "Success" });
}
})
.catch(err => {
// handle errors
console.error(err);
if (err) {
console.log('Error occurred:', err);
response.status(500).send({ message: 'Error verifying your info' });
}
});
});
});
});
Adicione algumas marcações
Para coletar dados no lado do cliente, você precisará de dois formulários semelhantes: um formulário de administração e um formulário de cadastro. O formulário de administração acionará convites para novos usuários, e o formulário de cadastro criará novas sessões. Sua página index.html será a página inicial do seu projeto; você pode usá-la para fornecer qualquer informação ou funcionalidade que desejar. No entanto, copiar seu conteúdo para os arquivos admin.html e signup.html pode ser útil para que você tenha sua estrutura básica pronta.
Dentro da <main> tag no arquivo admin.html, substitua o código HTML por um formulário simples para coletar um número de telefone e um nome de usuário:
<main>
<h2>
Invite people to your application
</h2>
<form action="/invite" method="post">
<label>Phone number:
<input type="phone" id="phone" name="phone" />
</label>
<label>Username (optional):
<input type="text" id="username" name="username" />
</label>
<input type="submit" value="Invite" id="invite_btn" />
<h3 id="feedback"></h3>
</form>
</main>
O conteúdo do arquivo <main> no arquivo signup.html deve ser bem semelhante, exceto que lá você também coletará um PIN:
<main>
<h2>
Sign up or log in
</h2>
<form action="/login" method="post">
<label>Phone number:
<input type="phone" id="phone" name="phone" />
</label>
<label>Username:
<input type="text" id="username" name="username" />
</label>
<label>PIN:
<input type="text" id="pin" name="pin" />
</label>
<input type="submit" value="Sign up" id="signup_btn" />
<h3 id="feedback"></h3>
</form>
</main>
Ao manter a estrutura HTML padrão, você continuará criando links client.js entre as duas páginas. Como os formulários nessas páginas têm um design semelhante, um único arquivo de script client.jspode gerenciar com eficiência suas funcionalidades. Após reinicializar client.js para um estado em branco, esse é o momento perfeito para implementar seu script personalizado.
Neste script, comece identificando e coletando os elementos do formulário essenciais para a interação. Em seguida, detecte os botões de envio em ambos os formulários e defina ouvintes de eventos de clique. Esses ouvintes invocarão uma função comum que lida com o envio dos dados do formulário ao servidor, ao mesmo tempo em que interceptam e impedem o comportamento padrão de envio do formulário. Embora seja possível gerenciar o envio de formulários exclusivamente com HTML, o uso de JavaScript para essa tarefa oferece maior flexibilidade, especialmente à medida que sua aplicação evolui e exige um tratamento mais sofisticado.
let phone = document.querySelector('#phone');
let username = document.querySelector('#username');
let feedback = document.querySelector('#feedback');
// Invite Form
let invite_btn = document.querySelector('#invite_btn');
if (invite_btn) {
invite_btn.onclick = function(e) {
e.preventDefault();
let body = JSON.stringify({
phone: phone.value,
username: username.value
});
goFetch('/invite', body, e.target);
return false;
};
}
// Sign Up From
let signup_btn = document.querySelector('#signup_btn');
if (signup_btn) {
signup_btn.onclick = function(e) {
e.preventDefault();
let body = JSON.stringify({
phone: phone.value,
username: username.value,
pin: document.querySelector('#pin').value
});
goFetch('/login', body, e.target);
return false;
};
}
function goFetch(url, body, btn) {
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: body
})
.then(response => response.json())
.then(data => {
feedback.innerText = data.message || 'Thank you!';
btn.style.display = 'none';
});
}
Executar o projeto
Para iniciar o projeto, execute “npm run start” no terminal, na mesma porta em que o ngrok está em execução.
Observação: Seu aplicativo está pronto, mas há um porém: você precisa de direitos de administrador para convidar outras pessoas, mas, para obter esses direitos, é necessário receber um convite. Resolver essa questão poderia envolver a criação de uma solução alternativa para desenvolvedores (como você) com acesso total ao código. Uma solução rápida é modificar a função
isAdminparareturn true, permitindo temporariamente acesso irrestrito.
Conclusão e próximos passos
Você chegou ao fim deste tutorial! A inclusão do tratamento de erros aprimorará as interações do usuário, como a seleção de um nome de usuário já utilizado ou a inserção de caracteres inválidos. Além disso, a criação de endpoints de gerenciamento de usuários, juntamente com estratégias para gerenciar listas de permissão e prolongar a duração das sessões com base na atividade do usuário ou por meio de um recurso de renovação, facilitará o acesso contínuo, minimizando a dependência de verificações regulares por SMS.
O código deste tutorial está disponível no GitHub.
Para ficar por dentro das últimas notícias, siga-nos no nosso canal do Slack para desenvolvedores no Slack da Comunidade de Desenvolvedores, no X, anteriormente conhecido como Twitter, e em eventos.