From 0aa632dd9ea77b03ab6607fbfc57f5c3c312773c Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 7 Jul 2021 12:48:08 +0200 Subject: [PATCH] refactor access token collection --- app/controllers/Challenge.scala | 2 +- app/controllers/OAuth.scala | 6 +- app/controllers/OAuthToken.scala | 7 +- app/views/dgt.scala | 2 +- app/views/oAuth/token/index.scala | 4 +- conf/routes | 22 +-- modules/oauth/src/main/AccessToken.scala | 33 ++-- modules/oauth/src/main/AccessTokenApi.scala | 177 +++++++++----------- modules/oauth/src/main/Env.scala | 38 +---- modules/oauth/src/main/OAuthForm.scala | 10 +- modules/oauth/src/main/OAuthServer.scala | 7 - modules/security/src/main/Env.scala | 2 +- modules/security/src/main/SecurityApi.scala | 23 +-- 13 files changed, 134 insertions(+), 199 deletions(-) diff --git a/app/controllers/Challenge.scala b/app/controllers/Challenge.scala index 8a48547de4..8bcf3d70ea 100644 --- a/app/controllers/Challenge.scala +++ b/app/controllers/Challenge.scala @@ -371,7 +371,7 @@ final class Challenge( challenge: lila.challenge.Challenge, strToken: String )(managedBy: lila.user.User, message: Option[Template]) = - env.security.api.oauthScoped( + env.oAuth.server.auth( Bearer(strToken), List(lila.oauth.OAuthScope.Challenge.Write) ) flatMap { diff --git a/app/controllers/OAuth.scala b/app/controllers/OAuth.scala index 66bcad7c1f..3e540e23fb 100644 --- a/app/controllers/OAuth.scala +++ b/app/controllers/OAuth.scala @@ -84,7 +84,7 @@ final class OAuth(env: Env) extends LilaController(env) { Json .obj( "token_type" -> "Bearer", - "access_token" -> token.id.secret + "access_token" -> token.plain.secret ) .add("expires_in" -> token.expires.map(_.getSeconds - nowSeconds)) ) @@ -106,7 +106,7 @@ final class OAuth(env: Env) extends LilaController(env) { Json .obj( "token_type" -> "Bearer", - "access_token" -> token.id.secret, + "access_token" -> token.plain.secret, "refresh_token" -> s"invalid_for_bc_${lila.common.ThreadLocalRandom.nextString(17)}" ) .add("expires_in" -> token.expires.map(_.getSeconds - nowSeconds)) @@ -121,7 +121,7 @@ final class OAuth(env: Env) extends LilaController(env) { def tokenRevoke = Scoped() { implicit req => _ => HTTPRequest.bearer(req) ?? { token => - env.oAuth.tokenApi.revoke(token) inject NoContent + env.oAuth.tokenApi.revoke(AccessToken.Id.from(token)) inject NoContent } } diff --git a/app/controllers/OAuthToken.scala b/app/controllers/OAuthToken.scala index 611696dcd3..68ba14e180 100644 --- a/app/controllers/OAuthToken.scala +++ b/app/controllers/OAuthToken.scala @@ -3,6 +3,7 @@ package controllers import views._ import lila.app._ +import lila.oauth.AccessToken final class OAuthToken(env: Env) extends LilaController(env) { @@ -38,8 +39,8 @@ final class OAuthToken(env: Env) extends LilaController(env) { ) } - def delete(publicId: String) = - Auth { _ => me => - tokenApi.revokeByPublicId(publicId, me) inject Redirect(routes.OAuthToken.index).flashSuccess + def delete(id: String) = + Auth { _ => _ => + tokenApi.revoke(AccessToken.Id(id)) inject Redirect(routes.OAuthToken.index).flashSuccess } } diff --git a/app/views/dgt.scala b/app/views/dgt.scala index 19ce145d07..b64461e795 100644 --- a/app/views/dgt.scala +++ b/app/views/dgt.scala @@ -69,7 +69,7 @@ object dgt { ) def play(token: AccessToken)(implicit ctx: Context) = - layout("play", embedJsUnsafeLoadThen(s"""LichessDgt.playPage("${token.id.secret}")"""))( + layout("play", embedJsUnsafeLoadThen(s"""LichessDgt.playPage("${token.plain.secret}")"""))( div(id := "dgt-play-zone")(pre(id := "dgt-play-zone-log")), div(cls := "dgt__play__help")( h2(iconTag("", "If a move is not detected")), diff --git a/app/views/oAuth/token/index.scala b/app/views/oAuth/token/index.scala index 79c07d48ac..3457d8208c 100644 --- a/app/views/oAuth/token/index.scala +++ b/app/views/oAuth/token/index.scala @@ -54,7 +54,7 @@ object index { br, "You won’t be able to see it again!" ), - code(token.id.secret) + code(token.plain.secret) ) ) }, @@ -75,7 +75,7 @@ object index { } ), td(cls := "action")( - postForm(action := routes.OAuthToken.delete(t.publicId.stringify))( + postForm(action := routes.OAuthToken.delete(t.id.value))( submitButton( cls := "button button-red button-empty confirm", st.title := "Delete this access token" diff --git a/conf/routes b/conf/routes index 895839ea0f..a7eec02fc6 100644 --- a/conf/routes +++ b/conf/routes @@ -718,17 +718,17 @@ GET /account/info controllers.Account.info GET /account/now-playing controllers.Account.nowPlaying # OAuth -GET /oauth controllers.OAuth.authorize -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 -GET /account/oauth/token controllers.OAuthToken.index -GET /account/oauth/token/create controllers.OAuthToken.create -POST /account/oauth/token/create controllers.OAuthToken.createApply -POST /account/oauth/token/:publicId/delete controllers.OAuthToken.delete(publicId: String) +GET /oauth controllers.OAuth.authorize +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 +GET /account/oauth/token controllers.OAuthToken.index +GET /account/oauth/token/create controllers.OAuthToken.create +POST /account/oauth/token/create controllers.OAuthToken.createApply +POST /account/oauth/token/:id/delete controllers.OAuthToken.delete(id: String) # Events GET /event/$id<\w{8}> controllers.Event.show(id: String) diff --git a/modules/oauth/src/main/AccessToken.scala b/modules/oauth/src/main/AccessToken.scala index f9a4541931..1322f2b011 100644 --- a/modules/oauth/src/main/AccessToken.scala +++ b/modules/oauth/src/main/AccessToken.scala @@ -2,16 +2,17 @@ package lila.oauth import org.joda.time.DateTime import reactivemongo.api.bson._ +import com.roundeights.hasher.Algo import lila.common.{ Bearer, SecureRandom } import lila.user.User case class AccessToken( - id: Bearer, - publicId: BSONObjectID, + id: AccessToken.Id, + plain: Bearer, userId: User.ID, - createdAt: Option[DateTime] = None, // for personal access tokens - description: Option[String] = None, // for personal access tokens + createdAt: Option[DateTime], + description: Option[String], // for personal access tokens usedAt: Option[DateTime] = None, scopes: List[OAuthScope], clientOrigin: Option[String], @@ -22,15 +23,20 @@ case class AccessToken( object AccessToken { + case class Id(value: String) extends AnyVal + object Id { + def from(bearer: Bearer) = Id(Algo.sha256(bearer.secret).hex) + } + case class ForAuth(userId: User.ID, scopes: List[OAuthScope]) object BSONFields { - val id = "access_token_id" - val publicId = "_id" - val userId = "user_id" - val createdAt = "create_date" + val id = "_id" + val plain = "plain" + val userId = "userId" + val createdAt = "created" val description = "description" - val usedAt = "used_at" + val usedAt = "used" val scopes = "scopes" val clientOrigin = "clientOrigin" val expires = "expires" @@ -46,7 +52,8 @@ object AccessToken { BSONFields.scopes -> true ) - implicit private[oauth] val accessTokenIdHandler = stringAnyValHandler[Bearer](_.secret, Bearer.apply) + implicit private[oauth] val idHandler = stringAnyValHandler[Id](_.value, Id.apply) + implicit private[oauth] val bearerHandler = stringAnyValHandler[Bearer](_.secret, Bearer.apply) implicit val ForAuthBSONReader = new BSONDocumentReader[ForAuth] { def readDocument(doc: BSONDocument) = @@ -62,8 +69,8 @@ object AccessToken { def reads(r: BSON.Reader): AccessToken = AccessToken( - id = r.get[Bearer](id), - publicId = r.get[BSONObjectID](publicId), + id = r.get[Id](id), + plain = r.get[Bearer](plain), userId = r str userId, createdAt = r.getO[DateTime](createdAt), description = r strO description, @@ -76,7 +83,7 @@ object AccessToken { def writes(w: BSON.Writer, o: AccessToken) = $doc( id -> o.id, - publicId -> o.publicId, + plain -> o.plain, userId -> o.userId, createdAt -> o.createdAt, description -> o.description, diff --git a/modules/oauth/src/main/AccessTokenApi.scala b/modules/oauth/src/main/AccessTokenApi.scala index 8fb7bcab2f..e3d027d159 100644 --- a/modules/oauth/src/main/AccessTokenApi.scala +++ b/modules/oauth/src/main/AccessTokenApi.scala @@ -8,14 +8,14 @@ import lila.common.Bearer import lila.db.dsl._ import lila.user.{ User, UserRepo } -final class AccessTokenApi(colls: OauthColls, cacheApi: lila.memo.CacheApi, userRepo: UserRepo)(implicit +final class AccessTokenApi(coll: Coll, cacheApi: lila.memo.CacheApi, userRepo: UserRepo)(implicit ec: scala.concurrent.ExecutionContext ) { import OAuthScope.scopeHandler - import AccessToken.{ BSONFields => F, _ } + import AccessToken.{ BSONFields => F } - def create(token: AccessToken): Funit = colls.token(_.insert.one(token).void) + def create(token: AccessToken): Funit = coll.insert.one(token).void def create(setup: OAuthForm.token.Data, me: User, isStudent: Boolean): Funit = (fuccess(isStudent) >>| userRepo.isManaged(me.id)) flatMap { noBot => @@ -28,12 +28,13 @@ final class AccessTokenApi(colls: OauthColls, cacheApi: lila.memo.CacheApi, user } def create(granted: AccessTokenRequest.Granted): Fu[AccessToken] = { + val plain = Bearer.random() val token = AccessToken( - id = Bearer.random(), - publicId = BSONObjectID.generate(), + id = AccessToken.Id.from(plain), + plain = plain, userId = granted.userId, + description = None, createdAt = DateTime.now().some, - description = granted.redirectUri.clientOrigin.some, scopes = granted.scopes, clientOrigin = granted.redirectUri.clientOrigin.some, expires = DateTime.now().plusMonths(12).some @@ -42,127 +43,101 @@ final class AccessTokenApi(colls: OauthColls, cacheApi: lila.memo.CacheApi, user } def listPersonal(user: User): Fu[List[AccessToken]] = - colls.token { - _.find( + coll + .find( $doc( F.userId -> user.id, F.clientOrigin -> $exists(false) ) ) - .sort($sort desc F.createdAt) - .cursor[AccessToken]() - .list(100) - } + .sort($sort desc F.createdAt) + .cursor[AccessToken]() + .list(100) def countPersonal(user: User): Fu[Int] = - colls.token { - _.countSel( - $doc( - F.userId -> user.id, - F.clientOrigin -> $exists(false) - ) + coll.countSel( + $doc( + F.userId -> user.id, + F.clientOrigin -> $exists(false) ) - } + ) def findCompatiblePersonal(user: User, scopes: Set[OAuthScope]): Fu[Option[AccessToken]] = - colls.token { - _.one[AccessToken]( - $doc( - F.userId -> user.id, - F.clientOrigin -> $exists(false), - F.scopes $all scopes.toSeq - ) + coll.one[AccessToken]( + $doc( + F.userId -> user.id, + F.clientOrigin -> $exists(false), + F.scopes $all scopes.toSeq ) - } + ) def listClients(user: User, limit: Int): Fu[List[AccessTokenApi.Client]] = - colls - .token { - _.aggregateList(limit) { framework => - import framework._ - Match( - $doc( - F.userId -> user.id, - F.clientOrigin -> $exists(true) - ) - ) -> List( - Unwind(path = F.scopes, includeArrayIndex = None, preserveNullAndEmptyArrays = Some(true)), - GroupField(F.clientOrigin)( - F.usedAt -> MaxField(F.usedAt), - F.scopes -> AddFieldToSet(F.scopes) - ), - Sort(Descending(F.usedAt)) - ) - } - } - .map { docs => - for { - doc <- docs - origin <- doc.getAsOpt[String]("_id") - usedAt = doc.getAsOpt[DateTime](F.usedAt) - scopes <- doc.getAsOpt[List[OAuthScope]](F.scopes) - } yield AccessTokenApi.Client(origin, usedAt, scopes) - } - - def revoke(token: Bearer): Funit = - colls.token { - _.delete.one($doc(F.id -> token)).map(_ => invalidateCached(token)) + coll.aggregateList(limit) { framework => + import framework._ + Match( + $doc( + F.userId -> user.id, + F.clientOrigin -> $exists(true) + ) + ) -> List( + Unwind(path = F.scopes, includeArrayIndex = None, preserveNullAndEmptyArrays = Some(true)), + GroupField(F.clientOrigin)( + F.usedAt -> MaxField(F.usedAt), + F.scopes -> AddFieldToSet(F.scopes) + ), + Sort(Descending(F.usedAt)) + ) + } map { docs => + for { + doc <- docs + origin <- doc.getAsOpt[String]("_id") + usedAt = doc.getAsOpt[DateTime](F.usedAt) + scopes <- doc.getAsOpt[List[OAuthScope]](F.scopes) + } yield AccessTokenApi.Client(origin, usedAt, scopes) } + def revoke(id: AccessToken.Id): Funit = + coll.delete.one($id(id)).map(_ => invalidateCached(id)) + def revokeByClientOrigin(clientOrigin: String, user: User): Funit = - colls.token { coll => - coll - .find( - $doc( - F.userId -> user.id, - F.clientOrigin -> clientOrigin - ), - $doc(F.id -> 1).some - ) - .sort($sort desc F.usedAt) - .cursor[Bdoc]() - .list(100) - .flatMap { invalidate => - coll.delete - .one( - $doc( - F.userId -> user.id, - F.clientOrigin -> clientOrigin - ) + coll + .find( + $doc( + F.userId -> user.id, + F.clientOrigin -> clientOrigin + ), + $doc(F.id -> 1).some + ) + .sort($sort desc F.usedAt) + .cursor[Bdoc]() + .list(100) + .flatMap { invalidate => + coll.delete + .one( + $doc( + F.userId -> user.id, + F.clientOrigin -> clientOrigin ) - .map(_ => invalidate.flatMap(_.getAsOpt[Bearer](F.id)).foreach(invalidateCached)) - } - } - - def revokeByPublicId(publicId: String, user: User): Funit = - BSONObjectID.parse(publicId).toOption ?? { objectId => - colls.token { coll => - coll.findAndModify($doc(F.publicId -> objectId, F.userId -> user.id), coll.removeModifier) map { - _.result[AccessToken].foreach { token => - invalidateCached(token.id) - } - } + ) + .map(_ => invalidate.flatMap(_.getAsOpt[AccessToken.Id](F.id)).foreach(invalidateCached)) } - } - def get(bearer: Bearer) = accessTokenCache.get(bearer) + def get(bearer: Bearer) = accessTokenCache.get(AccessToken.Id.from(bearer)) private val accessTokenCache = - cacheApi[Bearer, Option[AccessToken.ForAuth]](32, "oauth.access_token") { + cacheApi[AccessToken.Id, Option[AccessToken.ForAuth]](32, "oauth.access_token") { _.expireAfterWrite(5 minutes) .buildAsyncFuture(fetchAccessToken) } - private def fetchAccessToken(tokenId: Bearer): Fu[Option[AccessToken.ForAuth]] = - colls.token { - _.ext.findAndUpdate[AccessToken.ForAuth]( - selector = $doc(F.id -> tokenId), - update = $set(F.usedAt -> DateTime.now()), - fields = AccessToken.forAuthProjection.some - ) - } + private def fetchAccessToken(id: AccessToken.Id): Fu[Option[AccessToken.ForAuth]] = + coll.ext.findAndUpdate[AccessToken.ForAuth]( + selector = $id(id), + update = $set(F.usedAt -> DateTime.now()), + fields = AccessToken.forAuthProjection.some + ) - private def invalidateCached(id: Bearer): Unit = + private def invalidateCached(id: AccessToken.Id): Unit = accessTokenCache.put(id, fuccess(none)) } diff --git a/modules/oauth/src/main/Env.scala b/modules/oauth/src/main/Env.scala index 39ed5a4157..ada3d995cb 100644 --- a/modules/oauth/src/main/Env.scala +++ b/modules/oauth/src/main/Env.scala @@ -2,53 +2,27 @@ package lila.oauth import akka.actor._ import com.softwaremill.macwire._ -import io.methvin.play.autoconfig._ import play.api.Configuration import scala.concurrent.duration._ -import lila.common.config._ -import lila.db.AsyncColl - -private case class OauthConfig( - @ConfigName("mongodb.uri") mongoUri: String, - @ConfigName("collection.access_token") tokenColl: CollName -) +import lila.common.config.CollName @Module final class Env( appConfig: Configuration, cacheApi: lila.memo.CacheApi, userRepo: lila.user.UserRepo, - lilaDb: lila.db.Db, + db: lila.db.Db, mongo: lila.db.Env )(implicit ec: scala.concurrent.ExecutionContext, system: ActorSystem ) { - private val config = appConfig.get[OauthConfig]("oauth")(AutoConfig.loader) - - private lazy val db = mongo.asyncDb("oauth", config.mongoUri) - - private lazy val colls = new OauthColls(db(config.tokenColl)) - - lazy val server = wire[OAuthServer] - - lazy val tryServer: OAuthServer.Try = () => - scala.concurrent - .Future { - server.some - } - .withTimeoutDefault(50 millis, none) recover { case e: Exception => - lila.log("security").warn("oauth", e) - none - } - - lazy val tokenApi = wire[AccessTokenApi] - lazy val authorizationApi = new AuthorizationApi(lilaDb(CollName("oauth2_authorization"))) - lazy val legacyClientApi = new LegacyClientApi(lilaDb(CollName("oauth2_legacy_client"))) + lazy val legacyClientApi = new LegacyClientApi(db(CollName("oauth2_legacy_client"))) + lazy val authorizationApi = new AuthorizationApi(db(CollName("oauth2_authorization"))) + lazy val tokenApi = new AccessTokenApi(db(CollName("oauth2_access_token")), cacheApi, userRepo) + lazy val server = wire[OAuthServer] def forms = OAuthForm } - -private class OauthColls(val token: AsyncColl) diff --git a/modules/oauth/src/main/OAuthForm.scala b/modules/oauth/src/main/OAuthForm.scala index 5087422ce6..c7e6417a9b 100644 --- a/modules/oauth/src/main/OAuthForm.scala +++ b/modules/oauth/src/main/OAuthForm.scala @@ -28,17 +28,19 @@ object OAuthForm { description: String, scopes: List[String] ) { - def make(user: lila.user.User) = + def make(user: lila.user.User) = { + val plain = Bearer.randomPersonal() AccessToken( - id = Bearer.randomPersonal(), - publicId = BSONObjectID.generate(), + id = AccessToken.Id.from(plain), + plain = plain, userId = user.id, - createdAt = DateTime.now.some, + createdAt = DateTime.now().some, description = description.some, scopes = scopes.flatMap(OAuthScope.byKey.get), clientOrigin = None, expires = None ) + } } } diff --git a/modules/oauth/src/main/OAuthServer.scala b/modules/oauth/src/main/OAuthServer.scala index 43f49d0d01..a5ee21c534 100644 --- a/modules/oauth/src/main/OAuthServer.scala +++ b/modules/oauth/src/main/OAuthServer.scala @@ -2,7 +2,6 @@ package lila.oauth import org.joda.time.DateTime import play.api.mvc.{ RequestHeader, Result } -import scala.concurrent.duration._ import lila.common.{ Bearer, HTTPRequest } import lila.db.dsl._ @@ -14,8 +13,6 @@ final class OAuthServer( cacheApi: lila.memo.CacheApi )(implicit ec: scala.concurrent.ExecutionContext) { - import AccessToken.accessTokenIdHandler - import AccessToken.{ BSONFields => F } import OAuthServer._ def auth(req: RequestHeader, scopes: List[OAuthScope]): Fu[AuthResult] = @@ -55,9 +52,7 @@ object OAuthServer { type AuthResult = Either[AuthError, OAuthScope.Scoped] sealed abstract class AuthError(val message: String) extends lila.base.LilaException - case object ServerOffline extends AuthError("OAuth server is offline! Try again soon.") case object MissingAuthorizationHeader extends AuthError("Missing authorization header") - case object InvalidAuthorizationHeader extends AuthError("Invalid authorization header") case object NoSuchToken extends AuthError("No such token") case class MissingScope(scopes: List[OAuthScope]) extends AuthError("Missing scope") case object NoSuchUser extends AuthError("No such user") @@ -70,6 +65,4 @@ object OAuthServer { "X-OAuth-Scopes" -> OAuthScope.keyList(availableScopes), "X-Accepted-OAuth-Scopes" -> OAuthScope.keyList(acceptedScopes) ) - - type Try = () => Fu[Option[OAuthServer]] } diff --git a/modules/security/src/main/Env.scala b/modules/security/src/main/Env.scala index 76d23349b2..ee1354fcad 100644 --- a/modules/security/src/main/Env.scala +++ b/modules/security/src/main/Env.scala @@ -25,7 +25,7 @@ final class Env( noteApi: lila.user.NoteApi, cacheApi: lila.memo.CacheApi, settingStore: lila.memo.SettingStore.Builder, - tryOAuthServer: OAuthServer.Try, + oAuthServer: OAuthServer, mongoCache: lila.memo.MongoCache.Api, db: lila.db.Db )(implicit diff --git a/modules/security/src/main/SecurityApi.scala b/modules/security/src/main/SecurityApi.scala index 48605d4e0e..bcd51a08a7 100644 --- a/modules/security/src/main/SecurityApi.scala +++ b/modules/security/src/main/SecurityApi.scala @@ -26,7 +26,7 @@ final class SecurityApi( geoIP: GeoIP, authenticator: lila.user.Authenticator, emailValidator: EmailAddressValidator, - tryOauthServer: lila.oauth.OAuthServer.Try, + oAuthServer: lila.oauth.OAuthServer, tor: Tor )(implicit ec: scala.concurrent.ExecutionContext, @@ -122,17 +122,9 @@ final class SecurityApi( def oauthScoped( req: RequestHeader, - scopes: List[lila.oauth.OAuthScope], - retries: Int = 2 + scopes: List[lila.oauth.OAuthScope] ): Fu[lila.oauth.OAuthServer.AuthResult] = - tryOauthServer().flatMap { - case None if retries > 0 => - lila.common.Future.delay(2 seconds) { - oauthScoped(req, scopes, retries - 1) - } - case None => fuccess(Left(OAuthServer.ServerOffline)) - case Some(server) => server.auth(req, scopes) map { _ map stripRolesOfOAuthUser } - } + oAuthServer.auth(req, scopes) map { _ map stripRolesOfOAuthUser } private lazy val nonModRoles: Set[String] = Permission.nonModPermissions.map(_.dbKey) @@ -146,15 +138,6 @@ final class SecurityApi( private def stripRolesOfUser(user: User) = user.copy(roles = user.roles.filter(nonModRoles.contains)) - def oauthScoped( - tokenId: Bearer, - scopes: List[lila.oauth.OAuthScope] - ): Fu[lila.oauth.OAuthServer.AuthResult] = - tryOauthServer().flatMap { - case None => fuccess(Left(OAuthServer.ServerOffline)) - case Some(server) => server.auth(tokenId, scopes) - } - def locatedOpenSessions(userId: User.ID, nb: Int): Fu[List[LocatedSession]] = store.openSessions(userId, nb) map { _.map { session =>