Merge pull request #9286 from niklasf/legacy-oauth
implement bc for legacy oauth flow
This commit is contained in:
commit
29054a4d16
|
@ -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 =>
|
||||
|
|
|
@ -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."
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
41
modules/oauth/src/main/LegacyClientApi.scala
Normal file
41
modules/oauth/src/main/LegacyClientApi.scala
Normal 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)")
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue