Flutter

1. 보안 인증과 권한 (Authentication & Authorization)

모리선생 2023. 3. 13. 22:47
728x90

목표

현대에서 가장 많이 사용하고 있는 Token Authentication 중에서 Refresh Token과 Access  Token을 사용하여 인증을 진행하는 시스템을 공부한다. Dio를 이용하여 자동으로 토큰을 갱신하는 방법도 확인한다.


* 해당 내용은 본인의 개발 공부에 있어서 여러가지 참고하며 기록했던 내용을 복기하고 이해하고자 작성한 블로그 글입니다. 같이 공부를 해나가며 Flutter라는 언어에 취미를 가지신 분 들이 개념을 잡는데 도움이 되었으면 합니다. 참고한 홈페이지 등에 대해서는 각 페이지별 하단에 명시하여 두었습니다. 수정 및 문의 사항이 있으면 알려주시길 바랍니다.

Authentication(인증)

 

정의

자신이 누구라고 주장하는 사람이 ‘누구’인지 확인하는 절차 (예: 아이디와 비밀번호)

 

방식 

  • two-factor authentication (2FA) - 아이디와 비밀번호 그리고 공인인증서로 인증
  • multi-factor authentication - 세번 이상 인증하는 방식 

 

Token Authentication (토큰기반 인증)

클라이언트와의 소통을 위해 토큰을 발행하여, response의 cookie에 담아 전달한다.

즉, 클라이언트의 인증 정보를 보관한다고 이해하면 된다. 해당 방식이 유효한 이유는 유저 정보를 암호화한 상태로 담기 때문이다. (이와 반대되는 개념에는 세션 기반인증이 있으며, 서버에 유저 정보를 담는 인증 방식이다)

 

토큰 기반 인증 방식

  • JWT

JWT 구조의 설명 (출처: Lulu 우당탕탕 개발일기)

  • Header: 어떤 종류의 토큰인지 어떤 알고리즘으로 암호화 할지가 서술
  • Payload: 어떤 정보에 접근 가능한지 권한을 담거나 사용자 유저 이름 등 필요한 데이터를 담아 암호화
  • Signature: 원하는 비밀키 (암호화시 추가할 salt)를 사용하여 암호화

 

JWT 구조의 예시
JWT 구조의 예시 (출처: Lulu 우당탕탕 개발일기)

 

  • 토큰기반 인증절차

토큰 기반 인증 절차(출처: Lulu 우당탕탕 개발일기)

  • 절차
  1. 아이디/비밀번호를 담아 클라이언트가 서버에 로그인 요청
  2. 아이디/비밀번호 일치 확인 후 클라이언트에게 보낼 암호화 토큰 생성
  3. 토큰을 클라이언트에게 보내주면 클라이언트는 토큰 저장
  4. 클라이언트가 HTTP 헤더(Authorization)헤더에 토큰을 담아 보냄
  5. 서버측에서 토큰 매치 여부 확인 후 클라이언트의 요청을 처리하여 응답

 

  • 토큰 기반 인증 절차 (Access token 만료시)

토큰 기반 인증 절차(출처: Lulu 우당탕탕 개발일기)

  1. 사용자가 로그인 시도
  2. 서버는 DB에 사용자가 저장되었는지 확인
  3. 사용자가 존재시 access token과 refresh token 발급
  4. 발급된 refresh token은 id와 token 테이블에 저장
  5. 서버는 access, refresh token들에 응답
  6. 사용자가 서버에 요청시 서버는 access token 검증 후 요청한 데이터 응답
  7. access token이 만료된 경우
  8. access token 만료시 사용자가 서버에 요청하면 access token 만료를 응답
  9. access token의 만료 확인 후 client는 서버에 access token의 refresh 요청
  10. 만료된 access token에 사용자 id얻은 후 id로 DB에 refresh token 유효성 확인
  11. refresh token 유효시 access token 재발급 >> client에 전송 >> refresh token(최대 2주)도 만료 시에는 새로 로그인 요청

 

토큰기반 인증 장점

  1. Stateless & Scalability (무상태성과 확장성)
  2. 안전성
  3. 생성성
  4. 권한 부여의 용이성

세션 기반 인증의 구조 (출처: Lulu 우당탕탕 개발일기)
토큰인증과 세션인증의 구조적 차이 (출처: Lulu 우당탕탕 개발일기)

 

Access Token

특징

  1. JWT (jsonwebtoekn)을 기반으로 함
  2. Authorization에 저장하여 페이지마다 token 검증
  3. Token 탈취 위험이 있어 짧은 시간의 유효기간 (30분 ~ 1시간)

Authorization (권한)

 

정의

특정자원에 대한 접근 권한이 있는지 확인하는 절차

 

특징

  1. 권한부여 (grant authority)
  2. 리소스 가로채기 (intercept)

 

 

유저의 궁극적인 목표

: 리소스에 접근하는 것 (리소스의 예. 웹 페이지, 텍스트, 이미지, 특정 html element 등)

 

리소스에 접근하기 위한 절차

: 인증과 권한

 


Dio

: A powerful Http client for Dart

http처럼 서버와 통신을 하기 위해 필요한 패키지. 네트워킹 라이브러리로써, Dio 패키지가 지원하는 기능은 http 라이브러리를 사용하여 수행할 수 있으나, 인터넷 호출을 빠르고 쉽게 배우고 수핼할 수 있어 더 선호한다.

 

