implement magic link login - closes #5635

magic-link
Thibault Duplessis 2019-11-10 13:19:34 -06:00
parent 044f5ad628
commit 283c3bb648
9 changed files with 174 additions and 7 deletions

View File

@ -440,6 +440,55 @@ object Auth extends LilaController {
}
}
def magicLink = Open { implicit ctx =>
forms.passwordResetWithCaptcha map {
case (form, captcha) => Ok(html.auth.bits.magicLink(form, captcha))
}
}
def magicLinkApply = OpenBody { implicit ctx =>
implicit val req = ctx.body
forms.magicLink.bindFromRequest.fold(
err => forms.anyCaptcha map { captcha =>
BadRequest(html.auth.bits.magicLink(err, captcha, false.some))
},
data => {
UserRepo.enabledWithEmail(data.realEmail.normalize) flatMap {
case Some((user, storedEmail)) => {
lila.mon.user.auth.magicLinkRequest("success")()
Env.security.magicLink.send(user, storedEmail) inject Redirect(routes.Auth.magicLinkSent(storedEmail.conceal))
}
case _ => {
lila.mon.user.auth.magicLinkRequest("no_email")()
forms.magicLinkWithCaptcha map {
case (form, captcha) => BadRequest(html.auth.bits.magicLink(form, captcha, false.some))
}
}
}
}
)
}
def magicLinkSent(email: String) = Open { implicit ctx =>
fuccess {
Ok(html.auth.bits.magicLinkSent(email))
}
}
def magicLinkLogin(token: String) = Open { implicit ctx =>
Env.security.magicLink confirm token flatMap {
case None => {
lila.mon.user.auth.magicLinkConfirm("token_fail")()
notFound
}
case Some(user) => {
authLog(user.username, "-", "Magic link")
authenticateUser(user) >>-
lila.mon.user.auth.magicLinkConfirm("success")()
}
}
}
def makeLoginToken = Auth { implicit ctx => me =>
JsonOk {
env.loginToken generate me map { token =>

View File

@ -82,6 +82,39 @@ object bits {
)
}
def magicLink(form: Form[_], captcha: lila.common.Captcha, ok: Option[Boolean] = None)(implicit ctx: Context) =
views.html.base.layout(
title = "Log in by email",
moreCss = cssTag("auth"),
moreJs = captchaTag
) {
main(cls := "auth auth-signup box box-pad")(
h1(
ok.map { r =>
span(cls := (if (r) "is-green" else "is-red"), dataIcon := (if (r) "E" else "L"))
},
"Log in by email"
),
p("We will send you an email containing a link to log you in."),
postForm(cls := "form3", action := routes.Auth.magicLinkApply)(
form3.group(form("email"), trans.email())(form3.input(_, typ = "email")(autofocus)),
views.html.base.captcha(form, captcha),
form3.action(form3.submit(trans.emailMeALink()))
)
)
}
def magicLinkSent(email: String)(implicit ctx: Context) =
views.html.base.layout(
title = "Log in by email"
) {
main(cls := "page-small box box-pad")(
h1(cls := "is-green text", dataIcon := "E")(trans.checkYourEmail()),
p(s"We've sent you an email with a log in link."),
p(trans.ifYouDoNotSeeTheEmailCheckOtherPlaces())
)
}
def checkYourEmailBanner(userEmail: lila.security.EmailConfirm.UserEmail) = frag(
styleTag("""
body { margin-top: 45px; }

View File

@ -39,7 +39,8 @@ object login {
),
div(cls := "alternative")(
a(href := routes.Auth.signup())(trans.signUp()),
a(href := routes.Auth.passwordReset())(trans.passwordReset())
a(href := routes.Auth.passwordReset())(trans.passwordReset()),
a(href := routes.Auth.magicLink)("Log in by email")
)
)
}

View File

@ -349,6 +349,10 @@ POST /password/reset/confirm/:token controllers.Auth.passwordResetConfirmAppl
POST /auth/set-fp/:fp/:ms controllers.Auth.setFingerPrint(fp: String, ms: Int)
POST /auth/token controllers.Auth.makeLoginToken
GET /auth/token/:token controllers.Auth.loginWithToken(token: String)
GET /auth/magic-link controllers.Auth.magicLink
POST /auth/magic-link/send controllers.Auth.magicLinkApply
GET /auth/magic-link/sent/:email controllers.Auth.magicLinkSent(email: String)
GET /auth/magic-link/login/:token controllers.Auth.magicLinkLogin(token: String)
# Mod
POST /mod/:username/engine/:v controllers.Mod.engine(username: String, v: Boolean)

View File

@ -303,6 +303,9 @@ object mon {
def passwordResetRequest(s: String) = inc(s"user.auth.password_reset_request.$s")
def passwordResetConfirm(s: String) = inc(s"user.auth.password_reset_confirm.$s")
def magicLinkRequest(s: String) = inc(s"user.auth.magic_link_request.$s")
def magicLinkConfirm(s: String) = inc(s"user.auth.magic_link_confirm.$s")
}
object oauth {
object usage {
@ -390,6 +393,7 @@ object mon {
object email {
object types {
val resetPassword = inc("email.reset_password")
val magicLink = inc("email.magic_link")
val fix = inc("email.fix")
val change = inc("email.change")
val confirmation = inc("email.confirmation")

View File

@ -113,6 +113,15 @@ final class DataForm(
_.samePasswords
))
val magicLink = Form(mapping(
"email" -> anyEmail, // allow unacceptable emails for BC
"gameId" -> text,
"move" -> text
)(MagicLink.apply)(_ => None)
.verifying(captchaFailMessage, validateCaptcha _))
def magicLinkWithCaptcha = withCaptcha(magicLink)
def changeEmail(u: User, old: Option[EmailAddress]) = authenticator loginCandidate u map { candidate =>
Form(mapping(
"passwd" -> passwordMapping(candidate),
@ -205,6 +214,14 @@ object DataForm {
def realEmail = EmailAddress(email)
}
case class MagicLink(
email: String,
gameId: String,
move: String
) {
def realEmail = EmailAddress(email)
}
case class ChangeEmail(passwd: String, email: String) {
def realEmail = EmailAddress(email)
}

View File

@ -133,6 +133,12 @@ final class Env(
tokenerSecret = PasswordResetSecret
)
lazy val magicLink = new MagicLink(
mailgun = mailgun,
baseUrl = NetBaseUrl,
tokenerSecret = PasswordResetSecret
)
lazy val emailChange = new EmailChange(
mailgun = mailgun,
baseUrl = NetBaseUrl,

View File

@ -1,7 +1,9 @@
package lila.security
import org.joda.time.DateTime
import scala.concurrent.duration._
import lila.common.Iso
import lila.user.{ User, UserRepo }
final class LoginToken(secret: String) {
@ -11,17 +13,22 @@ final class LoginToken(secret: String) {
def consume(token: String): Fu[Option[User]] =
tokener read token flatMap { _ ?? UserRepo.byId }
private object DateStr {
def toStr(date: DateTime) = date.getMillis.toString
def toDate(str: String) = parseLongOption(str) map { new DateTime(_) }
}
private val tokener = LoginToken.makeTokener(secret, 1 minute)
}
private val tokener = new StringToken[User.ID](
private object LoginToken {
def makeTokener(secret: String, lifetime: FiniteDuration) = new StringToken[User.ID](
secret = secret,
getCurrentValue = _ => fuccess(DateStr toStr DateTime.now),
currentValueHashSize = none,
valueChecker = StringToken.ValueChecker.Custom(v => fuccess {
DateStr.toDate(v) exists DateTime.now.minusMinutes(1).isBefore
DateStr.toDate(v) exists DateTime.now.minusSeconds(lifetime.toSeconds.toInt).isBefore
})
)
object DateStr {
def toStr(date: DateTime) = date.getMillis.toString
def toDate(str: String) = parseLongOption(str) map { new DateTime(_) }
}
}

View File

@ -0,0 +1,46 @@
package lila.security
import scalatags.Text.all._
import scala.concurrent.duration._
import lila.common.{ Lang, EmailAddress }
import lila.i18n.I18nKeys.{ emails => trans }
import lila.user.{ User, UserRepo }
final class MagicLink(
mailgun: Mailgun,
baseUrl: String,
tokenerSecret: String
) {
import Mailgun.html._
def send(user: User, email: EmailAddress)(implicit lang: Lang): Funit =
tokener make user.id flatMap { token =>
lila.mon.email.types.magicLink()
val url = s"$baseUrl/auth/magic-link/login/$token"
mailgun send Mailgun.Message(
to = email,
subject = "Log in to lichess.org",
text = s"""
${trans.passwordReset_clickOrIgnore.literalTxtTo(lang)}
$url
${trans.common_orPaste.literalTxtTo(lang)}
${Mailgun.txt.serviceNote}
""",
htmlBody = emailMessage(
p(trans.passwordReset_clickOrIgnore.literalTo(lang)),
potentialAction(metaName("Log in"), Mailgun.html.url(url)),
serviceNote
).some
)
}
def confirm(token: String): Fu[Option[User]] =
tokener read token flatMap { _ ?? UserRepo.byId }
private val tokener = LoginToken.makeTokener(tokenerSecret, 10 minutes)
}