diff --git a/app/controllers/Auth.scala b/app/controllers/Auth.scala index e131da8df533..2d9f83b5f3f3 100644 --- a/app/controllers/Auth.scala +++ b/app/controllers/Auth.scala @@ -47,6 +47,16 @@ final class Auth( ) map authenticateCookie(sessionId) } recoverWith authRecovery + private def authenticateAppealUser(u: UserModel, redirect: String => Result)(implicit + ctx: Context + ): Fu[Result] = + api.appeal.saveAuthentication(u.id) flatMap { sessionId => + negotiate( + html = redirect(routes.Appeal.home.url).fuccess, + api = _ => NotFound.fuccess + ) map authenticateCookie(sessionId) + } recoverWith authRecovery + private def authenticateCookie(sessionId: String)(result: Result)(implicit req: RequestHeader) = result.withCookies( env.lilaCookie.withSession { @@ -111,7 +121,9 @@ final class Auth( case None => InternalServerError("Authentication error").fuccess case Some(u) if u.disabled => negotiate( - html = redirectTo(routes.Account.reopen.url).fuccess, + html = + if (u.marks.dirty) authenticateAppealUser(u, redirectTo) + else redirectTo(routes.Account.reopen.url).fuccess, api = _ => Unauthorized(jsonError("This account is closed.")).fuccess ) case Some(u) => diff --git a/app/controllers/LilaController.scala b/app/controllers/LilaController.scala index e1c182a6a4d0..54aeea727fab 100644 --- a/app/controllers/LilaController.scala +++ b/app/controllers/LilaController.scala @@ -16,7 +16,7 @@ import lila.common.{ ApiVersion, HTTPRequest, Nonce } import lila.i18n.I18nLangPicker import lila.notify.Notification.Notifies import lila.oauth.{ OAuthScope, OAuthServer } -import lila.security.{ FingerPrintedUser, Granter, Permission } +import lila.security.{ AppealUser, FingerPrintedUser, Granter, Permission } import lila.user.{ User => UserModel, UserContext } abstract private[controllers] class LilaController(val env: Env) @@ -353,11 +353,6 @@ abstract private[controllers] class LilaController(val env: Env) _.fold(notFoundJson())(a => fuccess(Ok(Json toJson a) as JSON)) } - protected def JsOk(fua: Fu[String], headers: (String, String)*) = - fua map { a => - Ok(a) as JAVASCRIPT withHeaders (headers: _*) - } - protected def FormResult[A](form: Form[A])(op: A => Fu[Result])(implicit req: Request[_]): Fu[Result] = form .bindFromRequest() @@ -454,7 +449,9 @@ abstract private[controllers] class LilaController(val env: Env) protected def authenticationFailed(implicit ctx: Context): Fu[Result] = negotiate( html = fuccess { - Redirect(routes.Auth.signup) withCookies env.lilaCookie + Redirect( + if (HTTPRequest.isAppeal(ctx.req)) routes.Auth.login else routes.Auth.signup + ) withCookies env.lilaCookie .session(env.security.api.AccessUri, ctx.req.uri) }, api = _ => @@ -512,33 +509,34 @@ abstract private[controllers] class LilaController(val env: Env) ctx.me.fold(fuccess(PageData.anon(ctx.req, nonce, blindMode(ctx)))) { me => env.pref.api.getPref(me, ctx.req) zip (if (isGranted(_.Teacher, me)) fuccess(true) else env.clas.api.student.isStudent(me.id)) zip { - if (isPage) { - env.user.lightUserApi preloadUser me - env.team.api.nbRequests(me.id) zip - env.challenge.api.countInFor.get(me.id) zip - env.notifyM.api.unreadCount(Notifies(me.id)).dmap(_.value) zip - env.mod.inquiryApi.forMod(me) - } else - fuccess { - (((0, 0), 0), none) - } - } map { + if (isPage) { + env.user.lightUserApi preloadUser me + val enabledId = me.enabled option me.id + enabledId.??(env.team.api.nbRequests) zip + enabledId.??(env.challenge.api.countInFor.get) zip + enabledId.??(id => env.notifyM.api.unreadCount(Notifies(id)).dmap(_.value)) zip + env.mod.inquiryApi.forMod(me) + } else + fuccess { + (((0, 0), 0), none) + } + } map { case ( (pref, hasClas), (((teamNbRequests, nbChallenges), nbNotifications), inquiry) ) => - PageData( - teamNbRequests, - nbChallenges, - nbNotifications, - pref, - blindMode = blindMode(ctx), - hasFingerprint = hasFingerPrint, - hasClas = hasClas, - inquiry = inquiry, - nonce = nonce - ) - } + PageData( + teamNbRequests, + nbChallenges, + nbNotifications, + pref, + blindMode = blindMode(ctx), + hasFingerprint = hasFingerPrint, + hasClas = hasClas, + inquiry = inquiry, + nonce = nonce + ) + } } } @@ -551,13 +549,16 @@ abstract private[controllers] class LilaController(val env: Env) type RestoredUser = (Option[FingerPrintedUser], Option[UserModel]) private def restoreUser(req: RequestHeader): Fu[RestoredUser] = env.security.api restoreUser req dmap { - case Some(d) if !env.isProd => + case Some(Left(AppealUser(user))) if HTTPRequest.isAppeal(req) => + FingerPrintedUser(user, true).some + case Some(Right(d)) if !env.isProd => d.copy(user = d.user .addRole(lila.security.Permission.Beta.dbKey) .addRole(lila.security.Permission.Prismic.dbKey) ).some - case d => d + case Some(Right(d)) => d.some + case _ => none } flatMap { case None => fuccess(None -> None) case Some(d) => @@ -664,4 +665,4 @@ abstract private[controllers] class LilaController(val env: Env) protected val notationContentType = "text/plain" // I guess, there is nothing better for shogi protected val ndJsonContentType = "application/x-ndjson" -} +} \ No newline at end of file diff --git a/app/views/base/layout.scala b/app/views/base/layout.scala index 141af5f84b31..1ae452fcd4d1 100644 --- a/app/views/base/layout.scala +++ b/app/views/base/layout.scala @@ -279,7 +279,7 @@ object layout { wrapClass -> wrapClass.nonEmpty ) )(body), - ctx.isAuth option div( + ctx.me.exists(_.enabled) option div( id := "friend_box", dataPreload := safeJsonValue(Json.obj("i18n" -> i18nJsObject(i18nKeys))) )( @@ -348,16 +348,21 @@ object layout { ) ), ctx.blind option h2("Navigation"), - topnav() + !ctx.isAppealUser option topnav() ), div(cls := "site-buttons")( if (ctx.req.path == "/") switchLanguage else "", - clinput, + !ctx.isAppealUser option clinput, reports, teamRequests, - ctx.me map { me => - frag(allNotifications, dasher(me)) - } getOrElse { !ctx.pageData.error option anonDasher(playing) } + if (ctx.isAppealUser) + postForm(action := routes.Auth.logout)( + submitButton(cls := "button button-red link")(trans.logOut()) + ) + else + ctx.me map { me => + frag(allNotifications, dasher(me)) + } getOrElse { !ctx.pageData.error option anonDasher(playing) } ) ) } diff --git a/modules/common/src/main/HTTPRequest.scala b/modules/common/src/main/HTTPRequest.scala index 9834a4c7e78a..fca0bc476f15 100644 --- a/modules/common/src/main/HTTPRequest.scala +++ b/modules/common/src/main/HTTPRequest.scala @@ -113,6 +113,8 @@ object HTTPRequest { } } + def isAppeal(req: RequestHeader) = req.path.startsWith("/appeal") + def clientName(req: RequestHeader) = if (isXhr(req)) "xhr" else if (isCrawler(req)) "crawler" diff --git a/modules/memo/src/main/CacheApi.scala b/modules/memo/src/main/CacheApi.scala index 3c7a8b79a485..d1c4d63b0ea1 100644 --- a/modules/memo/src/main/CacheApi.scala +++ b/modules/memo/src/main/CacheApi.scala @@ -59,6 +59,16 @@ final class CacheApi( cache } + def notLoadingSync[K, V](initialCapacity: Int, name: String)( + build: Builder => Cache[K, V] + ): Cache[K, V] = { + val cache = build { + scaffeine.recordStats().initialCapacity(actualCapacity(initialCapacity)) + } + monitor(name, cache) + cache + } + def monitor(name: String, cache: AsyncCache[_, _]): Unit = monitor(name, cache.underlying.synchronous) diff --git a/modules/security/src/main/Granter.scala b/modules/security/src/main/Granter.scala index 0f739aa907a2..8cd4090bb0d3 100644 --- a/modules/security/src/main/Granter.scala +++ b/modules/security/src/main/Granter.scala @@ -5,10 +5,10 @@ import lila.user.User object Granter { def apply(permission: Permission)(user: User): Boolean = - apply(permission, user.roles) + user.enabled && apply(permission, user.roles) def apply(f: Permission.Selector)(user: User): Boolean = - apply(f(Permission), user.roles) + user.enabled && apply(f(Permission), user.roles) def apply(permission: Permission, roles: Seq[String]): Boolean = Permission(roles).exists(_ is permission) diff --git a/modules/security/src/main/MagicLink.scala b/modules/security/src/main/MagicLink.scala index 9aacfd5bb4f4..630138d10cd3 100644 --- a/modules/security/src/main/MagicLink.scala +++ b/modules/security/src/main/MagicLink.scala @@ -43,7 +43,9 @@ ${Mailgun.txt.serviceNote} } def confirm(token: String): Fu[Option[User]] = - tokener read token flatMap { _ ?? userRepo.byId } + tokener read token flatMap { _ ?? userRepo.byId } map { + _.filter(_.canFullyLogin) + } private val tokener = LoginToken.makeTokener(tokenerSecret, 10 minutes) } diff --git a/modules/security/src/main/PasswordReset.scala b/modules/security/src/main/PasswordReset.scala index 9eb5f6d38730..f18466e45b88 100644 --- a/modules/security/src/main/PasswordReset.scala +++ b/modules/security/src/main/PasswordReset.scala @@ -45,7 +45,9 @@ ${Mailgun.txt.serviceNote} } def confirm(token: String): Fu[Option[User]] = - tokener read token flatMap { _ ?? userRepo.byId } + tokener read token flatMap { _ ?? userRepo.byId } map { + _.filter(_.canFullyLogin) + } private val tokener = new StringToken[User.ID]( secret = tokenerSecret, diff --git a/modules/security/src/main/SecurityApi.scala b/modules/security/src/main/SecurityApi.scala index f896a0c69fb6..790a8df4eff4 100644 --- a/modules/security/src/main/SecurityApi.scala +++ b/modules/security/src/main/SecurityApi.scala @@ -22,6 +22,7 @@ final class SecurityApi( userRepo: UserRepo, store: Store, firewall: Firewall, + cacheApi: lila.memo.CacheApi, geoIP: GeoIP, authenticator: lila.user.Authenticator, emailValidator: EmailAddressValidator, @@ -103,12 +104,16 @@ final class SecurityApi( store.save(s"SIG-$sessionId", userId, req, apiVersion, up = false, fp = fp) } - def restoreUser(req: RequestHeader): Fu[Option[FingerPrintedUser]] = + def restoreUser(req: RequestHeader): Fu[Option[Either[AppealUser, FingerPrintedUser]]] = firewall.accepts(req) ?? reqSessionId(req) ?? { sessionId => - store.authInfo(sessionId) flatMap { - _ ?? { d => - userRepo byId d.user dmap { _ map { FingerPrintedUser(_, d.hasFp) } } - } + appeal.authenticate(sessionId) match { + case Some(userId) => userRepo byId userId map2 { u => Left(AppealUser(u)) } + case None => + store.authInfo(sessionId) flatMap { + _ ?? { d => + userRepo byId d.user dmap { _ map { u => Right(FingerPrintedUser(u, d.hasFp)) } } + } + } } } @@ -187,6 +192,28 @@ final class SecurityApi( ), ReadPreference.secondaryPreferred ) + + // special temporary auth for marked closed accounts so they can use appeal endpoints + object appeal { + + private type SessionId = String + + private val prefix = "appeal:" + + private val store = cacheApi.notLoadingSync[SessionId, User.ID](256, "security.session.appeal")( + _.expireAfterAccess(7.days).build() + ) + + def authenticate(sessionId: SessionId): Option[User.ID] = + sessionId.startsWith(prefix) ?? store.getIfPresent(sessionId) + + def saveAuthentication(userId: User.ID)(implicit req: RequestHeader): Fu[SessionId] = { + val sessionId = s"$prefix${Random secureString 22}" + store.put(sessionId, userId) + logger.info(s"Appeal login by $userId") + fuccess(sessionId) + } + } } object SecurityApi { diff --git a/modules/security/src/main/model.scala b/modules/security/src/main/model.scala index 55400f847225..15e36a69db7d 100644 --- a/modules/security/src/main/model.scala +++ b/modules/security/src/main/model.scala @@ -16,6 +16,8 @@ case class AuthInfo(user: User.ID, hasFp: Boolean) case class FingerPrintedUser(user: User, hasFingerPrint: Boolean) +case class AppealUser(user: User) + case class UserSession( _id: String, ip: IpAddress, diff --git a/modules/user/src/main/Authenticator.scala b/modules/user/src/main/Authenticator.scala index 470ebbba8eff..6d3edeae0e4f 100644 --- a/modules/user/src/main/Authenticator.scala +++ b/modules/user/src/main/Authenticator.scala @@ -59,7 +59,7 @@ final class Authenticator( private def loginCandidate(select: Bdoc): Fu[Option[User.LoginCandidate]] = userRepo.coll.one[AuthData](select, authProjection)(AuthDataBSONHandler) zip userRepo.coll .one[User](select) map { - case (Some(authData), Some(user)) if user.enabled || !user.lameOrTroll => + case (Some(authData), Some(user)) => User.LoginCandidate(user, authWithBenefits(authData)).some case _ => none } diff --git a/modules/user/src/main/User.scala b/modules/user/src/main/User.scala index 82a18baea14d..a3c2b168a6de 100644 --- a/modules/user/src/main/User.scala +++ b/modules/user/src/main/User.scala @@ -36,7 +36,7 @@ case class User( override def hashCode: Int = id.hashCode override def toString = - s"User $username(${perfs.bestRating}) games:${count.game}${marks.troll ?? " troll"}${marks.engine ?? " engine"}" + s"User $username(${perfs.bestRating}) games:${count.game}${marks.troll ?? " troll"}${marks.engine ?? " engine"}${!enabled ?? " closed"}" def light = LightUser(id = id, name = username, title = title.map(_.value), isPatron = isPatron) @@ -79,6 +79,8 @@ case class User( def lameOrAlt = lame || marks.alt def lameOrTrollOrAlt = lameOrTroll || marks.alt + def canFullyLogin = enabled || !lameOrTrollOrAlt + def withMarks(f: UserMarks => UserMarks) = copy(marks = f(marks)) def lightPerf(key: String) = diff --git a/modules/user/src/main/UserContext.scala b/modules/user/src/main/UserContext.scala index d40e4c8cd749..6f41f1a1892f 100644 --- a/modules/user/src/main/UserContext.scala +++ b/modules/user/src/main/UserContext.scala @@ -61,6 +61,7 @@ trait UserContextWrapper extends UserContext { val impersonatedBy = userContext.impersonatedBy def isBot = me.exists(_.isBot) def noBot = !isBot + def isAppealUser = me.exists(!_.enabled) } object UserContext {