기본사용법

  • 설치

dependencies:

   dio: ^4.0.0

 

  • Import
import 'package:dio/dio.dart';


void getHttp() async {
try {
var response = await Dio().get('http://www.google.com');
print(response);
}catch (e) {
print(e);

}}
 
  • 설정방식
var dio = Dio(); // with default Options

// Set default configs
dio.options.baseUrl = 'https://www.xx.com/api';
dio.options.connectTimeout = 5000; //5s
dio.options.receiveTimeout = 3000;
  • ConnectTimeout: 서버로부터 응답 받을 때까지의 시간 - 설정시간 초과시 
  • ConnectionTimeout Exception 발생
  • ReceiveTimeout: 서버로부터 응답 받을때 스트리밍의 연결 지속 시간 의미
  • 연결 지속 시간 초과시 receiveTimeout Exception 발생

 

  • Response Schema
{
/// Response body.  may have been transformed, please refer to [Response Type].
T? data;
/// Response headers.
Headers headers;
/// The corresponding request info.
Options request;
/// Http status code.
int? statusCode;
String? statusMessage;
/// Whether redirect
bool? is Redirect;
/// redirect info
List<RedirectInfo> redirects;
/// Returns the final real request uri (maybe redirect).
Uri realUri;
/// Custom field that you can retrieve later in 'then'.
Map<String, dynamic> extra;
}

response는 generic을 통해 return file type 지정이 가능 (Map 타입 활용)

 

  • Interceptors

Client들에 대한 요청/응답/오류 핸들링 가능

 

  1. 요청에 대한 핸들링: api요청전 authentication 토큰 값 확인
  2. 응답에 대한 핸들링: api를 통해 응답을 받아 공통적으로 처리해야하는 부분을 선처리 후 repository로 넘겨줌 (예) json.decode
  3. 오류에 대한 핸들링: error에 대해 메시지 처리하거나 path에 따라 error 발생대신 mock 데이터 return 발생등의 작업 진행 가능

 

  • 선언방법
dio.interceptors.add(InterceptorsWrapper(
onRequest:(options, handler){
// Do something before request is sent
return handler.next(options); //continue
// If you want to resolve the request with some custom data,
// you can resolve a 'Response' object eg: return 'dio.resolve(response)'.
// If you want to reject the request with an error message,
// you can reject a 'DioError' object eg: return 'dio.reject(dioError)'
},
onResponse:(response,handler){
// Do something with response data
return handler.next(response); // continue
//If you want to reject the request with a error message,
// you can reject a 'DioError' object eg: return 'dio.reject(dioError)'
},
onError: (DioError e, handler){
// Do something with response error
return handler.next(e); // continue
// If you want to resolve the request with some custom data,
// you can resolve a 'Response' object eg: return 'dio.resolve(response)',
}
));
 
  • FormData
  • 싱글 파일로 요청하기
var formData = FormData.fromMap({
'name' : 'wendux',
'age' : 25,
'file' : await MultipartFile.fromFile('./text.txt',filename: 'upload.txt')
});
response = await dio.post('/info', data: formData);
  • 멀티 파일로 요청하기
FormData.fromMap({
'files':[
MultipartFile.fromFileSync('./example/upload.txt', filename: 'upload.txt'),
MultipartFile.fromFileSync('./example/upload.txt', filename: 'upload.txt'),
]
});

파일과 데이터 동시 전송 및 다중 파일 업로드 구현시 사용

 


실제 예시 적용 - Dio interceptor를 통해 인증 토큰 갱신하기

플로터에서 Access Token 만료 시 HTTP 통신 재요청과 Refresh Token을 사용해서 인증 토큰을 갱신하는법

 

인증 토큰 갱신 과정

  1. Client에서 서버로 AccessToken을 담아 API 요청
  2. AccessToken 만료시 Server에 인증 오류 반환
  3. Client에 인증오류 확인 후 RefreshToken을 담아 토큰 갱신 API 요청
  4. RefreshToken 만료에 따른 분기

 

구현방식

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

 

필요 라이브러리

  1. Dio
  2. Flutter_secure_storage

 

코드 예시

 

// 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;
}

 

예시코드 활용을 위한 page

import 'auth_dio.dart';

Future<void> postEvent(BuildContext context) async {

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

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

 

Reference

  1. 우당탕개발일기 https://velog.io/@devjade/Token-based-Authentication
  2. marshmel.log https://nowgnas.github.io/posts/refreshtoken/
  3. 개발하는남자 https://sudarlife.tistory.com/entry/플러터-라이브러리-API-통신에-편리한-dio의-기능정리
  4. YJYOON’s Code Story https://blog.yjyoon.dev/flutter/2021/11/28/flutter-06/
728x90

'Flutter' 카테고리의 다른 글

5. Riverpod과 Provider  (0) 2023.03.15
4. Retrofit  (0) 2023.03.15
3. JasonSerializable(JSON의 직렬화)  (0) 2023.03.14
2. 페이지네이션, 페이지 기반과 커서기반의 차이 (Pagination)  (0) 2023.03.14
0. Flutter 실전 정리  (0) 2023.03.13