문제
회사에서 앱 개발 도중 Access Token이 만료되면 RefreshToken으로 토큰을 갱신해줘야 하는데 기존 코드 방식으로는 갱신이 되지 않아서 dio interceptor를 수정해 봤다.
토큰 갱신 과정
- Client에서 Server로 AccessToken을 담아 API 요청
- 이 때 AccessToken이 만료됨에 따라 Server에서 인증 오류 반환
- Client에서 인증 오류를 확인하고 RefreshToken을 담아 토큰 갱신 API 요청
- 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를 이용하지만 인증 토큰과 같은 민감한 정보들을 기기에 저장할 때에는 이와 같은 보안 저장소를 이용한다.
이러한 토큰을 기기에 따로 저장하는 가장 큰 이유는 바로 자동 로그인 때문이다. 자동 로그인을 사용하지 않으면 사용자는 앱을 실행하는 매 순간마다 번거롭게 로그인 버튼을 눌러 로그인을 진행해야 한다.
하지만 이와 같이 기기의 안전한 곳에 토큰을 저장해두면 앱을 최초로 로드하는 부분에서 이를 불러옴으로써 로그인 과정을 생략할 수 있다. 그러나 이렇게 자동 로그인을 구현하였다면 위 코드와 같이 로그인이 만료되었을 시 기기에 저장된 토큰 정보도 삭제해줘야 하는 것에 유의하자.