Skip to content

Commit

Permalink
Implement email verification (#7161)
Browse files Browse the repository at this point in the history
  • Loading branch information
frcroth authored Jul 25, 2023
1 parent b250649 commit 4d9fd8c
Show file tree
Hide file tree
Showing 34 changed files with 522 additions and 29 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released
[Commits](https://github.com/scalableminds/webknossos/compare/23.08.0...HEAD)

### Added
- Added configuration to require users' emails to be verified, added flow to verify emails via link. [#7161](https://github.com/scalableminds/webknossos/pull/7161)

### Changed

Expand Down
7 changes: 7 additions & 0 deletions MIGRATIONS.unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,11 @@ User-facing changes are documented in the [changelog](CHANGELOG.released.md).
## Unreleased
[Commits](https://github.com/scalableminds/webknossos/compare/23.08.0...HEAD)

- Postgres Evolution 105 (see below) adds email verification and sets the emails of all existing users as verified.
To set all email addresses as unverified, execute this query:
```sql
UPDATE webknossos.multiUsers SET isEmailVerified = false;
```

### Postgres Evolutions:
- [105-verify-email.sql](conf/evolutions/105-verify-email.sql)
38 changes: 26 additions & 12 deletions app/controllers/AuthenticationController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ class AuthenticationController @Inject()(
voxelyticsDAO: VoxelyticsDAO,
wkSilhouetteEnvironment: WkSilhouetteEnvironment,
openIdConnectClient: OpenIdConnectClient,
emailVerificationService: EmailVerificationService,
sil: Silhouette[WkEnv])(implicit ec: ExecutionContext, bodyParsers: PlayBodyParsers)
extends Controller
with AuthForms
Expand Down Expand Up @@ -116,7 +117,8 @@ class AuthenticationController @Inject()(
autoActivate: Boolean,
password: Option[String],
inviteBox: Box[Invite] = Empty,
registerBrainDB: Boolean = false)(implicit mp: MessagesProvider): Fox[User] = {
registerBrainDB: Boolean = false,
isEmailVerified: Boolean = false)(implicit mp: MessagesProvider): Fox[User] = {
val passwordInfo: PasswordInfo = userService.getPasswordInfo(password)
for {
user <- userService.insert(organization._id,
Expand All @@ -126,7 +128,8 @@ class AuthenticationController @Inject()(
autoActivate,
passwordInfo,
isAdmin = false,
isOrganizationOwner = false) ?~> "user.creation.failed"
isOrganizationOwner = false,
isEmailVerified = isEmailVerified) ?~> "user.creation.failed"
multiUser <- multiUserDAO.findOne(user._multiUser)(GlobalAccessContext)
_ = analyticsService.track(SignupEvent(user, inviteBox.isDefined))
_ <- Fox.runIf(inviteBox.isDefined)(Fox.runOptional(inviteBox.toOption)(i =>
Expand Down Expand Up @@ -163,6 +166,8 @@ class AuthenticationController @Inject()(
authenticator <- combinedAuthenticatorService.create(loginInfo)
value <- combinedAuthenticatorService.init(authenticator)
result <- combinedAuthenticatorService.embed(value, Ok)
_ <- Fox.runIf(conf.WebKnossos.User.EmailVerification.activated)(emailVerificationService
.assertEmailVerifiedOrResendVerificationMail(user)(GlobalAccessContext, ec))
_ <- multiUserDAO.updateLastLoggedInIdentity(user._multiUser, user._id)(GlobalAccessContext)
_ = userDAO.updateLastActivity(user._id)(GlobalAccessContext)
} yield result
Expand Down Expand Up @@ -503,7 +508,7 @@ class AuthenticationController @Inject()(
}

// Is called after user was successfully authenticated
def loginOrSignupViaOidc(oidc: OpenIdConnectClaimSet): Request[AnyContent] => Future[Result] = {
private def loginOrSignupViaOidc(oidc: OpenIdConnectClaimSet): Request[AnyContent] => Future[Result] = {
implicit request: Request[AnyContent] =>
userService.userFromMultiUserEmail(oidc.email)(GlobalAccessContext).futureBox.flatMap {
case Full(user) =>
Expand All @@ -513,7 +518,13 @@ class AuthenticationController @Inject()(
for {
organization: Organization <- organizationService.findOneByInviteByNameOrDefault(None, None)(
GlobalAccessContext)
user <- createUser(organization, oidc.email, oidc.given_name, oidc.family_name, autoActivate = true, None)
user <- createUser(organization,
oidc.email,
oidc.given_name,
oidc.family_name,
autoActivate = true,
None,
isEmailVerified = true) // Assuming email verification was done by OIDC provider
// After registering, also login
loginInfo = LoginInfo("credentials", user._id.toString)
loginResult <- loginUser(loginInfo)
Expand Down Expand Up @@ -553,14 +564,17 @@ class AuthenticationController @Inject()(
organization <- organizationService.createOrganization(
Option(signUpData.organization).filter(_.trim.nonEmpty),
signUpData.organizationDisplayName) ?~> "organization.create.failed"
user <- userService.insert(organization._id,
email,
firstName,
lastName,
isActive = true,
passwordHasher.hash(signUpData.password),
isAdmin = true,
isOrganizationOwner = true) ?~> "user.creation.failed"
user <- userService.insert(
organization._id,
email,
firstName,
lastName,
isActive = true,
passwordHasher.hash(signUpData.password),
isAdmin = true,
isOrganizationOwner = true,
isEmailVerified = false
) ?~> "user.creation.failed"
_ = analyticsService.track(SignupEvent(user, hadInvite = false))
multiUser <- multiUserDAO.findOne(user._multiUser)
dataStoreToken <- bearerTokenAuthenticatorService
Expand Down
30 changes: 30 additions & 0 deletions app/controllers/EmailVerificationController.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package controllers

import com.mohiva.play.silhouette.api.Silhouette
import com.scalableminds.util.accesscontext.GlobalAccessContext
import com.scalableminds.util.tools.FoxImplicits
import models.user.EmailVerificationService
import oxalis.security.WkEnv
import play.api.mvc.{Action, AnyContent, PlayBodyParsers}

import javax.inject.Inject
import scala.concurrent.ExecutionContext

class EmailVerificationController @Inject()(emailVerificationService: EmailVerificationService, sil: Silhouette[WkEnv])(
implicit ec: ExecutionContext,
val bodyParsers: PlayBodyParsers)
extends Controller
with FoxImplicits {

def verify(key: String): Action[AnyContent] = Action.async { implicit request =>
for {
_ <- emailVerificationService.verify(key)(GlobalAccessContext, ec)
} yield Ok
}

def requestVerificationMail: Action[AnyContent] = sil.SecuredAction.async { implicit request =>
for {
_ <- emailVerificationService.sendEmailVerification(request.identity)
} yield Ok
}
}
2 changes: 2 additions & 0 deletions app/controllers/InitialDataController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ Samplecountry
defaultUserEmail,
userService.createPasswordInfo(defaultUserPassword),
isSuperUser = conf.WebKnossos.SampleOrganization.User.isSuperUser,
isEmailVerified = true
)
private val defaultUser = User(
userId,
Expand All @@ -112,6 +113,7 @@ Samplecountry
defaultUserEmail2,
userService.createPasswordInfo(defaultUserPassword),
isSuperUser = false,
isEmailVerified = true
)
private val defaultUser2 = User(
userId2,
Expand Down
61 changes: 61 additions & 0 deletions app/models/user/EmailVerificationKey.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package models.user

import com.scalableminds.util.time.Instant
import com.scalableminds.util.tools.Fox
import com.scalableminds.webknossos.schema.Tables
import com.scalableminds.webknossos.schema.Tables.{Emailverificationkeys, EmailverificationkeysRow}
import slick.lifted.{Rep, TableQuery}
import utils.ObjectId
import utils.sql.{SQLDAO, SqlClient}

import javax.inject.Inject
import scala.concurrent.ExecutionContext

case class EmailVerificationKey(_id: ObjectId,
key: String,
email: String,
_multiUser: ObjectId,
validUntil: Option[Instant],
isUsed: Boolean)

class EmailVerificationKeyDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext)
extends SQLDAO[EmailVerificationKey, EmailverificationkeysRow, Emailverificationkeys](sqlClient) {
override protected def collection: TableQuery[Tables.Emailverificationkeys] = Emailverificationkeys

override protected def idColumn(x: Tables.Emailverificationkeys): Rep[String] = x._Id

override protected def isDeletedColumn(x: Tables.Emailverificationkeys): Rep[Boolean] = ???

override protected def parse(
row: _root_.com.scalableminds.webknossos.schema.Tables.EmailverificationkeysRow): Fox[EmailVerificationKey] =
Fox.successful(
EmailVerificationKey(
ObjectId(row._Id),
row.key,
row.email,
ObjectId(row._Multiuser),
row.validuntil.map(Instant.fromSql),
row.isused
)
)

def insertOne(evk: EmailVerificationKey): Fox[Unit] =
for {
_ <- run(q"""insert into webknossos.emailVerificationKeys(_id, key, email, _multiUser, validUntil, isUsed)
values(${evk._id}, ${evk.key}, ${evk.email}, ${evk._multiUser}, ${evk.validUntil}, ${evk.isUsed})""".asUpdate)
} yield ()

def findOneByKey(key: String): Fox[EmailVerificationKey] =
for {
r <- run(q"select $columns from webknossos.emailVerificationKeys where key = $key".as[EmailverificationkeysRow])
parsed <- parseFirst(r, key)
} yield parsed

def markAsUsed(emailVerificationKeyId: ObjectId): Fox[Unit] =
for {
_ <- run(q"""update webknossos.emailVerificationKeys set
isused = true
where _id = $emailVerificationKeyId""".asUpdate)
} yield ()

}
83 changes: 83 additions & 0 deletions app/models/user/EmailVerificationService.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package models.user

import akka.actor.ActorSystem
import com.scalableminds.util.accesscontext.DBAccessContext
import com.scalableminds.util.time.Instant
import com.scalableminds.util.tools.Fox
import com.typesafe.scalalogging.LazyLogging
import oxalis.mail.{DefaultMails, Send}
import oxalis.security.RandomIDGenerator
import utils.{ObjectId, WkConf}

import javax.inject.Inject
import scala.concurrent.ExecutionContext

class EmailVerificationService @Inject()(conf: WkConf,
emailVerificationKeyDAO: EmailVerificationKeyDAO,
multiUserDAO: MultiUserDAO,
defaultMails: DefaultMails,
actorSystem: ActorSystem)
extends LazyLogging {

private lazy val Mailer =
actorSystem.actorSelection("/user/mailActor")

def sendEmailVerification(user: User)(implicit ctx: DBAccessContext): Fox[Unit] =
for {
multiUser <- multiUserDAO.findOne(user._multiUser)(ctx)
key: String = RandomIDGenerator.generateBlocking(32)
expiration = conf.WebKnossos.User.EmailVerification.linkExpiry.map(Instant.now + _)
evk: EmailVerificationKey = EmailVerificationKey(ObjectId.generate,
key,
multiUser.email,
multiUser._id,
expiration,
isUsed = false)
_ <- emailVerificationKeyDAO.insertOne(evk)
fullVerificationLink = s"${conf.Http.uri}/verifyEmail/$key"
_ = logger.info(s"Sending email verification mail for user with email ${multiUser.email}")
_ = Mailer ! Send(defaultMails.emailVerificationMail(user, multiUser.email, fullVerificationLink))
} yield ()

def verify(key: String)(implicit ctx: DBAccessContext, ec: ExecutionContext): Fox[Unit] =
for {
isEmailVerified <- isEmailAlreadyVerifiedByKey(key)
_ <- Fox.runIf(!isEmailVerified)(checkAndVerify(key))
} yield ()

private def isEmailAlreadyVerifiedByKey(key: String)(implicit ctx: DBAccessContext): Fox[Boolean] =
for {
evk <- emailVerificationKeyDAO.findOneByKey(key) ?~> "user.email.verification.keyInvalid"
multiUser <- multiUserDAO.findOne(evk._multiUser) ?~> "user.notFound"
} yield multiUser.isEmailVerified

private def checkAndVerify(key: String)(implicit ctx: DBAccessContext, ec: ExecutionContext): Fox[Unit] =
for {
evk <- emailVerificationKeyDAO.findOneByKey(key) ?~> "user.email.verification.keyInvalid"
multiUser <- multiUserDAO.findOne(evk._multiUser) ?~> "user.notFound"
_ <- Fox.bool2Fox(!evk.isUsed) ?~> "user.email.verification.keyUsed"
_ <- Fox.bool2Fox(evk.validUntil.forall(!_.isPast)) ?~> "user.email.verification.linkExpired"
_ <- Fox.bool2Fox(evk.email == multiUser.email) ?~> "user.email.verification.emailDoesNotMatch"
_ = multiUserDAO.updateEmailVerification(evk._multiUser, verified = true)
_ <- emailVerificationKeyDAO.markAsUsed(evk._id)
} yield ()

def assertEmailVerifiedOrResendVerificationMail(user: User)(
implicit ctx: DBAccessContext,
ec: ExecutionContext
): Fox[Unit] =
for {
emailVerificationOk <- userHasVerifiedEmail(user)
_ <- Fox.runIf(!emailVerificationOk)(sendEmailVerification(user))
_ <- Fox.bool2Fox(emailVerificationOk) ?~> "user.email.notVerified"
} yield ()

private def userHasVerifiedEmail(user: User)(
implicit ctx: DBAccessContext
): Fox[Boolean] =
for {
multiUser: MultiUser <- multiUserDAO.findOne(user._multiUser) ?~> "user.notFound"
endOfGracePeriod: Instant = multiUser.created + conf.WebKnossos.User.EmailVerification.gracePeriod
overGracePeriod = endOfGracePeriod.isPast
} yield !conf.WebKnossos.User.EmailVerification.required || multiUser.isEmailVerified || !overGracePeriod
}
16 changes: 13 additions & 3 deletions app/models/user/MultiUser.scala
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ case class MultiUser(
novelUserExperienceInfos: JsObject = Json.obj(),
selectedTheme: Theme = Theme.auto,
created: Instant = Instant.now,
isEmailVerified: Boolean = false,
isDeleted: Boolean = false
)

Expand Down Expand Up @@ -55,6 +56,7 @@ class MultiUserDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext
novelUserExperienceInfos,
theme,
Instant.fromSql(r.created),
r.isemailverified,
r.isdeleted
)
}
Expand All @@ -65,11 +67,11 @@ class MultiUserDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext
_ <- run(q"""insert into webknossos.multiusers(_id, email, passwordInfo_hasher,
passwordInfo_password,
isSuperUser, novelUserExperienceInfos, selectedTheme,
created, isDeleted)
created, isEmailVerified, isDeleted)
values(${u._id}, ${u.email}, $passwordInfoHasher,
${u.passwordInfo.password},
${u.isSuperUser}, ${u.novelUserExperienceInfos}, ${u.selectedTheme},
${u.created}, ${u.isDeleted})""".asUpdate)
${u.created}, ${u.isEmailVerified}, ${u.isDeleted})""".asUpdate)
} yield ()

def updatePasswordInfo(multiUserId: ObjectId, passwordInfo: PasswordInfo)(implicit ctx: DBAccessContext): Fox[Unit] =
Expand All @@ -86,7 +88,15 @@ class MultiUserDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext
for {
_ <- assertUpdateAccess(multiUserId)
_ <- run(q"""update webknossos.multiusers set
email = $email
email = $email, isEmailVerified = false
where _id = $multiUserId""".asUpdate)
} yield ()

def updateEmailVerification(multiUserId: ObjectId, verified: Boolean)(implicit ctx: DBAccessContext): Fox[Unit] =
for {
_ <- assertUpdateAccess(multiUserId)
_ <- run(q"""update webknossos.multiusers set
isemailverified = $verified
where _id = $multiUserId""".asUpdate)
} yield ()

Expand Down
Loading

0 comments on commit 4d9fd8c

Please sign in to comment.