Skip to content

Instantly share code, notes, and snippets.

@cesartl
Last active June 3, 2023 22:29
Show Gist options
  • Save cesartl/3dd0541d0b71771daac28bad9a08512d to your computer and use it in GitHub Desktop.
Save cesartl/3dd0541d0b71771daac28bad9a08512d to your computer and use it in GitHub Desktop.
Feign Http Digest Authorization
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)
@Kudesnik99
Copy link

Tank you very much for this example! It's very useful for me. But two warnings:

  • parseDigestResponseHeader, parseTuple works incorrect if nonce contains '=' because of TUPLE_REGEX = """(.)=(.)""".toRegex()
  • URI_REGEX incorrect if url contains port number (like http://192.168.1.1:4567/....)
    I'm using java, so I translated this example to java and there is my version of these places:
private static DigestResponseHeader parseDigestResponseHeader(String headerValue) {
        String values = extractAuthenticateHeaderElements(headerValue);

        Map<String, String> valuesMap = new HashMap<>();
        Arrays.asList(values.split(", ")).forEach(value -> {
            int eqPos = value.indexOf('=');
            valuesMap.put(value.substring(0, eqPos), value.substring(eqPos + 1));
        });

and

private static final Pattern URI_REGEX = Pattern.compile("(https?://([\\w.]+)(:[0-9]+)?)?(/.+)");

private static String extractUri(String url) {
        Matcher matcher = URI_REGEX.matcher(url);
        if (matcher.find() && matcher.group(4) != null) {
            return matcher.group(4);
        } else {
            throw new IllegalArgumentException("Could not get uri from " + url);
        }
    }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment