Retrofit2를 사용하여 서버의 Digest 인증을 처리하기

2019. 11. 19. 20:27Programming/Android

반응형

올해가 삼제긴 삼제인지, 어쩌다보니 Digest와 관련된 내용만 세 번을 처리하게 됐다. 요즈음에는 OAuth2.0을 사용하는 경우가 많아서, Digest 인증을 처리할 일은 거의 없겠지만... 아무튼 이번에는 Retrofit2로 Digest 인증을 처리하는 내용을 다룬다.

우선 Android에서는 iOS와 달리 Basic/Digest 인증에 대한 처리를 지원하지 않기 때문에, 직접 구현해야한다. 이는 Volley를 사용하거는 경우에도 별반 다르지 않다. Volley로 구현되어있는 내용을 Retrofit2로 교체하는 작업이 한창이었기 때문에 내심 별 일 없겠거니 생각하고 있었는데, 레거시 코드를 살펴보니 Deprecated된 Apache 라이브러리를 사용하여 처리하고 있었다. Apache 라이브러리를 사용하지 않기 위해서 Volley를 제거하고 있는 작업을 진행하고 있었으므로, 기존의 레거시 코드는 참조할만한 코드도 못 되는 셈이었다. 결국 서버에서 401 에러와 함께 반환하는 WWW-Authenticate값을 파싱하여, Authenticate 필드를 채워주기로 했다.

 

Digest에 대한 인증처리에 앞서 Basic Authentication on Android, FutureStudio.io 글을 읽어보고, Basic 인증을 어떻게 처리하는지 살펴보자. 인증을 위해서 헤더의 값을 일괄적으로 처리하기 위해서, Retrofit2의 Interceptor를 상속받아 처리하는 것을 볼 수 있다.

public class AuthenticationInterceptor implements Interceptor {
  private String authToken;

  public AuthenticationInterceptor(String token) {
    this.authToken = token;
  }

  @Override
  Request response = chain.request();

  Request.Builder builder = response.newBuilder()
          .header("Authorization", authToken);

  Request request = builder.build();
  return chain.proceed(request);
}

.Kt파일에 위의 코드를 복사/붙여넣기하면 자동으로 Kotlin으로 변경해주기 때문에 굳이 기재할 필요는 없을 듯 하지만, Kotlin으로 재작성하면 다음과 같다.

class AuthenticationInterceptor(private val authToken: String):Interceptor {
  override fun intercept(chain: Interceptor.Chain): Response {
    val response = chain.request()
    val builder = response.newBuilder()
                          .header("Authorization", authToken)

    val request = builder.build()
    return chain.proceed(request)
  }
}

 

Retrofit 객체를 빌드하기 전에 위에서 작성한 Interceptor를 등록해주게 되면, HTTP 통신을 하기 전에 Interceptor에서 통신에 대한 처리를 해줄 수 있다. 위에서 작성한 Interceptor는 authToken값을 받아서, 헤더에 Authorization필드에 채워주고 있다. Basic/Digest인증 모두 결과적으로는 Authorization 필드에다, 인증에 필요한 값을 채워넣어 준다는 점을 염두한 상태로 위의 코드를 살펴보도록 하자.

chain.request()를 호출하면 통신에 대한 응답(Response)를 반환하게 된다. 위의 예제 코드에는 인증 없이 보낸 API는 당연히 401로 올거라고 판단했는지, HTTP 상태코드에 대한 처리가 누락되어있다. 물론 서버의 설정이 제대로 되어있다면 HTTP 상태 코드는 401로 올 것이므로, 위의 코드는 정상적으로 동작하게 된다.

일반적으로 운용중인 서버의 설정값을 임의로 변경하는 경우는 잘 없지만, 임베디드 환경의 경우라면 조금 다를 수 있다. response의 code값을 참조하면 HTTP 상태 코드에 대한 처리를 추가할 수 있다.

class AuthenticationInterceptor(private val authToken: String):Interceptor {
  override fun intercept(chain: Interceptor.Chain): Response {
    var response = chain.request()
    if (response.code() == 401) {
      val builder = response.newBuilder()
                            .header("Authorization", authToken)

      val request = builder.build()
      response = chain.proceed(request)
    }

    return response
  }
}

 

HTTP 상태 코드에 대한 처리를 추가했다. if문 내에서 chain.proceed(request)에 대한 반환값을 즉시 리턴해주더라도 동작에는 큰 차이가 없지만, 중간에 return을 추가함으로써 코드의 흐름에 분기가 생기는 걸 막고자 responsevar로 변경하고, 마지막에 response를 반환하도록 수정했다.

