PDM · DAW 2

Aula 17 · Semana 8 · Terça, 27 de outubro de 2026 · 08:30 às 10:30

Autenticação com token e favoritos sincronizados

Teoria: autenticação em mobile: OAuth2 e OpenID Connect, JWT, tokens de acesso e refresh, biometria. Prática: token guardado em armazenamento seguro, renovação em 401 por um interceptor, e favoritos sincronizados com o servidor entre dispositivos.

Repositório: pdm-aula-17

No fim desta aula consegues

Autenticação vs autorização; cookies vs tokens

Para os testes: 401 é autenticação, 403 é autorização; em mobile usam-se tokens Bearer, não cookies.

JWT (1 de 2): o que tem dentro

{
  "sub": "grupo-07",
  "iss": "api.pdm.example",
  "iat": 1761494400,
  "exp": 1761495300,
  "scope": "forecast warnings favorites"
}

Para os testes: JWT = header.payload.signature; o payload é legível por todos, não é secreto.

JWT (2 de 2): assinatura, expiração e o que nunca lá vai

Para os testes: a assinatura dá integridade, não confidencialidade; nunca se põem dados sensíveis num JWT.

Analogia Uma pulseira de festival. Diz quem és e até quando vale, e tem a assinatura do organizador; qualquer porteiro a verifica à porta sem telefonar à bilheteira (o servidor valida a assinatura sem ir à base de dados). Quem tentar alterar a data estraga a assinatura. E não se escreve nela nada que não se queira que todos leiam: a pulseira está à vista de toda a gente.

OAuth2 e OIDC em traços largos: authorization code + PKCE

Para os testes: apps móveis não guardam passwords nem client secrets; usam authorization code com PKCE no browser do sistema.

Access token curto, refresh token longo, logout

Armazenamento seguro App API 1 · POST /v1/auth/token (credenciais) 2 · access (15 min) + refresh (30 dias) 3 · guardar os dois (secure storage) 4 · GET /v1/me/favorites · Bearer access 200 OK 5 · outro pedido · Bearer access expirado 6 · 401 Unauthorized 7 · POST /v1/auth/token com o refresh 8 · access novo 9 · guardar o access novo 10 · repetir o pedido original, uma vez 200 OK access curto: em cada pedido · refresh longo: só para renovar Se o refresh também falhar: logout e apagar os dois.
O access token vai em cada pedido e dura minutos; o refresh vive em secure storage e só serve para pedir um access novo.
Access tokenRefresh token
DuraçãoMinutos a uma horaDias a meses
UsoEm cada pedido, como BearerSó para pedir um access token novo
Onde guardarMemória; secure storage se tiver de sobreviver ao arranqueSecure storage, sempre
Se for roubadoVale pouco tempoGrave: revogar no servidor

Para os testes: access curto em cada pedido; refresh longo em secure storage, só para renovar; logout apaga ambos.

Analogia O access token é a pulseira do festival: mostra-se a cada porteiro e vale poucas horas. O refresh token é o bilhete que se troca por uma pulseira nova quando a antiga expira, sem voltar à fila da bilheteira (sem novo login). O bilhete fica guardado no bolso interior (secure storage) e só se mostra no balcão de troca; perdê-lo é grave, porque quem o tiver pode ir buscar pulseiras novas.

Biometria e Sign in with Apple

Para os testes: a biometria desbloqueia localmente; a autenticação no servidor continua a ser feita pelo token.

Repositório da aula

git clone https://github.com/pdm-daw-2026/pdm-aula-17.git
cd pdm-aula-17/starter
flutter pub get
flutter run --dart-define-from-file=.env.json
Antes de começar Pede um token no Swagger, cola-o em jwt.io e lê o exp. Converte-o para hora local (DateTime.fromMillisecondsSinceEpoch(exp * 1000)). É daqui a quanto tempo?

1. Obter o token e guardá-lo em segurança

