implement magic link login - closes #5635
parent
044f5ad628
commit
283c3bb648
|
@ -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 =>
|
||||
|
|
|
@ -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; }
|
||||
|
|
|
@ -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")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(_) }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
Loading…
Reference in New Issue