이렇게 Basic 인증에 대한 Interceptor가 완성됐다면 Basic 인증을 하기 위해, Retrofit객체를 빌드하기 전에 Interceptor를 등록해주도록 하자.

class ServiceGenerator {
  val API_BASE_URL = "https://your.api-base.url"
  private val httpClient = OkHttpClient.Builder()
  private val builder = Retrofit.Builder()
          .baseUrl(API_BASE_URL)
          .addConverterFactory(GsonConverterFactory.create())
  private val retrofit = builder.build()


  fun <S> createService(
          serviceClass: Class<S>, authToken: String): S {
    val interceptor = AuthenticationInterceptor(authToken)
    httpClient.addInterceptor(interceptor)

    builder.client(httpClient.build())
    retrofit = builder.build()

    return retrofit.create(serviceClass)
  }
}

 

FutureStudio.io의 예제 중 서비스를 생성해주는 ServiceGenerator만 떼왔다. 필요한 코드만 설명하기 위해 authToken 파라메터의 널 처리등을 제거했으니, 실제로 적용할 때는 해당 처리를 추가해주도록 하자.

잘 알고 있겠지만 Basic 인증을 할 때는 ID:PWMD5로 암호화 한 값이 Authentication 필드에 들어가게 된다. 따라서 authToken에 ID:PWMD5
암호화 한 값을 넣어주면 되는데, Credentials.basic(ID, PW)의 결과값을 넣어주면 authToken에 해당하는 값이 나오게 된다. 이 값을 넣어서 Interceptor가 제대로 동작하는지, Break를 걸어서 확인해보도록 하자.

위에서 생성한 Interceptorchain.proceed(request)를 통해서 서버에 HTTP 통신을 요청한 뒤, 응답을 받아 반환하게 된다. 이 값의 HTTP 상태 코드가 401이라면, InterceptorAuthorization필드에 authToken값을 입력한 뒤 다시 chain.proceed(request)를 호출하여 다시 HTTP 통신을 하게 된다.

만약 Interceptor내에서 response.close()를 호출하지 않고 chain.proceed(request)를 호출하게되면, 통신이 완료되지 않은 상태에서 다시 HTTP 통신을 시도하려 하기 때문에 Exception이 발생할 수 있다. 에러가 발생하는 경우에는 response.close()를 호출해주도록 하자.

여기까지 따라왔다면 Retorift2에서 어떠한 방식으로 Basic/Digest 인증을 처리하는지 파악했을 것이다. 요약하면 다음과 같다.

  • HTTP 통신을 시도한다.
  • 401에러가 발생했다면 헤더의 Authorization필드에, 인증에 필요한 값을 입력하여 request를 다시 생성한다.
  • 다시 HTTP 통신을 시도한 뒤, 통신 결과를 반환한다. 이 뒤에는 등록한 Callback이 실행된다.

 

제법 심플하다. 그렇다면 Interceptor에 Digest와 관련된 코드를 추가해보도록 하자. Digest 인증에 대한 내용은 Digest Authentication, RFC2617문서를 참조하도록 하자.

서버에서는 클라이언트에서 요청이 들어왔을 때 헤더를 확인한 후, Authentication필드가 없다면 401 에러를 반환한다. 이 때 헤더에는 WWW-Authenticate필드가 추가되는데, 인증에 필요한 값들이 이 필드에 들어있다. 이는 Basic도 마찬가지이므로, 위에서 구현한 Interceptor에 Basic인증인지 Digest인증인지 구별하기 위해 WWW-Authenticate필드의 값을 파싱하여 판단할 수 있다.

서버가 Basic인증으로 설정되어있다면 WWW-Authenticate 필드는 Basic으로 시작되기 때문에, 별도의 파싱이 필요없다. 하지만 Digest인증으로 설정되어있다면 WWW-Authenticate에는 기본적으로 realm, nonce, qop값이 포함되어 있으므로 이를 파싱할 필요가 있다.

val authField = response.headers["WWW-Authenticate"]
val token = "realm=\""
val startIndex = authField.indexOf(token)+token.length
val endIndex = authField.indexOf("\"", authField.indexOf(token)+token.length)
val realm = authField.substring(startIndex, endIndex)

 

위의 코드는 WWW-Authenticate필드에서 realm값을 파싱하는 코드다. Kotlin에 익숙하지 않아서 코드가 부족하다. 위와같이 startIndexendIndex를 따로 구한 뒤 substring으로 realm값을 추출해내는 이유는 따로 있는데, 서버마다 WWW-Authenticate필드 자체의 포맷은 동일하지만 {}로 래핑을 하는 등의 처리가 다르기 때문이다. 만약 이러한 고려가 필요 없는 어플리케이션을 작성하고 있다면, 크게 고민하지 않아도 된다.

