No primeiro artigo dessa série fizemos uma POC simples mostrando uma alternativa ao Firestore para um aplicativo offline-first construido com flutter.
No segundo artigo instalamos um servidor Couchbase na nuvem usando o serviço Lightsail da Amazon.
Neste artigo iremos configurar o servidor para exigir usuário e senha via TLS (https/wss) e ajustar nosso aplicativo pra funcionar com essa autenticação sem expôr esses em nosso código dart.
Índice
- Vamos falar de segurança
- Habilitando autenticação no Couchbase Gateway
- Configurando TLS (https/wss)
- Restringindo os dados
- Conclusão
Conforme descrito na documentação há três formas de autenticar o acesso de usuários em nosso Couchbase Gateway.
1 - Autenticação anônima
2 - Autenticação básica
3 - Autenticação usando provedores externos
Desnecessário dizer que se você quer um alto nível de segurança deverá estudar como implantar a terceira opção.
Mas para o nosso projeto vamos usar a segunda opção e aproveitar para abordar um tema bem interessante.
Vamos falar de segurança
Sempre que estudo conteúdo sobre segurança chego no mesmo conceito. Nada é 100% seguro! Geralmente as boas práticas de segurança tem o objetivo de adicionar barreiras para tornar os ataques mais difíceis para um invasor. A quantidade dessas barreiras e o nível de complexidade delas vai depender do seu orçamento. No final das contas é como uma competição de quem tem mais recursos. Você precisa ter mais recursos do que seu inimigo. Ou seja, se seu aplicativo é um aplicativo de banco, com certeza você vai precisar gastar um bom tempo e dinheiro com soluções de segurança de alto nível.
Mas não somos um banco. E há muitos serviços que precisamos acessar que não possuem esse alto nível de segurança em suas estruturas. Muitos serviços de terceiros usam uma autenticação básica em que devemos enviar um token, ou então um usuário e uma senha para validar nosso acesso.
Então vamos fazer a pergunta fatídica: Como usar chaves de API de serviços de terceiros de maneira relativamente segura usando os recursos que temos. Combinamos que não iríamos usar Firebase lembra? Então não iremos usar o Remote Config. Não é que a solução deles seja ruim, mas se você estiver desenvolvendo um aplicativo para Windows terá dificuldade de implementar isso enquanto a INVERTASE não finalizar o desenvolvimento do flutterfire em dart puro.
Então voltando a nossa pergunta, se procurar na internet encontrará vários posts com respostas sobre isso. Mas eu recomendo a leitura desse artigo que aborda o assunto com a devida profundidade. Nele o autor descreve algumas formas de fazer isso e quais são as melhoras.
Segundo a documentação oficial do flutter, podemos usar variáveis de ambiente preenchidas em tempo de compilação para guardar informações sensíveis.
Continuous Integration (CI) systems generally support encrypted environment variables to store private data. You can pass these environment variables using --dart-define MY_VAR=MY_VALUE while building the app.
Isso fará com que esses valores sejam preenchidos apenas no momento que você compila o seu aplicativo.
Então vamos usar essas variáveis para guardar nossa chave, e fazer algumas verificações simples pra confirmar que isso que estamos fazendo traz um nível mínimo de dificuldade para um possível curioso tentando pegar nosso token.
Dentro do código você pode acessar essas variáveis com o seguinte código:
const String apiSecret = String.fromEnvironment('MY_VAR');
Atenção:
Você precisa atribuir o valor a uma variável do tipoconst
caso contrário o dart não irá atribuir o valor corretamente.
Vamos fazer isso e ver o que acontece. Vou adicionar esse código no inicio do aplicativo que criamos no primeiro artigo. Se eu criar uma variável e nunca usar ela, o processo de compilação remove o código não usado do aplicativo final, então para que nosso teste funcione vou usar um print()
logo após receber a variável:
Agora vamos gerar um .apk passando um valor para essa variável no momento da compilação e vamos ver onde e como ela aparecerá para alguém tentando fazer engenharia reversa em nosso aplicativo:
Para gerar o .apk passando as variáveis vamos usar o seguinte comando:
flutter build apk --dart-define MY_VAR=CH@V3S3KR3T@
Vamos usar o jadx para descompilar o apk e ver o código dele. Para detalhes de como fazer isso recomendo esse artigo.
Ao abrir o .apk e procurar nossa chave percebemos que ela não aparece numa busca direta:
Vamos insistir mais um pouco e tentar achar essa chave diretamente em qualquer arquivo inclusive executáveis. Para isso vou descompactar o .apk em uma pasta e usar o Agent Ransack para tentar achar esse valor em qualquer arquivo dentro dessa pasta:
Pronto, achamos ela. Mas como vimos ela não está associada a nenhum nome de variável, e mesmo olhando código puro ela não aparece com facilidade. Se criarmos uma chave complexa com simbolos e de tamanho considerável será mais dificil ainda para um invasor saber o que encontrar. Podemos também codificar ela com algum algoritmo de nossa escolha como o base64, o que faria com o que o invasor tivesse que analisar a lógica do nosso código pra descobrir o que estamos fazendo pra depois tentar achar a chave e decodificar ela de volta e conseguir finalmente acessar nossa API de terceiros. Também podemos usar essa técnica com a url de acesso da api, o que faria com que o atacante perdesse mais tempo ainda identificando o que precisa decodificar e qual valor é usado em qual variável.
Isso já traz dificuldade suficiente para o nosso projeto. Como eu disse acima, há outras soluções mais avançadas que você pode implantar se seu projeto precisa de mais proteção.
Para usar essas variáveis enquanto estiver em modo debug, você precisa adicionar elas na chave toolArgs
do seu arquivo launch.json
conforme descrito aqui:
Então primeiro vamos configurar o Couchbase Gateway para exigir autenticação com usuário e senha, e depois vamos ajustar nosso aplicativo para usar essas variáveis em tempo de compilação e enviar os dados para o Couchbase Gateway de maneira segura.
Habilitando autenticação no Couchbase Gateway
Lá no começo falamos das três formas de autenticação que o Couchbase Gateway suporta.
A autenticação anônima é a que já está sendo usando na instalação padrão que fizemos. Por isso que conseguimos inserir o documento no banco diretamente pelo powershell sem nenhuma senha.
O que diz para o Couchbase Gateway permitir isso é a seguinte diretiva no arquivo de configurações:
Então o que vamos fazer é desativar o acesso anônimo, e adicionar um usuário com senha nessa lista. Nosso arquivo vai ficar assim:
Reinicie o serviço e verifique se o console está respondendo corretamente, se não estiver revise o arquivo antes de seguir.
Agora se tentarmos fazer a inclusão via powershell, devemos receber o erro de login requerido:
Configurando TLS (https)
Nada do que fizemos faz sentido se a solicitação estiver sendo enviada em texto puro para o servidor. Então o próximo passo é habilitar a comunicação segura entre o nosso aplicativo e o servidor. Para ativar o TLS precisamos de um certificado válido. E para fazer isso direito precisamos de uma autoridade certificadora (CA) do mundo real em vez de usar um certificado auto-assinado. Para isso vamos usar a Let's Encrypt uma CA real que nos permite gerar certificados de forma gratuita.
Para podermos gerar o certificado precisamos de um domínio válido configurado. Se seu projeto for bem pequeno e você não tem um domnínio ainda, a próxima etapa não se aplica a seu caso.
Vou acessar o provedor do meu domínio na parte de DNS e apontar o serviço de DNS para a Amazon.
Depois disso podemos alterar as configurações do DNS diretamente no Lightsail. Acesse o console e na aba Networking, escolha Create DNS zone:
Agora coloque o nome do seu domínio e escolha Create DNS zone:
Depois de criado, vamos na opção adcionar registro:
Preecha conforme imagem abaixo:
Depois disso você deverá ser capaz de acessar as APIs usando o seu dominio em vez de usar o ip:
Se ao tentar acessar seu navegador automaticamente redirecionar para https, você pode desativar isso seguindo esse artigo.
Agora que temos o domínio configurado, vamos gerar o certificado.
Primeiro vamos instalar as dependências:
sudo yum install epel-release -y
sudo yum install snapd -y
Agora vamos ativar o serviço e habilitar o suporte clássico conforme descrito aqui:
sudo systemctl enable --now snapd.socket
sudo ln -s /var/lib/snapd/snap /snap
Agora vamos instalar o certbot:
sudo snap install --classic certbot
Agora vamos criar os certificados:
sudo certbot certonly --standalone -d example.com
Ao fazer isso serão criados os seguintes arquivos:
/etc/letsencrypt/live/example.com/fullchain.pem
/etc/letsencrypt/live/example.com/privkey.pem
Vamos dar permissão de leitura para esses arquivos:
sudo chmod -R 755 /etc/letsencrypt
Agora vamos configurar o Couchbase Gateway para usar esse certificado. Ao fazer isso ele automaticamente passa a usar TLS na comunicação da API. Conforme descrito nessa parte da documentação, devemos adicionar o caminho dos arquivos do certificado e da chave no nosso arquivo de configuração do Couchbase Gateway.
Reinicie o serviço e teremos a API respondendo via HTTPS.
Com isso temos o servidor devidamente configurado para ser acessado através via TLS e exigindo usuário e senha.
Agora vamos voltar para o flutter e ajustar nosso app para utilizar o usuário e senha que criamos e sincronizar usando TLS.
O que vamos fazer é o seguinte. Vamos adicionar o parametro authenticator
nas configurações do replicador. Nele vamos passar o usuário e a senha que configuramos no Couchbase Gateway.
Mas como falamos no começo do artigo, esses valores não iremos digitar direto no código. Vamos trazer eles através das variáveis preenchidas em tempo de execução. Vamos aproveitar e fazer isso também para a url da API do Couachbase Gateway. Também vamos usar base64 para ofuscar elas.
Então vamos começar declarando e populando as variáveis.
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
Database? database;
var replicator;
// Aqui recebemos as variáveis que virão do ambiente via dart-define
static const String constCouchGwUser = String.fromEnvironment('couchGwUser');
static const String constCouchGwPass = String.fromEnvironment('couchGwPwd');
static const String constEndPointUrl = String.fromEnvironment('endPointUrl');
// Aqui decodificamos de base64 para o valor original
String couchGwUser = Utf8Codec().decode(base64Decode(constCouchGwUser));
String couchGwPwd = Utf8Codec().decode(base64Decode(constCouchGwPwd));
String endPointUrl = Utf8Codec().decode(base64Decode(constEndPointUrl));
Future<void> _incrementCounter() async {
Agora vamos alterar as configurações do replicador. Vamos usar a variável da url do endpint que criamos, e vamos incluir o parâmetro authenticator
passando um objeto do tipo BasicAuthenticator
com o nome de usuário e a senha:
// replicador
replicator = await Replicator.create(
ReplicatorConfiguration(
database: database!,
target: UrlEndpoint(Uri.parse(endPointUrl)),
// authenticador
authenticator: BasicAuthenticator(username: couchGwUser, couchGwPwd: key),
),
);
O valor das variáveis nós iremos enviar em base64, então vamos codificar esses valores usando o site base64encode.org:
E agora a url do endpoint:
Importante!
Nós precisamos mudar a url porque agora a comunicação será via TSL então em vez de usar o prefixows://
vamos usarwss://
:
Para podermos usar essas variáveis em modo debug vamos alterar o lauch.json da seguinte forma:
{
"version": "0.2.0",
"configurations": [
{
"name": "poc_flutter_couchbase",
"request": "launch",
"type": "dart",
// Variaveis que serão passadas em tempo de compilação
"toolArgs": [
"--dart-define",
"couchGwUser=c3luY19jbGllbnQ=",
"--dart-define",
"couchGwPwd=Q0hAVjNTM0tSM1RA",
"--dart-define",
"endPointUrl=d3NzOi8vZXhhbXBsZS5jb206NDk4NC9teS1kYXRhYmFzZQ==",
]
},
{
"name": "poc_flutter_couchbase (profile mode)",
"request": "launch",
"type": "dart",
"flutterMode": "profile"
}
]
}
Rode o programa e faça o teste, se tudo estiver certo você verá no log que o documento foi incluido e verá no servidor que o documento foi criado com sucesso.
Restringindo os dados
Por último vamos falar de um problema que precisamos eliminar se quisermos usar isso tudo em um aplicativo real.
Imagine que fizemos tudo isso, e agora desenvolvemos um aplicativo para que os usuários guardem suas anotações pessoais.
Da forma como o código está, se 10 usuários estiverem usando o aplicativo e incluirem 10 notas, todos os 10 usuários receberão 100 notas durante a sincronização. Ou seja, não há nenhum filtro. Você pode pensar em fazer um filtro no seu código, mas isso não impediriam que os dados fossem enviados para o seu aplicativo, o que sobrecarregaria a conexão do usuário, sem falar na franquia. Só quando todos os dados de todos os usuários chegassem no seu aplicativo é que você descartaria os registros. Imagina isso em escala, para mil usuários, um milhão. Acho que já deu pra entender o problema.
A solução seria de alguma forma informar ao servidor que durante a sincronização ele só deve nos enviar dados do usuário atual, não de todos os documentos.
Para isso existe o recurso de 'canais' no couchbase. Quando criamos o replicador podemos dizer quais 'canais' queremos escutar. Com isso o replicador só irá receber documentos desses 'canais' que definirmos.
No nosso exemplo, podemos criar um canal específico para o usuário, e configurar o replicador para receber somente documentos desse usuário.
Para isso vamos alterar novamente o código da seguinte forma:
// replicador
replicator = await Replicator.create(
ReplicatorConfiguration(
database: database!,
target: UrlEndpoint(Uri.parse(endPointUrl)),
channels: ['userA'],
// authenticador
authenticator: BasicAuthenticator(username: couchGwUser, couchGwPwd: key),
),
);
Com isso estamos dizendo que desejamos receber dados apenas do userA
. É claro que você deve usar uma váriavel aqui que tenha o identificador do seu usuário.
Depois de fazer isso no replicador precisamos fazer mais uma coisa. Toda vez que enviarmos um documento para o servidor, precisamos registrar no documento a qual canal aquele documento pertence. A configuração de canais é bem flexível, podemos alterar o script do gateway para buscar isso de um campo específico ou usar qualquer lógica que atenda nossa necessidade. Mas por padrão o gateway irá definir o canal do documento usando o campo channels
que aceita uma lista de valores. Então na criação do documento iremos alterar o código da seguinte forma:
final doc = MutableDocument({
'type': 'logMessage',
'createdAt': DateTime.now(),
'message': 'teste',
'channels': ['userA'] // canal que esse documento será atribuido
});
Com isso nosso aplicativo só irá sincronizar dados referente ao usuário userA
.
Conclusão
Nessa série de artigos implementamos uma solução offline-first em Flutter sem usar os serviços do Google Firebase.
- Criamos um VPS (Servidor Virtual Privado) na Amazon usando o Lightsail.
- Instalamos o Couchbase Server e o Couchbase Gateway no Centos7.
- Criamos os certificados usando a Let’s Encrypt como CA (Autoridade Certificadora) e ativamos o TLS (Transport Layer Security) para que a comunicação seja feita via https/wss.
- Configuramos a API do Couchbase Gateway para exigir autenticação básica (usuário e senha).
- Usamos o aplicativo de demonstração do Flutter para gravar os dados de modo offline e depois sincronizar esses dados com o Couchbase Gateway.
- Usamos
dart-define
para não expor a senha em nosso código. - Configuramos um canal para filtrar o envio e recebimento dos documentos do nosso usuário.
Não achei muito material abordando esse tema, especialmente em português então espero que esses artigos sejam úteis para quem precisar.
😉
Top comments (0)