typesafe Email

pull/2936/head
Thibault Duplessis 2017-04-14 12:17:19 +02:00
parent 3d4026346a
commit cb3a87829f
19 changed files with 99 additions and 71 deletions

View File

@ -4,8 +4,8 @@ import play.api.mvc._
import lila.api.Context
import lila.app._
import lila.common.LilaCookie
import lila.common.PimpedJson._
import lila.common.{ LilaCookie, Email }
import lila.user.{ User => UserModel, UserRepo }
import views._
@ -80,7 +80,7 @@ object Account extends LilaController {
private def emailForm(user: UserModel) = UserRepo email user.id map { email =>
Env.security.forms.changeEmail(user).fill(
lila.security.DataForm.ChangeEmail(~email, "")
lila.security.DataForm.ChangeEmail(email.??(_.value), "")
)
}
@ -98,7 +98,7 @@ object Account extends LilaController {
FormFuResult(Env.security.forms.changeEmail(me)) { err =>
fuccess(html.account.email(me, err))
} { data =>
val email = Env.security.emailAddress.validate(data.email) err s"Invalid email ${data.email}"
val email = Env.security.emailAddress.validate(Email(data.email)) err s"Invalid email ${data.email}"
for {
ok UserRepo.authenticateById(me.id, data.passwd).map(_.isDefined)
_ ok ?? UserRepo.email(me.id, email)

View File

@ -118,7 +118,7 @@ object Auth extends LilaController {
) flatMap { mustConfirmEmail =>
lila.mon.user.register.website()
lila.mon.user.register.mustConfirmEmail(mustConfirmEmail)()
val email = env.emailAddress.validate(data.email) err s"Invalid email ${data.email}"
val email = env.emailAddress.validate(data.realEmail) err s"Invalid email ${data.email}"
UserRepo.create(data.username, data.password, email.some, ctx.blindMode, none,
mustConfirmEmail = mustConfirmEmail)
.flatten(s"No user could be created for ${data.username}")
@ -139,7 +139,7 @@ object Auth extends LilaController {
val mustConfirmEmail = false
lila.mon.user.register.mobile()
lila.mon.user.register.mustConfirmEmail(mustConfirmEmail)()
val email = data.email flatMap env.emailAddress.validate
val email = data.realEmail flatMap env.emailAddress.validate
UserRepo.create(data.username, data.password, email, false, apiVersion.some,
mustConfirmEmail = mustConfirmEmail)
.flatten(s"No user could be created for ${data.username}") flatMap authenticateUser
@ -203,7 +203,7 @@ object Auth extends LilaController {
BadRequest(html.auth.passwordReset(err, captcha, false.some))
},
data => {
val email = env.emailAddress.validate(data.email) | data.email
val email = env.emailAddress.validate(data.realEmail) | data.realEmail
UserRepo enabledByEmail email flatMap {
case Some(user) =>
Env.security.passwordReset.send(user, email) inject Redirect(routes.Auth.passwordResetSent(data.email))

View File

@ -2,7 +2,7 @@ package controllers
import lila.api.Context
import lila.app._
import lila.common.IpAddress
import lila.common.{ IpAddress, Email }
import lila.user.{ UserRepo, User => UserModel }
import views._
@ -118,7 +118,7 @@ object Mod extends LilaController {
Env.security.forms.modEmail(user).bindFromRequest.fold(
err => BadRequest(err.toString).fuccess,
rawEmail => {
val email = Env.security.emailAddress.validate(rawEmail) err s"Invalid email ${rawEmail}"
val email = Env.security.emailAddress.validate(Email(rawEmail)) err s"Invalid email ${rawEmail}"
modApi.setEmail(me.id, user.id, email) inject redirect(user.username, mod = true)
}
)

View File

@ -5,6 +5,7 @@ import play.api.mvc._
import lila.api.Context
import lila.app._
import lila.plan.{ StripeCustomer, MonthlyCustomerInfo, OneTimeCustomerInfo }
import lila.common.Email
import lila.user.{ User => UserModel, UserRepo }
import views._
@ -44,7 +45,7 @@ object Plan extends LilaController {
renderIndex(email, patron = none)
}
private def renderIndex(email: Option[String], patron: Option[lila.plan.Patron])(implicit ctx: Context): Fu[Result] = for {
private def renderIndex(email: Option[Email], patron: Option[lila.plan.Patron])(implicit ctx: Context): Fu[Result] = for {
recentIds <- Env.plan.api.recentChargeUserIds
bestIds <- Env.plan.api.topPatronUserIds
_ <- Env.user.lightUserApi preloadMany { recentIds ::: bestIds }
@ -59,7 +60,7 @@ object Plan extends LilaController {
private def indexPatron(me: UserModel, patron: lila.plan.Patron, customer: StripeCustomer)(implicit ctx: Context) =
Env.plan.api.customerInfo(me, customer) flatMap {
case Some(info: MonthlyCustomerInfo) => Ok(html.plan.indexStripe(me, patron, info)).fuccess
case Some(info: OneTimeCustomerInfo) => renderIndex(info.customer.email, patron.some)
case Some(info: OneTimeCustomerInfo) => renderIndex(info.customer.email map Email.apply, patron.some)
case None => UserRepo email me.id flatMap { email =>
renderIndex(email, patron.some)
}

View File

@ -1,4 +1,4 @@
@(email: Option[String], stripePublicKey: String, patron: Option[lila.plan.Patron], recentIds: List[String], bestIds: List[String])(implicit ctx: Context)
@(email: Option[lila.common.Email], stripePublicKey: String, patron: Option[lila.plan.Patron], recentIds: List[String], bestIds: List[String])(implicit ctx: Context)
@title = @{"Become a Patron of lichess.org"}

View File

@ -1,4 +1,4 @@
@(u: User, email: Option[String], spy: lila.security.UserSpy, optionAggregateAssessment: Option[lila.evaluation.PlayerAggregateAssessment.WithGames], bans: Map[String, Int], history: List[lila.mod.Modlog], charges: List[lila.plan.Charge], reports: lila.report.Report.ByAndAbout, pref: lila.pref.Pref, notes: List[lila.user.Note])(implicit ctx: Context)
@(u: User, email: Option[lila.common.Email], spy: lila.security.UserSpy, optionAggregateAssessment: Option[lila.evaluation.PlayerAggregateAssessment.WithGames], bans: Map[String, Int], history: List[lila.mod.Modlog], charges: List[lila.plan.Charge], reports: lila.report.Report.ByAndAbout, pref: lila.pref.Pref, notes: List[lila.user.Note])(implicit ctx: Context)
@import lila.evaluation.Display
@import lila.pref.Pref

View File

@ -26,4 +26,6 @@ object Iso {
implicit val stringIsoIdentity: Iso[String, String] = isoIdentity[String]
implicit val ipAddressIso = string[IpAddress](IpAddress.apply, _.value)
implicit val emailIso = string[Email](Email.apply, _.value)
}

View File

@ -19,4 +19,19 @@ object IpAddress {
def isv4(a: IpAddress) = ipv4Regex matches a.value
def isv6(a: IpAddress) = ipv6Regex matches a.value
def from(str: String): Option[IpAddress] = {
ipv4Regex.matches(str) || ipv6Regex.matches(str)
} option IpAddress(str)
}
case class Email(value: String) extends AnyVal with StringValue
object Email {
private val regex =
"""^[a-zA-Z0-9\.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$""".r
def from(str: String): Option[Email] =
regex.matches(str) option Email(str)
}

View File

@ -6,7 +6,7 @@ import reactivemongo.bson._
import scalaz.NonEmptyList
import lila.common.Iso._
import lila.common.{ Iso, IpAddress }
import lila.common.{ Iso, IpAddress, Email }
trait Handlers {
@ -65,4 +65,6 @@ trait Handlers {
}(breakOut)
implicit val ipAddressHandler = isoHandler[IpAddress, String, BSONString](ipAddressIso)
implicit val emailHandler = isoHandler[Email, String, BSONString](emailIso)
}

View File

@ -1,6 +1,6 @@
package lila.mod
import lila.common.IpAddress
import lila.common.{ IpAddress, Email }
import lila.security.Permission
import lila.security.{ Firewall, UserSpy, Store => SecurityStore }
import lila.user.{ User, UserRepo, LightUserApi }
@ -105,7 +105,7 @@ final class ModApi(
lightUserApi.invalidate(user.id)
}
def setEmail(mod: String, username: String, email: String): Funit = withUser(username) { user =>
def setEmail(mod: String, username: String, email: Email): Funit = withUser(username) { user =>
UserRepo.email(user.id, email) >>
UserRepo.setEmailConfirmed(user.id) >>
logApi.setEmail(mod, user.id)

View File

@ -1,5 +1,6 @@
package lila.mod
import lila.common.{ Email, IpAddress }
import lila.user.{ User, UserRepo }
final class UserSearch(
@ -7,29 +8,18 @@ final class UserSearch(
emailAddress: lila.security.EmailAddress
) {
// http://stackoverflow.com/questions/106179/regular-expression-to-match-hostname-or-ip-address
private val ipv4Pattern = """^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$""".r.pattern
// ipv6 in standard form
private val ipv6Pattern = """^((0|[1-9a-f][0-9a-f]{0,3}):){7}(0|[1-9a-f][0-9a-f]{0,3})""".r.pattern
// from playframework
private val emailPattern =
"""^[a-zA-Z0-9\.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$""".r.pattern
def apply(query: String): Fu[List[User]] =
if (query.isEmpty) fuccess(Nil)
else if (emailPattern.matcher(query).matches) searchEmail(query)
else if (ipv4Pattern.matcher(query).matches) searchIp(query)
else if (ipv6Pattern.matcher(query).matches) searchIp(query)
else searchUsername(query)
else Email.from(query).map(searchEmail) orElse
IpAddress.from(query).map(searchIp) getOrElse
searchUsername(query)
private def searchIp(ip: String) =
private def searchIp(ip: IpAddress) =
securityApi recentUserIdsByIp ip map (_.reverse) flatMap UserRepo.usersFromSecondary
private def searchUsername(username: String) = UserRepo named username map (_.toList)
private def searchEmail(email: String) = emailAddress.validate(email) ?? { fixed =>
private def searchEmail(email: Email) = emailAddress.validate(email) ?? { fixed =>
UserRepo byEmail fixed map (_.toList)
}
}

View File

@ -8,7 +8,7 @@ import play.api.mvc.RequestHeader
import reactivemongo.api.ReadPreference
import reactivemongo.bson._
import lila.common.{ ApiVersion, IpAddress }
import lila.common.{ ApiVersion, IpAddress, Email }
import lila.db.BSON.BSONJodaDateTimeHandler
import lila.db.dsl._
import lila.user.{ User, UserRepo }
@ -38,7 +38,7 @@ final class Api(
.verifying("Invalid username or password", _.isDefined))
def loadLoginForm(str: String): Fu[Form[Option[User]]] = {
emailAddress.validate(str) match {
emailAddress.validate(Email(str)) match {
case Some(email) => UserRepo.checkPasswordByEmail(email)
case None if User.couldBeUsername(str) => UserRepo.checkPasswordById(User normalize str)
case _ => fuccess(none)
@ -114,7 +114,7 @@ final class Api(
def recentUserIdsByFingerprint = recentUserIdsByField("fp") _
def recentUserIdsByIp = recentUserIdsByField("ip") _
def recentUserIdsByIp(ip: IpAddress) = recentUserIdsByField("ip")(ip.value)
private def recentUserIdsByField(field: String)(value: String): Fu[List[String]] =
coll.distinct[String, List](

View File

@ -4,7 +4,7 @@ import play.api.data._
import play.api.data.Forms._
import play.api.data.validation.Constraints
import lila.common.LameName
import lila.common.{ LameName, Email }
import lila.user.{ User, UserRepo }
final class DataForm(
@ -103,19 +103,27 @@ object DataForm {
`g-recaptcha-response`: Option[String]
) {
def recaptchaResponse = `g-recaptcha-response`
def realEmail = Email(email)
}
case class MobileSignupData(
username: String,
password: String,
email: Option[String]
)
username: String,
password: String,
email: Option[String]
) {
def realEmail = email flatMap Email.from
}
case class PasswordReset(
email: String,
gameId: String,
move: String
)
email: String,
gameId: String,
move: String
) {
def realEmail = Email(email)
}
case class ChangeEmail(email: String, passwd: String)
case class ChangeEmail(email: String, passwd: String) {
def realEmail = Email(email)
}
}

View File

@ -1,6 +1,7 @@
package lila.security
import lila.user.User
import lila.common.Email
import play.api.data.validation._
@ -10,10 +11,10 @@ import play.api.data.validation._
final class EmailAddress(disposable: DisposableEmailDomain) {
// email was already regex-validated at this stage
def validate(email: String): Option[String] =
def validate(email: Email): Option[Email] =
// start by lower casing the entire address
email.toLowerCase
email.value.toLowerCase
// separate name from domain
.split('@') match {
@ -22,19 +23,19 @@ final class EmailAddress(disposable: DisposableEmailDomain) {
.replace(".", "") // remove all dots
.takeWhile('+'!=) // skip everything after the first +
.some.filter(_.nonEmpty) // make sure something remains
.map(radix => s"$radix@$domain") // okay
.map(radix => Email(s"$radix@$domain")) // okay
// disposable addresses
case Array(_, domain) if disposable(domain) => none
// other valid addresses
case Array(name, domain) if domain contains "." => s"$name@$domain".some
case Array(name, domain) if domain contains "." => Email(s"$name@$domain").some
// invalid addresses
case _ => none
}
def isValid(email: String) = validate(email).isDefined
def isValid(email: Email) = validate(email).isDefined
/**
* Returns true if an E-mail address is taken by another user.
@ -43,7 +44,7 @@ final class EmailAddress(disposable: DisposableEmailDomain) {
* If they already have it assigned, returns false.
* @return
*/
private def isTakenBySomeoneElse(email: String, forUser: Option[User]): Boolean = validate(email) ?? { e =>
private def isTakenBySomeoneElse(email: Email, forUser: Option[User]): Boolean = validate(email) ?? { e =>
(lila.user.UserRepo.idByEmail(e) awaitSeconds 2, forUser) match {
case (None, _) => false
case (Some(userId), Some(user)) => userId != user.id
@ -52,12 +53,12 @@ final class EmailAddress(disposable: DisposableEmailDomain) {
}
val acceptableConstraint = Constraint[String]("constraint.email_acceptable") { e =>
if (isValid(e)) Valid
if (isValid(Email(e))) Valid
else Invalid(ValidationError("error.email_acceptable"))
}
def uniqueConstraint(forUser: Option[User]) = Constraint[String]("constraint.email_unique") { e =>
if (isTakenBySomeoneElse(e, forUser))
if (isTakenBySomeoneElse(Email(e), forUser))
Invalid(ValidationError(s"Email address is already in use by another account"))
else Valid
}

View File

@ -7,13 +7,14 @@ import play.api.Play.current
import scala.concurrent.duration._
import lila.common.String.base64
import lila.common.Email
import lila.user.{ User, UserRepo }
trait EmailConfirm {
def effective: Boolean
def send(user: User, email: String, tryNb: Int = 1): Funit
def send(user: User, email: Email, tryNb: Int = 1): Funit
def confirm(token: String): Fu[Option[User]]
}
@ -22,7 +23,7 @@ object EmailConfirmSkip extends EmailConfirm {
def effective = false
def send(user: User, email: String, tryNb: Int = 1) = UserRepo setEmailConfirmed user.id
def send(user: User, email: Email, tryNb: Int = 1) = UserRepo setEmailConfirmed user.id
def confirm(token: String): Fu[Option[User]] = fuccess(none)
}
@ -41,12 +42,12 @@ final class EmailConfirmMailGun(
val maxTries = 3
def send(user: User, email: String, tryNb: Int = 1): Funit = tokener make user flatMap { token =>
def send(user: User, email: Email, tryNb: Int = 1): Funit = tokener make user flatMap { token =>
lila.mon.email.confirmation()
val url = s"$baseUrl/signup/confirm/$token"
WS.url(s"$apiUrl/messages").withAuth("api", apiKey, WSAuthScheme.BASIC).post(Map(
"from" -> Seq(sender),
"to" -> Seq(email),
"to" -> Seq(email.value),
"h:Reply-To" -> Seq(replyTo),
"o:tag" -> Seq("registration"),
"subject" -> Seq(s"Confirm your lichess.org account, ${user.username}"),
@ -107,7 +108,7 @@ This is a service email related to your use of lichess.org. If you did not regis
private def makeHash(msg: String) = Algo.hmac(secret).sha1(msg).hex take 14
private def getHashedEmail(userId: User.ID) = UserRepo email userId map { p =>
makeHash(~p) take 6
makeHash(p.??(_.value)) take 6
}
private def makePayload(userId: String, passwd: String) = s"$userId$separator$passwd"

View File

@ -5,6 +5,7 @@ import play.api.libs.ws.{ WS, WSAuthScheme }
import play.api.Play.current
import lila.common.String.base64
import lila.common.Email
import lila.user.{ User, UserRepo }
final class PasswordReset(
@ -16,12 +17,12 @@ final class PasswordReset(
secret: String
) {
def send(user: User, email: String): Funit = tokener make user flatMap { token =>
def send(user: User, email: Email): Funit = tokener make user flatMap { token =>
lila.mon.email.resetPassword()
val url = s"$baseUrl/password/reset/confirm/$token"
WS.url(s"$apiUrl/messages").withAuth("api", apiKey, WSAuthScheme.BASIC).post(Map(
"from" -> Seq(sender),
"to" -> Seq(email),
"to" -> Seq(email.value),
"h:Reply-To" -> Seq(replyTo),
"o:tag" -> Seq("password"),
"subject" -> Seq("Reset your lichess.org password"),

View File

@ -4,6 +4,7 @@ import akka.actor._
import com.typesafe.config.Config
import lila.common.PimpedConfig._
import lila.common.Email
final class Env(
config: Config,
@ -52,7 +53,7 @@ final class Env(
def cli = new lila.common.Cli {
def process = {
case "user" :: "email" :: userId :: email :: Nil =>
UserRepo.email(User normalize userId, email) inject "done"
UserRepo.email(User normalize userId, Email(email)) inject "done"
}
}

View File

@ -181,6 +181,7 @@ object User {
def glicko(perf: String) = s"$perfs.$perf.gl"
val email = "email"
val mustConfirmEmail = "mustConfirmEmail"
val prevEmail = "prevEmail"
val colorIt = "colorIt"
val plan = "plan"
}

View File

@ -7,6 +7,7 @@ import reactivemongo.api.commands.GetLastError
import reactivemongo.bson._
import lila.common.ApiVersion
import lila.common.Email
import lila.db.BSON.BSONJodaDateTimeHandler
import lila.db.dsl._
import lila.rating.{ Perf, PerfType }
@ -33,12 +34,12 @@ object UserRepo {
def byIdsSecondary(ids: Iterable[ID]): Fu[List[User]] = coll.byIds[User](ids, ReadPreference.secondaryPreferred)
def byEmail(email: String): Fu[Option[User]] = coll.uno[User]($doc(F.email -> email))
def byEmail(email: Email): Fu[Option[User]] = coll.uno[User]($doc(F.email -> email))
def idByEmail(email: String): Fu[Option[String]] =
def idByEmail(email: Email): Fu[Option[String]] =
coll.primitiveOne[String]($doc(F.email -> email), "_id")
def enabledByEmail(email: String): Fu[Option[User]] = byEmail(email) map (_ filter (_.enabled))
def enabledByEmail(email: Email): Fu[Option[User]] = byEmail(email) map (_ filter (_.enabled))
def pair(x: Option[ID], y: Option[ID]): Fu[(Option[User], Option[User])] =
coll.byIds[User](List(x, y).flatten) map { users =>
@ -226,7 +227,7 @@ object UserRepo {
def authenticateById(id: ID, password: String): Fu[Option[User]] =
checkPasswordById(id) map { _ flatMap { _(password) } }
def authenticateByEmail(email: String, password: String): Fu[Option[User]] =
def authenticateByEmail(email: Email, password: String): Fu[Option[User]] =
checkPasswordByEmail(email) map { _ flatMap { _(password) } }
private case class AuthData(password: String, salt: String, sha512: Option[Boolean]) {
@ -238,7 +239,7 @@ object UserRepo {
def checkPasswordById(id: ID): Fu[Option[User.LoginCandidate]] =
checkPassword($id(id))
def checkPasswordByEmail(email: String): Fu[Option[User.LoginCandidate]] =
def checkPasswordByEmail(email: Email): Fu[Option[User.LoginCandidate]] =
checkPassword($doc(F.email -> email))
private def checkPassword(select: Bdoc): Fu[Option[User.LoginCandidate]] =
@ -253,7 +254,7 @@ object UserRepo {
def create(
username: String,
password: String,
email: Option[String],
email: Option[Email],
blind: Boolean,
mobileApiVersion: Option[ApiVersion],
mustConfirmEmail: Boolean
@ -319,8 +320,10 @@ object UserRepo {
def disable(user: User) = coll.update(
$id(user.id),
$set(F.enabled -> false) ++
user.lameOrTroll.fold($empty, $unset("email"))
$set(F.enabled -> false) ++ {
if (user.lameOrTroll) $empty
else $doc("$rename" -> $doc(F.email -> F.prevEmail))
}
)
def passwd(id: ID, password: String): Funit =
@ -333,9 +336,11 @@ object UserRepo {
}
}
def email(id: ID, email: String): Funit = coll.updateField($id(id), F.email, email).void
def email(id: ID, email: Email): Funit = coll.updateField($id(id), F.email, email).void
def email(id: ID): Fu[Option[Email]] = coll.primitiveOne[Email]($id(id), F.email)
def email(id: ID): Fu[Option[String]] = coll.primitiveOne[String]($id(id), F.email)
def prevEmail(id: ID, prevEmail: Email): Funit = coll.updateField($id(id), F.prevEmail, prevEmail).void
def prevEmail(id: ID): Fu[Option[Email]] = coll.primitiveOne[Email]($id(id), F.prevEmail)
def hasEmail(id: ID): Fu[Boolean] = email(id).map(_.isDefined)
@ -416,7 +421,7 @@ object UserRepo {
private def newUser(
username: String,
password: String,
email: Option[String],
email: Option[Email],
blind: Boolean,
mobileApiVersion: Option[ApiVersion],
mustConfirmEmail: Boolean