class AuthService {
  AuthService(this._api, this._storage);
  final ApiClient _api;
  final TokenStorage _storage;   // flutter_secure_storage, da aula 16

  static const _clientId = String.fromEnvironment('PDM_CLIENT_ID');
  static const _secret = String.fromEnvironment('PDM_SECRET');

  Future<String> obterToken() async {
    final json = await _api.post('/v1/auth/token', body: {
      'client_id': _clientId,
      'secret': _secret,
    });
    final token = json['access_token'] as String;
    await _storage.guardar(token);
    return token;
  }

  Future<String> tokenAtual() async =>
      await _storage.ler() ?? await obterToken();
}

1. Token: o que interessa

Exercício 1 Implementa bool expiraEm(String token, Duration margem) que descodifica o payload e compara exp com DateTime.now(). Usa-o em tokenAtual() para renovar quando faltar menos de um minuto. O teste test/auth_service_test.dart traz tokens de exemplo.

2. AuthClient: Bearer em todos os pedidos, renovação em 401

class AuthClient extends http.BaseClient {
  AuthClient(this._inner, this._auth);
  final http.Client _inner;
  final AuthService _auth;

  @override
  Future<http.StreamedResponse> send(http.BaseRequest request) async {
    if (request.url.path.endsWith('/auth/token')) return _inner.send(request);
    var resposta = await _enviarCom(await _auth.tokenAtual(), request);
    if (resposta.statusCode == 401) {
      resposta = await _enviarCom(await _auth.obterToken(), request);  // renova uma vez
    }
    return resposta;
  }

  Future<http.StreamedResponse> _enviarCom(String token, http.BaseRequest original) {
    final pedido = http.Request(original.method, original.url)
      ..headers.addAll(original.headers)
      ..headers['Authorization'] = 'Bearer $token';
    if (original is http.Request) pedido.bodyBytes = original.bodyBytes;
    return _inner.send(pedido);
  }
}

// main.dart: o ApiClient passa a usar o AuthClient por baixo
final api = ApiClient(baseUrl: baseUrl, client: AuthClient(http.Client(), auth));

2. Interceptor: o que interessa

Exercício 2 Teste com MockClient: a primeira resposta é 401, a segunda 200. Verifica que o AuthClient fez três pedidos no total (original, token, repetição) e que o último tem o header Authorization com o token novo. Depois: dois 401 seguidos têm de resultar em ApiException(statusCode: 401), não num terceiro pedido.

3. FavoritosRepository sobre /v1/me/favorites

abstract class FavoritosRepository {
  Future<List<int>> listar();
  Future<void> adicionar(int localidadeId);
  Future<void> remover(int localidadeId);
}

class FavoritosRepositoryApi implements FavoritosRepository {
  FavoritosRepositoryApi(this._api);
  final ApiClient _api;

  @override
  Future<List<int>> listar() async {
    final json = await _api.get('/v1/me/favorites') as List<dynamic>;
    return json.map((e) => (e as Map<String, dynamic>)['locationId'] as int).toList();
  }

  @override
  Future<void> adicionar(int localidadeId) =>
      _api.post('/v1/me/favorites', body: {'locationId': localidadeId});

  @override
  Future<void> remover(int localidadeId) =>
      _api.delete('/v1/me/favorites/$localidadeId');
}

3. FavoritosNotifier: UI otimista com rollback

class FavoritosNotifier extends ChangeNotifier {
  FavoritosNotifier(this._repo);
  final FavoritosRepository _repo;
  Set<int> _ids = {};
  bool contem(int id) => _ids.contains(id);

  Future<void> carregar() async {
    _ids = (await _repo.listar()).toSet();
    notifyListeners();
  }

