Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Resolve #6068 digestauth challenge redux #6138

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
a34ffa6
Exposing callable parameters for DigestAuth.challenge
blast-hardcheese Mar 21, 2022
4ee881c
Update server/src/main/scala/org/http4s/server/middleware/authenticat…
blast-hardcheese Mar 21, 2022
1cbfe59
Embedding new NonceKeeper in F.delay
blast-hardcheese Mar 21, 2022
b645a3b
Chasing F.delay for NonceKeeper
blast-hardcheese Mar 21, 2022
98a1dcb
Update server/src/main/scala/org/http4s/server/middleware/authenticat…
blast-hardcheese Mar 21, 2022
8196978
quicklint
Mar 21, 2022
4819197
Promote DigestAuth.apply to F[_]
blast-hardcheese Mar 21, 2022
7a60bd1
Switching AuthenticationSuite to use DigestAuth.applyF
blast-hardcheese Mar 21, 2022
db00ac4
First pass of overhauling NonceKeeper with Ref and Semaphore
blast-hardcheese Mar 21, 2022
7163a30
Remove return
blast-hardcheese Mar 21, 2022
e50b67b
Promote DigestAuth.apply to F[_]
blast-hardcheese Mar 21, 2022
f5ff20b
Break out NonceKeeperF
blast-hardcheese Mar 21, 2022
a4054ee
Switch to NonceKeeperF
blast-hardcheese Mar 21, 2022
46c68a6
Break out Nonce
blast-hardcheese Mar 21, 2022
8a2c072
Switch to NonceF
blast-hardcheese Mar 21, 2022
c57ae72
bincompat
blast-hardcheese Mar 21, 2022
b5283e6
tailrec -> tailRecM
blast-hardcheese Mar 21, 2022
5c7f4d9
Flesh out AuthenticationStore
blast-hardcheese Mar 21, 2022
d241359
Bumping deprecated version number
blast-hardcheese Mar 21, 2022
5fe1648
getRandomData should be F-suspended
blast-hardcheese Mar 21, 2022
0ad0342
Strike fear into the hearts of men
blast-hardcheese Mar 21, 2022
f702b42
Moving nonces into NonceKeeperF constructor
blast-hardcheese Mar 21, 2022
c69302c
F-suspend getRandomData
blast-hardcheese Mar 21, 2022
b748965
Documentation
blast-hardcheese Mar 21, 2022
4345b53
Preparing for more secure authStore test
blast-hardcheese Mar 21, 2022
645d2da
Adding a test for Md5HashedAuthenticationStore
blast-hardcheese Mar 21, 2022
3122229
De-case-ize AuthenticationStore members
blast-hardcheese Mar 21, 2022
aa6ab18
Renaming AuthenticationStore to AuthStore
blast-hardcheese Mar 21, 2022
61bcac3
Adding Md5HashedAuthStore.precomputeHash helper
blast-hardcheese Mar 21, 2022
8c6ef83
Swapping ju.Date to Instant
blast-hardcheese Mar 21, 2022
35dbb6e
Switching from Instant and realTime to monotonic millis
blast-hardcheese Mar 21, 2022
4a5f76e
Swapping out Long to Duration
blast-hardcheese Mar 21, 2022
a31c5d6
Wiring through Blocking and ContextShift for #6165
blast-hardcheese Mar 21, 2022
f84f759
uri: String -> Uri
blast-hardcheese Mar 21, 2022
733dcf7
bincompat
blast-hardcheese Mar 21, 2022
e028cf5
Stub out Blocker since this is all just going away in CE3 anyway
blast-hardcheese Mar 22, 2022
11ef78b
Push Blocker out to the user's control
blast-hardcheese Mar 22, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -22,25 +22,62 @@ package authentication
import cats.Monad
import cats.data.Kleisli
import cats.data.NonEmptyList
import cats.effect.Blocker
import cats.effect.Concurrent
import cats.effect.ContextShift
import cats.effect.Sync
import cats.effect.Timer
import cats.syntax.all._
import org.http4s.crypto.Hash
import org.http4s.crypto.unsafe.SecureRandom
import org.http4s.headers._

import java.math.BigInteger
import java.util.Date
import scala.concurrent.duration._

