Digest Auth Without Using httpclient
chrisjenx opened this issue · 2 comments
I rewrote the DigestAuthenticator
(in Kotlin, but you can convert it to Java).
This does quite a few things:
- Handles MD5-sess
- Handles qop=auth (needs a little more work for auth-int)
- Has a significantly smaller foot print (one class now, it could be split up a little bit)
- Handles caching correctly (remembers nonce and nonceCount)
- Will fail on incorrect user/pass.
Noted issues:
- Does not retry on
STALE=true
(a valid auth which is stale will cause 401)
Have a look through, if you are happy I can move to Java and do a PR.
/**
* Created by chris on 08/06/2016.
* For project Owlr Android
*/
@Suppress("NOTHING_TO_INLINE")
class DigestAuthenticator() : CachingAuthenticator {
/**
* Previous cached auth headers <host:port>,<field,value>>
*/
private val cachedAuthFields = mutableMapOf<String, MutableMap<String, String>>()
private val charset = Charset.forName("ISO-8859-1")
override fun authenticate(route: Route?, response: Response): Request? {
val request = response.request()
// We see if this request has previously been authed by this nonce
request.url().let { "${it.host()}:${it.port()}" }.let { it to cachedAuthFields[it] }.apply {
val (key, fields) = this
val nonce: String = fields?.get("nonce") ?: return@apply
if (havePreviouslyAuthedWithSameNonce(request, nonce)) {
Timber.w("Previous Digest authentication failed, returning null")
// Clear the cachedAuthFields just in case the nonce is invalid
cachedAuthFields.remove(key)
return null
}
}
// If no Digest Request we don't auth.
val auth = findAuthenticationDigestHeader(response.headers()) ?: return null
val authFields = splitAuthFields(auth.substring(7))
return authFromRequest(authFields, response.request())
?.apply {
// We only cache the authFields if we manage to actually create a auth response
request.url().apply { cachedAuthFields.put("${host()}:${port()}", authFields) }
}
}
override fun authenticateWithState(request: Request): Request? {
// If we haven't authed this host previously this will be null, so we can't preemptively auth it
val cachedAuthFields = request.url().let { "${it.host()}:${it.port()}" }.let { cachedAuthFields[it] } ?: return null
cachedAuthFields["realm"] ?: return null
cachedAuthFields["nonce"] ?: return null
return authFromRequest(cachedAuthFields, request)
}
fun authFromRequest(authFields: MutableMap<String, String>, request: Request): Request? {
// Pull out username and password from request
val (username, password) = Credentials(request)
// We generate a cnonce and nc for the following situations
var cnonce = ""
var nc = ""
when {
authFields["algorithm"] == "MD5-sess"
|| authFields["qop"] == "auth"
|| authFields["qop"] == "auth-int" -> {
cnonce = getClientNonce()
nc = getNonceCount(authFields["nc"])
authFields["nc"] = nc
}
}
val md5: MessageDigest = try {
MessageDigest.getInstance("MD5")
} catch(e: NoSuchAlgorithmException) {
return null
}
val HA1: String? = try {
md5.reset()
val ha1str = arrayOf(username, authFields["realm"], password).joinToString(":")
md5.update(ha1str.toByteArray(charset))
md5.digest().toHexString().let {
if (authFields["algorithm"] != "MD5-sess") return@let it
val ha1Ha1Str = arrayOf(it, authFields["nonce"], cnonce).joinToString(":")
md5.apply {
reset(); update(ha1Ha1Str.toByteArray(charset))
}.digest().toHexString()
}
} catch(e: UnsupportedEncodingException) {
return null
}
val HA2 = try {
md5.reset()
val ha2str = arrayOf(request.method(), request.url().getUri()).joinToString(":")
md5.update(ha2str.toByteArray(charset))
md5.digest().toHexString()
} catch(e: UnsupportedEncodingException) {
return null
}
val HA3 = try {
md5.reset()
val ha3str = when (authFields["qop"]) {
"auth", "auth-int" -> arrayOf(HA1, authFields["nonce"], nc, cnonce, authFields["qop"], HA2).joinToString(":")
else -> arrayOf(HA1, authFields["nonce"], HA2).joinToString(":")
}
md5.update(ha3str.toByteArray(charset))
md5.digest().toHexString()
} catch(e: UnsupportedEncodingException) {
return null
}
val authHeader = buildString {
append("Digest ")
append("username").append("=\"").append(username).append("\", ")
append("realm").append("=\"").append(authFields["realm"]).append("\", ")
append("nonce").append("=\"").append(authFields["nonce"]).append("\", ")
append("uri").append("=\"").append(request.url().getUri()).append("\", ")
append("qop").append('=').append(authFields["qop"] ?: "").append(", ")
if (cnonce.isNotBlank()) {
append("nc").append('=').append(nc).append(", ")
append("cnonce").append("=\"").append(cnonce).append("\", ")
}
append("response").append("=\"").append(HA3).append("\", ")
append("opaque").append("=\"").append(authFields["opaque"] ?: "").append("\"")
}
return request.newBuilder()
.header("Authorization", authHeader)
.build()
}
companion object {
private val secureRandom by lazy { SecureRandom() }
private inline fun ByteArray.toHexString(): String = ByteString.of(*this).hex()
/**
* Generates a unique client nonce
*/
private inline fun getClientNonce(): String {
val cnonceByteArrray = ByteArray(16)
secureRandom.nextBytes(cnonceByteArrray)
return ByteString.of(*cnonceByteArrray).hex()
}
/**
* Will see if there was a previous nonceCount and increment the previous count if so.
*/
private inline fun getNonceCount(previousNC: String?): String {
return ((previousNC?.toInt() ?: 0) + 1).toString().padStart(8, '0')
}
private inline fun HttpUrl.getUri(): String {
return "${this.encodedPath()}${this.encodedQuery() ?: ""}"
}
fun splitAuthFields(authString: String): MutableMap<String, String> {
val fields = mutableMapOf<String, String>()
authString.splitToSequence(',').filter { it.isNotBlank() }
.forEach { keyPair ->
keyPair.splitToSequence('=')
.map { it.trim('"', '\t', ' ') }
.take(2)
.toList()
.apply {
fields.put(get(0), get(1))
}
}
return fields;
}
private fun havePreviouslyAuthedWithSameNonce(request: Request, nonce: String): Boolean {
// prevent infinite loops when the password is wrong
request.header("Authorization")?.apply {
if (startsWith("Digest")) {
// Safety
val requestNonce = splitAuthFields(this.substring(7)).let {
val nonce = it["nonce"]
Timber.d("Last Request nonce: $nonce")
nonce
} ?: return false
if (requestNonce == nonce) {
return true
}
}
}
return false
}
private fun findAuthenticationDigestHeader(headers: Headers): String? {
return headers.values("WWW-Authenticate").firstOrNull { it.startsWith("Digest ") }
}
}
}
In principle the code looks good, however since this is a newer implementation we should add the tests from HttpClient as well and make sure it passes
Sure I'll translate the tests on my local build to double check then submit
a PR :)
On Tue, 12 Jul 2016, 05:03 rburgst, notifications@github.com wrote:
In principle the code looks good, however since this is a newer
implementation we should add the tests from HttpClient as well and make
sure it passes—
You are receiving this because you authored the thread.
Reply to this email directly, view it on GitHub
#14 (comment),
or mute the thread
https://github.com/notifications/unsubscribe/ABHRsdo76YxwKJxCFYvH7rvJLGGv-vesks5qUxIIgaJpZM4JILID
.