[Flutter] Dio interceptor를 통해 인증 토큰 갱신하기


이슈

위 포스트에서 OAuth2.0 방식의 소셜 로그인을 구현함으로써 AccessTokenRefreshToken을 이용한 인증 방식을 사용하였다고 밝혔다. 이 때 RefreshTokenAccessToken이 탈취당했을 상황을 고려하여 토큰에 만료 시간을 부여함에 따라 토큰을 갱신해줄 때 사용되는 토큰이다. 토큰이 갱신되는 과정을 나열하면 다음과 같다.

인증 토큰 갱신 과정

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

어러한 과정을 flutter에서 어떻게 구현할 수 있을까? 매 API 통신 코드마다 해당 과정을 수행하는 코드를 덧붙일 순 없을 것이다. 이를 더욱 쉽게 핸들링하는 방법은 무엇일까? 더 나아가 매 요청마다 항상 AccessToken을 자동으로 담아줄 순 없을까?


해결

핵심

Dio의 interceptor을 이용하여 매 통신마다 수행할 핸들러를 구현한다.

라이브러리

코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
// auth_dio.dart

import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

Future<Dio> 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.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 발생 후 로그인 페이지로 이동
          // . . .
        }
        return handler.next(error);
      }));

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

      // 토큰 갱신 API 요청
      final refreshResponse = await refreshDio.get('/token/refresh');

      // response로부터 새로 갱신된 AccessToken과 RefreshToken 파싱
      final newAccessToken = refreshResponse.headers['Authorization']![0];
      final newRefreshToken = refreshResponse.headers['Refresh']![0];

      // 기기에 저장된 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;
}


고찰

Dio interceptor

함수의 인자로 BuildContext를 따로 받는 이유는 Dialog를 사용해 로그인 만료를 알리거나 Navigator를 통해 로그인 재요청 페이지로 이동하려면 현재 사용자가 머물고 있는 화면의 context가 필요하기 때문이다. 또한 요청하는 API의 도메인이 항상 같다면 baseUrl 속성을 본 dio 객체에 부여할 수도 있다.

위 코드에서 최종적으로 반환하는 dio 객체가 하는 일은 다음과 같다.

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

따라서 위 코드를 따로 선언해두고 앞으로 모든 요청에 대해 본 dio 객체를 사용하면 된다. 예를 들어 다음과 같다.

1
2
3
4
5
6
7
8
9
10
import 'auth_dio.dart';

Future<void> postEvent(BuildContext context) async {

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

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

위와 같이 일반적인 API 통신에서 앞서 구현한 auth dio를 사용함으로써 매 요청마다 토큰을 담거나 갱신하는 것에 대해 따로 신경 쓸 필요가 없어진다.

Flutter Secure Storage

위 코드에서 잠깐 등장한 flutter secure storage는 기기의 보안 저장소를 이용할 수 있게 하는 라이브러리이다. 단순한 상태설정값들을 저장할 때는 SharedPreferences를 이용하지만 인증 토큰과 같은 민감한 정보들을 기기에 저장할 때에는 이와 같은 보안 저장소를 이용한다. 이러한 토큰을 기기에 따로 저장하는 가장 큰 이유는 바로 자동 로그인 때문이다. 자동 로그인을 사용하지 않으면 사용자는 앱을 실행하는 매 순간마다 번거롭게 로그인 버튼을 눌러 로그인을 진행해야 한다. 하지만 이와 같이 기기의 안전한 곳에 토큰을 저장해두면 앱을 최초로 로드하는 부분에서 이를 불러옴으로써 로그인 과정을 생략할 수 있다. 앞서 포스팅한 Splash 화면을 통한 초기 로딩 구현하기 와 연계되는 부분이다. 그러나 이렇게 자동 로그인을 구현하였다면 위 코드와 같이 로그인이 만료되었을 시 기기에 저장된 토큰 정보도 삭제해줘야 하는 것에 유의하자.

0%