refactor Authenticator

This commit is contained in:
Thibault Duplessis 2017-09-27 20:05:29 -05:00
parent dff3f69cc2
commit a545324172
12 changed files with 133 additions and 110 deletions

View file

@ -14,15 +14,14 @@ object Account extends LilaController {
private def env = Env.user
private def relationEnv = Env.relation
private def forms = lila.user.DataForm
def profile = Auth { implicit ctx => me =>
Ok(html.account.profile(me, forms profileOf me)).fuccess
Ok(html.account.profile(me, env.forms profileOf me)).fuccess
}
def profileApply = AuthBody { implicit ctx => me =>
implicit val req: Request[_] = ctx.body
FormFuResult(forms.profile) { err =>
FormFuResult(env.forms.profile) { err =>
fuccess(html.account.profile(me, err))
} { profile =>
UserRepo.setProfile(me.id, profile) inject Redirect(routes.User show me.username)
@ -79,7 +78,7 @@ object Account extends LilaController {
}
def passwd = Auth { implicit ctx => me =>
forms passwd me map { form =>
env.forms passwd me map { form =>
Ok(html.account.passwd(form))
}
}
@ -87,11 +86,11 @@ object Account extends LilaController {
def passwdApply = AuthBody { implicit ctx => me =>
HasherRateLimit(me.username) {
implicit val req = ctx.body
forms passwd me flatMap { form =>
env.forms passwd me flatMap { form =>
FormFuResult(form) { err =>
fuccess(html.account.passwd(err))
} { data =>
UserRepo.passwd(me.id, data.newPasswd1) inject
Env.user.authenticator.setPassword(me.id, data.newPasswd1) inject
Redirect(s"${routes.Account.passwd}?ok=1")
}
}
@ -144,7 +143,7 @@ object Account extends LilaController {
FormFuResult(Env.security.forms.closeAccount) { err =>
fuccess(html.account.close(me, err))
} { password =>
UserRepo.authenticateById(me.id, password).map(_.isDefined) flatMap {
Env.user.authenticator.authenticateById(me.id, password).map(_.isDefined) flatMap {
case false => BadRequest(html.account.close(me, Env.security.forms.closeAccount)).fuccess
case true => doClose(me) inject {
Redirect(routes.User show me.username) withCookies LilaCookie.newSession

View file

@ -167,7 +167,8 @@ object Auth extends LilaController {
lila.mon.user.register.mustConfirmEmail(mustConfirm.value)()
authLog(data.username, s"Signup website must confirm email: $mustConfirm")
val email = env.emailAddressValidator.validate(data.realEmail) err s"Invalid email ${data.email}"
UserRepo.create(data.username, data.password, email, ctx.blindMode, none,
val passwordHash = Env.user.authenticator passEnc data.password
UserRepo.create(data.username, passwordHash, email, ctx.blindMode, none,
mustConfirmEmail = mustConfirm.value)
.flatten(s"No user could be created for ${data.username}")
.map(_ -> email).flatMap {
@ -192,7 +193,8 @@ object Auth extends LilaController {
lila.mon.user.register.mustConfirmEmail(mustConfirm.value)()
authLog(data.username, s"Signup mobile must confirm email: $mustConfirm")
val email = env.emailAddressValidator.validate(data.realEmail) err s"Invalid email ${data.email}"
UserRepo.create(data.username, data.password, email, false, apiVersion.some,
val passwordHash = Env.user.authenticator passEnc data.password
UserRepo.create(data.username, passwordHash, email, false, apiVersion.some,
mustConfirmEmail = mustConfirm.value)
.flatten(s"No user could be created for ${data.username}")
.map(_ -> email).flatMap {
@ -315,7 +317,7 @@ object Auth extends LilaController {
FormFuResult(forms.passwdReset) { err =>
fuccess(html.auth.passwordResetConfirm(user, token, err, false.some))
} { data =>
UserRepo.passwd(user.id, data.newPasswd1) >>
Env.user.authenticator.setPassword(user.id, data.newPasswd1) >>
env.store.disconnect(user.id) >>
authenticateUser(user) >>-
lila.mon.user.auth.passwordResetConfirm("success")()

View file

@ -24,7 +24,7 @@ object Cli extends LilaController {
}
private def CliAuth(password: String)(op: => Fu[Result]): Fu[Result] =
lila.user.UserRepo.authenticateById(Env.api.CliUsername, password).map(_.isDefined) flatMap {
Env.user.authenticator.authenticateById(Env.api.CliUsername, password).map(_.isDefined) flatMap {
_.fold(op, fuccess(Unauthorized))
}
}

View file

@ -6,9 +6,6 @@ private[security] final class Cli extends lila.common.Cli {
def process = {
case "security" :: "passwd" :: uid :: pwd :: Nil =>
perform(uid, user => UserRepo.passwd(user.id, pwd))
case "security" :: "roles" :: uid :: Nil =>
UserRepo named uid map {
_.fold("User %s not found" format uid)(_.roles mkString " ")

View file

@ -9,6 +9,7 @@ import lila.user.{ User, UserRepo }
final class DataForm(
val captcher: akka.actor.ActorSelection,
authenticator: lila.user.Authenticator,
emailValidator: EmailAddressValidator
) extends lila.hub.CaptchedForm {
@ -81,7 +82,7 @@ final class DataForm(
_.samePasswords
))
def changeEmail(u: User, old: Option[EmailAddress]) = UserRepo loginCandidate u map { candidate =>
def changeEmail(u: User, old: Option[EmailAddress]) = authenticator loginCandidate u map { candidate =>
Form(mapping(
"passwd" -> nonEmptyText.verifying("incorrectPassword", candidate.check),
"email" -> acceptableUniqueEmail(candidate.user.some).verifying(emailValidator differentConstraint old)

View file

@ -8,6 +8,7 @@ import lila.common.PimpedConfig._
final class Env(
config: Config,
captcher: akka.actor.ActorSelection,
authenticator: lila.user.Authenticator,
system: akka.actor.ActorSystem,
scheduler: lila.common.Scheduler,
asyncCache: lila.memo.AsyncCache.Builder,
@ -66,6 +67,7 @@ final class Env(
lazy val forms = new DataForm(
captcher = captcher,
authenticator = authenticator,
emailValidator = emailAddressValidator
)
@ -129,7 +131,7 @@ final class Env(
scheduler.once(30 seconds)(tor.refresh(_ => funit))
scheduler.effect(TorRefreshDelay, "Refresh Tor exit nodes")(tor.refresh(firewall.unblockIps))
lazy val api = new SecurityApi(storeColl, firewall, geoIP, emailAddressValidator)
lazy val api = new SecurityApi(storeColl, firewall, geoIP, authenticator, emailAddressValidator)
lazy val csrfRequestHandler = new CSRFRequestHandler(NetDomain)
@ -144,6 +146,7 @@ object Env {
lazy val current = "security" boot new Env(
config = lila.common.PlayApp loadConfig "security",
db = lila.db.Env.current,
authenticator = lila.user.Env.current.authenticator,
system = lila.common.PlayApp.system,
scheduler = lila.common.PlayApp.scheduler,
asyncCache = lila.memo.Env.current.asyncCache,

View file

@ -17,6 +17,7 @@ final class SecurityApi(
coll: Coll,
firewall: Firewall,
geoIP: GeoIP,
authenticator: lila.user.Authenticator,
emailValidator: EmailAddressValidator
) {
@ -39,8 +40,8 @@ final class SecurityApi(
def loadLoginForm(str: String): Fu[Form[Option[User]]] = {
emailValidator.validate(EmailAddress(str)) match {
case Some(email) => UserRepo.loginCandidateByEmail(email)
case None if User.couldBeUsername(str) => UserRepo.loginCandidateById(User normalize str)
case Some(email) => authenticator.loginCandidateByEmail(email)
case None if User.couldBeUsername(str) => authenticator.loginCandidateById(User normalize str)
case _ => fuccess(none)
}
} map loadedLoginForm _

View file

@ -0,0 +1,92 @@
package lila.user
import com.roundeights.hasher.Implicits._
import reactivemongo.bson.Macros
import lila.common.EmailAddress
import lila.db.dsl._
import lila.user.User.{ BSONFields => F }
final class Authenticator(
passHasher: PasswordHasher,
userRepo: UserRepo.type,
upgradeShaPasswords: Boolean,
onShaLogin: () => Unit
) {
import Authenticator._
def passEnc(pass: String): Array[Byte] = passHasher.hash(pass)
def compare(auth: AuthData, p: String): Boolean = {
val newP = auth.salt.fold(p) { s =>
val salted = s"$p{$s}" // BC
(~auth.sha512).fold(salted.sha512, salted.sha1).hex
}
auth.bpass match {
// Deprecated fallback. Log & fail after DB migration.
case None => auth.password ?? { p => onShaLogin(); p == newP }
case Some(bHash) => passHasher.check(bHash, newP)
}
}
def authenticateById(id: User.ID, password: String): Fu[Option[User]] =
loginCandidateById(id) map { _ flatMap { _(password) } }
def authenticateByEmail(email: EmailAddress, password: String): Fu[Option[User]] =
loginCandidateByEmail(email) map { _ flatMap { _(password) } }
// This creates a bcrypt hash using the existing sha as input,
// allowing us to migrate all users in bulk.
def upgradePassword(a: AuthData) = (a.bpass, a.password) match {
case (None, Some(pass)) => Some(userRepo.coll.update(
$id(a._id),
$set(F.bpass -> passEnc(pass)) ++ $unset(F.password)
).void >>- lila.mon.user.auth.shaBcUpgrade())
case _ => None
}
def loginCandidate(u: User): Fu[User.LoginCandidate] =
loginCandidateById(u.id) map { _ | User.LoginCandidate(u, _ => false) }
def loginCandidateById(id: User.ID): Fu[Option[User.LoginCandidate]] =
loginCandidate($id(id))
def loginCandidateByEmail(email: EmailAddress): Fu[Option[User.LoginCandidate]] =
loginCandidate($doc(F.email -> email))
def setPassword(id: User.ID, pass: String): Funit =
userRepo.coll.update(
$id(id),
$set(F.bpass -> passEnc(pass)) ++ $unset(F.salt, F.password, F.sha512)
).void
private def authWithBenefits(auth: AuthData)(p: String): Boolean = {
val res = compare(auth, p)
if (res && auth.salt.isDefined && upgradeShaPasswords)
setPassword(id = auth._id, pass = p) >>- lila.mon.user.auth.bcFullMigrate()
res
}
private def loginCandidate(select: Bdoc): Fu[Option[User.LoginCandidate]] =
userRepo.coll.uno[AuthData](select)(AuthDataBSONHandler) zip userRepo.coll.uno[User](select) map {
case (Some(authData), Some(user)) if user.enabled =>
User.LoginCandidate(user, authWithBenefits(authData)).some
case _ => none
}
}
object Authenticator {
case class AuthData(
_id: User.ID,
bpass: Option[Array[Byte]] = None,
password: Option[String] = None,
salt: Option[String] = None,
sha512: Option[Boolean] = None
) {
def hashToken: String = bpass.fold(~password) { _.sha512.hex }
}
implicit val AuthDataBSONHandler = Macros.handler[AuthData]
}

View file

@ -3,7 +3,7 @@ package lila.user
import play.api.data._
import play.api.data.Forms._
object DataForm {
final class DataForm(authenticator: Authenticator) {
val note = Form(mapping(
"text" -> nonEmptyText(minLength = 3, maxLength = 2000),
@ -36,13 +36,16 @@ object DataForm {
def samePasswords = newPasswd1 == newPasswd2
}
def passwd(u: User) = UserRepo loginCandidate u map { candidate =>
def passwd(u: User) = authenticator loginCandidate u map { candidate =>
Form(mapping(
"oldPasswd" -> nonEmptyText.verifying("incorrectPassword", candidate.check),
"newPasswd1" -> nonEmptyText(minLength = 2),
"newPasswd2" -> nonEmptyText(minLength = 2)
)(Passwd.apply)(Passwd.unapply).verifying("the new passwords don't match", _.samePasswords))
}
}
object DataForm {
val title = Form(single("title" -> optional(nonEmptyText)))
}

View file

@ -44,8 +44,6 @@ final class Env(
lazy val jsonView = new JsonView(isOnline)
val forms = DataForm
def lightUser(id: User.ID): Fu[Option[lila.common.LightUser]] = lightUserApi async id
def lightUserSync(id: User.ID): Option[lila.common.LightUser] = lightUserApi sync id
@ -90,16 +88,18 @@ final class Env(
rankingApi = rankingApi
)
lazy val passwordAuth = new Authenticator(
new PasswordHasher(
lazy val authenticator = new Authenticator(
passHasher = new PasswordHasher(
secret = PasswordBPassSecret,
logRounds = 10,
hashTimer = lila.mon.measure(_.user.auth.hashTime)
),
userRepo = UserRepo,
upgradeShaPasswords = PasswordUpgradeSha,
onShaLogin = lila.mon.user.auth.shaLogin
)
lazy val upgradeShaPasswords = PasswordUpgradeSha
lazy val forms = new DataForm(authenticator)
}
object Env {

View file

@ -258,34 +258,3 @@ object User {
)
}
}
final class Authenticator(passHasher: PasswordHasher, onShaLogin: () => Unit) {
import com.roundeights.hasher.Implicits._
def passEnc(pass: String): Array[Byte] = passHasher.hash(pass)
case class AuthData(
_id: String,
bpass: Option[Array[Byte]] = None,
password: Option[String] = None,
salt: Option[String] = None,
sha512: Option[Boolean] = None
) {
def compare(p: String): Boolean = {
val newP = salt.fold(p) { s =>
val salted = s"$p{$s}" // BC
(~sha512).fold(salted.sha512, salted.sha1).hex
}
bpass match {
// Deprecated fallback. Log & fail after DB migration.
case None => password ?? { p => onShaLogin(); p == newP }
case Some(bHash) => passHasher.check(bHash, newP)
}
}
def hashToken = bpass.fold(~password) { _.sha512.hex }
}
implicit val AuthDataBSONHandler = Macros.handler[AuthData]
}

View file

@ -19,7 +19,7 @@ object UserRepo {
import User.{ BSONFields => F }
// dirty
private val coll = Env.current.userColl
private[user] val coll = Env.current.userColl
import reactivemongo.api.collections.bson.BSONBatchCommands.AggregationFramework.{ Match, Group, SumField }
val normalize = User normalize _
@ -224,55 +224,9 @@ object UserRepo {
def incToints(id: ID, nb: Int) = coll.update($id(id), $inc("toints" -> nb))
def removeAllToints = coll.update($empty, $unset("toints"), multi = true)
def authenticateById(id: ID, password: String): Fu[Option[User]] =
loginCandidateById(id) map { _ flatMap { _(password) } }
def authenticateByEmail(email: EmailAddress, password: String): Fu[Option[User]] =
loginCandidateByEmail(email) map { _ flatMap { _(password) } }
import Env.current.passwordAuth._
// This creates a bcrypt hash using the existing sha as input,
// allowing us to migrate all users in bulk.
def upgradePassword(a: AuthData) = (a.bpass, a.password) match {
case (None, Some(pass)) => Some(coll.update(
$id(a._id),
$set(F.bpass -> passEnc(pass)) ++ $unset(F.password)
).void >>- lila.mon.user.auth.shaBcUpgrade())
case _ => None
}
def loginCandidateById(id: ID): Fu[Option[User.LoginCandidate]] =
loginCandidate($id(id))
def loginCandidateByEmail(email: EmailAddress): Fu[Option[User.LoginCandidate]] =
loginCandidate($doc(F.email -> email))
def loginCandidate(u: User): Fu[User.LoginCandidate] =
loginCandidateById(u.id) map { _ | User.LoginCandidate(u, _ => false) }
def authWithBenefits(auth: AuthData)(p: String) = {
val res = auth compare p
if (res && auth.salt.isDefined && Env.current.upgradeShaPasswords)
passwd(id = auth._id, pass = p) >>- lila.mon.user.auth.bcFullMigrate()
res
}
private def loginCandidate(select: Bdoc): Fu[Option[User.LoginCandidate]] =
coll.uno[AuthData](select) zip coll.uno[User](select) map {
case (Some(authData), Some(user)) if user.enabled =>
User.LoginCandidate(user, authWithBenefits(authData)).some
case _ => none
}
def getPasswordHash(id: ID): Fu[Option[String]] = coll.byId[AuthData](id) map {
_.map { _.hashToken }
}
def create(
username: String,
password: String,
passwordHash: Array[Byte],
email: EmailAddress,
blind: Boolean,
mobileApiVersion: Option[ApiVersion],
@ -280,7 +234,7 @@ object UserRepo {
): Fu[Option[User]] =
!nameExists(username) flatMap {
_ ?? {
val doc = newUser(username, password, email, blind, mobileApiVersion, mustConfirmEmail) ++
val doc = newUser(username, passwordHash, email, blind, mobileApiVersion, mustConfirmEmail) ++
("len" -> BSONInteger(username.size))
coll.insert(doc) >> named(normalize(username))
}
@ -347,9 +301,11 @@ object UserRepo {
}
)
def passwd(id: ID, pass: String): Funit =
coll.update($id(id), $set(F.bpass -> passEnc(pass))
++ $unset(F.salt, F.password, F.sha512)).void
import Authenticator.AuthDataBSONHandler
def getPasswordHash(id: User.ID): Fu[Option[String]] =
coll.byId[Authenticator.AuthData](id) map {
_.map { _.hashToken }
}
def email(id: ID, email: EmailAddress): Funit =
coll.update($id(id), $set(F.email -> email) ++ $unset(F.prevEmail)).void
@ -444,7 +400,7 @@ object UserRepo {
private def newUser(
username: String,
password: String,
passwordHash: Array[Byte],
email: EmailAddress,
blind: Boolean,
mobileApiVersion: Option[ApiVersion],
@ -460,7 +416,7 @@ object UserRepo {
F.username -> username,
F.email -> email,
F.mustConfirmEmail -> mustConfirmEmail.option(DateTime.now),
F.bpass -> passEnc(password),
F.bpass -> passwordHash,
F.perfs -> $empty,
F.count -> Count.default,
F.enabled -> true,