WWW-Authenticate 필드의 값을 파싱하는 작업이 끝났다면, 이 값을 이용해서 Digest인증에 필요한 토큰을 생성하는 작업이 남아있다. 우선은 cnonce값과 nonce값을 생성하도록 하자. nonce값은 Digest 인증을 시도한 횟수가 되는데, 카운트하기보다는 1회 시도하여 실패하면 로그인 실패 메시지를 띄우는 게 일반적이다. 따라서 일반적인 경우라면 클라이언트에서 보내는 nonce값은 "00000001"이 된다. cnonce값은 클라이언트에서 서버로 보내는 nonce값으로, 클라이언트가 지정한 임의의 난수값이 된다. 다음과 같은 방법으로 cnonce를 생성해주도록 하자.

val byteArray = ByteArray(16)
val secureRandom = SecureRandom()
secureRandom.nextBytes(byteArray)
val cnonce ByteString.of(*byteArray).hex()

 

cnonce값과 nonce값을 생성했다면, 남은 건 Authorization필드의 값을 생성하는 것 만 남았다. 다음의 코드를 참조하도록 하자.

val plainHA1 = "${username}:${realm}:${password}"
val plainHA2 = "${method}:${uri}"
val HA1 = getMD5Hash(plainHA1)
val HA2 = getMD5Hash(plainHA2)
val plainResponse = when(qop) {
    "auth", "auth-int" -> "${HA1}:${nonce}:${nc}:${cnonce}:${qop}:${HA2}"
    else -> "${HA1}:${nonce}:${HA2}"
}
val response = getMD5Hash(plainResponse)

val authorization = "Digest username=\"${username}\", realm=\"${realm}\", nonce=\"${nonce}\", " +
        "uri=\"${uri}\", qop=\"${qop}\", nc=\"${nc}\", cnonce=\"${cnonce}\", response=\"${response}\", " +
        "opaque=\"${opaque}\""

 

위 코드를 설명하면 다음과 같다.

  • username:realm:password값을 MD5로 암호화하여 HA1을 생성한다.
  • method:url값을 MD5로 암호화하여 HA2를 생성한다. 이 때 method는 HTTP 통신에 사용할 method를 말한다. url은 HTTP 요청을 보낼 주소를 말한다.
  • qop값이 auth 혹은 auth-int인 경우에는 HA1:nonce:nc:cnonce:qop:HA2값을, 그렇지 않은 경우에는 HA1:nonce:HA2값을 MD5로 암호화하여 response값을 생성한다.
  • opaque값은 옵션이므로, 공백 스트링을 넣어준다.
  • 위에서 생성된 username, realm, nonce, uri, qop, nc, cnonce, response, opaque값을 Authorization 필드에 입력해준다. 이 때 각 값들은 ""로 묶여있어야하며, Authorization필드는 Digest로 시작되어야 한다.

이제 이 값을 interceptor에서 처리해주면, 정상적으로 Digest 인증이 되는 것을 확인할 수 있다. 마지막으로 간략하게 Interceptor에서 HTTP 인증에 대한 처리를 하는 과정을 요약하고 마치도록 하겠다.

  • chain.proceed(request)를 통해 HTTP요청에 대한 응답을 받아온다.
  • 헤더의 HTTP 상태 코드를 확인하여, 401인 경우 WWW-Authenticate필드가 있는지 확인한다.
  • WWW-Authenticate필드가 Basic으로 시작한다면, Basic인증에 대한 MD5 해쉬값을 Authorization필드에 채워준다.
    • 다시 chain.proceed(request)를 통해 HTTP요청을 보내게 되며, HTTP요청에 대한 응답을 반환하게 된다.
  • WWW-Authenticate필드가 Digest로 시작한다면, WWW-Authenticate필드를 파싱한다.
    • nonce값과 cnonce값을 생성한 후, WWW-Authenticate필드를 파싱한 값들과 함께 MD5 해쉬값을 생성한다.
    • Digest인증에 대한 MD5 해쉬값을 Authorization필드에 채워준다.
    • 다시 chain.proceed(request)를 통해 HTTP요청을 보내게 되며, HTTP요청에 대한 응답을 반환하게 된다.
  • 반환된 값에 의해 Callback이 실행된다.

이걸로 간단하게(?) Digest 인증에 대한 처리가 완료됐다. 와! 간단하다! 대체 왜 Retrofit2&OkHttp3.Credentials에서는 Basic만 지원해서 Digest를 손수 구현해야하는지는 의문이다. iOS는 다 해준다던데 ㅠㅡㅜ) 휴우.

참조:
Basic Authentication on Android, FutureStudio.io
Digest Authentication, RFC2617

반응형