Módulo 11 — Exercícios: Autenticação e Segurança
Chegamos ao módulo que conecta tudo que você aprendeu sobre Flutter com o problema mais sensível de qualquer aplicação em produção: saber quem é o usuário, garantir que ele é quem diz ser, e proteger cada camada da pilha de acesso não autorizado. Neste módulo você implementou OAuth 2.0 com PKCE, estruturou o armazenamento seguro de tokens com flutter_secure_storage, construiu um cliente HTTP que renova credenciais de forma transparente, habilitou autenticação biométrica com local_auth, e escreveu um Lambda Authorizer em Python que valida JWTs antes de qualquer requisição chegar à lógica de negócio. Os três exercícios a seguir percorrem esse caminho de forma progressiva: o primeiro estabelece a fundação do armazenamento e da biometria; o segundo integra o interceptor HTTP com a máquina de estados de sessão; o terceiro fecha o ciclo com o Lambda Authorizer e o roteamento protegido com go_router. Leia cada enunciado com cuidado antes de escrever qualquer linha de código, pois as decisões de design que você tomar aqui têm consequências diretas na segurança do aplicativo.
Exercício 1
TokenRepositorio e BiometriaServico: a fundação do armazenamento seguro
Antes de autenticar, é preciso saber guardar com segurança
Toda a cadeia de autenticação do seu aplicativo de delivery repousa sobre dois pilares: um lugar confiável para guardar os tokens que provam a identidade do usuário, e um mecanismo para confirmar, na reabertura do aplicativo, que a pessoa que está segurando o dispositivo é a mesma que realizou o login. Sem o primeiro pilar, os tokens ficam expostos a qualquer processo com acesso ao armazenamento do dispositivo. Sem o segundo, um dispositivo desbloqueado ou compartilhado oferece acesso irrestrito à conta do usuário sem nenhuma fricção adicional. Este exercício trata exatamente disso: você vai implementar o TokenRepositorio e o BiometriaServico, que juntos formam a infraestrutura de segurança de mais baixo nível do aplicativo, e vai integrá-los em uma tela de splash que usa os dois para tomar a decisão de para onde navegar.
O contexto importa aqui. Você já sabe que SharedPreferences é inadequado para tokens porque os arquivos por ele gerados não são protegidos pelo sistema operacional contra outros processos — em um dispositivo com root, qualquer aplicação pode ler esses arquivos sem restrição. O flutter_secure_storage resolve esse problema de formas distintas em cada plataforma: no Android, ele usa o EncryptedSharedPreferences combinado com o Android Keystore, que mantém a chave de criptografia em hardware isolado; no iOS, ele usa o Keychain protegido pelo Secure Enclave. A consequência prática é que, mesmo que um atacante obtenha o arquivo de armazenamento, não consegue ler os dados sem a chave, que nunca sai do hardware.
O primeiro componente que você deve implementar é o TokenRepositorio. Essa classe é responsável por toda a persistência e recuperação dos tokens de autenticação, e deve ser construída de forma a nunca vazar informações sensíveis. Para instanciar o FlutterSecureStorage, use as opções específicas de plataforma: no Android, configure encryptedSharedPreferences: true dentro de AndroidOptions; no iOS, configure accessibility: KeychainAccessibility.first_unlock_this_device dentro de IOSOptions. Essa opção específica do iOS tem uma implicação importante que você deve compreender: ela instrui o Keychain a tornar o dado disponível apenas após o primeiro desbloqueio do dispositivo após uma reinicialização, e marca o dado como não migrável para outros dispositivos via backup do iCloud. Isso significa que se um usuário restaurar um novo iPhone a partir de um backup, os tokens não serão transferidos — o que é o comportamento correto para credenciais de segurança.
A instância de FlutterSecureStorage deve ser recebida pelo construtor com um valor padrão, permitindo que em testes unitários você substitua o armazenamento real por um mock sem alterar nenhum código de produção. Utilize as seguintes chaves de string para persistência: 'delivery_access_token', 'delivery_refresh_token', 'delivery_token_expiracao' e 'delivery_usuario_id'.
Os métodos obrigatórios do TokenRepositorio são cinco. O método salvarSessao deve receber um access token, um refresh token, a data e hora de expiração do access token e o ID do usuário, e persistir os quatro valores em paralelo usando Future.wait. Essa decisão não é arbitrária: quatro escritas sequenciais com await encadeados levariam quatro vezes mais tempo que escritas paralelas, e o momento de salvar a sessão — imediatamente após um login — é exatamente quando o usuário aguarda a tela principal aparecer. Os métodos lerAccessToken e lerRefreshToken fazem leituras simples e diretas. O método accessTokenEstaValido lê a data de expiração armazenada, retorna false imediatamente se ela for nula, e em seguida verifica se o token ainda terá validade daqui a pelo menos 60 segundos — essa margem existe para evitar que um token expire durante o trânsito da requisição até o servidor. Use DateTime.parse(exp).subtract(const Duration(seconds: 60)).isAfter(DateTime.now()) para essa verificação. O método temSessaoAtiva retorna true apenas se o refresh token lido for não-nulo e não-vazio, pois é o refresh token que define se há uma sessão recuperável — o access token pode estar expirado, mas enquanto houver um refresh token válido, a sessão pode ser renovada. Por fim, o método limparTodosOsTokens deve chamar deleteAll(), que remove todos os valores persistidos pelo flutter_secure_storage para esta aplicação.
O segundo componente é o BiometriaServico, que encapsula o LocalAuthentication do pacote local_auth. Da mesma forma que o TokenRepositorio, ele deve receber uma instância de LocalAuthentication pelo construtor com um valor padrão, garantindo a injetabilidade em testes. O método biometriaEstaDisponivel deve retornar true somente se três condições forem verdadeiras simultaneamente: isDeviceSupported() retornar true, canCheckBiometrics retornar true, e getAvailableBiometrics() retornar uma lista não vazia. As três verificações devem ser feitas antes de tentar a autenticação para evitar exceções desnecessárias.
O método autenticar deve receber um parâmetro nomeado motivoExibido e passá-lo como localizedReason para _auth.authenticate(), configurado com AuthenticationOptions(biometricOnly: false, stickyAuth: true, sensitiveTransaction: true). A opção biometricOnly: false permite que o usuário use o PIN do dispositivo como fallback quando a biometria falha após algumas tentativas — o que é especialmente importante para usuários idosos ou com dificuldades com leitores biométricos. A opção stickyAuth: true mantém o diálogo de autenticação aberto quando o aplicativo vai para segundo plano momentaneamente. A opção sensitiveTransaction: true instrui o sistema operacional a exibir uma mensagem mais explícita sobre o que está sendo autorizado.
A parte mais importante do método autenticar é o tratamento de exceções. O local_auth lança PlatformException com códigos específicos para diferentes situações: notAvailable quando o hardware não está disponível, notEnrolled quando nenhuma biometria está registrada, lockedOut quando o leitor está temporariamente bloqueado após muitas tentativas incorretas, e permanentlyLockedOut quando o bloqueio requer intervenção do usuário para ser desfeito. Para esses quatro códigos, o método deve capturar a exceção e retornar false. Para qualquer outro código de PlatformException — por exemplo, um erro inesperado de hardware — o método deve relançar a exceção. Importar package:local_auth/error_codes.dart como auth_error permite usar as constantes auth_error.notAvailable, auth_error.notEnrolled, auth_error.lockedOut e auth_error.permanentlyLockedOut para fazer a comparação sem usar strings literais.
O terceiro componente é a TelaSplash, um StatefulWidget que recebe o TokenRepositorio e o BiometriaServico pelo construtor. Em initState, ela deve disparar um método assíncrono _verificarEstadoInicial() que executa a seguinte lógica: primeiro, verifica se há sessão ativa com temSessaoAtiva(); se não houver, navega para /login; se houver, verifica se a biometria está disponível; se estiver, solicita a autenticação com o motivo 'Confirme sua identidade para continuar'; se bem-sucedida, navega para /home; se falhar, exibe um SnackBar com a mensagem 'Autenticação biométrica falhou' e permanece na tela de splash aguardando nova tentativa; se a biometria não estiver disponível, navega diretamente para /home. Durante toda a verificação, a tela deve exibir um CircularProgressIndicator centralizado. Para navegar, use Navigator.pushReplacementNamed. Em cada ponto do código que sucede um await, verifique mounted antes de chamar setState ou qualquer método de navegação, pois o widget pode ter sido removido da árvore enquanto a operação assíncrona estava em andamento.
Implemente os três componentes em um único arquivo g_ex1.dart, que deve conter também uma função main com um MaterialApp simples para demonstrar o funcionamento. Não use SharedPreferences em nenhum contexto relacionado a tokens. Não imprima tokens com print em nenhuma circunstância, pois logs de aplicação podem ser capturados por ferramentas de depuração em dispositivos não confiáveis.
Antes de implementar, reflita sobre três questões que vão além da sintaxe. Por que Future.wait é preferível a quatro chamadas await sequenciais no método salvarSessao? O que acontece com os dados do Keychain iOS quando o dispositivo é restaurado a partir de um backup, e por que a configuração first_unlock_this_device evita que os tokens migrem para outro dispositivo? Por que relançar uma PlatformException com código desconhecido no método autenticar é a decisão correta, em vez de simplesmente retornar false e tratar o problema silenciosamente?
O método authenticate do local_auth pode lançar PlatformException com códigos que não estão entre os quatro tratados explicitamente — por exemplo, se o hardware biométrico apresentar uma falha inesperada associada a um código proprietário do fabricante do dispositivo. Retornar false silenciosamente nesses casos faria com que o aplicativo se comportasse como se a biometria simplesmente não funcionasse, sem nenhuma mensagem de erro e sem nenhum registro que permitisse diagnosticar o problema. O padrão correto é tratar apenas os códigos que você conhece e compreende, e relançar todos os demais para que a camada superior possa decidir o que fazer — seja exibir uma mensagem de erro diferenciada, seja registrar o evento em um sistema de monitoramento.
O que deve ser entregue: um arquivo chamado g_ex1.dart, onde g é o nome do seu grupo.
Exercício 2
HttpClienteAutenticado e GerenciadorRenovacaoToken: autorização transparente
Um token expirado nunca deve interromper o fluxo do usuário
Imagine que o usuário do aplicativo de delivery abre a tela de cardápio após uma hora sem usar o aplicativo. O access token expirou. Se o aplicativo simplesmente retornar um erro 401 e redirecionar para a tela de login, o usuário fica confuso — ele acabou de abrir o aplicativo, não fez nada de errado, e de repente está na tela de login novamente. Esse comportamento é tecnicamente correto, mas péssimo em termos de experiência. A solução certa é tornar a renovação do token completamente transparente: o interceptor HTTP detecta que o token está prestes a expirar, renova-o silenciosamente antes de fazer a requisição, e o usuário nunca percebe o que aconteceu. Este exercício implementa exatamente esse mecanismo, com um detalhe de concorrência que não pode ser ignorado.
O problema da concorrência é o seguinte: quando o usuário abre o aplicativo após o token expirar, a tela inicial frequentemente dispara várias requisições em paralelo — cardápio, endereços salvos, pedidos recentes, promoções. Se cada uma dessas requisições detectar independentemente que o token expirou e tentar renová-lo, você terá dez chamadas simultâneas ao endpoint /token do servidor de autorização, das quais nove retornarão erro porque o refresh token de uso único já foi consumido pela primeira. O resultado é que nove das dez telas retornam erro desnecessariamente. O GerenciadorRenovacaoToken existe para resolver exatamente esse problema.
O primeiro componente a implementar é o GerenciadorRenovacaoToken. Ele possui um único campo privado: Future<void>? _pendente. O método público renovar recebe uma função Future<void> Function() fn e implementa a seguinte lógica: _pendente ??= fn().whenComplete(() => _pendente = null); return _pendente!;. Esse padrão funciona porque Future em Dart é multicast — múltiplos await no mesmo objeto Future recebem o mesmo resultado quando ele completar. Quando a primeira requisição chama renovar, o campo _pendente é nulo, então a função fn é chamada e o Future resultante é armazenado em _pendente. As demais nove requisições que chegam em seguida encontram _pendente já preenchido, então o operador ??= não executa fn novamente — elas simplesmente fazem await no mesmo Future já em andamento. Quando a renovação terminar, todas as dez requisições recebem o resultado simultaneamente e prosseguem com o token renovado. O whenComplete garante que _pendente seja limpo ao final, independentemente de sucesso ou falha.
O segundo componente é a exceção SessaoExpiradaException. Implemente-a como class SessaoExpiradaException implements Exception com um campo final String mensagem, um construtor const SessaoExpiradaException(this.mensagem) e um override de toString() que retorna uma representação legível. Usar const no construtor é importante porque essa exceção pode ser instanciada em locais críticos de performance, e o compilador Dart pode otimizá-la.
O terceiro e principal componente é o HttpClienteAutenticado, que deve estender http.BaseClient. Ele recebe por construtor três colaboradores: TokenRepositorio tokenRepositorio, Future<void> Function() renovarToken (a função de renovação fornecida pela camada de autenticação), e http.Client? clienteInterno com padrão http.Client(). O GerenciadorRenovacaoToken deve ser instanciado internamente — não injetado pelo construtor — pois ele é um detalhe de implementação que não deve vazar para os consumidores da classe.
A implementação do método send deve seguir a sequência exata a seguir. Primeiro, verifica proativamente se o access token é válido com await _tokenRepositorio.accessTokenEstaValido(); se não for, chama await _gerenciador.renovar(_renovarToken). Essa verificação proativa é preferível à verificação reativa porque evita o round-trip de rede de uma requisição que inevitavelmente falharia — o custo de verificar a validade localmente é negligenciável comparado ao custo de uma requisição que retorna 401 e precisa ser repetida. Segundo, lê o access token com await _tokenRepositorio.lerAccessToken(); se o resultado for nulo mesmo após a renovação, lança um StateError com mensagem descritiva, pois essa situação indica um bug na implementação da renovação. Terceiro, adiciona o header Authorization: Bearer $accessToken na requisição. Quarto, delega para await _inner.send(req). Quinto, verifica se resposta.statusCode == 401; se sim, chama await _tokenRepositorio.limparTodosOsTokens() e lança const SessaoExpiradaException('Sessão encerrada. Faça login novamente.'), pois um 401 que persiste após a renovação indica que o refresh token também foi revogado ou expirou. Por fim, retorna a resposta. Sobrescreva também o método close() para chamar _inner.close() e então super.close(), garantindo que o cliente interno seja devidamente descartado.
O quarto componente é o SessaoNotifier, que deve estender ChangeNotifier. Antes de definir a classe, declare o enum EstadoSessao com os valores verificando, autenticacaoBiometricaNecessaria, autenticado e naoAutenticado. Para fins deste exercício, você pode declarar interfaces mínimas para os colaboradores: IAutenticacaoRepositorio com os métodos abstratos necessários, além de usar as classes concretas TokenRepositorio e BiometriaServico que você implementou no exercício anterior.
O estado inicial do SessaoNotifier deve ser verificando. O campo EstadoSessao _estado deve ser privado com um getter público. Um campo String? _mensagemErro deve expor a última mensagem de erro disponível. O campo ModeloUsuarioAutenticado? _usuario deve guardar os dados do usuário logado.
O método verificarSessaoInicial() executa a máquina de estados inicial: verifica temSessaoAtiva() no repositório de tokens; se falso, muda _estado para naoAutenticado e notifica; se verdadeiro, verifica se a biometria está disponível; se sim, muda para autenticacaoBiometricaNecessaria e notifica; se não, chama o método privado _restaurarSessao(). O método confirmarBiometria() solicita autenticação biométrica com o motivo 'Confirme sua identidade para acessar o delivery'; se bem-sucedida, chama _restaurarSessao(); se não, define _mensagemErro e notifica. O método privado _restaurarSessao() chama renovarToken() no repositório de autenticação; em caso de sucesso, muda para autenticado e notifica; em caso de qualquer exceção, chama limparTodosOsTokens() e muda para naoAutenticado. Os métodos fazerLogin() e fazerLogout() implementam as transições correspondentes, com fazerLogout() chamando limparTodosOsTokens() antes de mudar o estado.
O quinto componente é uma TelaPrincipal que usa Consumer<SessaoNotifier> e exibe conteúdo diferente via switch sobre notifier.estado: para verificando, exibe um CircularProgressIndicator centralizado; para autenticacaoBiometricaNecessaria, exibe um botão “Usar biometria” que chama notifier.confirmarBiometria(); para autenticado, exibe o texto 'Bem-vindo, ${notifier.usuario?.nome}' seguido de um botão “Sair” que chama notifier.fazerLogout(); para naoAutenticado, exibe um botão “Fazer login” que chama notifier.fazerLogin().
O HttpClienteAutenticado não deve ter conhecimento de nenhuma lógica de negócio do delivery. Ele deve funcionar como um cliente HTTP genérico autenticado, capaz de ser usado em qualquer projeto que precise de renovação transparente de tokens. Não importe modelos de domínio do delivery dentro dessa classe.
Reflita sobre o que aconteceria se o GerenciadorRenovacaoToken não existisse e cada instância de HttpClienteAutenticado tentasse renovar o token de forma independente. Quantas chamadas ao endpoint /token seriam disparadas quando dez requisições chegam simultaneamente com um token expirado? Por que a verificação proativa da validade do token no início do método send é preferível à verificação reativa — ou seja, por que é melhor verificar antes de tentar do que tentar e verificar somente quando receber 401?
O campo headers de um http.BaseRequest é um Map<String, String> mutável somente enquanto a requisição ainda não foi enviada. No momento em que _inner.send(req) é chamado, a requisição é “fechada” pelo framework e qualquer tentativa posterior de modificar os headers lança um StateError. Por isso, o ponto correto para injetar o header Authorization: Bearer é imediatamente antes de chamar _inner.send(req), que é exatamente a sequência descrita no enunciado. Qualquer inversão dessa ordem produzirá um erro em tempo de execução que pode ser difícil de depurar.
O que deve ser entregue: um arquivo chamado g_ex2.dart, onde g é o nome do seu grupo.