lila/app/controllers/Auth.scala

439 lines
17 KiB
Scala

package controllers
import ornicar.scalalib.Zero
import play.api.data.FormError
import play.api.libs.json._
import play.api.mvc._
import scala.concurrent.duration._
import lila.api.Context
import lila.app._
import lila.common.{ LilaCookie, HTTPRequest, IpAddress, EmailAddress }
import lila.memo.RateLimit
import lila.security.{ EmailAddressValidator, FingerPrint }
import lila.user.{ UserRepo, User => UserModel, PasswordHasher }
import UserModel.ClearPassword
import views._
object Auth extends LilaController {
private def env = Env.security
private def api = env.api
private def forms = env.forms
private def mobileUserOk(u: UserModel, sessionId: String): Fu[Result] =
lila.game.GameRepo urgentGames u map { povs =>
Ok {
Env.user.jsonView(u) ++ Json.obj(
"nowPlaying" -> JsArray(povs take 20 map Env.api.lobbyApi.nowPlaying),
"sessionId" -> sessionId
)
}
}
private val refRegex = """[\w@/-]++""".r
private def goodReferrer(referrer: String): Boolean = {
referrer.nonEmpty &&
referrer.stripPrefix("/") != "mobile" && {
(!referrer.contains("//") && refRegex.matches(referrer)) ||
referrer.startsWith(Env.oAuth.baseUrl)
}
}
def authenticateUser(u: UserModel, result: Option[String => Result] = None)(implicit ctx: Context): Fu[Result] = {
if (u.ipBan) fuccess(Redirect(routes.Lobby.home))
else api.saveAuthentication(u.id, ctx.mobileApiVersion) flatMap { sessionId =>
negotiate(
html = fuccess {
val redirectTo = get("referrer").filter(goodReferrer) orElse
ctxReq.session.get(api.AccessUri) getOrElse
routes.Lobby.home.url
result.fold(Redirect(redirectTo))(_(redirectTo))
},
api = _ => mobileUserOk(u, sessionId)
) map authenticateCookie(sessionId)
} recoverWith authRecovery
}
private def authenticateCookie(sessionId: String)(result: Result)(implicit req: RequestHeader) =
result.withCookies(
LilaCookie.withSession {
_ + ("sessionId" -> sessionId) - api.AccessUri - lila.security.EmailConfirm.cookie.name
}
)
private def authRecovery(implicit ctx: Context): PartialFunction[Throwable, Fu[Result]] = {
case lila.security.SecurityApi.MustConfirmEmail(_) => fuccess {
if (HTTPRequest isXhr ctx.req) Ok(s"ok:${routes.Auth.checkYourEmail}")
else BadRequest(Account.renderCheckYourEmail)
}
}
def login = Open { implicit ctx =>
val referrer = get("referrer").filter(goodReferrer)
Ok(html.auth.login(api.loginForm, referrer)).fuccess
}
private val is2fa = Set("MissingTotpToken", "InvalidTotpToken")
def authenticate = OpenBody { implicit ctx =>
Firewall({
implicit val req = ctx.body
val referrer = get("referrer")
api.usernameOrEmailForm.bindFromRequest.fold(
err => negotiate(
html = Unauthorized(html.auth.login(api.loginForm, referrer)).fuccess,
api = _ => Unauthorized(ridiculousBackwardCompatibleJsonError(errorsAsJson(err))).fuccess
),
usernameOrEmail => HasherRateLimit(usernameOrEmail, ctx.req) { chargeIpLimiter =>
api.loadLoginForm(usernameOrEmail) flatMap { loginForm =>
loginForm.bindFromRequest.fold(
err => {
chargeIpLimiter(1)
negotiate(
html = fuccess {
err.errors match {
case List(FormError("", List(err), _)) if is2fa(err) => Ok(err)
case _ => Unauthorized(html.auth.login(err, referrer))
}
},
api = _ => Unauthorized(ridiculousBackwardCompatibleJsonError(errorsAsJson(err))).fuccess
)
},
result => result.toOption match {
case None => InternalServerError("Authentication error").fuccess
case Some(u) =>
UserRepo.email(u.id) foreach {
_ foreach { garbageCollect(u, _) }
}
authenticateUser(u, Some(redirectTo => Ok(s"ok:$redirectTo")))
}
)
}
}
)
}, Ok(s"ok:/").fuccess)
}
def logout = Open { implicit ctx =>
ctxReq.session get "sessionId" foreach lila.security.Store.delete
negotiate(
html = Redirect(routes.Auth.login).fuccess,
api = _ => Ok(Json.obj("ok" -> true)).fuccess
) map (_ withCookies LilaCookie.newSession)
}
// mobile app BC logout with GET
def logoutGet = Open { implicit ctx =>
negotiate(
html = notFound,
api = _ => {
ctxReq.session get "sessionId" foreach lila.security.Store.delete
Ok(Json.obj("ok" -> true)).withCookies(LilaCookie.newSession).fuccess
}
)
}
def signup = Open { implicit ctx =>
NoTor {
Ok(html.auth.signup(forms.signup.website, env.recaptchaPublicConfig)).fuccess
}
}
private sealed abstract class MustConfirmEmail(val value: Boolean)
private object MustConfirmEmail {
case object Nope extends MustConfirmEmail(false)
case object YesBecausePrintExists extends MustConfirmEmail(true)
case object YesBecausePrintMissing extends MustConfirmEmail(true)
case object YesBecauseIpExists extends MustConfirmEmail(true)
case object YesBecauseIpSusp extends MustConfirmEmail(true)
case object YesBecauseMobile extends MustConfirmEmail(true)
case object YesBecauseUA extends MustConfirmEmail(true)
def apply(print: Option[FingerPrint])(implicit ctx: Context): Fu[MustConfirmEmail] = {
val ip = HTTPRequest lastRemoteAddress ctx.req
api.recentByIpExists(ip) flatMap { ipExists =>
if (ipExists) fuccess(YesBecauseIpExists)
else if (HTTPRequest weirdUA ctx.req) fuccess(YesBecauseUA)
else print.fold[Fu[MustConfirmEmail]](fuccess(YesBecausePrintMissing)) { fp =>
api.recentByPrintExists(fp) flatMap { printFound =>
if (printFound) fuccess(YesBecausePrintExists)
else Env.security.ipTrust.isSuspicious(ip).map {
case true => YesBecauseIpSusp
case _ => Nope
}
}
}
}
}
}
private def authLog(user: String, msg: String) = lila.log("auth").info(s"$user $msg")
def signupPost = OpenBody { implicit ctx =>
implicit val req = ctx.body
NoTor {
Firewall {
forms.preloadEmailDns >> negotiate(
html = forms.signup.website.bindFromRequest.fold(
err => {
err("username").value foreach { authLog(_, s"Signup fail: ${err.errors mkString ", "}") }
BadRequest(html.auth.signup(err, env.recaptchaPublicConfig)).fuccess
},
data => env.recaptcha.verify(~data.recaptchaResponse, req).flatMap {
case false =>
authLog(data.username, "Signup recaptcha fail")
BadRequest(html.auth.signup(forms.signup.website fill data, env.recaptchaPublicConfig)).fuccess
case true => HasherRateLimit(data.username, ctx.req) { _ =>
MustConfirmEmail(data.fingerPrint) flatMap { mustConfirm =>
lila.mon.user.register.website()
lila.mon.user.register.mustConfirmEmail(mustConfirm.toString)()
val email = env.emailAddressValidator.validate(data.realEmail) err s"Invalid email ${data.email}"
authLog(data.username, s"${email.acceptable} fp: ${data.fingerPrint} mustConfirm: $mustConfirm req:${ctx.req}")
val passwordHash = Env.user.authenticator passEnc ClearPassword(data.password)
UserRepo.create(data.username, passwordHash, email.acceptable, ctx.blind, none,
mustConfirmEmail = mustConfirm.value)
.flatten(s"No user could be created for ${data.username}")
.map(_ -> email).flatMap {
case (user, EmailAddressValidator.Acceptable(email)) if mustConfirm.value =>
env.emailConfirm.send(user, email) >> {
if (env.emailConfirm.effective)
api.saveSignup(user.id, ctx.mobileApiVersion, data.fingerPrint) inject {
Redirect(routes.Auth.checkYourEmail) withCookies
lila.security.EmailConfirm.cookie.make(user, email)(ctx.req)
}
else welcome(user, email) >> redirectNewUser(user)
}
case (user, EmailAddressValidator.Acceptable(email)) =>
welcome(user, email) >> redirectNewUser(user)
}
}
}
}
),
api = apiVersion => forms.signup.mobile.bindFromRequest.fold(
err => {
err("username").value foreach { authLog(_, s"Signup fail: ${err.errors mkString ", "}") }
jsonFormError(err)
},
data => HasherRateLimit(data.username, ctx.req) { _ =>
val email = env.emailAddressValidator.validate(data.realEmail) err s"Invalid email ${data.email}"
val mustConfirm = MustConfirmEmail.YesBecauseMobile
lila.mon.user.register.mobile()
lila.mon.user.register.mustConfirmEmail(mustConfirm.toString)()
authLog(data.username, s"Signup mobile must confirm email: $mustConfirm")
val passwordHash = Env.user.authenticator passEnc ClearPassword(data.password)
UserRepo.create(data.username, passwordHash, email.acceptable, false, apiVersion.some,
mustConfirmEmail = mustConfirm.value)
.flatten(s"No user could be created for ${data.username}")
.map(_ -> email).flatMap {
case (user, EmailAddressValidator.Acceptable(email)) if mustConfirm.value =>
env.emailConfirm.send(user, email) >> {
if (env.emailConfirm.effective) Ok(Json.obj("email_confirm" -> true)).fuccess
else welcome(user, email) >> authenticateUser(user)
}
case (user, _) => welcome(user, email.acceptable) >> authenticateUser(user)
}
}
)
)
}
}
}
private def welcome(user: UserModel, email: EmailAddress)(implicit ctx: Context) = {
garbageCollect(user, email)
env.automaticEmail.welcome(user, email)
}
private def garbageCollect(user: UserModel, email: EmailAddress)(implicit ctx: Context) =
Env.security.garbageCollector.delay(user, email, ctx.req)
def checkYourEmail = Open { implicit ctx =>
ctx.me match {
case Some(me) => Redirect(routes.User.show(me.username)).fuccess
case None => lila.security.EmailConfirm.cookie get ctx.req match {
case None => Ok(Account.renderCheckYourEmail).fuccess
case Some(userEmail) =>
UserRepo nameExists userEmail.username map {
case false => Redirect(routes.Auth.signup) withCookies LilaCookie.newSession(ctx.req)
case true => Ok(Account.renderCheckYourEmail)
}
}
}
}
// after signup and before confirmation
def fixEmail = OpenBody { implicit ctx =>
lila.security.EmailConfirm.cookie.get(ctx.req) ?? { userEmail =>
implicit val req = ctx.body
forms.preloadEmailDns >> forms.fixEmail(userEmail.email).bindFromRequest.fold(
err => BadRequest(html.auth.checkYourEmail(userEmail.some, err.some)).fuccess,
email => UserRepo.named(userEmail.username) flatMap {
_.fold(Redirect(routes.Auth.signup).fuccess) { user =>
UserRepo.mustConfirmEmail(user.id) flatMap {
case false => Redirect(routes.Auth.login).fuccess
case _ =>
val newUserEmail = userEmail.copy(email = EmailAddress(email))
EmailConfirmRateLimit(newUserEmail, ctx.req) {
lila.mon.email.types.fix()
UserRepo.setEmail(user.id, newUserEmail.email) >>
env.emailConfirm.send(user, newUserEmail.email) inject {
Redirect(routes.Auth.checkYourEmail) withCookies
lila.security.EmailConfirm.cookie.make(user, newUserEmail.email)(ctx.req)
}
}
}
}
}
)
}
}
def signupConfirmEmail(token: String) = Open { implicit ctx =>
import lila.security.EmailConfirm.Result
Env.security.emailConfirm.confirm(token) flatMap {
case Result.NotFound =>
lila.mon.user.register.confirmEmailResult(false)()
notFound
case Result.AlreadyConfirmed(user) if ctx.is(user) =>
Redirect(routes.User.show(user.username)).fuccess
case Result.AlreadyConfirmed(user) =>
Redirect(routes.Auth.login).fuccess
case Result.JustConfirmed(user) =>
lila.mon.user.register.confirmEmailResult(true)()
UserRepo.email(user.id).flatMap {
_.?? { email =>
authLog(user.username, s"Confirmed email $email")
welcome(user, email)
}
} >> redirectNewUser(user)
}
}
private def redirectNewUser(user: UserModel)(implicit ctx: Context) = {
api.saveAuthentication(user.id, ctx.mobileApiVersion) flatMap { sessionId =>
negotiate(
html = Redirect(routes.User.show(user.username)).fuccess,
api = _ => mobileUserOk(user, sessionId)
) map authenticateCookie(sessionId)
} recoverWith authRecovery
}
def setFingerPrint(fp: String, ms: Int) = Auth { ctx => me =>
api.setFingerPrint(ctx.req, FingerPrint(fp)) flatMap {
_ ?? { hash =>
!me.lame ?? {
api.recentUserIdsByFingerHash(hash).map(_.filter(me.id!=)) flatMap {
case otherIds if otherIds.size >= 2 => UserRepo countEngines otherIds flatMap {
case nb if nb >= 2 && nb >= otherIds.size / 2 => Env.report.api.autoCheatPrintReport(me.id)
case _ => funit
}
case _ => funit
}
}
}
} inject NoContent
}
def passwordReset = Open { implicit ctx =>
forms.passwordResetWithCaptcha map {
case (form, captcha) => Ok(html.auth.bits.passwordReset(form, captcha))
}
}
def passwordResetApply = OpenBody { implicit ctx =>
implicit val req = ctx.body
forms.passwordReset.bindFromRequest.fold(
err => forms.anyCaptcha map { captcha =>
BadRequest(html.auth.bits.passwordReset(err, captcha, false.some))
},
data => {
UserRepo.enabledWithEmail(data.realEmail.normalize) flatMap {
case Some((user, storedEmail)) => {
lila.mon.user.auth.passwordResetRequest("success")()
Env.security.passwordReset.send(user, storedEmail) inject Redirect(routes.Auth.passwordResetSent(storedEmail.conceal))
}
case _ => {
lila.mon.user.auth.passwordResetRequest("no_email")()
forms.passwordResetWithCaptcha map {
case (form, captcha) => BadRequest(html.auth.bits.passwordReset(form, captcha, false.some))
}
}
}
}
)
}
def passwordResetSent(email: String) = Open { implicit ctx =>
fuccess {
Ok(html.auth.bits.passwordResetSent(email))
}
}
def passwordResetConfirm(token: String) = Open { implicit ctx =>
Env.security.passwordReset confirm token flatMap {
case None => {
lila.mon.user.auth.passwordResetConfirm("token_fail")()
notFound
}
case Some(user) => {
authLog(user.username, "Reset password")
lila.mon.user.auth.passwordResetConfirm("token_ok")()
fuccess(html.auth.bits.passwordResetConfirm(user, token, forms.passwdReset, none))
}
}
}
def passwordResetConfirmApply(token: String) = OpenBody { implicit ctx =>
Env.security.passwordReset confirm token flatMap {
case None => {
lila.mon.user.auth.passwordResetConfirm("token_post_fail")()
notFound
}
case Some(user) =>
implicit val req = ctx.body
FormFuResult(forms.passwdReset) { err =>
fuccess(html.auth.bits.passwordResetConfirm(user, token, err, false.some))
} { data =>
HasherRateLimit(user.username, ctx.req) { _ =>
Env.user.authenticator.setPassword(user.id, ClearPassword(data.newPasswd1)) >>
UserRepo.setEmailConfirmed(user.id) >>
UserRepo.disableTwoFactor(user.id) >>
env.store.disconnect(user.id) >>
authenticateUser(user) >>-
lila.mon.user.auth.passwordResetConfirm("success")()
}
}
}
}
def makeLoginToken = Auth { implicit ctx => me =>
JsonOk {
env.loginToken generate me map { token =>
Json.obj(
"userId" -> me.id,
"url" -> s"${Env.api.Net.BaseUrl}${routes.Auth.loginWithToken(token).url}"
)
}
}
}
def loginWithToken(token: String) = Open { implicit ctx =>
Firewall {
env.loginToken consume token flatMap {
_.fold(notFound)(authenticateUser(_))
}
}
}
private implicit val limitedDefault = Zero.instance[Result](TooManyRequest)
private[controllers] def HasherRateLimit =
PasswordHasher.rateLimit[Result](enforce = Env.api.Net.RateLimit) _
private[controllers] def EmailConfirmRateLimit = lila.security.EmailConfirm.rateLimit[Result] _
}