카테고리 없음

[Flutter] Dio interceptor 토큰 갱신하기

Voyage_dev 2023. 11. 8. 19:00

 

문제

회사에서 앱 개발 도중 Access Token이 만료되면 RefreshToken으로 토큰을 갱신해줘야 하는데 기존 코드 방식으로는 갱신이 되지 않아서 dio interceptor를 수정해 봤다.

 

토큰 갱신 과정

  1. Client에서 Server로 AccessToken을 담아 API 요청
  2. 이 때 AccessToken이 만료됨에 따라 Server에서 인증 오류 반환
  3. Client에서 인증 오류를 확인하고 RefreshToken을 담아 토큰 갱신 API 요청
  4. RefreshToken 만료에 따른 분기
    • RefreshToken이 만료되지 않았을 경우 Server에서 새로운 AccessToken 발급
    • RefreshToken도 만료되었을 경우 Server에서 다시 인증 오류를 반환하고 Client에서 이를 확인 후 로그인 재요청 화면 표시

이 과정은 보통 웹과 별 다를게 없지만 flutter에서는 어떻게 구현할 수 있을까? 이 문제를 해결하기 전까지는 매 요청마다 헤더에 직접 token을 넣어서 보냈는데 매 요청마다 항상 AccessToken을 자동으로 담아서 보내보자

 

사용한 라이브러리

  • dio 5.3.3
  • flutter_secure_storage 9.0.0

코드

import 'package:dio/dio.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutterpractice/pages/login/login.dart';

Future authDio(BuildContext context) async {
  var dio = Dio();
  final storage = new FlutterSecureStorage();

  dio.interceptors.clear();

  dio.interceptors.add(InterceptorsWrapper(onRequest: (options, handler) async {

    // 기기에 저장된 AccessToken 로드
    final accessToken = await storage.read(key: 'ACCESS_TOKEN');

    // 매 요청마다 헤더에 AccessToken을 포함
    options.baseUrl = "<https://api.ailee-shopperhouse.com>";
    options.headers['Authorization'] = 'Bearer $accessToken';
    return handler.next(options);
  }, onError: (error, handler) async {

    // 인증 오류가 발생했을 경우: AccessToken의 만료
    if (error.response?.statusCode == 401) {

      // 기기에 저장된 AccessToken과 RefreshToken 로드
      final accessToken = await storage.read(key: 'ACCESS_TOKEN');
      final refreshToken = await storage.read(key: 'REFRESH_TOKEN');

      // 토큰 갱신 요청을 담당할 dio 객체 구현 후 그에 따른 interceptor 정의
      var refreshDio = Dio();

      refreshDio.interceptors.clear();

      refreshDio.interceptors
          .add(InterceptorsWrapper(onError: (error, handler) async {

        // 다시 인증 오류가 발생했을 경우: RefreshToken의 만료
        if (error.response?.statusCode == 401) {

          // 기기의 자동 로그인 정보 삭제
          await storage.deleteAll();

          // . . .
          // 로그인 만료 dialog 발생 후 로그인 페이지로 이동
          Navigator.push(context, MaterialPageRoute(builder: (context) => LoginPage()));
          // . . .
        }
        // return handler.next(error);
      }));

      // 토큰 갱신 API 요청 시 AccessToken(만료), RefreshToken 포함
      // refreshDio.options.headers['Authorization'] = 'Bearer $accessToken';
      // refreshDio.options.headers['Refresh'] = 'Bearer $refreshToken';

      // 토큰 갱신 API 요청
      refreshDio.options.baseUrl = "<https://api.ailee-shopperhouse.com>";
      final refreshResponse = await refreshDio.post('/api/users/refresh', data:{
        "refreshToken":refreshToken
      });

      // response로부터 새로 갱신된 AccessToken과 RefreshToken 파싱
      final newAccessToken = refreshResponse.data["result"]['token'];
      // final newRefreshToken = refreshResponse.data["result"]['refreshTo'];

      // 기기에 저장된 AccessToken과 RefreshToken 갱신
      await storage.write(key: 'ACCESS_TOKEN', value: newAccessToken);
      // await storage.write(key: 'REFRESH_TOKEN', value: newRefreshToken);

      // // AccessToken의 만료로 수행하지 못했던 API 요청에 담겼던 AccessToken 갱신
      error.requestOptions.headers['Authorization'] = 'Bearer $newAccessToken';
      // 수행하지 못했던 API 요청 복사본 생성
      final clonedRequest = await dio.request(error.requestOptions.path,
          options: Options(
              method: error.requestOptions.method,
              headers: error.requestOptions.headers),
          data: error.requestOptions.data,
          queryParameters: error.requestOptions.queryParameters);

      // API 복사본으로 재요청
      return handler.resolve(clonedRequest);
    }

    return handler.next(error);
  }));

  return dio;
}

간략하게 정리하면

  • 매 요청마다 헤더에 AccessToken 추가
  • 인증 오류(401) 발생 시 RefreshToken을 담아 토큰 갱신 API 요청
  • 토큰 갱신 성공 시 새로운 AccessToken으로 교체하여 기존 API 재요청
  • RefreshToken 만료로 인해 토큰 갱신 실패 시 로그인 재요청

생성한 dio 객체 사용법

Future<void> postEvent(BuildContext context) async {

  // 모든 인증 관련 핸들링이 구현되어 있는 dio
  var dio = await authDio(context);

  // API 요청
  final response = await dio.post('/event');
}

Flutter Secure Storage

위 코드에서 잠깐 등장한 flutter secure storage는 기기의 보안 저장소를 이용할 수 있게 하는 라이브러리이다. 단순한 상태나 설정값들을 저장할 때는 SharedPreferences를 이용하지만 인증 토큰과 같은 민감한 정보들을 기기에 저장할 때에는 이와 같은 보안 저장소를 이용한다.

 

이러한 토큰을 기기에 따로 저장하는 가장 큰 이유는 바로 자동 로그인 때문이다. 자동 로그인을 사용하지 않으면 사용자는 앱을 실행하는 매 순간마다 번거롭게 로그인 버튼을 눌러 로그인을 진행해야 한다.

 

하지만 이와 같이 기기의 안전한 곳에 토큰을 저장해두면 앱을 최초로 로드하는 부분에서 이를 불러옴으로써 로그인 과정을 생략할 수 있다. 그러나 이렇게 자동 로그인을 구현하였다면 위 코드와 같이 로그인이 만료되었을 시 기기에 저장된 토큰 정보도 삭제해줘야 하는 것에 유의하자.

 

 

 

 

 

 

 

출처 : https://blog.yjyoon.dev/flutter/2021/11/28/flutter-06/