refactor signup out of the Auth controller

pull/6024/head
Thibault Duplessis 2020-02-11 13:26:25 -06:00
parent d018e4db77
commit a3af326fdb
13 changed files with 226 additions and 169 deletions

View File

@ -3,14 +3,14 @@ package controllers
import io.lemonlabs.uri.{ AbsoluteUrl, Url }
import com.github.ghik.silencer.silent
import ornicar.scalalib.Zero
import play.api.data.{ Form, FormError }
import play.api.data.FormError
import play.api.libs.json._
import play.api.mvc._
import lila.api.Context
import lila.app._
import lila.common.{ ApiVersion, EmailAddress, HTTPRequest }
import lila.security.{ EmailAddressValidator, FingerPrint }
import lila.common.{ EmailAddress, HTTPRequest }
import lila.security.{ FingerPrint, Signup }
import lila.user.{ User => UserModel, PasswordHasher }
import UserModel.ClearPassword
import views._
@ -171,167 +171,42 @@ final class Auth(
}
}
sealed abstract private 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, email: String, msg: String) =
lila.log("auth").info(s"$user $email $msg")
private def signupErrLog(err: Form[_])(implicit ctx: Context) =
for {
username <- err("username").value
email <- err("email").value
} {
authLog(username, email, s"Signup fail: ${Json stringify errorsAsJson(err)}")
if (err.errors.exists(_.messages.contains("error.email_acceptable")) &&
err("email").value.exists(EmailAddress.matches))
authLog(username, email, s"Signup with unacceptable email")
}
def signupPost = OpenBody { implicit ctx =>
implicit val req = ctx.body
NoTor {
Firewall {
forms.preloadEmailDns >> negotiate(
html = forms.signup.website.bindFromRequest.fold(
err => {
signupErrLog(err)
BadRequest(html.auth.signup(err, env.security.recaptchaPublicConfig)).fuccess
},
data =>
env.security.recaptcha.verify(~data.recaptchaResponse, req).flatMap {
case false =>
authLog(data.username, data.email, "Signup recaptcha fail")
BadRequest(
html.auth.signup(forms.signup.website fill data, env.security.recaptchaPublicConfig)
).fuccess
case true =>
HasherRateLimit(data.username, ctx.req) {
_ =>
MustConfirmEmail(data.fingerPrint) flatMap {
mustConfirm =>
lila.mon.user.register.count(none)
lila.mon.user.register.mustConfirmEmail(mustConfirm.toString).increment()
val email = env.security.emailAddressValidator
.validate(data.realEmail) err s"Invalid email ${data.email}"
val passwordHash = env.user.authenticator passEnc ClearPassword(data.password)
env.user.repo
.create(
data.username,
passwordHash,
email.acceptable,
ctx.blind,
none,
mustConfirmEmail = mustConfirm.value
)
.orFail(s"No user could be created for ${data.username}")
.addEffect { logSignup(_, email.acceptable, data.fingerPrint, none, mustConfirm) }
.map(_ -> email)
.flatMap {
case (user, EmailAddressValidator.Acceptable(email)) if mustConfirm.value =>
env.security.emailConfirm.send(user, email) >> {
if (env.security.emailConfirm.effective)
api.saveSignup(user.id, ctx.mobileApiVersion, data.fingerPrint) inject {
Redirect(routes.Auth.checkYourEmail) withCookies
lila.security.EmailConfirm.cookie
.make(env.lilaCookie, 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 => {
signupErrLog(err)
jsonFormError(err)
},
data =>
HasherRateLimit(data.username, ctx.req) { _ =>
val email = env.security.emailAddressValidator
.validate(data.realEmail) err s"Invalid email ${data.email}"
val mustConfirm = MustConfirmEmail.YesBecauseMobile
lila.mon.user.register.count(apiVersion.some)
lila.mon.user.register.mustConfirmEmail(mustConfirm.toString).increment()
val passwordHash = env.user.authenticator passEnc ClearPassword(data.password)
env.user.repo
.create(
data.username,
passwordHash,
email.acceptable,
false,
apiVersion.some,
mustConfirmEmail = mustConfirm.value
)
.orFail(s"No user could be created for ${data.username}")
.addEffect { logSignup(_, email.acceptable, none, apiVersion.some, mustConfirm) }
.map(_ -> email)
.flatMap {
case (user, EmailAddressValidator.Acceptable(email)) if mustConfirm.value =>
env.security.emailConfirm.send(user, email) >> {
if (env.security.emailConfirm.effective)
Ok(Json.obj("email_confirm" -> true)).fuccess
else welcome(user, email) >> authenticateUser(user)
}
case (user, _) => welcome(user, email.acceptable) >> authenticateUser(user)
}
html = env.security.signup
.website(ctx.blind)
.flatMap {
case Signup.RateLimited => limitedDefault.zero.fuccess
case Signup.Bad(err) =>
BadRequest(html.auth.signup(err, env.security.recaptchaPublicConfig)).fuccess
case Signup.ConfirmEmail(user, email) =>
fuccess {
Redirect(routes.Auth.checkYourEmail) withCookies
lila.security.EmailConfirm.cookie
.make(env.lilaCookie, user, email)(ctx.req)
}
)
case Signup.AllSet(user, email) => welcome(user, email) >> redirectNewUser(user)
},
api = apiVersion =>
env.security.signup
.mobile(apiVersion)
.flatMap {
case Signup.RateLimited => limitedDefault.zero.fuccess
case Signup.Bad(err) => jsonFormError(err)
case Signup.ConfirmEmail(_, _) => Ok(Json.obj("email_confirm" -> true)).fuccess
case Signup.AllSet(user, email) => welcome(user, email) >> authenticateUser(user)
}
)
}
}
}
private def logSignup(
user: UserModel,
email: EmailAddress,
fingerPrint: Option[lila.security.FingerPrint],
apiVersion: Option[ApiVersion],
mustConfirm: MustConfirmEmail
)(implicit ctx: Context) = {
authLog(
user.username,
email.value,
s"fp: ${fingerPrint} mustConfirm: $mustConfirm fp: ${fingerPrint.??(_.value)} api: ${apiVersion.??(_.value)}"
)
val ip = HTTPRequest lastRemoteAddress ctx.req
env.security.ipTrust.isSuspicious(ip) foreach { susp =>
env.slack.api.signup(user, email, ip, fingerPrint.flatMap(_.hash).map(_.value), apiVersion, susp)
}
}
private def welcome(user: UserModel, email: EmailAddress)(implicit ctx: Context): Funit = {
garbageCollect(user, email)
env.security.automaticEmail.welcome(user, email)

View File

@ -142,7 +142,7 @@ final class Challenge(
10 minute,
name = "challenge create per IP",
key = "challenge_create_ip",
enforce = env.net.rateLimit
enforce = env.net.rateLimit.value
)
private val ChallengeUserRateLimit = new lila.memo.RateLimit[lila.user.User.ID](
@ -150,7 +150,7 @@ final class Challenge(
1 minute,
name = "challenge create per user",
key = "challenge_create_user",
enforce = env.net.rateLimit
enforce = env.net.rateLimit.value
)
def toFriend(id: String) = AuthBody { implicit ctx => _ =>

View File

@ -29,7 +29,7 @@ final class Setup(
1 minute,
name = "setup post",
key = "setup_post",
enforce = env.net.rateLimit
enforce = env.net.rateLimit.value
)
def aiForm = Open { implicit ctx =>

View File

@ -29,6 +29,7 @@ object config {
case class NetDomain(value: String) extends AnyVal with StringValue
case class AssetDomain(value: String) extends AnyVal with StringValue
case class RateLimit(value: Boolean) extends AnyVal
case class NetConfig(
domain: NetDomain,
@ -37,7 +38,7 @@ object config {
@ConfigName("asset.domain") assetDomain: AssetDomain,
@ConfigName("socket.domains") socketDomains: List[String],
crawlable: Boolean,
@ConfigName("ratelimit") rateLimit: Boolean,
@ConfigName("ratelimit") rateLimit: RateLimit,
email: EmailAddress,
ip: IpAddress
)
@ -52,6 +53,7 @@ object config {
implicit val netDomainLoader = strLoader(NetDomain.apply)
implicit val assetDomainLoader = strLoader(AssetDomain.apply)
implicit val ipLoader = strLoader(IpAddress.apply)
implicit val rateLimitLoader = boolLoader(RateLimit.apply)
implicit val netLoader = AutoConfig.loader[NetConfig]
implicit val strListLoader: ConfigLoader[List[String]] = ConfigLoader { c => k =>

View File

@ -4,7 +4,7 @@ import akka.stream.scaladsl._
import play.api.libs.json._
import lila.common.{ Bus, HTTPRequest }
import lila.security.Signup
import lila.security.UserSignup
final class ModStream {
@ -12,9 +12,9 @@ final class ModStream {
private val blueprint =
Source
.queue[Signup](32, akka.stream.OverflowStrategy.dropHead)
.queue[UserSignup](32, akka.stream.OverflowStrategy.dropHead)
.map {
case Signup(user, email, req, fp, suspIp) =>
case UserSignup(user, email, req, fp, suspIp) =>
Json.obj(
"t" -> "signup",
"username" -> user.username,
@ -31,7 +31,7 @@ final class ModStream {
def apply(): Source[String, _] = blueprint mapMaterializedValue { queue =>
val sub = Bus.subscribeFun(classifier) {
case signup: Signup => queue offer signup
case signup: UserSignup => queue offer signup
}
queue.watchCompletion dforeach { _ =>

View File

@ -1,7 +1,6 @@
package lila.security
import scalatags.Text.all._
import play.api.i18n.Lang
import lila.common.config._
import lila.common.EmailAddress
import lila.i18n.I18nKeys.{ emails => trans }

View File

@ -107,6 +107,8 @@ final class Env(
lazy val automaticEmail = wire[AutomaticEmail]
lazy val signup = wire[Signup]
private lazy val dnsApi: DnsApi = wire[DnsApi]
private lazy val checkMail: CheckMail = wire[CheckMail]

View File

@ -58,7 +58,7 @@ final class GarbageCollector(
val printOpt = spy.prints.headOption
logger.debug(s"apply ${data.user.username} print=${printOpt}")
Bus.publish(
lila.security.Signup(user, email, req, printOpt.map(_.value), ipSusp),
lila.security.UserSignup(user, email, req, printOpt.map(_.value), ipSusp),
"userSignup"
)
printOpt.map(_.value) filter printBan.blocks match {

View File

@ -1,6 +1,5 @@
package lila.security
import play.api.i18n.Lang
import scala.concurrent.duration._
import scalatags.Text.all._

View File

@ -91,7 +91,7 @@ final class SecurityApi(
def saveSignup(userId: User.ID, apiVersion: Option[ApiVersion], fp: Option[FingerPrint])(
implicit req: RequestHeader
): Funit = {
val sessionId = Random secureString 22
val sessionId = Random nextString 22
store.save(s"SIG-$sessionId", userId, req, apiVersion, up = false, fp = fp)
}
@ -139,10 +139,6 @@ final class SecurityApi(
def reqSessionId(req: RequestHeader): Option[String] =
req.session.get(sessionIdKey) orElse req.headers.get(sessionIdKey)
def recentByIpExists(ip: IpAddress): Fu[Boolean] = store recentByIpExists ip
def recentByPrintExists(fp: FingerPrint): Fu[Boolean] = store recentByPrintExists fp
def recentUserIdsByFingerHash(fh: FingerHash) = recentUserIdsByField("fp")(fh.value)
def recentUserIdsByIp(ip: IpAddress) = recentUserIdsByField("ip")(ip.value)

View File

@ -0,0 +1,184 @@
package lila.security
import play.api.data._
import play.api.i18n.Lang
import play.api.mvc.{ Request, RequestHeader }
import scala.util.chaining._
import lila.common.config.NetConfig
import lila.common.{ ApiVersion, EmailAddress, HTTPRequest }
import lila.user.{ PasswordHasher, User }
final class Signup(
store: Store,
api: SecurityApi,
ipTrust: IpTrust,
forms: DataForm,
emailAddressValidator: EmailAddressValidator,
emailConfirm: EmailConfirm,
recaptcha: Recaptcha,
authenticator: lila.user.Authenticator,
userRepo: lila.user.UserRepo,
slack: lila.slack.SlackApi,
netConfig: NetConfig
)(implicit ec: scala.concurrent.ExecutionContext) {
sealed abstract private 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 req: RequestHeader): Fu[MustConfirmEmail] = {
val ip = HTTPRequest lastRemoteAddress req
store.recentByIpExists(ip) flatMap { ipExists =>
if (ipExists) fuccess(YesBecauseIpExists)
else if (HTTPRequest weirdUA req) fuccess(YesBecauseUA)
else
print.fold[Fu[MustConfirmEmail]](fuccess(YesBecausePrintMissing)) { fp =>
store.recentByPrintExists(fp) flatMap { printFound =>
if (printFound) fuccess(YesBecausePrintExists)
else
ipTrust.isSuspicious(ip).map {
case true => YesBecauseIpSusp
case _ => Nope
}
}
}
}
}
}
def website(blind: Boolean)(implicit req: Request[_], lang: Lang): Fu[Signup.Result] =
forms.signup.website.bindFromRequest.fold[Fu[Signup.Result]](
err => fuccess(Signup.Bad(err tap signupErrLog)),
data =>
recaptcha.verify(~data.recaptchaResponse, req).flatMap {
case false =>
authLog(data.username, data.email, "Signup recaptcha fail")
fuccess(Signup.Bad(forms.signup.website fill data))
case true =>
HasherRateLimit(data.username, req) {
_ =>
MustConfirmEmail(data.fingerPrint) flatMap {
mustConfirm =>
lila.mon.user.register.count(none)
lila.mon.user.register.mustConfirmEmail(mustConfirm.toString).increment()
val email = emailAddressValidator
.validate(data.realEmail) err s"Invalid email ${data.email}"
val passwordHash = authenticator passEnc User.ClearPassword(data.password)
userRepo
.create(
data.username,
passwordHash,
email.acceptable,
blind,
none,
mustConfirmEmail = mustConfirm.value
)
.orFail(s"No user could be created for ${data.username}")
.addEffect { logSignup(req, _, email.acceptable, data.fingerPrint, none, mustConfirm) }
.flatMap {
confirmOrAllSet(email, mustConfirm, data.fingerPrint, none)
}
}
}
}
)
private def confirmOrAllSet(
email: EmailAddressValidator.Acceptable,
mustConfirm: MustConfirmEmail,
fingerPrint: Option[FingerPrint],
apiVersion: Option[ApiVersion]
)(user: User)(implicit req: RequestHeader, lang: Lang): Fu[Signup.Result] =
if (mustConfirm.value) {
emailConfirm.send(user, email.acceptable) >> {
if (emailConfirm.effective)
api.saveSignup(user.id, apiVersion, fingerPrint) inject
Signup.ConfirmEmail(user, email.acceptable)
else fuccess(Signup.AllSet(user, email.acceptable))
}
} else fuccess(Signup.AllSet(user, email.acceptable))
def mobile(
apiVersion: ApiVersion
)(implicit req: Request[_], lang: Lang): Fu[Signup.Result] =
forms.signup.mobile.bindFromRequest.fold[Fu[Signup.Result]](
err => fuccess(Signup.Bad(err tap signupErrLog)),
data =>
HasherRateLimit(data.username, req) { _ =>
val email = emailAddressValidator
.validate(data.realEmail) err s"Invalid email ${data.email}"
val mustConfirm = MustConfirmEmail.YesBecauseMobile
lila.mon.user.register.count(apiVersion.some)
lila.mon.user.register.mustConfirmEmail(mustConfirm.toString).increment()
val passwordHash = authenticator passEnc User.ClearPassword(data.password)
userRepo
.create(
data.username,
passwordHash,
email.acceptable,
false,
apiVersion.some,
mustConfirmEmail = mustConfirm.value
)
.orFail(s"No user could be created for ${data.username}")
.addEffect { logSignup(req, _, email.acceptable, none, apiVersion.some, mustConfirm) }
.flatMap {
confirmOrAllSet(email, mustConfirm, none, apiVersion.some)
}
}
)
implicit private val ResultZero = ornicar.scalalib.Zero.instance[Signup.Result](Signup.RateLimited)
private def HasherRateLimit =
PasswordHasher.rateLimit[Signup.Result](enforce = netConfig.rateLimit) _
private def logSignup(
req: RequestHeader,
user: User,
email: EmailAddress,
fingerPrint: Option[FingerPrint],
apiVersion: Option[ApiVersion],
mustConfirm: MustConfirmEmail
) = {
authLog(
user.username,
email.value,
s"fp: ${fingerPrint} mustConfirm: $mustConfirm fp: ${fingerPrint.??(_.value)} api: ${apiVersion.??(_.value)}"
)
val ip = HTTPRequest lastRemoteAddress req
ipTrust.isSuspicious(ip) foreach { susp =>
slack.signup(user, email, ip, fingerPrint.flatMap(_.hash).map(_.value), apiVersion, susp)
}
}
private def signupErrLog(err: Form[_]) =
for {
username <- err("username").value
email <- err("email").value
} {
if (err.errors.exists(_.messages.contains("error.email_acceptable")) &&
err("email").value.exists(EmailAddress.matches))
authLog(username, email, s"Signup with unacceptable email")
}
private def authLog(user: String, email: String, msg: String) =
lila.log("auth").info(s"$user $email $msg")
}
object Signup {
sealed trait Result
case class Bad(err: Form[_]) extends Result
case object RateLimited extends Result
case class ConfirmEmail(user: User, email: EmailAddress) extends Result
case class AllSet(user: User, email: EmailAddress) extends Result
}

View File

@ -31,7 +31,7 @@ case class RecaptchaPublicConfig(key: String, enabled: Boolean)
case class LameNameCheck(value: Boolean) extends AnyVal
case class Signup(
case class UserSignup(
user: User,
email: EmailAddress,
req: RequestHeader,

View File

@ -110,9 +110,9 @@ object PasswordHasher {
)
def rateLimit[A: Zero](
enforce: Boolean
enforce: lila.common.config.RateLimit
)(username: String, req: RequestHeader)(run: RateLimit.Charge => Fu[A]): Fu[A] =
if (enforce) {
if (enforce.value) {
val cost = 1
val ip = HTTPRequest lastRemoteAddress req
rateLimitPerUser(User normalize username, cost = cost) {