Files
agendula/dav/src/test/kotlin/at/bitfire/dav4jvm/BasicDigestAuthHandlerTest.kt
T
makiolaj 89c3a5f66e sync: do not cache a challenge we refuse to answer
The cleartext gate sits on the emission, but the 401 branch cached the
Basic challenge before reaching it. So after refusing a plain-HTTP
challenge the handler still believed Basic was in play: the preemptive
block is skipped because it requires both caches empty, the refusal
repeats, and the 401 after that logs "Basic credentials didn't work last
time" about a credential that never reached the wire.
2026-09-07 21:45:44 +02:00

353 lines
16 KiB
Kotlin

/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
import okhttp3.Challenge
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.Response.Builder
import org.junit.Assert.*
import org.junit.Test
class BasicDigestAuthHandlerTest {
// ⚠️ Amended from upstream: https, not http. Basic over cleartext is now
// refused whether or not it was challenged — see PROVENANCE change 9. The
// cleartext behaviour this used to assert is pinned by
// `cleartextBasicIsRefusedEvenWhenChallenged` below.
@Test
fun testBasic() {
var authenticator = BasicDigestAuthHandler(null, "user", "password")
val original = Request.Builder()
.url("https://example.com")
.build()
var response = Builder()
.request(original)
.protocol(Protocol.HTTP_1_1)
.code(401).message("Authentication required")
.header("WWW-Authenticate", "Basic realm=\"WallyWorld\"")
.build()
var request = authenticator.authenticateRequest(original, response)
assertEquals("Basic dXNlcjpwYXNzd29yZA==", request!!.header("Authorization"))
// special characters: always use UTF-8 (and don't crash on RFC 7617 charset header)
authenticator = BasicDigestAuthHandler(null, "username", "paßword")
response = response.newBuilder()
.header("WWW-Authenticate", "Basic realm=\"WallyWorld\",charset=UTF-8")
.build()
request = authenticator.authenticateRequest(original, response)
assertEquals("Basic dXNlcm5hbWU6cGHDn3dvcmQ=", request!!.header("Authorization"))
}
@Test
fun cleartextBasicIsRefusedEvenWhenChallenged() {
val authenticator = BasicDigestAuthHandler(null, "user", "password")
val original = Request.Builder().url("http://example.com").build()
val response = Builder()
.request(original)
.protocol(Protocol.HTTP_1_1)
.code(401).message("Authentication required")
.header("WWW-Authenticate", "Basic realm=\"WallyWorld\"")
.build()
assertNull(authenticator.authenticateRequest(original, response))
}
@Test
fun aRefusedCleartextChallengeIsNotCached() {
val authenticator = BasicDigestAuthHandler(null, "user", "password")
val original = Request.Builder().url("http://example.com").build()
val response = Builder()
.request(original)
.protocol(Protocol.HTTP_1_1)
.code(401).message("Authentication required")
.header("WWW-Authenticate", "Basic realm=\"WallyWorld\"")
.build()
assertNull(authenticator.authenticateRequest(original, response))
// Caching it would make the *next* 401 report that the credentials did
// not work, about a credential that was never sent.
assertNull(authenticator.authenticateRequest(original, response))
}
@Test
fun cleartextBasicIsSentWhenExplicitlyAllowed() {
val authenticator = BasicDigestAuthHandler(null, "user", "password", insecureBasic = true)
val original = Request.Builder().url("http://example.com").build()
val response = Builder()
.request(original)
.protocol(Protocol.HTTP_1_1)
.code(401).message("Authentication required")
.header("WWW-Authenticate", "Basic realm=\"WallyWorld\"")
.build()
val request = authenticator.authenticateRequest(original, response)
assertEquals("Basic dXNlcjpwYXNzd29yZA==", request!!.header("Authorization"))
}
@Test
fun cleartextDigestIsStillAnswered() {
// Digest never puts the password on the wire, so gating it would break a
// LAN server for nothing.
val authenticator = BasicDigestAuthHandler(null, "user", "password")
val original = Request.Builder().url("http://example.com").build()
val response = Builder()
.request(original)
.protocol(Protocol.HTTP_1_1)
.code(401).message("Authentication required")
.header(
"WWW-Authenticate",
"Digest realm=\"WallyWorld\", nonce=\"abc\", qop=\"auth\"",
)
.build()
val request = authenticator.authenticateRequest(original, response)
assertTrue(request!!.header("Authorization")!!.startsWith("Digest"))
}
@Test
fun testDigestRFCExample() {
// use cnonce from example
val authenticator = BasicDigestAuthHandler(null, "Mufasa", "Circle Of Life")
BasicDigestAuthHandler.clientNonce = "0a4f113b"
BasicDigestAuthHandler.nonceCount.set(1)
// construct WWW-Authenticate
val authScheme = Challenge("Digest", mapOf<String?, String>(
Pair("realm", "testrealm@host.com"),
Pair("qop", "auth"),
Pair("nonce", "dcd98b7102dd2f0e8b11d0f600bfb0c093"),
Pair("opaque", "5ccc069c403ebaf9f0171e9517f40e41")
))
val original = Request.Builder()
.get()
.url("http://www.nowhere.org/dir/index.html")
.build()
val request = authenticator.digestRequest(original, authScheme)
val auth = request!!.header("Authorization")
assertTrue(auth!!.contains("username=\"Mufasa\""))
assertTrue(auth.contains("realm=\"testrealm@host.com\""))
assertTrue(auth.contains("nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\""))
assertTrue(auth.contains("uri=\"/dir/index.html\""))
assertTrue(auth.contains("qop=auth"))
assertTrue(auth.contains("nc=00000001"))
assertTrue(auth.contains("cnonce=\"0a4f113b\""))
assertTrue(auth.contains("response=\"6629fae49393a05397450978507c4ef1\""))
assertTrue(auth.contains("opaque=\"5ccc069c403ebaf9f0171e9517f40e41\""))
}
@Test
fun testDigestRealWorldExamples() {
var authenticator = BasicDigestAuthHandler(null, "demo", "demo")
BasicDigestAuthHandler.clientNonce = "MDI0ZDgxYTNmZDk4MTA1ODM0NDNjNmJjNDllYjQ1ZTI="
BasicDigestAuthHandler.nonceCount.set(1)
// example 1
var authScheme = Challenge("Digest", mapOf<String?, String>(
Pair("realm", "Group-Office"),
Pair("qop", "auth"),
Pair("nonce", "56212407212c8"),
Pair("opaque", "df58bdff8cf60599c939187d0b5c54de")
))
var original = Request.Builder()
.method("PROPFIND", null)
.url("https://demo.group-office.eu/caldav/")
.build()
var request = authenticator.digestRequest(original, authScheme)
var auth = request!!.header("Authorization")
assertTrue(auth!!.contains("username=\"demo\""))
assertTrue(auth.contains("realm=\"Group-Office\""))
assertTrue(auth.contains("nonce=\"56212407212c8\""))
assertTrue(auth.contains("uri=\"/caldav/\""))
assertTrue(auth.contains("cnonce=\"MDI0ZDgxYTNmZDk4MTA1ODM0NDNjNmJjNDllYjQ1ZTI=\""))
assertTrue(auth.contains("nc=00000001"))
assertTrue(auth.contains("qop=auth"))
assertTrue(auth.contains("response=\"de3b3b194d85ddc62537208c9c3637dc\""))
assertTrue(auth.contains("opaque=\"df58bdff8cf60599c939187d0b5c54de\""))
// example 2
authenticator = BasicDigestAuthHandler(null, "test", "test")
authScheme = Challenge("digest", mapOf<String?, String>( // lower case
Pair("nonce", "87c4c2aceed9abf30dd68c71"),
Pair("algorithm", "md5"),
Pair("opaque", "571609eb7058505d35c7bf7288fbbec4-ODdjNGMyYWNlZWQ5YWJmMzBkZDY4YzcxLDAuMC4wLjAsMTQ0NTM3NzE0Nw=="),
Pair("realm", "ieddy.ru")
))
original = Request.Builder()
.method("OPTIONS", null)
.url("https://ieddy.ru/")
.build()
request = authenticator.digestRequest(original, authScheme)
auth = request!!.header("Authorization")
assertTrue(auth!!.contains("algorithm=\"MD5\"")) // some servers require it
assertTrue(auth.contains("username=\"test\""))
assertTrue(auth.contains("realm=\"ieddy.ru\""))
assertTrue(auth.contains("nonce=\"87c4c2aceed9abf30dd68c71\""))
assertTrue(auth.contains("uri=\"/\""))
assertFalse(auth.contains("cnonce="))
assertFalse(auth.contains("nc=00000001"))
assertFalse(auth.contains("qop="))
assertTrue(auth.contains("response=\"d42a39f25f80b0d6907286a960ff9c7d\""))
assertTrue(auth.contains("opaque=\"571609eb7058505d35c7bf7288fbbec4-ODdjNGMyYWNlZWQ5YWJmMzBkZDY4YzcxLDAuMC4wLjAsMTQ0NTM3NzE0Nw==\""))
}
@Test
fun testDigestMD5Sess() {
val authenticator = BasicDigestAuthHandler(null, "admin", "12345")
BasicDigestAuthHandler.clientNonce = "hxk1lu63b6c7vhk"
BasicDigestAuthHandler.nonceCount.set(1)
val authScheme = Challenge("Digest", mapOf<String?, String>(
Pair("realm", "MD5-sess Example"),
Pair("qop", "auth"),
Pair("algorithm", "MD5-sess"),
Pair("nonce", "dcd98b7102dd2f0e8b11d0f600bfb0c093"),
Pair("opaque", "5ccc069c403ebaf9f0171e9517f40e41")
))
/* A1 = h("admin:MD5-sess Example:12345"):dcd98b7102dd2f0e8b11d0f600bfb0c093:hxk1lu63b6c7vhk =
4eaed818bc587129e73b39c8d3e8425a:dcd98b7102dd2f0e8b11d0f600bfb0c093:hxk1lu63b6c7vhk a994ee9d33e2f077d3a6e13e882f6686
A2 = POST:/plain.txt 1b557703454e1aa1230c5523f54380ed
h("a994ee9d33e2f077d3a6e13e882f6686:dcd98b7102dd2f0e8b11d0f600bfb0c093:00000001:hxk1lu63b6c7vhk:auth:1b557703454e1aa1230c5523f54380ed") =
af2a72145775cfd08c36ad2676e89446
*/
val original = Request.Builder()
.method("POST", "PLAIN TEXT".toRequestBody("text/plain".toMediaType()))
.url("http://example.com/plain.txt")
.build()
val request = authenticator.digestRequest(original, authScheme)
val auth = request!!.header("Authorization")
assertTrue(auth!!.contains("username=\"admin\""))
assertTrue(auth.contains("realm=\"MD5-sess Example\""))
assertTrue(auth.contains("nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\""))
assertTrue(auth.contains("uri=\"/plain.txt\""))
assertTrue(auth.contains("cnonce=\"hxk1lu63b6c7vhk\""))
assertTrue(auth.contains("nc=00000001"))
assertTrue(auth.contains("qop=auth"))
assertTrue(auth.contains("response=\"af2a72145775cfd08c36ad2676e89446\""))
assertTrue(auth.contains("opaque=\"5ccc069c403ebaf9f0171e9517f40e41\""))
}
@Test
fun testDigestMD5AuthInt() {
val authenticator = BasicDigestAuthHandler(null, "admin", "12435")
BasicDigestAuthHandler.clientNonce = "hxk1lu63b6c7vhk"
BasicDigestAuthHandler.nonceCount.set(1)
val authScheme = Challenge("Digest", mapOf<String?, String>(
Pair("realm", "AuthInt Example"),
Pair("qop", "auth-int"),
Pair("nonce", "367sj3265s5"),
Pair("opaque", "87aaxcval4gba36")
))
/* A1 = admin:AuthInt Example:12345 380dc3fc1305127cd2aa81ab68ef3f34
h("PLAIN TEXT") = 20296edbd4c4275fb416b64e4be752f9
A2 = POST:/plain.txt:20296edbd4c4275fb416b64e4be752f9 a71c4c86e18b3993ffc98c6e426fe4b0
h(380dc3fc1305127cd2aa81ab68ef3f34:367sj3265s5:00000001:hxk1lu63b6c7vhk:auth-int:a71c4c86e18b3993ffc98c6e426fe4b0) =
81d07cb3b8d412b34144164124c970cb
*/
val original = Request.Builder()
.method("POST", "PLAIN TEXT".toRequestBody("text/plain".toMediaType()))
.url("http://example.com/plain.txt")
.build()
val request = authenticator.digestRequest(original, authScheme)
val auth = request!!.header("Authorization")
assertTrue(auth!!.contains("username=\"admin\""))
assertTrue(auth.contains("realm=\"AuthInt Example\""))
assertTrue(auth.contains("nonce=\"367sj3265s5\""))
assertTrue(auth.contains("uri=\"/plain.txt\""))
assertTrue(auth.contains("cnonce=\"hxk1lu63b6c7vhk\""))
assertTrue(auth.contains("nc=00000001"))
assertTrue(auth.contains("qop=auth-int"))
assertTrue(auth.contains("response=\"5ab6822b9d906cc711760a7783b28dca\""))
assertTrue(auth.contains("opaque=\"87aaxcval4gba36\""))
}
@Test
fun testDigestLegacy() {
val authenticator = BasicDigestAuthHandler(null, "Mufasa", "CircleOfLife")
// construct WWW-Authenticate
val authScheme = Challenge("Digest", mapOf<String?, String>(
Pair("realm", "testrealm@host.com"),
Pair("nonce", "dcd98b7102dd2f0e8b11d0f600bfb0c093"),
Pair("opaque", "5ccc069c403ebaf9f0171e9517f40e41")
))
val original = Request.Builder()
.get()
.url("http://www.nowhere.org/dir/index.html")
.build()
val request = authenticator.digestRequest(original, authScheme)
val auth = request!!.header("Authorization")
assertTrue(auth!!.contains("username=\"Mufasa\""))
assertTrue(auth.contains("realm=\"testrealm@host.com\""))
assertTrue(auth.contains("nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\""))
assertTrue(auth.contains("uri=\"/dir/index.html\""))
assertFalse(auth.contains("qop="))
assertFalse(auth.contains("nc="))
assertFalse(auth.contains("cnonce="))
assertTrue(auth.contains("response=\"1949323746fe6a43ef61f9606e7febea\""))
assertTrue(auth.contains("opaque=\"5ccc069c403ebaf9f0171e9517f40e41\""))
}
@Test
fun testIncompleteAuthenticationRequests() {
val authenticator = BasicDigestAuthHandler(null, "demo", "demo")
val original = Request.Builder()
.get()
.url("http://www.nowhere.org/dir/index.html")
.build()
assertNull(authenticator.digestRequest(original, Challenge("Digest", mapOf<String?, String>())))
assertNull(authenticator.digestRequest(original, Challenge("Digest", mapOf<String?, String>(
Pair("realm", "Group-Office")
))))
assertNull(authenticator.digestRequest(original, Challenge("Digest", mapOf<String?, String>(
Pair("realm", "Group-Office"),
Pair("qop", "auth")
))))
assertNotNull(authenticator.digestRequest(original, Challenge("Digest", mapOf<String?, String>(
Pair("realm", "Group-Office"),
Pair("qop", "auth"),
Pair("nonce", "56212407212c8")
))))
}
@Test
fun testAuthenticateNull() {
val authenticator = BasicDigestAuthHandler(null, "demo", "demo")
// must not crash (route may be null)
val request = Request.Builder()
.get()
.url("http://example.com")
.build()
val response = Response.Builder()
.request(request)
.protocol(Protocol.HTTP_2)
.code(200).message("OK")
.build()
authenticator.authenticate(null, response)
}
}