Skip to content

Commit

Permalink
integrate mobile app connections into security/modding
Browse files Browse the repository at this point in the history
  • Loading branch information
ornicar committed Jul 18, 2023
1 parent fff5915 commit 71294bb
Show file tree
Hide file tree
Showing 12 changed files with 51 additions and 34 deletions.
7 changes: 4 additions & 3 deletions app/controllers/Api.scala
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import lila.app.{ given, * }
import lila.common.config.{ MaxPerPage, MaxPerSecond }
import lila.common.{ HTTPRequest, IpAddress, LightUser }
import lila.gathering.Condition.GetMyTeamIds
import lila.security.Mobile

final class Api(
env: Env,
Expand All @@ -23,14 +24,14 @@ final class Api(

private lazy val apiStatusJson = Json.obj(
"api" -> Json.obj(
"current" -> lila.api.Mobile.Api.currentVersion.value,
"current" -> Mobile.Api.currentVersion.value,
"olds" -> Json.arr()
)
)

val status = Anon:
val appVersion = get("v")
val mustUpgrade = appVersion exists lila.api.Mobile.AppVersion.mustUpgrade
val mustUpgrade = appVersion exists Mobile.AppVersion.mustUpgrade
JsonOk(apiStatusJson.add("mustUpgrade", mustUpgrade))

def index = Anon:
Expand Down Expand Up @@ -379,7 +380,7 @@ final class Api(
js map toHttp

def MobileApiRequest(js: RequestHeader ?=> Fu[ApiResult]) = Anon:
if lila.api.Mobile.Api.requested(req)
if lila.security.Mobile.Api.requested(req)
then js map toHttp
else NotFound

Expand Down
2 changes: 1 addition & 1 deletion app/controllers/Challenge.scala
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ final class Challenge(
case Some(bearer) =>
val required = OAuthScope.select(_.Challenge.Write) into EndpointScopes
env.oAuth.server.auth(bearer, required, ctx.req.some) map {
case Right(OAuthScope.Scoped(op, _)) if pov.opponent.isUser(op) =>
case Right(access) if pov.opponent.isUser(access.user) =>
lila.common.Bus.publish(Tell(id.value, AbortForce), "roundSocket")
jsonOkResult
case Right(_) => BadRequest(jsonError("Not the opponent token"))
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/TheftPrevention.scala
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ private[controllers] trait TheftPrevention:
case (Some(_), None) => true
case (Some(playerUserId), Some(userId)) => playerUserId != userId
case (None, _) =>
!lila.api.Mobile.Api.requested(ctx.req) &&
!lila.security.Mobile.Api.requested(ctx.req) &&
!ctx.req.cookies.get(AnonCookie.name).exists(_.value == pov.playerId.value)
}

Expand Down
2 changes: 1 addition & 1 deletion app/http/HttpFilter.scala
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ final class HttpFilter(env: Env)(using val mat: Materializer)(using Executor)
val actionName = HTTPRequest actionName req
val reqTime = nowMillis - startTime
val statusCode = result.header.status
val mobile = lila.api.Mobile.LichessMobileUa.parse(req)
val mobile = lila.security.Mobile.LichessMobileUa.parse(req)
val client = if mobile.isDefined then "mobile" else HTTPRequest clientName req
lila.mon.http.time(actionName, client, req.method, statusCode).record(reqTime)
if logRequests then logger.info(s"$statusCode $client $req $actionName ${reqTime}ms")
Expand Down
2 changes: 1 addition & 1 deletion app/http/KeyPages.scala
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ final class KeyPages(val env: Env)(using Executor)
NotFound.page(html.base.notFound())

def blacklisted(using ctx: Context): Fu[Result] =
if lila.api.Mobile.Api requested ctx.req then
if lila.security.Mobile.Api requested ctx.req then
fuccess:
Results.Unauthorized:
Json.obj:
Expand Down
2 changes: 1 addition & 1 deletion app/http/ResponseBuilder.scala
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ trait ResponseBuilder(using Executor)
def JsonBadRequest(msg: String): Result = JsonBadRequest(jsonError(msg))

def negotiateApi(html: => Fu[Result], api: ApiVersion => Fu[Result])(using ctx: Context): Fu[Result] =
lila.api.Mobile.Api
lila.security.Mobile.Api
.requestVersion(ctx.req)
.fold(html): v =>
api(v).dmap(_ as JSON)
Expand Down
2 changes: 1 addition & 1 deletion app/templating/Environment.scala
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ object Environment
if siteName == "lichess.org" then frag("lichess", span(".org"))
else frag(siteName)

def apiVersion = lila.api.Mobile.Api.currentVersion
def apiVersion = lila.security.Mobile.Api.currentVersion

def explorerEndpoint = env.explorerEndpoint
def tablebaseEndpoint = env.tablebaseEndpoint
Expand Down
2 changes: 1 addition & 1 deletion modules/oauth/src/main/OAuthScope.scala
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ object OAuthScope:
case object Mobile extends OAuthScope("web:mobile", I18nKey("Official Lichess mobile app"))
case object Mod extends OAuthScope("web:mod", trans.webMod)

case class Scoped(me: lila.user.Me, token: TokenScopes):
case class Scoped(me: lila.user.Me, scopes: TokenScopes):
def user: User = me.value

case class Access(scoped: Scoped, tokenId: AccessToken.Id):
Expand Down
10 changes: 5 additions & 5 deletions modules/security/src/main/Mobile.scala
Original file line number Diff line number Diff line change
Expand Up @@ -43,18 +43,18 @@ object Mobile:

object LichessMobileUa:
private val RegexOld =
"""lichess mobile/(\S+) \((\d*)\) as:(\S+) os:(android|ios)/(\S+) dev:(.*)""".r // remove soon
"""(?i)lichess mobile/(\S+) \((\d*)\) as:(\S+) os:(android|ios)/(\S+) dev:(.*)""".r // remove soon
private val Regex =
"""lichess mobile/(\S+) \((\d*)\) as:(\S+) sri:(\S+) os:(android|ios)/(\S+) dev:(.*)""".r
"""(?i)lichess mobile/(\S+) \((\d*)\) as:(\S+) sri:(\S+) os:(android|ios)/(\S+) dev:(.*)""".r
def parse(req: RequestHeader): Option[LichessMobileUa] = HTTPRequest.userAgent(req) flatMap parse
def parse(ua: UserAgent): Option[LichessMobileUa] = ua.value
.startsWith("Lichess Mobile/")
.so:
ua.value.toLowerCase match
ua.value match
case Regex(version, build, user, sri, osName, osVersion, device) =>
val userId = (user != "anon") option UserId(user)
val userId = (user != "anon") option UserStr(user).id
LichessMobileUa(version, ~build.toIntOption, userId, Sri(sri), osName, osVersion, device).some
case RegexOld(version, build, user, osName, osVersion, device) =>
val userId = (user != "anon") option UserId(user)
val userId = (user != "anon") option UserStr(user).id
LichessMobileUa(version, ~build.toIntOption, userId, Sri("old"), osName, osVersion, device).some
case wut => none
28 changes: 16 additions & 12 deletions modules/security/src/main/SecurityApi.scala
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,6 @@ final class SecurityApi(
val sessionId = SecureRandom nextString 22
store.save(s"SIG-$sessionId", userId, req, apiVersion, up = false, fp = fp)

def upsertOauth(access: OAuthScope.Access, sri: Option[Sri])(using req: RequestHeader): Funit =
store.upsertOAuth(access.user.id, access.tokenId, sri, req)

private type AppealOrUser = Either[AppealUser, FingerPrintedUser]
def restoreUser(req: RequestHeader): Fu[Option[AppealOrUser]] =
firewall.accepts(req) so reqSessionId(req) so { sessionId =>
Expand All @@ -124,30 +121,37 @@ final class SecurityApi(
: Fu[Option[AppealOrUser]]
}

private val shouldOauthUpsert = lila.memo.OnceEvery[UserId](1.hour)
def oauthScoped(
req: RequestHeader,
required: lila.oauth.EndpointScopes
): Fu[lila.oauth.OAuthServer.AuthResult] =
oAuthServer
.auth(req, required)
.map(_ map stripRolesOfOAuthUser)
.addEffect:
case Right(access) if shouldOauthUpsert(access.user.id) =>
upsertOauth(access)(using req)
case _ => ()
case Right(access) => upsertOauth(access, req)
case _ => ()
.map(_.map(access => stripRolesOfOAuthUser(access.scoped)))

private object upsertOauth:
private val sometimes = lila.memo.OnceEvery[UserId](1.hour)
def apply(access: OAuthScope.Access, req: RequestHeader): Unit = if sometimes(access.user.id) then
val mobile = Mobile.LichessMobileUa.parse(req)
store.upsertOAuth(access.user.id, access.tokenId, mobile, req)

private lazy val nonModRoles: Set[String] = Permission.nonModPermissions.map(_.dbKey)

private def stripRolesOfOAuthUser(access: OAuthScope.Access) =
if access.scopes.has(_.Web.Mod) then access
else access.copy(scoped = OAuthScope.Scoped(me = stripRolesOf(access.me)))
private def stripRolesOfOAuthUser(scoped: OAuthScope.Scoped) =
if scoped.scopes.has(_.Web.Mod) then scoped
else scoped.copy(me = stripRolesOf(scoped.me))

private def stripRolesOfCookieUser(me: Me) =
if mode == Mode.Prod && me.totpSecret.isEmpty then stripRolesOf(me)
else me

private def stripRolesOf(me: Me) = me.map(_.copy(roles = me.roles.filter(nonModRoles.contains)))
private def stripRolesOf(me: Me) =
if me.roles.nonEmpty
then me.map(_.copy(roles = me.roles.filter(nonModRoles.contains)))
else me

def locatedOpenSessions(userId: UserId, nb: Int): Fu[List[LocatedSession]] =
store.openSessions(userId, nb) map {
Expand Down
10 changes: 7 additions & 3 deletions modules/security/src/main/Store.scala
Original file line number Diff line number Diff line change
Expand Up @@ -69,21 +69,25 @@ final class Store(val coll: Coll, cacheApi: lila.memo.CacheApi)(using
private[security] def upsertOAuth(
userId: UserId,
tokenId: AccessToken.Id,
sri: Option[Sri],
mobile: Option[Mobile.LichessMobileUa],
req: RequestHeader
): Funit =
val id = s"TOK-${tokenId.value.take(20)}"
val ua = mobile
.map(m => s"""Lichess Mobile ${m.version} ${m.osName} ${m.osVersion} ${m.device}""")
.orElse(HTTPRequest.userAgent(req).map(_.value))
.getOrElse("?")
coll.update
.one(
$id(id),
$doc(
"_id" -> id,
"user" -> userId,
"ip" -> HTTPRequest.ipAddress(req),
"ua" -> HTTPRequest.userAgent(req).fold("?")(_.value),
"ua" -> ua,
"date" -> nowInstant,
"up" -> true,
"fp" -> sri.map(_.value) // lichess mobile
"fp" -> mobile.map(_.sri.value)
),
upsert = true
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package lila.api
package lila.security

import lila.socket.Socket.Sri

Expand All @@ -20,18 +20,26 @@ class MobileTest extends munit.FunSuite:
Sri("uw-y3_79sz"),
"android",
"11.0.2",
"moto g (4)"
"Moto G (4)"
).some
)
assertEquals(
LichessMobileUa.parse("Lichess Mobile/1.0.0_ALPHA-2 () as:anon sri:uwy379sz os:iOS/what-3v3r dev:"),
LichessMobileUa("1.0.0_alpha-2", 0, None, Sri("uwy379sz"), "ios", "what-3v3r", "").some
LichessMobileUa("1.0.0_ALPHA-2", 0, None, Sri("uwy379sz"), "iOS", "what-3v3r", "").some
)

test("sri casing"):
assertEquals(
LichessMobileUa
.parse("Lichess Mobile/1.0.0_ALPHA-2 () as:anon sri:fp_Osk6zKPF96MXI os:iOS/what-3v3r dev:")
.map(_.sri),
Some(Sri("fp_Osk6zKPF96MXI"))
)

test("old instance"):
assertEquals(
LichessMobileUa.parse("Lichess Mobile/1.0.0_ALPHA-2 () as:anon os:iOS/what-3v3r dev:"),
LichessMobileUa("1.0.0_alpha-2", 0, None, Sri("old"), "ios", "what-3v3r", "").some
LichessMobileUa("1.0.0_ALPHA-2", 0, None, Sri("old"), "iOS", "what-3v3r", "").some
)

test("invalid UAs"):
Expand Down

0 comments on commit 71294bb

Please sign in to comment.