/** Provides Digest Authentication from RFC 2617.
*/
object DigestAuth {

@deprecated(
"AuthenticationStore is going away, in favor of explicit subclasses of AuthStore. PlainTextAuthStore maintains the previous, insecure behaviour, whereas Md5HashedAuthStore is the new advised implementation going forward.",
"0.22.13",
)
type AuthenticationStore[F[_], A] = String => F[Option[(A, String)]]

sealed trait AuthStore[F[_], A]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to be sealed? I see we match on them in checkAuthParams. Would it be better if those cases were a method on this, to permit more pluggable implementations? Or are plaintext and MD5 the two that are specified?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on how I understand the spec, there are only two sensible implementations:

Plain-text passwords:

md5($un:$realm:$pw):$nonce:$nc:$cnonce:$qop:md5($method:$uri)

Pre-hashed passwords:

md5($un:$realm:$pw) -> ha1 in the db
                |
         retrieve from db
                v
               $ha1:$nonce:$nc:$cnonce:$qop:md5($method:$uri)

The way I see it, by pushing more into subclasses of AuthStore, we'd need to expose all these parameters for dubious benefit. ha1 is the only secret that needs to be exposed to user code

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewing the spec again for another comment, this is actually called out explicitly by the spec:

Note that the HTTP server does not actually need to know the user's
cleartext password. As long as H(A1) is available to the server, the
validity of an Authorization header may be verified.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An optional header allows the server to specify the algorithm used to
create the checksum or digest. By default the MD5 algorithm is used
and that is the only algorithm described in this document.

It looks like it can be md5, md5-sess, or extensions that probably nobody understands. I think it would be unpleasant to register and understand more for little gain.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The need to add support for this stuff may motivate new contributors 😉

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Additionally, adding the required helper methods, similar to Md5HashedAuthStore.precomputeHash)


/** A function mapping username to a user object and password, or
* None if no user exists. Requires that the server can recover
* the password in clear text, which is _strongly_ discouraged.
* None if no user exists.
*
* Requires that the server can recover the password in clear text,
* which is _strongly_ discouraged. Please use Md5HashedAuthStore
* if you can.
*/
type AuthenticationStore[F[_], A] = String => F[Option[(A, String)]]
object PlainTextAuthStore {
def apply[F[_], A](func: String => F[Option[(A, String)]]): AuthStore[F, A] =
new PlainTextAuthStore(func)
}
final class PlainTextAuthStore[F[_], A](val func: String => F[Option[(A, String)]])
extends AuthStore[F, A]

/** A function mapping username to a user object and precomputed md5
* hash of the username, realm, and password, or None if no user exists.
*
* More secure than PlainTextAuthStore due to only needing to
* store the digested hash instead of the password in plain text.
*/
object Md5HashedAuthStore {
def apply[F[_], A](func: String => F[Option[(A, String)]]): AuthStore[F, A] =
new Md5HashedAuthStore(func)

def precomputeHash[F[_]: Monad: Hash](
username: String,
realm: String,
password: String,
): F[String] =
DigestUtil.computeHa1(username, realm, password)
}
final class Md5HashedAuthStore[F[_], A](val func: String => F[Option[(A, String)]])
extends AuthStore[F, A]

