rburgst/okhttp-digest

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

https://github.com/apache/httpclient/blob/4.3.x/httpclient/src/test/java/org/apache/http/impl/auth/TestDigestScheme.java

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

https://github.com/apache/httpclient/blob/4.3.x/httpclient/src/test/java/org/apache/http/impl/auth/TestDigestScheme.java


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
.