티스토리 뷰

안녕하세요.

 

이전에 [iOS] Authenticator 프로토콜로 JWT 인증 구현하기 라는 JWT 인증 관련 글을 올린 적이 있는데 그때는 무슨 일인지 adapt와 retry가 호출되지 않아서 Authenticator를 사용 했었는데요.

 

이번에 다시 해보니 잘 호출되고 오히려 Authenticator 보다 쉽고 간편한 부분이 있어 RequestInterceptor 프로토콜에 대해 포스팅하려고 합니다.

 

JWT 인증

JWT 인증에 대해 앱 개발 관점에서 간단히 짚고 넘어가자면,

우리가 api 요청을 할 때 서버에서 인증된 사용자의 요청인지 판단하는 인증 방식으로 보통 로그인할때 accessToken과 refreshToken을 내려받습니다.

 

각각은 토큰의 유효기간이 있고 accessToken은 일반적인 요청을 할때 헤더에 추가하여 사용하며 유효기간이 refreshToken에 비해 짧습니다.

refreshToken은 accessToken의 갱신을 위해 존재하며 accessToken이 만료됐을 경우 refreshToken을 헤더에 추가해 accessToken 갱신을 위한 api를 요청합니다.

 

갱신할 때 서버에서는 두 개의 토큰을 전달받아 refreshToken이 만료됐는지, 유효한지 검증한 후 갱신된(유효기간이 늘어난) accessToken과 refreshToken을 reponse 합니다.

 

앱에서는 갱신이 성공하면 accessToken이 만료되어 통신이 실패했던 기존 api를 갱신된 토큰으로 재요청합니다.

 

이 플로우를 구현하기 위해 RequestInterceptor가 필요합니다.

 

RequestInterceptor

RequestInterceptor 프로토콜은 Alamofire에서 제공해주는 프로토콜입니다.

설명을 보면 RequestAdapter와 RequestRetrier의 기능을 모두 제공한다고 나와있죠?

Codable이 Decodable과 Encodable을 모두 포함하고 있는 것과 같은 맥락이라고 보시면 됩니다.

 

RequestAdapter 프로토콜에는 adapt라는 메서드가 있고, RequestRetrier 프로토콜에는 retry라는 메서드가 정의되어 있습니다.

이 두 가지의 메서드만 있으면 JWT 인증을 쉽게 구현할 수 있습니다.

 

일단 RequestInterceptor 프로토콜을 채택한 클래스와 메서드를 만들어줍니다.

먼저 adapt 메서드는 우리가 Alamofire를 통해 api에 request를 요청하면 통신이 시작되기 전 호출되어 인자로 해당 request를 전달받습니다.

그러면 전달받은 request를 가지고 검사나, 조정을 하고 결과에 따라 completion에 error 또는 success를 전달합니다.

 

간단한 flow는 retry 메서드를 통해 에러코드가 토큰 만료를 뜻할 경우(보통 401) token refresh를 요청하고 성공했을 경우 (401 에러가 떨어진) 이전 요청을 다시 실행하는 것이고,

adapt 메서드를 통해 api 요청이 만약 토큰 갱신을 위한 경우 헤더에 refresh token을 넣어주는 형식입니다.

 

저는 이렇게 구현했습니다.

func adapt(_ urlRequest: URLRequest, for session: Alamofire.Session, completion: @escaping (Result<URLRequest, Error>) -> Void) {
        guard let accessToken = UserManager.shared.accessToken, let refreshToken = UserManager.shared.refreshToken else {
            completion(.success(urlRequest))
            return
        }
        var urlRequest = urlRequest
        if let urlString = urlRequest.url?.absoluteString, urlString == API_REFRESH_TOKEN {
            urlRequest.headers.add(name: "refresh-token", value: refreshToken)
        }
        urlRequest.headers.add(.authorization(bearerToken: accessToken))
        completion(.success(urlRequest))
    }

제가 만드는 앱은 미 로그인 상태(토큰 X)에서도 동작이 가능한 앱이기 때문에 guard문으로 토큰이 없는 경우 completion을 통해 받은 request 그대로 success를 전달하여 api요청이 실행되게 하였습니다.

 

urlString == API_REFRESH_TOKEN 조건을 통해 현재 요청한 request의 url이 토큰 갱신을 위한 url인지 판단하여 맞는 경우 헤더에 refreshToken을 추가합니다.

 

모든 요청은 adapt 메서드를 통하므로 urlRequest.headers.add(.authorization(bearerToken: accessToken))을 통해 기본적으로 accessToken을 포함시키는 코드도 있습니다.

 

# .authorization(bearerToken: accessToken)은 헤더에 ["Authorization" : "Bearer XXXXXXXX"] 형식으로 들어갑니다.

기본적인 형태라서 따로 연관타입으로 빼둔것 같은데 만약 accessToken에 Bearer가 안들어가거나 헤더네임이 다를 경우엔 urlRequest.headers.add(name: "", value: "")를 통해 헤더를 추가해주세요

 

마지막으로 completion(.success(urlRequest))를 통해 수정한 UrlRequest객체를 전달하여 이후 요청이 진행되게 합니다.

 

func retry(_ request: Request, for session: Alamofire.Session, dueTo error: Error, completion: @escaping (RetryResult) -> Void) {
        guard let response = request.task?.response as? HTTPURLResponse, response.statusCode == 401 else {
            completion(.doNotRetryWithError(error))
            return
        }
        
        APIManager.requestWithName(API_REFRESH_TOKEN, method: .get, parameters: nil, responseType: TokenModel.self) { result in
            switch result {
            case .success(let response):
                print("Token Refresh Success")
                
                UserManager.shared.accessToken = response.accessToken
                UserManager.shared.refreshToken = response.refreshToken
                completion(.retry)
                
            case .failure(let error):
                print("Token Refresh Fail : \(error)")
                
                UserManager.shared.currentProfile = nil
                completion(.doNotRetryWithError(error))
            }
        }
    }

retry 메서드는 통신에러가 났을 때 호출되는데 미리 정의된 토큰 만료 에러코드 401이 아닌 다른 에러코드일 경우 completion(.doNotRetryWithError(error))를 통해 retry 하지 않고 넘어갑니다.

 

에러코드 401(토큰 만료) 일 경우 토큰 갱신을 위한 api를 호출하고(이 경우는 adpat 메서드에서 refreshToken 추가됨) success일 경우 새롭게 전달받은 accessToken과 refreshToken을 각자 앱에 맞는 방식대로 저장한 후 completion(.retry) 코드를 통해 에러 났던 이전 요청을 다시 retry 합니다. 그러면 다시 한번 adapt 메서드가 호출되어 갱신된 accessToken을 헤더에 넣어 요청을 하겠죠?

 

만약 refreshToken이 만료되었다거나 서버 문제로 토큰이 갱신되지 않았을 경우에는 completion(.doNotRetryWithError(error)) 코드를 통해 이전 요청을 retry 하지 않고 completion에 error를 전달하여 이전 요청에도 error가 전달됩니다.

 

갱신이 실패한 경우엔 각자 앱에 맞게 로그아웃을 시키는 등의 추가 처리를 해주면 됩니다.

 

이렇게 RequestInterceptor을 채택하여 메서드를 구현했다면 alamofire를 통해 요청할 때 interceptor 파라미터를 통해 클래스를 전달해주어야 합니다.

이렇게 alamofire의 request 메서드를 호출할 때 interceptor 파라미터에 구현한 클래스를 인스턴스 화하여 전달해주면 끝입니다.

 

댓글