Last active
June 3, 2023 22:29
-
-
Save cesartl/3dd0541d0b71771daac28bad9a08512d to your computer and use it in GitHub Desktop.
Feign Http Digest Authorization
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import feign.* | |
import feign.codec.ErrorDecoder | |
import org.apache.commons.codec.digest.DigestUtils | |
import org.apache.commons.lang.RandomStringUtils | |
import org.slf4j.Logger | |
import org.slf4j.LoggerFactory | |
import kotlin.properties.ReadWriteProperty | |
import kotlin.reflect.KProperty | |
/** | |
* Data class to hold information sent by the server via the WWW-Authenticate header | |
*/ | |
data class DigestResponseHeader(val realm: String, val domain: String, val nonce: String, val algorithm: String, val qop: String) | |
data class RetryForHttpDigestAuthenticationException(val httpMethod: Request.HttpMethod) : RetryableException("Retrying with HTTP digest", httpMethod, null) | |
/** | |
* This class implements the HTTP Digest authentication scheme described in [https://tools.ietf.org/html/rfc2617]. | |
* As opposed to basic HTTP authentication, in this scheme the client must first make a request without authentication which should return 401 and the WWW-Authenticate | |
* header. This header contains information required to produce the Authorization header for the subsequent request. | |
* | |
* This behaviour cannot be implemented with a [RequestInterceptor] alone. This class also implements [Retryer] and [ErrorDecoder] which usage are summarised here: | |
* | |
* 1) [ErrorDecoder] first check if the reply is a 401 and it contains the WWW-Authenticate header. If it does it makes sure the exception is marked as retryable. Otherwise the error is forwarded to the inner [ErrorDecoder] | |
* The decode method will also extract the content of the WWW-Authenticate header and save it as a [DigestResponseHeader] to be saved in a ThreadLocal | |
* 2) [Retryer] make sure the request is always retried by checking the exception type. If it is [RetryForHttpDigestAuthenticationException] it should always be retried | |
* 3) [RequestInterceptor] if there is a [DigestResponseHeader] in the thread local variable, it will be used to produce the Authorisation header following the specification | |
* | |
* The nounce given by the server is kept in the thread local and is reused until the server returns a 401 with the `stale = true` flag. Not doing so would mean doubling the number of request which would be inneficient | |
* | |
*/ | |
data class HttpDigestInterceptor(val innerErrorDecoder: ErrorDecoder, val innerRetryer: Retryer, val username: String, val password: String) : RequestInterceptor, Retryer, ErrorDecoder { | |
override fun clone(): Retryer { | |
return copy(innerRetryer = innerRetryer.clone()) | |
} | |
override fun continueOrPropagate(e: RetryableException) { | |
when (e) { | |
is RetryForHttpDigestAuthenticationException -> { | |
// we need to always retry in this case | |
} | |
else -> innerRetryer.continueOrPropagate(e) //otherwise we let the inner retryer decide | |
} | |
} | |
override fun decode(methodKey: String, response: Response): Exception { | |
if (response.status() == 401) { | |
val headerValue = response.headers()["WWW-Authenticate"]?.firstOrNull() | |
// we force a retry on the first 401 (i.e digestResponseHeader==null) or when the stale flag is set | |
if (headerValue != null && (digestResponseHeader == null || headerValue.contains("stale=true"))) { | |
resetDigestResponseHeader(parseDigestResponseHeader(headerValue)) | |
return RetryForHttpDigestAuthenticationException(response.request().httpMethod()) | |
} | |
} | |
// this is not an expected error, we let the inner decoder do its job | |
val decode = innerErrorDecoder.decode(methodKey, response) | |
when (decode) { | |
is FeignException -> log.error("Feign exception with message: ${decode.contentUTF8()}") | |
} | |
return decode | |
} | |
private fun resetDigestResponseHeader(newDigestResponseHeader: DigestResponseHeader) { | |
digestResponseHeader = newDigestResponseHeader | |
requestCount = 0 | |
} | |
override fun apply(template: RequestTemplate) { | |
setUpDigestHeader(template) | |
} | |
private fun setUpDigestHeader(template: RequestTemplate) { | |
//"Digest username="xxx", realm="MMS Public API", nonce="x0T053O+XMt3eImsWMMCu3K/1emmOPCW", | |
// uri="/api/foo/bar", algorithm="MD5", | |
// qop=auth, nc=00000001, cnonce="RKKOGlXl", response="5406590c45df63ca5efa874bccdc11f0"" | |
val theDigestResponseHeader = digestResponseHeader //used for smart cast | |
if (theDigestResponseHeader != null) { | |
val cnonce = RandomStringUtils.randomAlphanumeric(8) | |
val uri = extractUri(template.url()) | |
val method = template.request().httpMethod().name | |
val ncvalue = (++requestCount).toString(16).padStart(8, '0') | |
val requestDigest = requestDigest(theDigestResponseHeader, method, uri, ncvalue, cnonce, username, password) | |
val elements = mutableMapOf<String, String>() | |
elements["username"] = quote(username) | |
elements["realm"] = quote(theDigestResponseHeader.realm) | |
elements["uri"] = quote(uri) | |
elements["algorithm"] = quote(theDigestResponseHeader.algorithm) | |
elements["qop"] = theDigestResponseHeader.qop | |
elements["nc"] = ncvalue | |
elements["response"] = quote(requestDigest) | |
elements["cnonce"] = quote(cnonce) | |
elements["nonce"] = quote(theDigestResponseHeader.nonce) | |
val authorization = elements.entries.joinToString(", ") { "${it.key}=${it.value}" } | |
template.header("Authorization", "Digest $authorization") | |
} | |
} | |
companion object { | |
val log: Logger = LoggerFactory.getLogger(HttpDigestInterceptor::class.java) | |
private val DIGEST_REGEX = """Digest (.+)""".toRegex() | |
private val TUPLE_REGEX = """(.*)=(.*)""".toRegex() | |
private val URI_REGEX = """(https?:\/\/([\w \.]+))?(\/.+)""".toRegex() | |
private var digestResponseHeader: DigestResponseHeader? by thread_local(null as DigestResponseHeader?) | |
private var requestCount by thread_local(0) | |
fun extractUri(url: String): String { | |
return URI_REGEX.matchEntire(url)?.groupValues?.get(3) | |
?: throw IllegalArgumentException("could not get uri from $url") | |
} | |
fun extractAuthenticateHeaderElements(headerValue: String): String { | |
return DIGEST_REGEX.matchEntire(headerValue)?.groupValues?.get(1) | |
?: throw IllegalArgumentException("Invalid header value: $headerValue") | |
} | |
fun requestDigest(digestResponseHeader: DigestResponseHeader, method: String, uri: String, ncvalue: String, cnonce: String, username: String, password: String): String { | |
if (digestResponseHeader.algorithm != "MD5") throw IllegalArgumentException("Algorithm ${digestResponseHeader.algorithm} not supported") | |
if (digestResponseHeader.qop != "auth") throw IllegalArgumentException("qop ${digestResponseHeader.qop} not supported") | |
val a1 = H("$username:${digestResponseHeader.realm}:$password") | |
val a2 = "$method:$uri" | |
return KD(a1, "${digestResponseHeader.nonce}:$ncvalue:$cnonce:${digestResponseHeader.qop}:${H(a2)}") | |
} | |
fun parseDigestResponseHeader(headerValue: String): DigestResponseHeader { | |
// Digest realm="MMS Public API", domain="", nonce="woyKXeH/0j83UPH2lcAxJR1Tx9Ow95Rt", algorithm=MD5, qop="auth", stale=false | |
val values = extractAuthenticateHeaderElements(headerValue).splitToSequence(",").map { it.trim() }.map { parseTuple(it) }.toMap() | |
return DigestResponseHeader( | |
realm = values["realm"] ?: "", | |
domain = values["domain"] ?: "", | |
nonce = values["nonce"] ?: "", | |
algorithm = values["algorithm"] ?: "", | |
qop = values["qop"] ?: "" | |
) | |
} | |
private fun parseTuple(tuple: String): Pair<String, String> { | |
val match = TUPLE_REGEX.matchEntire(tuple) ?: throw IllegalArgumentException("Invalid string $tuple") | |
return match.groupValues[1] to unquote(match.groupValues[2]) | |
} | |
private fun unquote(quoted: String): String { | |
if (quoted.startsWith(""""""")) { | |
return quoted.substring(1 until quoted.length - 1) | |
} | |
return quoted | |
} | |
private infix fun quote(s: String) = """"$s"""" | |
/** | |
* https://tools.ietf.org/html/rfc2617#page-10 | |
*/ | |
private fun H(data: String): String = DigestUtils.md5Hex(data) | |
/** | |
* https://tools.ietf.org/html/rfc2617#page-10 | |
*/ | |
private fun KD(secret: String, data: String): String = H("$secret:$data") | |
} | |
} | |
/** | |
* Kotlin Delegate to use ThreadLocal variable as normal variables | |
*/ | |
class ThreadLocalDelegate<T>(val local: ThreadLocal<T>) | |
: ReadWriteProperty<Any, T> { | |
companion object { | |
fun <T> late_init() = ThreadLocalDelegate<T>(ThreadLocal()) | |
} | |
constructor (initial: T) : | |
this(ThreadLocal.withInitial { initial }) | |
constructor (initial: () -> T) : | |
this(ThreadLocal.withInitial(initial)) | |
override fun getValue(thisRef: Any, property: KProperty<*>): T = local.get() | |
override fun setValue(thisRef: Any, property: KProperty<*>, value: T) = local.set(value) | |
} | |
typealias thread_local<T> = ThreadLocalDelegate<T> | |
operator fun <T> ThreadLocal<T>.provideDelegate(self: Any, prop: KProperty<*>) = ThreadLocalDelegate(this) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Tank you very much for this example! It's very useful for me. But two warnings:
I'm using java, so I translated this example to java and there is my version of these places:
and