  /// Devolve false se o servidor recusou e a alteração foi desfeita.
  Future<bool> alternar(int id) async {
    final antes = {..._ids};
    final adicionar = !_ids.contains(id);
    adicionar ? _ids.add(id) : _ids.remove(id);        // 1. a UI muda já
    notifyListeners();
    try {
      await (adicionar ? _repo.adicionar(id) : _repo.remover(id));   // 2. servidor
      return true;
    } catch (_) {
      _ids = antes;                                     // 3. rollback
      notifyListeners();
      return false;
    }
  }
}

3. O botão de favorito com feedback em caso de erro

class BotaoFavorito extends StatelessWidget {
  const BotaoFavorito({super.key, required this.localidadeId});
  final int localidadeId;

  @override
  Widget build(BuildContext context) {
    final marcado = context.select<FavoritosNotifier, bool>((f) => f.contem(localidadeId));
    return IconButton(
      icon: Icon(marcado ? Icons.star : Icons.star_border),
      tooltip: marcado ? 'Remover dos favoritos' : 'Adicionar aos favoritos',
      onPressed: () async {
        final ok = await context.read<FavoritosNotifier>().alternar(localidadeId);
        if (!ok && context.mounted) {
          ScaffoldMessenger.of(context).showSnackBar(
            const SnackBar(content: Text('Não foi possível guardar o favorito. Tenta de novo.')),
          );
        }
      },
    );
  }
}

3. Favoritos sincronizados: o que interessa

Exercício 3 Regista o FavoritosRepositoryApi e o notifier no main, chama carregar() no arranque, e escreve o teste "alternar faz rollback quando o repository falha" com um fake cujo adicionar lança.

4. Ecrã inicial com favoritos iguais em dois telemóveis

class FavoritosScreen extends StatelessWidget {
  const FavoritosScreen({super.key});

  @override
  Widget build(BuildContext context) {
    final favoritos = context.watch<FavoritosNotifier>();
    final todas = context.watch<PrevisaoNotifier>().localidades;
    final lista = todas.where((l) => favoritos.contem(l.id)).toList();

    if (lista.isEmpty) {
      return const EstadoVazio(mensagem: 'Marca uma localidade com a estrela para a veres aqui.');
    }
    return RefreshIndicator(
      onRefresh: () => context.read<FavoritosNotifier>().carregar(),
      child: ListView(
        children: [
          for (final l in lista)
            ListTile(
              title: Text(l.nome),
              subtitle: Text(l.ilha),
              trailing: BotaoFavorito(localidadeId: l.id),
              onTap: () => context.push('/localidade/${l.id}'),
            ),
        ],
      ),
    );
  }
}
Exercício 4 Dois telemóveis do grupo com o mesmo client-id: marca uma localidade num, puxa para atualizar no outro. Tem de aparecer. Depois remove-a no segundo e confirma no primeiro. Grava 20 segundos de vídeo para o PR.

5. Logout: apagar token e cache

class SessaoNotifier extends ChangeNotifier {
  SessaoNotifier(this._storage, this._db, this._favoritos);
  final TokenStorage _storage;
  final BaseDados _db;
  final FavoritosNotifier _favoritos;

  Future<void> terminarSessao() async {
    await _storage.apagar();            // 1. token fora do Keychain/Keystore
    await _db.apagarTudo();             // 2. cache que pode ter dados do grupo
    _favoritos.limpar();                // 3. estado em memória
    notifyListeners();                  // 4. o router leva ao ecrã de entrada
  }
}
Exercício 5 Botão "Terminar sessão" nas definições. Depois de o carregar: sem token no secure storage, tabela de previsões vazia, ecrã de entrada. Ao entrar de novo, os favoritos voltam do servidor. Compara com solucao/.

Até amanhã

Para fazer

  • Favoritos no projeto com o mesmo padrão: FavoritosRepositoryApi, notifier otimista com rollback, ecrã de favoritos com pull-to-refresh.
  • Lê a introdução ao JWT e descodifica um token da API do curso à mão (base64url do segundo segmento).
  • Sprint 1 fecha na segunda, 2/11: PRs em revisão esta semana, não no domingo à noite.

Para ler