2019. 11. 19. 20:27ㆍProgramming/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
을 추가함으로써 코드의 흐름에 분기가 생기는 걸 막고자 response
를 var
로 변경하고, 마지막에 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:PW
를 MD5
로 암호화 한 값이 Authentication
필드에 들어가게 된다. 따라서 authToken에 ID:PW
를 MD5
로
암호화 한 값을 넣어주면 되는데, Credentials.basic(ID, PW)
의 결과값을 넣어주면 authToken
에 해당하는 값이 나오게 된다. 이 값을 넣어서 Interceptor가 제대로 동작하는지, Break를 걸어서 확인해보도록 하자.
위에서 생성한 Interceptor
는 chain.proceed(request)
를 통해서 서버에 HTTP 통신을 요청한 뒤, 응답을 받아 반환하게 된다. 이 값의 HTTP 상태 코드가 401이라면, Interceptor
는 Authorization
필드에 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에 익숙하지 않아서 코드가 부족하다. 위와같이 startIndex
와 endIndex
를 따로 구한 뒤 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
'Programming > Android' 카테고리의 다른 글
Android의 WebView를 사용할 때, 클라이언트 에러를 추적하는 방법 정리. (0) | 2020.03.02 |
---|---|
[Android] YUV와 RGB의 색공간 변환과 LIBYUV를 이용한 하드웨어 가속 (0) | 2019.12.13 |
CLEARTEXT communication to [TARGET_ADDRESS] not permitted by network security policy (0) | 2019.11.08 |
Retrofit2를 이용해서 서버로부터 Content-type이 image/jpeg로 이미지를 받아 Bitmap 객체로 처리하기 (0) | 2019.11.05 |
Kotlin <-> Java 클래스를 서로 참조 못할 때(Can not find symbol) (0) | 2019.11.03 |