feature/(directlogin): add http4s support for DirectLogin authentication

- Add `validatorFutureWithParams` function to validate DirectLogin parameters extracted from CallContext without depending on S.request
- Enhance `getUserFromDirectLoginHeaderFuture` to prefer DirectLogin parameters from CallContext (http4s path) and fall back to S.request (Lift path)
- Improve `extractDirectLoginParams` to support both new format (DirectLogin header) and old format (Authorization: DirectLogin header)
- Enhance `parseDirectLoginHeader` to match Lift's parsing logic with support for quoted and unquoted parameter values
- Update Http4s700 API info to remove UserNotLoggedIn error and add canGetRateLimits role requirement
- This enables DirectLogin authentication to work seamlessly in http4s context where S.request is unavailable
This commit is contained in:
hongwei 2026-01-16 15:59:26 +01:00
parent 2d139b157e
commit 64b1ac3c9d
3 changed files with 108 additions and 9 deletions

View File

@ -416,6 +416,69 @@ object DirectLogin extends RestHelper with MdcLoggable {
}
/**
* Validator that uses pre-extracted parameters from CallContext (for http4s support)
* This avoids dependency on S.request which is not available in http4s context
*/
def validatorFutureWithParams(requestType: String, httpMethod: String, parameters: Map[String, String]): Future[(Int, String, Map[String, String])] = {
def validAccessTokenFuture(tokenKey: String) = {
Tokens.tokens.vend.getTokenByKeyAndTypeFuture(tokenKey, TokenType.Access) map {
case Full(token) => token.isValid
case _ => false
}
}
var message = ""
var httpCode: Int = 500
val missingParams = missingDirectLoginParameters(parameters, requestType)
val validParams = validDirectLoginParameters(parameters)
val validF =
if (requestType == "protectedResource") {
validAccessTokenFuture(parameters.getOrElse("token", ""))
} else if (requestType == "authorizationToken" &&
APIUtil.getPropsAsBoolValue("direct_login_consumer_key_mandatory", true)) {
APIUtil.registeredApplicationFuture(parameters.getOrElse("consumer_key", ""))
} else {
Future { true }
}
for {
valid <- validF
} yield {
if (parameters.get("error").isDefined) {
message = parameters.get("error").getOrElse("")
httpCode = 400
}
else if (missingParams.nonEmpty) {
message = ErrorMessages.DirectLoginMissingParameters + missingParams.mkString(", ")
httpCode = 400
}
else if (SILENCE_IS_GOLDEN != validParams.mkString("")) {
message = validParams.mkString("")
httpCode = 400
}
else if (requestType == "protectedResource" && !valid) {
message = ErrorMessages.DirectLoginInvalidToken + parameters.getOrElse("token", "")
httpCode = 401
}
else if (requestType == "authorizationToken" &&
APIUtil.getPropsAsBoolValue("direct_login_consumer_key_mandatory", true) &&
!valid) {
logger.error("application: " + parameters.getOrElse("consumer_key", "") + " not found")
message = ErrorMessages.InvalidConsumerKey
httpCode = 401
}
else
httpCode = 200
if (message.nonEmpty)
logger.error("error message : " + message)
(httpCode, message, parameters)
}
}
private def generateTokenAndSecret(claims: JWTClaimsSet): (String, String) =
{
// generate random string
@ -473,12 +536,20 @@ object DirectLogin extends RestHelper with MdcLoggable {
}
def getUserFromDirectLoginHeaderFuture(sc: CallContext) : Future[(Box[User], Option[CallContext])] = {
val httpMethod = S.request match {
val httpMethod = if (sc.verb.nonEmpty) sc.verb else S.request match {
case Full(r) => r.request.method
case _ => "GET"
}
// Prefer directLoginParams from CallContext (http4s), fall back to S.request (Lift)
val directLoginParamsFromCC = sc.directLoginParams
for {
(httpCode, message, directLoginParameters) <- validatorFuture("protectedResource", httpMethod)
(httpCode, message, directLoginParameters) <- if (directLoginParamsFromCC.nonEmpty && directLoginParamsFromCC.contains("token")) {
// Use params from CallContext (http4s path)
validatorFutureWithParams("protectedResource", httpMethod, directLoginParamsFromCC)
} else {
// Fall back to S.request (Lift path)
validatorFuture("protectedResource", httpMethod)
}
_ <- Future { if (httpCode == 400 || httpCode == 401) Empty else Full("ok") } map { x => fullBoxOrException(x ?~! message) }
consumer <- OAuthHandshake.getConsumerFromTokenFuture(200, (if (directLoginParameters.isDefinedAt("token")) directLoginParameters.get("token") else Empty))
user <- OAuthHandshake.getUserFromTokenFuture(200, (if (directLoginParameters.isDefinedAt("token")) directLoginParameters.get("token") else Empty))

View File

@ -149,22 +149,50 @@ object Http4sCallContextBuilder {
/**
* Extract DirectLogin header parameters if present
* DirectLogin header format: DirectLogin token="xxx"
* Supports two formats:
* 1. New format (2021): DirectLogin: token=xxx
* 2. Old format (deprecated): Authorization: DirectLogin token=xxx
*/
private def extractDirectLoginParams(request: Request[IO]): Map[String, String] = {
// Try new format first: DirectLogin header
request.headers.get(CIString("DirectLogin"))
.map(h => parseDirectLoginHeader(h.head.value))
.getOrElse(Map.empty)
.getOrElse {
// Fall back to old format: Authorization: DirectLogin token=xxx
request.headers.get(CIString("Authorization"))
.filter(_.head.value.contains("DirectLogin"))
.map(h => parseDirectLoginHeader(h.head.value))
.getOrElse(Map.empty)
}
}
/**
* Parse DirectLogin header value into parameter map
* Format: DirectLogin token="xxx", username="yyy"
* Matches Lift's parsing logic in directlogin.scala getAllParameters
* Supports formats:
* - DirectLogin token="xxx"
* - DirectLogin token=xxx
* - token="xxx", username="yyy"
*/
private def parseDirectLoginHeader(headerValue: String): Map[String, String] = {
val pattern = """(\w+)="([^"]*)"""".r
pattern.findAllMatchIn(headerValue).map { m =>
m.group(1) -> m.group(2)
val directLoginPossibleParameters = List("consumer_key", "token", "username", "password")
// Strip "DirectLogin" prefix and split by comma, then trim each part (matches Lift logic)
val cleanedParameterList = headerValue.stripPrefix("DirectLogin").split(",").map(_.trim).toList
cleanedParameterList.flatMap { input =>
if (input.contains("=")) {
val split = input.split("=", 2)
val paramName = split(0).trim
// Remove surrounding quotes if present
val paramValue = split(1).replaceAll("^\"|\"$", "").trim
if (directLoginPossibleParameters.contains(paramName) && paramValue.nonEmpty)
Some(paramName -> paramValue)
else
None
} else {
None
}
}.toMap
}

View File

@ -63,11 +63,11 @@ object Http4s700 {
EmptyBody,
apiInfoJSON,
List(
$UserNotLoggedIn,
UnknownError,
"no connector set"
),
apiTagApi :: Nil,
Some(List(code.api.util.ApiRole.canGetRateLimits)),
http4sPartialFunction = Some(root)
)