private trait AuthReply[+A]
private final case class OK[A](authInfo: A) extends AuthReply[A]
Expand All @@ -52,6 +89,19 @@ object DigestAuth {
private case object NoCredentials extends AuthReply[Nothing]
private case object NoAuthorizationHeader extends AuthReply[Nothing]

@deprecated("Calling apply is side-effecting, please use applyF", "0.22.13")
def apply[F[_]: Sync, A](
realm: String,
store: String => F[Option[(A, String)]],
nonceCleanupInterval: Duration = 1.hour,
nonceStaleTime: Duration = 1.hour,
nonceBits: Int = 160,
): AuthMiddleware[F, A] = {
val nonceKeeper =
new NonceKeeper(nonceStaleTime.toMillis, nonceCleanupInterval.toMillis, nonceBits)
challenged(challenge(realm, store, nonceKeeper))
}

/** @param realm The realm used for authentication purposes.
* @param store A partial function mapping (realm, user) to the
* appropriate password.
Expand All @@ -62,115 +112,185 @@ object DigestAuth {
* purposes anymore).
* @param nonceBits The number of random bits a nonce should consist of.
*/
def apply[F[_]: Sync, A](
def applyF[F[_], A](
realm: String,
store: AuthenticationStore[F, A],
store: AuthStore[F, A],
blocker: Blocker,
nonceCleanupInterval: Duration = 1.hour,
nonceStaleTime: Duration = 1.hour,
nonceBits: Int = 160,
): AuthMiddleware[F, A] = {
val nonceKeeper =
new NonceKeeper(nonceStaleTime.toMillis, nonceCleanupInterval.toMillis, nonceBits)
challenged(challenge(realm, store, nonceKeeper))
}
)(implicit F: Concurrent[F], t: Timer[F], cs: ContextShift[F]): F[AuthMiddleware[F, A]] =
challenge[F, A](
realm = realm,
store = store,
nonceCleanupInterval = nonceCleanupInterval,
nonceStaleTime = nonceStaleTime,
nonceBits = nonceBits,
blocker = blocker,
).map { runChallenge =>
challenged(runChallenge)
}

/** Side-effect of running the returned task: If req contains a valid
def challenge[F[_], A](
realm: String,
store: String => F[Option[(A, String)]],
nonceKeeper: NonceKeeper,
)(implicit
F: Sync[F]
): Kleisli[F, Request[F], Either[Challenge, AuthedRequest[F, A]]] =
challengeInterop[F, A](
realm,
PlainTextAuthStore(store),
F.delay(nonceKeeper.newNonce()),
(data, nc) => F.delay(nonceKeeper.receiveNonce(data, nc)),
)

/** Similar to [[apply]], but exposing the underlying [[challenge]]
* [[cats.data.Kleisli]] instead of an entire [[AuthMiddleware]]
*
* Side-effect of running the returned task: If req contains a valid
* AuthorizationHeader, the corresponding nonce counter (nc) is increased.
*
* @param realm The realm used for authentication purposes.
* @param store A partial function mapping (realm, user) to the
* appropriate password.
* @param nonceCleanupInterval Interval (in milliseconds) at which stale
* nonces should be cleaned up.
* @param nonceStaleTime Amount of time (in milliseconds) after which a nonce
* is considered stale (i.e. not used for authentication
* purposes anymore).
* @param nonceBits The number of random bits a nonce should consist of.
*/
def challenge[F[_], A](realm: String, store: AuthenticationStore[F, A], nonceKeeper: NonceKeeper)(
implicit F: Sync[F]
def challenge[F[_], A](
realm: String,
store: AuthStore[F, A],
blocker: Blocker,
nonceCleanupInterval: Duration = 1.hour,
nonceStaleTime: Duration = 1.hour,
nonceBits: Int = 160,
)(implicit
F: Concurrent[F],
t: Timer[F],
cs: ContextShift[F],
): F[Kleisli[F, Request[F], Either[Challenge, AuthedRequest[F, A]]]] =
NonceKeeperF[F](nonceStaleTime, nonceCleanupInterval, nonceBits, blocker)
.map { nonceKeeper =>
challengeInterop[F, A](realm, store, nonceKeeper.newNonce(), nonceKeeper.receiveNonce _)
}

private[this] def challengeInterop[F[_], A](
realm: String,
store: AuthStore[F, A],
newNonce: F[String],
receiveNonce: (String, Int) => F[NonceKeeper.Reply],
)(implicit
F: Sync[F]
): Kleisli[F, Request[F], Either[Challenge, AuthedRequest[F, A]]] =
Kleisli { req =>
def paramsToChallenge(params: Map[String, String]) =
Either.left(Challenge("Digest", realm, params))

checkAuth(realm, store, nonceKeeper, req).flatMap {
checkAuth(realm, store, receiveNonce, req).flatMap {
case OK(authInfo) => F.pure(Either.right(AuthedRequest(authInfo, req)))
case StaleNonce => getChallengeParams(nonceKeeper, staleNonce = true).map(paramsToChallenge)
case _ => getChallengeParams(nonceKeeper, staleNonce = false).map(paramsToChallenge)
case StaleNonce =>
getChallengeParams(newNonce, staleNonce = true).map(paramsToChallenge)
case _ => getChallengeParams(newNonce, staleNonce = false).map(paramsToChallenge)
}
}

private def checkAuth[F[_]: Hash, A](
realm: String,
store: AuthenticationStore[F, A],
nonceKeeper: NonceKeeper,
store: AuthStore[F, A],
receiveNonce: (String, Int) => F[NonceKeeper.Reply],
req: Request[F],
)(implicit F: Monad[F]): F[AuthReply[A]] =
req.headers.get[Authorization] match {
case Some(Authorization(Credentials.AuthParams(AuthScheme.Digest, params))) =>
checkAuthParams(realm, store, nonceKeeper, req, params)
checkAuthParams(realm, store, receiveNonce, req, params)
case Some(_) =>
F.pure(NoCredentials)
case None =>
F.pure(NoAuthorizationHeader)
}

private def getChallengeParams[F[_]](nonceKeeper: NonceKeeper, staleNonce: Boolean)(implicit
private def getChallengeParams[F[_]](newNonce: F[String], staleNonce: Boolean)(implicit
F: Sync[F]
): F[Map[String, String]] =
F.delay {
val nonce = nonceKeeper.newNonce()
val m = Map("qop" -> "auth", "nonce" -> nonce)
if (staleNonce)
m + ("stale" -> "TRUE")
else
m
}
newNonce
.map { nonce =>
val m = Map("qop" -> "auth", "nonce" -> nonce)
if (staleNonce)
m + ("stale" -> "TRUE")
else
m
}

private def checkAuthParams[F[_]: Hash, A](
realm: String,
store: AuthenticationStore[F, A],
nonceKeeper: NonceKeeper,
store: AuthStore[F, A],
receiveNonce: (String, Int) => F[NonceKeeper.Reply],
req: Request[F],
paramsNel: NonEmptyList[(String, String)],
)(implicit F: Monad[F]): F[AuthReply[A]] = {
val params = paramsNel.toList.toMap
if (!Set("realm", "nonce", "nc", "username", "cnonce", "qop").subsetOf(params.keySet))
return F.pure(BadParameters)

val method = req.method.toString
val uri = req.uri.toString

if (params.get("realm") != Some(realm))
return F.pure(BadParameters)

val nonce = params("nonce")
val nc = params("nc")
nonceKeeper.receiveNonce(nonce, Integer.parseInt(nc, 16)) match {
case NonceKeeper.StaleReply => F.pure(StaleNonce)
case NonceKeeper.BadNCReply => F.pure(BadNC)
case NonceKeeper.OKReply =>
store(params("username")).flatMap {
case None => F.pure(UserUnknown)
case Some((authInfo, password)) =>
DigestUtil
.computeResponse(
method,
params("username"),
realm,
password,
uri,
nonce,
nc,
params("cnonce"),
params("qop"),
)
.map { resp =>
if (resp == params("response")) OK(authInfo)
else WrongResponse
}
if (!Set("realm", "nonce", "nc", "username", "cnonce", "qop").subsetOf(params.keySet)) {
F.pure(BadParameters)
} else {
val method = req.method.toString

if (!params.get("realm").contains(realm)) {
F.pure(BadParameters)
} else {
val nonce = params("nonce")
val nc = params("nc")
receiveNonce(nonce, Integer.parseInt(nc, 16)).flatMap {
case NonceKeeper.StaleReply => F.pure(StaleNonce)
case NonceKeeper.BadNCReply => F.pure(BadNC)
case NonceKeeper.OKReply =>
(store match {
case authStore: PlainTextAuthStore[F, A] =>
authStore.func(params("username")).flatMap {
case None => F.pure(UserUnknown)
case Some((authInfo, password)) =>
DigestUtil
.computeResponse(
method,
params("username"),
realm,
password,
req.uri,
nonce,
nc,
params("cnonce"),
params("qop"),
)
.map { resp =>
if (resp == params("response")) OK(authInfo)
else WrongResponse
}
}
case authStore: Md5HashedAuthStore[F, A] =>
authStore.func(params("username")).flatMap {
case None => F.pure(UserUnknown)
case Some((authInfo, ha1Hash)) =>
DigestUtil
.computeHashedResponse(
method,
ha1Hash,
req.uri,
nonce,
nc,
params("cnonce"),
params("qop"),
)
.map { resp =>
if (resp == params("response")) OK(authInfo)
else WrongResponse
}
}
})
}
}
}
}
}

private[authentication] class Nonce(val created: Date, var nc: Int, val data: String)

private[authentication] object Nonce {
val random = new SecureRandom()

private def getRandomData(bits: Int): String = new BigInteger(bits, random).toString(16)

def gen(bits: Int): Nonce = new Nonce(new Date(), 0, getRandomData(bits))
}