refactor access token collection

pull/9366/head
Niklas Fiekas 2021-07-07 12:48:08 +02:00
parent a367780a0a
commit 0aa632dd9e
13 changed files with 134 additions and 199 deletions

View File

@ -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 {

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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")),

View File

@ -54,7 +54,7 @@ object index {
br,
"You wont 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"

View File

@ -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)

View File

@ -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,

View File

@ -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))
}

View File

@ -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)

View File

@ -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
)
}
}
}

View File

@ -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]]
}

View File

@ -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

View File

@ -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 =>