Merge pull request #9286 from niklasf/legacy-oauth

implement bc for legacy oauth flow
This commit is contained in:
Niklas Fiekas 2021-06-29 07:51:56 +02:00 committed by GitHub
commit 29054a4d16
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 172 additions and 95 deletions

View file

@ -21,8 +21,8 @@ final class OAuth(env: Env) extends LilaController(env) {
responseType = get("response_type", req),
redirectUri = get("redirect_uri", req),
state = get("state", req),
codeChallenge = get("code_challenge", req),
codeChallengeMethod = get("code_challenge_method", req),
codeChallenge = get("code_challenge", req),
scope = get("scope", req)
)
@ -37,15 +37,22 @@ final class OAuth(env: Env) extends LilaController(env) {
Open { implicit ctx =>
withPrompt { prompt =>
fuccess(ctx.me.fold(Redirect(routes.Auth.login.url, Map("referrer" -> List(ctx.req.uri)))) { me =>
Ok(html.oAuth.app.authorize(prompt, me))
Ok(
html.oAuth.app.authorize(prompt, me, s"${routes.OAuth.authorizeApply}?${ctx.req.rawQueryString}")
)
})
}
}
def legacyAuthorize =
Action { req =>
MovedPermanently(s"${routes.OAuth.authorize}?${req.rawQueryString}")
}
def authorizeApply =
Auth { implicit ctx => me =>
withPrompt { prompt =>
prompt.authorize(me) match {
prompt.authorize(me, env.oAuth.legacyClientApi.apply) flatMap {
case Validated.Valid(authorized) =>
env.oAuth.authorizationApi.create(authorized) map { code =>
SeeOther(authorized.redirectUrl(code))
@ -60,6 +67,7 @@ final class OAuth(env: Env) extends LilaController(env) {
"grant_type" -> optional(text),
"code" -> optional(text),
"code_verifier" -> optional(text),
"client_secret" -> optional(text),
"redirect_uri" -> optional(text),
"client_id" -> optional(text)
)(AccessTokenRequest.Raw.apply)(AccessTokenRequest.Raw.unapply)
@ -87,6 +95,8 @@ final class OAuth(env: Env) extends LilaController(env) {
}
}
def legacyTokenApply = tokenApply
def tokenRevoke =
Scoped() { implicit req => _ =>
HTTPRequest.bearer(req) ?? { token =>

View file

@ -10,7 +10,7 @@ import lila.user.User
import lila.oauth.AuthorizationRequest
object authorize {
def apply(prompt: AuthorizationRequest.Prompt, me: User)(implicit ctx: Context) =
def apply(prompt: AuthorizationRequest.Prompt, me: User, authorizeUrl: String)(implicit ctx: Context) =
views.html.base.layout(
title = "Authorization",
moreCss = cssTag("oauth"),
@ -19,7 +19,7 @@ object authorize {
) {
main(cls := "oauth box box-pad")(
h1(dataIcon := "", cls := "text")("Authorize third party"),
postForm(
postForm(action := authorizeUrl)(
p(
strong(code(prompt.redirectUri.clientOrigin)),
" wants to access your ",
@ -52,6 +52,11 @@ object authorize {
a(href := prompt.cancelUrl)("Cancel"),
submitButton(cls := "button", disabled := true, id := "oauth-authorize")("Authorize")
)
),
prompt.maybeLegacy option p(
"Note for developers: The OAuth flow without PKCE is ",
a(href := "https://github.com/ornicar/lila/issues/9214")("deprecated"),
". Consider updating your app."
)
)
}

View file

@ -717,7 +717,9 @@ GET /account/now-playing controllers.Account.nowPlaying
# OAuth
GET /oauth controllers.OAuth.authorize
POST /oauth controllers.OAuth.authorizeApply
POST /oauth controllers.OAuth.legacyTokenApply
GET /oauth/authorize controllers.OAuth.legacyAuthorize
POST /oauth/authorize controllers.OAuth.authorizeApply
POST /oauth/revoke-client controllers.OAuth.revokeClient
POST /api/token controllers.OAuth.tokenApply
DELETE /api/token controllers.OAuth.tokenRevoke

View file

@ -10,23 +10,24 @@ object AccessTokenRequest {
grantType: Option[String],
code: Option[String],
codeVerifier: Option[String],
clientSecret: Option[String],
redirectUri: Option[String],
clientId: Option[String]
) {
def prepare: Validated[Error, Prepared] =
for {
grantType <- grantType.toValid(Error.GrantTypeRequired).andThen(GrantType.from)
code <- code.map(AuthorizationCode.apply).toValid(Error.CodeRequired)
codeVerifier <- codeVerifier.toValid(Error.CodeVerifierRequired).andThen(CodeVerifier.from)
redirectUri <- redirectUri.map(UncheckedRedirectUri.apply).toValid(Error.RedirectUriRequired)
clientId <- clientId.map(ClientId.apply).toValid(Error.ClientIdRequired)
} yield Prepared(grantType, code, codeVerifier, redirectUri, clientId)
redirectUri <- redirectUri.map(UncheckedRedirectUri.apply).toValid(Error.RedirectUriRequired)
clientId <- clientId.map(ClientId.apply).toValid(Error.ClientIdRequired)
grantType <- grantType.toValid(Error.GrantTypeRequired).andThen(GrantType.from)
code <- code.map(AuthorizationCode.apply).toValid(Error.CodeRequired)
} yield Prepared(grantType, code, codeVerifier, clientSecret, redirectUri, clientId)
}
case class Prepared(
grantType: GrantType,
code: AuthorizationCode,
codeVerifier: CodeVerifier,
codeVerifier: Option[String],
clientSecret: Option[String],
redirectUri: UncheckedRedirectUri,
clientId: ClientId
)

View file

@ -17,7 +17,7 @@ final class AuthorizationApi(val coll: Coll)(implicit ec: scala.concurrent.Execu
request.clientId,
request.user,
request.redirectUri,
request.codeChallenge,
request.challenge,
request.scopes,
DateTime.now().plusSeconds(120)
)
@ -27,30 +27,42 @@ final class AuthorizationApi(val coll: Coll)(implicit ec: scala.concurrent.Execu
def consume(
request: AccessTokenRequest.Prepared
): Fu[Validated[Protocol.Error, AccessTokenRequest.Granted]] =
coll.findAndModify($doc(F.hashedCode -> request.code.hashed), coll.removeModifier) map {
_.result[PendingAuthorization]
.toValid(Protocol.Error.AuthorizationCodeInvalid)
.ensure(Protocol.Error.AuthorizationCodeExpired)(_.expires.isAfter(DateTime.now()))
.ensure(Protocol.Error.MismatchingRedirectUri)(_.redirectUri.matches(request.redirectUri))
.ensure(Protocol.Error.MismatchingClient)(_.clientId == request.clientId)
.ensure(Protocol.Error.MismatchingCodeVerifier(request.codeVerifier))(
_.codeChallenge.matches(request.codeVerifier)
)
.map { pending =>
AccessTokenRequest.Granted(pending.userId, pending.scopes, pending.redirectUri)
coll.findAndModify($doc(F.hashedCode -> request.code.hashed), coll.removeModifier) map { doc =>
for {
pending <- doc
.result[PendingAuthorization]
.toValid(Protocol.Error.AuthorizationCodeInvalid)
.ensure(Protocol.Error.AuthorizationCodeExpired)(_.expires.isAfter(DateTime.now()))
.ensure(Protocol.Error.MismatchingRedirectUri)(_.redirectUri.matches(request.redirectUri))
.ensure(Protocol.Error.MismatchingClient)(_.clientId == request.clientId)
_ <- pending.challenge match {
case Left(hashedClientSecret) =>
request.clientSecret
.map(LegacyClientApi.ClientSecret)
.toValid(LegacyClientApi.ClientSecretRequired)
.ensure(LegacyClientApi.MismatchingClientSecret)(_.matches(hashedClientSecret))
.map(_.unit)
case Right(codeChallenge) =>
request.codeVerifier
.toValid(Protocol.Error.CodeVerifierRequired)
.andThen(Protocol.CodeVerifier.from)
.ensure(Protocol.Error.MismatchingCodeVerifier)(_.matches(codeChallenge))
.map(_.unit)
}
} yield AccessTokenRequest.Granted(pending.userId, pending.scopes, pending.redirectUri)
}
}
private object AuthorizationApi {
object BSONFields {
val hashedCode = "_id"
val clientId = "clientId"
val userId = "userId"
val redirectUri = "redirectUri"
val codeChallenge = "codeChallenge"
val scopes = "scopes"
val expires = "expires"
val hashedCode = "_id"
val clientId = "clientId"
val userId = "userId"
val redirectUri = "redirectUri"
val codeChallenge = "codeChallenge"
val hashedClientSecret = "hashedClientSecret"
val scopes = "scopes"
val expires = "expires"
}
case class PendingAuthorization(
@ -58,7 +70,7 @@ private object AuthorizationApi {
clientId: Protocol.ClientId,
userId: User.ID,
redirectUri: Protocol.RedirectUri,
codeChallenge: Protocol.CodeChallenge,
challenge: Either[LegacyClientApi.HashedClientSecret, Protocol.CodeChallenge],
scopes: List[OAuthScope],
expires: DateTime
)
@ -75,20 +87,24 @@ private object AuthorizationApi {
clientId = Protocol.ClientId(r.str(F.clientId)),
userId = r.str(F.userId),
redirectUri = Protocol.RedirectUri.unchecked(r.str(F.redirectUri)),
codeChallenge = Protocol.CodeChallenge(r.str(F.codeChallenge)),
challenge = r.strO(F.hashedClientSecret) match {
case Some(hashedClientSecret) => Left(LegacyClientApi.HashedClientSecret(hashedClientSecret))
case None => Right(Protocol.CodeChallenge(r.str(F.codeChallenge)))
},
scopes = r.get[List[OAuthScope]](F.scopes),
expires = r.get[DateTime](F.expires)
)
def writes(w: BSON.Writer, o: PendingAuthorization) =
$doc(
F.hashedCode -> o.hashedCode,
F.clientId -> o.clientId.value,
F.userId -> o.userId,
F.redirectUri -> o.redirectUri.value.toString,
F.codeChallenge -> o.codeChallenge.value,
F.scopes -> o.scopes,
F.expires -> o.expires
F.hashedCode -> o.hashedCode,
F.clientId -> o.clientId.value,
F.userId -> o.userId,
F.redirectUri -> o.redirectUri.value.toString,
F.codeChallenge -> o.challenge.toOption.map(_.value),
F.hashedClientSecret -> o.challenge.swap.toOption.map(_.value),
F.scopes -> o.scopes,
F.expires -> o.expires
)
}
}

View file

@ -17,37 +17,34 @@ object AuthorizationRequest {
state: Option[String],
redirectUri: Option[String],
responseType: Option[String],
codeChallenge: Option[String],
codeChallengeMethod: Option[String],
codeChallenge: Option[String],
scope: Option[String]
) {
// In order to show a prompt and redirect back with error codes a valid
// redirect_uri is absolutely required. Ignore all other errors for now.
def prompt: Validated[Error, Prompt] = {
redirectUri
.toValid(Error.RedirectUriRequired)
.andThen(RedirectUri.from)
.map { redirectUri =>
Prompt(
redirectUri,
state.map(State.apply),
clientId = clientId,
responseType = responseType,
codeChallenge = codeChallenge,
codeChallengeMethod = codeChallengeMethod,
scope = scope
)
}
}
def prompt: Validated[Error, Prompt] =
for {
redirectUri <- redirectUri.toValid(Error.RedirectUriRequired).andThen(RedirectUri.from)
clientId <- clientId.map(ClientId).toValid(Error.ClientIdRequired)
} yield Prompt(
redirectUri,
state.map(State.apply),
clientId = clientId,
responseType = responseType,
codeChallengeMethod = codeChallengeMethod,
codeChallenge = codeChallenge,
scope = scope
)
}
case class Prompt(
redirectUri: RedirectUri,
state: Option[State],
clientId: Option[String],
clientId: ClientId,
responseType: Option[String],
codeChallenge: Option[String],
codeChallengeMethod: Option[String],
codeChallenge: Option[String],
scope: Option[String]
) {
def errorUrl(error: Error) = redirectUri.error(error, state)
@ -66,35 +63,44 @@ object AuthorizationRequest {
def maybeScopes: List[OAuthScope] = validScopes.getOrElse(Nil)
def authorize(user: User): Validated[Error, Authorized] = {
for {
clientId <- clientId.map(ClientId.apply).toValid(Error.ClientIdRequired)
scopes <- validScopes
codeChallenge <- codeChallenge.map(CodeChallenge.apply).toValid(Error.CodeChallengeRequired)
responseType <- responseType.toValid(Error.ResponseTypeRequired).andThen(ResponseType.from)
codeChallengeMethod <- codeChallengeMethod
.toValid(Error.CodeChallengeMethodRequired)
.andThen(CodeChallengeMethod.from)
} yield Authorized(
clientId,
redirectUri,
state,
codeChallenge,
codeChallengeMethod,
user.id,
scopes
)
}
def maybeLegacy: Boolean = codeChallengeMethod.isEmpty && codeChallenge.isEmpty
def authorize(
user: User,
legacy: (ClientId, RedirectUri) => Fu[Option[LegacyClientApi.HashedClientSecret]]
): Fu[Validated[Error, Authorized]] =
(codeChallengeMethod match {
case None =>
legacy(clientId, redirectUri).dmap(
_.toValid[Error](Error.CodeChallengeMethodRequired).map(Left.apply)
)
case Some(method) =>
fuccess(CodeChallengeMethod.from(method).andThen { _ =>
codeChallenge.map(CodeChallenge).toValid[Error](Error.CodeChallengeRequired).map(Right.apply)
})
}) dmap { challenge =>
for {
challenge <- challenge
scopes <- validScopes
responseType <- responseType.toValid(Error.ResponseTypeRequired).andThen(ResponseType.from)
} yield Authorized(
clientId,
redirectUri,
state,
user.id,
scopes,
challenge
)
}
}
case class Authorized(
clientId: ClientId,
redirectUri: RedirectUri,
state: Option[State],
codeChallenge: CodeChallenge,
codeChallengeMethod: CodeChallengeMethod,
user: User.ID,
scopes: List[OAuthScope]
scopes: List[OAuthScope],
challenge: Either[LegacyClientApi.HashedClientSecret, CodeChallenge]
) {
def redirectUrl(code: AuthorizationCode) = redirectUri.code(code, state)
}

View file

@ -47,9 +47,9 @@ final class Env(
none
}
lazy val tokenApi = wire[AccessTokenApi]
lazy val tokenApi = wire[AccessTokenApi]
lazy val authorizationApi = new AuthorizationApi(lilaDb(CollName("oauth2_authorization")))
lazy val legacyClientApi = new LegacyClientApi(lilaDb(CollName("oauth2_legacy_client")))
def forms = OAuthForm
}

View file

@ -0,0 +1,41 @@
package lila.oauth
import org.joda.time.DateTime
import com.roundeights.hasher.Algo
import lila.db.dsl._
final class LegacyClientApi(val coll: Coll)(implicit ec: scala.concurrent.ExecutionContext) {
import LegacyClientApi.{ BSONFields => F, _ }
def apply(clientId: Protocol.ClientId, redirectUri: Protocol.RedirectUri): Fu[Option[HashedClientSecret]] =
coll
.findAndUpdate(
$doc(F.id -> clientId.value, F.redirectUri -> redirectUri.value.toString),
$set(F.usedAt -> DateTime.now())
)
.map {
_.result[Bdoc].flatMap(_.getAsOpt[String](F.hashedSecret)).map(HashedClientSecret)
}
}
object LegacyClientApi {
object BSONFields {
val id = "_id"
val redirectUri = "redirectUri"
val hashedSecret = "secret"
val usedAt = "used"
}
case class HashedClientSecret(value: String) extends AnyVal
case class ClientSecret(secret: String) extends AnyVal {
def matches(hash: HashedClientSecret) = Algo.sha256(secret).hex == hash.value
override def toString = "ClientSecret(***)"
}
case object MismatchingClientSecret
extends Protocol.Error.InvalidGrant("fix mismatching client secret (or update to pkce)")
case object ClientSecretRequired
extends Protocol.Error.InvalidRequest("client_secret required (or update to pkce)")
}

View file

@ -11,7 +11,7 @@ import lila.common.SecureRandom
object Protocol {
case class AuthorizationCode(secret: String) extends AnyVal {
def hashed = Algo.sha256(secret).hex
def hashed = Algo.sha256(secret).hex
override def toString = "AuthorizationCode(***)"
}
object AuthorizationCode {
@ -22,10 +22,6 @@ object Protocol {
case class State(value: String) extends AnyVal
case class CodeChallenge(value: String) extends AnyVal {
def matches(verifier: CodeVerifier) = verifier.challenge == this
}
case class CodeChallengeMethod()
object CodeChallengeMethod {
def from(codeChallengeMethod: String): Validated[Error, CodeChallengeMethod] =
@ -35,10 +31,11 @@ object Protocol {
}
}
case class CodeChallenge(value: String) extends AnyVal
case class CodeVerifier(value: String) extends AnyVal {
def challenge = CodeChallenge(
Base64.getUrlEncoder().withoutPadding().encodeToString(Algo.sha256(value).bytes)
)
def matches(challenge: CodeChallenge) =
Base64.getUrlEncoder().withoutPadding().encodeToString(Algo.sha256(value).bytes) == challenge.value
}
object CodeVerifier {
def from(value: String): Validated[Error, CodeVerifier] =
@ -150,8 +147,7 @@ object Protocol {
extends InvalidGrant("authorization code was issued for a different redirect_uri")
case object MismatchingClient
extends InvalidGrant("authorization code was issued for a different client_Id")
case class MismatchingCodeVerifier(val verifier: CodeVerifier) extends Error("invalid_grant") {
def description = s"hash '${verifier.challenge.value}' of code_verifier does not match code_challenge"
}
case object MismatchingCodeVerifier
extends InvalidGrant("hash of code_verifier does not match code_challenge")
}
}